Getting More Out of Vue Async Components

3 August, 2019

We mainly use Vue async components to split them into their own bundle to reduce our initial bundle size, I explore how to get out more of those components.

Async Component Options

If you are using Nuxt, you are bound to love the asyncData feature as it allows you to fetch arbitrary data and inject them into the page component's data. But it only works for page components, when it comes to component-level async data we have no real answer, at least for the time being.

Consider a component that has a dynamic layout that changes depending on the user configuration, like a tenant app with a dynamic layout. A simple implementation looks like this:

<template>
  <div>
    <div :class="`is-${layout[0]}`">
      Section 1
    </div>
    <div :class="`is-${layout[1]}`">
      Section 2
    </div>
    <div :class="`is-${layout[2]}`">
      Section 3
    </div>
  </div>
</template>

<script>
  export default {
    props: {
      layout: {
        type: Array,
        default: () => [6, 3, 3]
      }
    }
  };
</script>

Naturally you would fetch the layout information in this component's parent and pass them via the layout prop. This is fine but it would be much better if we could make this component self contained and decouple this logic from its parent. And if its going to be used a lot it will be problematic to keep fetching the config over and over.

I guess we can just fetch the information from an API, and instead of using a layout prop we could instead use local state:

<template>
  <div>
    <div :class="`is-${layout[0]}`">
      Profile Section
    </div>
    <div :class="`is-${layout[1]}`">
      Sidebar
    </div>
    <div :class="`is-${layout[2]}`">
      Ads
    </div>
  </div>
</template>

<script>
  export default {
    name: 'Layout',
    data: () => ({
      layout: [6, 3, 3]
    }),
    async mounted() {
      this.layout = await api.getUserLayout();
    }
  };
</script>

Cool, but we immediately notice a problem, our component will not render the correct configuration initially. This problem can be avoided in the first example, but can still happen.

Vue.js offers us the ability to build lazy components, we mainly use this feature to split our code into multiple chunks which is useful for large components like a vue-router's route component.

An async component is usually composed like this:

Vue.component('async-component', () => import('./async-component'));

But we could do a lot more with this idea, after all the function should return a promise that resolves to a component options object. So we are free to do any arbitrary operations in-between, we could fetch data from a remote source for example.

Vue.component('Layout', async () => {
  const layout = await api.getUserLayout();

  return {
    data: () => ({
      layout
    }),
    template: `
        <div>
          <div :class="\`is-${layout[0]}\`">
            Profile Section
          </div>
          <div :class="\`is-${layout[1]}\`">
            Sidebar
          </div>
          <div :class="\`is-${layout[2]}\`">
            Ads
          </div>
        </div>
      `
  };
});

Great, our component is now fully independent and it doesn't use life cycle methods. You can apply this technique to any component that fetches it's definition dynamically from a remote endpoint, the possibilities are endless at this point!

By looking at our component. I can see your disgust at the inline template, surely a larger component will be extremely annoying to inline like that, it would be cooler if we could use our Vue template along with this one.

Remember that we can put any arbitrary logic inside the async component resolver, so for instance we could load a component ourselves and inject the data in it.

A quick dirty hack would look like this:

const Layout = async () => {
  // Our SFC
  const component = await import('./components/Layout');
  const layout = await api.getUserLayout();

  // Good ol' JS monkey patching.
  const originalData = component.data;
  component.data = () => {
    return {
      ...originalData(),
      layout
    };
  };

  return component;
};

This patches our component definition with the remote data fetched, it also splits our component into its own chunk and so we are golden on both fronts. Now it we wouldn't be done unless this is reusable, right?

What we need is to inject data into an arbitrary component with a promise result. We could do this with a higher order component (HOC):

const withData = (component, callback) => async () => {
  const asyncData = await callback();
  // Handle both splitted components and directly imported ones.
  component = component.then ? await component : component;
  const originalData = component.data || (() => ({}));
  component.data = () => {
    return {
      ...originalData(),
      ...asyncData
    };
  };

  return component;
};

Now we could use this with any component:

Vue.component(
  'Layout',
  withData(() => import('./components/Layout.vue'), api.getUserLayout)
);

This is not a perfect solution as your component would only evaluate the resolver function once, meaning it won't update upon each visit to this component. But we explored the idea of injecting component options at runtime, this is useful if you are doing it once in a full-render like a loading a header or a footer.

Here is this example in action:

Async Features

Sometimes you face this scenario: your awesome component does a default thing, then based on a prop or some runtime condition it takes the code into a heavy execution path.

Let's illustrate this with an example that I ran into, I have this AppContent component that adds some styling and preprocessing to arbitrary text. For example it converts :emoji: to emoji graphics like Slack or discord.

