Tutorial | Dec. 08, 2020

One Link Component To Rule Them All

Tired of constantly switching between anchor tags and whatever link component your Vue.js framework uses? Let's build a generic one together for Vue 2 & 3!

One Link Component To Rule Them All
Let's consider we're building a menu navigation. It's a simple list of anchors to other pages right? Oh, and we also want to link our social media accounts there. No big deal, our trusty anchor tag can handle any type of link!

That was more or less the story with links before shiny front-end frameworks come into play. Now, for Vue.js, Vue Router introduced a router-link component. Nuxt.js extended it with nuxt-link, so did Gridsome with g-link. Similarly, Next.js has a simply named link component.

Basically, what all those link components are providing is client-side routing. This is also known as "not hard reloading the page when navigating". Some also do provide other fanciness like page prefetching but that's not what interests us here. Indeed, because now we have them, we have to decide each time, depending on our link type, between them and the regular HTML anchor tag.

Due to that, most of the time we'll end up using the framework component with internal links and an anchor tag with external ones. While this gymnastic is not too complicated, it can become an issue when we don't know what kinds of links we're dealing with. This happens especially when those are coming from user-generated content or a CMS.

So how can we handle that case within Vue.js? v-if/v-else everywhere is an answer but I don't really like it. Let's instead build together a smart link component that we'll be able to reuse from project to project!

Before writing anything let's state something first: links are opinionated. Indeed some people like to do links with a specific flavor while some others might prefer another one. Here we'll build a link component with a flavor that I like. Of course, feel free to drift to yours while following along!

Now that we're clear on that, let's define what we need to do:

  1. Figure out the interface of our smart link component;
  2. Define a way of choosing between the HTML anchor tag and our framework link component;
  3. Implement the two above within a functional Vue.js component;
  4. Improve and iterate on our link component.

Looking good? Let's go!

Our Dream Interface

When it comes to interfaces I have a confession to make. I don't like the to prop name most of our framework link components use. So let's state that we'll use href as our prop to tell our smart link component its destination.

Another thing that can be hard to deal with is the native target="_blank" attribute:

  1. Why has it to be _blank instead of simply blank? Short answer: it can be used to target a previously named window to open the link in. The underscore is here to define the blank special use case. More info here~
  2. When adding a blank target to a link it's also better to add a rel attribute to prevent some malicious usages. Let's never forget about it by automating that part later on.

Okay, I think that's it! With that we'll be able to use our new link component as follows:

<smart-link href="/art">Internal link</smart-link>
<smart-link href="/about" blank>Internal link, in another tab</smart-link>
<smart-link href="https://example.com">External website</smart-link>
<smart-link href="mailto:john.doe@example.com">Email</smart-link>

Now that we're happy with our interface, let's define, and actually code, the logic that will choose between an anchor tag and the framework link component.

To Anchor or Not to Anchor

In that part, we need to write a function that figures out for us what link tag needs to be used. Let's name it: getLinkTag and state that it will return either "a" for an anchor tag or "router-link" for our framework link component.

We use "router-link" here as it's Vue Router default. If you want to use this smart link component with Nuxt.js, for example, simply replace the string with "nuxt-link".

As for its inputs, let's assume our function gets the component props we defined earlier as an object. With that we are starting with something like this:

const getLinkTag = ({ href, blank }) => {
  /* Some magic... */
  return "a"; // or "router-link"...
};

So how do we know what to return? A really easy way that works most of the time is to check if the link we want to create, our href prop, starts with one /, but not two, as //example.com is a valid external link. If a link passes these two checks, then it leads to an internal page:

const getLinkTag = ({ href, blank }) => {
  if (href.startsWith("/") && !href.startsWith("//")) {
    return "router-link";
  } else {
    return "a";
  }
};