That's not a lot, but we also have another scenario where we might run into the three ticks ` to display some code snippet which will require some highlighting.

So we also need to load our highlighter of choice (Prism or Highlight.js) then load our theme of choice and apply it on the code snippet.

Now you will realize that this component even if lazily loaded will pack a bunch of kilobytes to support features that might not exist in all content written, and if we need to add more features it will grow very quickly. wouldn't it be nice to just load the absolute minimum and progressively load features when needed?

You could tackle this in multiple ways, assuming our content nodes are flat (no children) it will allow us to rule out recursive components, which is for the better because we want to keep it simple.

A great solution would be to map the nodes into components and these components will be lazily loaded with import().

<template>
  <div class="AppContent">
    <Component
      v-for="node in mappedNodes"
      :is="node.component"
      v-bind="node.props"
    >
      {{ node.body }}
    </Component>
  </div>
</template>


<script>
export default {
  components: {
    SnippetNode: () => import('./SnippetNode.vue'),
    EmojiNode: () => import('./EmojiNode.vue')
  },
  props: {
    nodes: {
      type: Array,
      required: true
    }
  },
  computed: {
    mappedNodes () {
      return this.nodes.map(node => {
        if (node.startsWith(':') && node.endsWith(':')) {
          // Emoji Node
          return {
            component: 'EmojiNode',
            props: {
              id: node.replace(/:/g, '')
            }
          };
        }

        if (node.startsWith('```') && node.endsWith('```')) {
          // Snippet node
          return {
            component: 'SnippetNode',
            props: {
              language: node.match(/```(w+)/)[1]
            },
            body: node.replace(/```(w+)?/g, '')
          };
        }

        // just a paragraph
        return {
         component: 'p',
         body: node
        };
      });
    }
  }
};
</script>

Here it is in action:

This looks fine at first glance, but you will notice that it loads both components anyways even if you remove some nodes from the nodes.json file.

Splitting them up into their own bundles isn't the goal here, we want to load them when needed, we want to save some valuable kilobytes as it will allow our readers to read the article faster. Currently we probably made it worse by adding the async overhead.

Let us define our goal here: We need to dynamically register components if they are needed, otherwise we don't register them at all.

A useful feature of the Dynamic component is that the is prop can accept a component options, so instead of registering the lazy components we can lazily load them when computing the nodes list. A slightly improved example looks like this:

// Create a lazy loader.
const loadComponent = function (component) {
  return () => import(`./${component}.vue`);
}

export default {
  props: {
    nodes: {
      type: Array,
      required: true
    }
  },
  computed: {
    mappedNodes () {
      return this.nodes.map(node => {
        if (node.startsWith(':') && node.endsWith(':')) {
          // Emoji Node
          return {
            component: loadComponent('EmojiNode'),
            props: {
              id: node.replace(/:/g, '')
            }
          };
        }

        if (node.startsWith('```') && node.endsWith('```')) {
          // Snippet node
          return {
            component: loadComponent('SnippetNode'),
            props: {
              language: node.match(/```(w+)/)[1]
            },
            body: node.replace(/```(w+)?/g, '')
          };
        }

        // just a paragraph
        return {
         component: 'p',
         body: node
        };
      });
    }
  }
};

This does the trick! it only loads the component down the wire only when needed.

By doubling down on laziness we managed to solve the problem, but can we get away with something much simpler?

Since we are only transforming text, we could render spans with HTML binding with v-html. And we can lazy load any JavaScript code using import() just like async components, it isn't exclusive to Vue components.

<template>
  <div class="AppContent">
    <p v-for="node in mappedNodes" v-html="node"></p>
  </div>
</template>

<script>
function transform(node) {
  function loadTransformer(transformer) {
    return import(`../transformers/${transformer}.js`).then(({ transform }) => {
      return transform;
    });
  }

  if (node.startsWith(":") && node.endsWith(":")) {
    return loadTransformer("emoji").then(t => t(node));
  }

  if (node.startsWith("```") && node.endsWith("```")) {
    return loadTransformer("code").then(t => t(node));
  }

  return node;
}

export default {
  props: {
    nodes: {
      type: Array,
      required: true
    }
  },
  data: () => ({
    mappedNodes: []
  }),
  async mounted() {
    for (const node of this.nodes) {
      this.mappedNodes.push(await transform(node));
    }
  }
};
</script>

That surprisingly looks a lot cleaner, instead of using async components, we use regular functions which will reduce our logic considerably. This is fine for small stuff, once the logic starts to grow you may opt-in for components as they will give you the flexibility needed but its good to know that we don't have to stuff everything into a component.


Conclusion

We explored a couple of interesting ideas we can do with async components, I'm sure you can think of a couple of crazy ideas as well.