We're already pretty happy with that! There's one edge case to consider although: if our link uses a blank target, meaning we used our blank prop, then, whatever the input href prop is, we need to return the HTML anchor tag. Indeed, while powerful, our framework link components aren't meant to handle this type of link. Let's implement that:

const getLinkTag = ({ href, blank }) => {
  if (blank) {
    return "a";
  } else if (href.startsWith("/") && !href.startsWith("//")) {
    return "router-link";
  } else {
    return "a";
  }
};

That's already it for our getLinkTag function! We can now to export it. We'll also give it a quick refactor for maintainability, turning our keywords into constants and our two checks on href into a single regular expression test:

const ANCHOR_TAG = "a";
const FRAMEWORK_LINK = "router-link"; // or "nuxt-link", "g-link"...

const getLinkTag = ({ href, blank }) => {
  if (blank) {
    return ANCHOR_TAG;
  } else if (/^\/(?!\/).*$/.test(href)) { // regex101.com/r/LU1iFL/2
    return FRAMEWORK_LINK;
  } else {
    return ANCHOR_TAG;
  }
};

export { getLinkTag as default, ANCHOR_TAG, FRAMEWORK_LINK };

We are done with the preparation work! Let's now dive into the heart of the matter with our actual smart link component.

A Low Level Vue.js Component

As teased we won't code a regular Vue.js component. Instead, we'll code a functional Vue.js component. What does that mean? Well, we can think of functional components as lower-level regular ones:

  • They are stateless, meaning they do not create reactive data;
  • They also do not have access to this, making them instanceless;
  • Finally, they do not have access to Vue.js lifecycle.

So when we remove all of that, what are we left with?

  • Props, functional components can receive props;
  • A render function, it allows us to programmatically define its template.

The point of using a functional component here is that our smart link component doesn't need much actually, so let's keep it to the minimum. The render function will also help us map our props correctly to whatever our getLinkTag function tells us to render. Let's start by declaring our component:

const SmartLink = {
  functional: true,
  props: {},
  render(createElement, context) {}
};

As we can see functional components takes the shape of an object with the following properties:

  • functional: it is always set to true and tells Vue.js that this component is... functional;
  • props: this is our array or object props definition like we'd define one on a standard component;
  • render: is our render function, it receives two arguments: createElement and context, we'll explain them later on.

Now that we're more familiar with the shape of our functional component let's start by filling its props. In the first step we defined our smart link component interface, we just have to represent it here:

const SmartLink = {
  functional: true,
  props: {
    href: {
      type: String,
      default: ""
    },
    blank: {
      type: Boolean,
      default: false
    }
  },
  render(createElement, context) {}
};

Great! We'll focus on our render function now. As we can see, it receives two arguments. The first one, createElement, is a helper function that returns a virtual node instance. We'll need to use it in the return statement of our render function. The second one, context, replaces this and contains information about our component instance: props, slots, etc.

Let's start slowly by actually rendering the tag our getLinkTag function returns us:

import getLinkTag from "./getLinkTag";

const SmartLink = {
  functional: true,
  props: { /* ... */ },
  render(createElement, context) {
    const tag = getLinkTag(context.props);

    return createElement(tag, context.data, context.children);
  }
};

OK, so what have we done here? We imported our getLinkTag function. Then, we executed it inside our render function, passing it our props from the context object it receives. Finally, we created an element with the tag it returned back.

In the createElement function call, we also passed two additional values:

  • context.data, it's an object that contains Vue.js and HTML attributes used on our component (class, style, v-on, etc.);
  • context.children, this one is our component default slot! That is to say, whatever gets passed between its opening tag and closing tag. Indeed, we want it to behave just like a regular tag at that level.

With that, we're already outputting the correct tag with the correct content on our front-end. One issue though, we didn't map any attribute yet to it! Perhaps it even led to a fatal error in our render. Let's fix that~

As explained the second argument we're passing to createElement is its attributes. We are going to extend this object with the extra information we need in it. Because the data object is quite a complex one, we will use vue-functional-data-merge. It's a widely adopted utility specifically designed for that use case. Install it with your package manager of choice:

$ npm install vue-functional-data-merge
# yarn counterpart
$ yarn add vue-functional-data-merge

We can now use it within our component:

import { mergeData } from "vue-functional-data-merge";
import getLinkTag, { ANCHOR_TAG, FRAMEWORK_LINK } from "./getLinkTag";

const SmartLink = {
  functional: true,
  props: { /* ... */ },
  render(createElement, context) {
    const tag = getLinkTag(context.props);

    const attrs = {};
    const props = {};
    switch (tag) {
      case ANCHOR_TAG:
        attrs.href = context.props.href;
        if (context.props.blank) {
          attrs.target = "_blank";
          attrs.rel = "noopener";
        }
        break;

      case FRAMEWORK_LINK:
        props.to = context.props.href;
        break;

      default:
        break;
    }

    const data = mergeData(context.data, { attrs, props });

    return createElement(tag, data, context.children);
  }
};

Let's break down what we did here. We imported mergeData from our freshly installed package. We also brought along our constants we defined earlier inside getLinkTag.js. We then proceeded to create our attributes, named attrs, and props objects to fit in the data object. Here we mapped our props to fit each tag interface, also taking into account the blank prop. Finally, we merged it with existing context.data using our imported helper, and forwarded the result to the createElement function.

Eeew! I hope this part was clear enough~ At least I have one great news for us. Our component is now working! Try it along, it should now behave as expected, following the interface we defined in the first part. Cool! Well, actually, I lied. I also have one bad news for us. But feel free to take a break before now!

OK, fresh? The last issue we're facing is related to Vue.js event system. When we want to listen to native click events on a component we have to use the .native modifier. This works as expected when our smart link component actually renders the framework link component, but fails when it renders an anchor tag. Indeed as we're forwarding events inside the data object directly to the created element, the HTML anchor tag has no idea what to do of a .native event. Thankfully, we can easily fix that by turning .native events into regular ones when our render function wants to render an anchor tag:

import { mergeData } from "vue-functional-data-merge";
import getLinkTag, { ANCHOR_TAG, FRAMEWORK_LINK } from "./getLinkTag";

const SmartLink = {
  functional: true,
  props: { /* ... */ },
  render(createElement, context) {
    const tag = getLinkTag(context.props);

    const attrs = {};
    const props = {};
    let on = {};
    switch (tag) {
      case ANCHOR_TAG:
        attrs.href = context.props.href;
        if (context.props.blank) {
          attrs.target = "_blank";
          attrs.rel = "noopener";
        }
        on = { ...context.data.nativeOn }; // Copy native events to regular ones
        delete context.data.nativeOn;      // Delete them from native events
        break;

      case FRAMEWORK_LINK:
        props.to = context.props.href;
        break;

      default:
        break;
    }

    const data = mergeData(context.data, { attrs, props, on });

    return createElement(tag, data, context.children);
  }
};

export default SmartLink;

Voilà! We also added the export so our link component is now ready for production! No worries if you're not quite there yet, full code will be shared at the end of the article. But stay with me! We still have some exciting things to see together~

Iterating to Make This Component Our

This is perhaps the most important step. What we coded so far is a pretty solid smart link component that works everywhere. Its interface is pretty basic, but it provides little to no fanciness. Now comes time for you to make it more opinionated in the way you'd like it to. So go ahead! Add additional features that will be useful for you across the different websites you'll build with it. That's the kind of power Vue.js gives us.

No idea what to do? OK let's implement a little one together. What about a new external prop that we'll use to force a link to be rendered with an anchor tag? This can become useful if we want, for example, to link to an image inside our static folder:

<smart-link href="/assets/example.png" external>Uses an anchor tag</smart-link>

Looks great? Let's code it! The first thing we need to do is to reflect it on our interface, to do so, we update our props definition:

import { mergeData } from "vue-functional-data-merge";
import getLinkTag, { ANCHOR_TAG, FRAMEWORK_LINK } from "./getLinkTag";

const SmartLink = {
  functional: true,
  props: {
    href: {
      type: String,
      default: ""
    },
    blank: {
      type: Boolean,
      default: false
    },
    external: {
      type: Boolean,
      default: false
    }
  },
  render(createElement, context) { /* ... */ }
};

export default SmartLink;

Good! Now we need to take into account this new prop by updating our getLinkTag function:

const ANCHOR_TAG = "a";
const FRAMEWORK_LINK = "router-link"; // or "nuxt-link", "g-link"...

const getLinkTag = ({ href, blank, external }) => {
  if (blank || external) {
    return ANCHOR_TAG;
  } else if (/^\/(?!\/).*$/.test(href)) { // regex101.com/r/LU1iFL/2
    return FRAMEWORK_LINK;
  } else {
    return ANCHOR_TAG;
  }
};

export { getLinkTag as default, ANCHOR_TAG, FRAMEWORK_LINK };

Done! That's all we needed to do. Now if we slap an external attribute to any kind of link it will force our component to render it with an HTML anchor tag.

Migrating to Vue 3

There's every chance that some of you already knew about it: functional components are getting somewhat deprecated with Vue 3. Hehe, stop! Don't run away. They are not actually deprecated: with Vue 2, functional components were coming with a nice performance gain, Vue 3 functional components don't provide it anymore. Not that they got slower, that's rather regular components that got as fast! Therefore, there's indeed much less point in pushing for functional components. Instead, we should rather use the new render functions that also make our life easier while being quite similar. Here's what a refactor of our smart link component could look like with render functions:

import { h, resolveComponent } from "vue";
import getLinkTag, { ANCHOR_TAG, FRAMEWORK_LINK } from "./getLinkTag";

const SmartLink = {
  props: { /* props remain the same */ },
  render() {
    const tag = getLinkTag(this);

    const attrs = {};
    let nodeTag;
    switch (tag) {
      case ANCHOR_TAG:
        nodeTag = tag;
        attrs.href = this.href;
        if (this.blank) {
          attrs.target = "_blank";
          attrs.rel = "noopener";
        }
        break;

      case FRAMEWORK_LINK:
        nodeTag = resolveComponent(FRAMEWORK_LINK);
        attrs.to = this.href;
        break;

      default:
        break;
    }

    return h(nodeTag, attrs, this.$slots.default());
  }
};

export default SmartLink;

So what are the differences? Our props remain the same. The render function no longer receives createElement and context: we now have to import the first one as h from Vue, and can access the context through this. We also no longer have to worry about native events, nor about merging with a complex data object. Our attributes and props objects also got merged into one, and that's the only thing we now have to forward. Cool! One drawback although, we now have to resolve our framework link with the resolveComponent function coming from Vue.

Overall, I have mixed feelings about those changes. They are quite convenient still! That's just that I've always been quite a hipster about the thing I use...

To Conclude...

Every time I start to work on a new post I'm like: "Oh I'll talk about this, sounds simple to do", and then I spend hours trying my best to explain it clearly. I hope it was!

As promised, here's a gist with the final code we discussed, even the Vue 3 one!

Here are also the references from Vue.js documentation if you want to dig the topic deeper on your side:

One last cool thing you could do with your brand new smart link component is publishing it as a package! This way you can easily maintain it and reuse it across your projects. That's what I did for mine, you can find its code here, and install it as @lihbr/utils-nuxt.smart-link from GitHub's registry (remember what I was saying about being a hipster...). Oh, and by the way, I've written an article about hosting private packages on GitHub two months ago, check it out!

Anyway, happy to discuss this article or answer your questions on Twitter, thanks for reading!

Plantae
An art by
Alina Frieske
Posted on Apr. 24, 2021