Vue: Bind vm instance to local async component registration

Created on 13 Oct 2018  ·  10Comments  ·  Source: vuejs/vue

What problem does this feature solve?

I work for a large firm with many developers, and we are defining a strategy for reusability while still providing the capability to provide conditional behavior. In other words, the design needs the ability to:

  1. provide behavioral differences for (grand)children
  2. code-split these differences for the purpose of scaling

EDIT: See this comment for a valid use-case.

This can easily be solved with vue mixins (or extends) and props (or inject) for a single level hierarchy, but things get complicated when trying to create multiple levels of nesting with configuration injected from a grand ancestor, when accounting for the need to code-split.

v-if is a nice pattern, but it requires the child component to still be loaded even if the condition will always be false. This is a non-option for us, because of the performance implications at-scale loading code that is never used. Therefore we have a need to conditionally split child components into separate bundles based on instance properties.

Vue provides the ability to allow behavioral differences with mixins/extends/props/inject, and also the ability to provide promises (and therefore create split points) for local component registration definitions. We have tried coming at this many different angles, but there is no apparent way to do both (without losing server-side rendering). More information can be found by reading the section on async components.

EDIT: It's also worth mentioning that SEO is a factor. Our application is fully universal (isomorphic) for the purpose of SEO and TTI. We do selective client lazy-loading where SEO is not important, but the typical use-case for code splitting the javascript is for the purpose of performance at-scale.

EDIT: There _is_ a way to do both. Thanks to the solution provided by @sirlancelot.

The pattern that we came up with to solve this business need looks like this:

<!-- @file feature.vue, n-th level from parent -->
<template>
  <div id="feature">
    <child/>
    <div>some other feature html</div>
  </div>
</template>

<script>
export default {
  inject: ['flavorOfChild'],
  components: {
    child() {
      if (this.flavorOfChild === 'flavor 2') {
        return import('./flavor-2');
      }

      return import('./flavor-1');
    }
  }
}
</script>

The issue is, the vm is not actually bound to the component function, and therefore this is undefined. We can, however, take advantage of the component instance lifecycle to create a reference to the instance:

let _this;
export default {
  inject: ['flavorOfChild'],
  created() {
    _this = this;
  },
  components: {
    child() {
      if (_this.flavorOfChild === 'flavor 2') {
        return import('./flavor-2');
      }

      return import('./flavor-1');
    }
  }
}

Although the above solution “works”, it has the following limitations:

  • It’s a bit messy to manually manage _this
  • created() would otherwise not be needed.
  • this opens up the opportunity for possible unexpected behavior for components that may be instantiated multiple times if the instances are created in parallel with different values.
  • async component registration is not documented as part of the lifecycle, so there is no confidence that this lifecycle will remain consistent between versions (and thus _this may not be defined if Vue changes source such that created() happens after async components are resolved)

This is the solution we will be going with despite the limitations. There is also perhaps another way to conditionally lazy load child components that we have not considered. We did, however, try to come up with every possible design to accomplish our overall goal and could not.

EDIT: There _is_ another way. Thanks to the solution provided by @sirlancelot.

EDIT: I have created a post to the vue forum to explore different design options (the need for this github issue assumes there is no other possible design that will solve our business need).

What does the proposed API look like?

export default {
  components: {
    child() {
      if (this.someCondition) {
        return import('./a');
      }

      return import('./b');
    }
  }
}

EDIT: Here you can see it demonstrated in a simplified form.

EDIT: Here you can see it demonstrated with vanilla js, agnostic of vue.

This seems like a simple change given the fact that the instance exists when these component functions are executed, and it is already possible to manage the binding manually like in the earlier examples.

I'd happily submit a PR but I could not find the right spot in the code to make the change.


EDIT: Now that a solution to my use-case has been provided (by @sirlancelot), this issue remains open for two reasons:

1) As @sirlancelot articulates here, the apparent difference between <component :is> and local component registration is the caching. computed properties are expected to change, where the component definitions will be cached forever. There _may_ be some benefit since in this use-case, the values are "configuration" and will never change

2) There may be some other use-case that could benefit from design opportunities opened up by the vm being bound to the local async component registration promise function

Most helpful comment

Thank you for such a detailed description! I took some time to read this and think it over before providing my response below. I think Vue can solve your issue as-is but until a core dev can provide a response, it's anyone's guess.

I've solved the scenario you're describing by using a computed property in a wrapper component. Using your example scenario I think you would write something like this:

<!-- "compnent-split.vue" -->
<template>
  <div id="feature">
    <component :is="childFeature">
    <div>some other feature html</div>
  </div>
</template>

<script>
export default {
  inject: ["flavorOfChild"],
  computed: {
    childFeature() {
      const feature = this.flavorOfChild.name
      return () => import(`./features/${feature}.vue`)
    }
  }
}
</script>

The most important part to this is that the computed property is returning a Function that starts the import process. Webpack will place the code-split there. Nothing more will be downloaded except what is required to fulfill that request. The added benefit you also get is that if flavorOfChild.name is a reactive object and its value changes, your computed property will "just work" and the new component will be downloaded and displayed.

You can take it a step further by returning an async loading component definition instead (as seen in Handling Loading State):

import LoadingSpinner from "./components/loading.vue"

export default {
  // ...snipped
  computed: {
    childFeature() {
      const feature = this.flavorOfChild.name
      return () => ({
        component: import(`./features/${feature}.vue`),
        loading: LoadingSpinner
      })
    }
  }
}

Overall, I think the idea behind the components object in a component definition is that those functions should remain stateless and pure because Vue will cache that result indefinitely. If your component definition depends on external state, a dynamic <component :is=""> is the best solution because it tells the person reading your code (or just future "you") that something here depends on the state of something else.

Update: Note that it's not required to use the component name as the file. This is a structure that I use for my larger applications. You can structure the files however you like. The important piece to keep in mind is that you can return a function and pass that function directly to <component :is=""> and let Vue handle the rest.

All 10 comments

Here is a use-case:

  • company-1

    • feature-1

      • component-1

      • component-2

      • component-3

      • component-split

        - component-4


  • company-2

    • feature-1

      • component-1

      • component-2

      • component-3

      • component-split



        • component-5



As you can see:

  • company-1 wants feature-1 to use component-4
  • company-2 wants feature-1 to use component-5

90% of the rest of feature-1 (and 100% of all other child components) will be the same, so it doesn't scale well to create a  feature-2 . This is especially true when multiple levels of recursion are in play, as the copypasta gets out-of-hand exponentially.

This has been answered using props or inject, _but_ like mentioned in the original issue description, both company-1 and company-2 will load component-4 as well as component-5 without conditionally importing. This is debilitating at-scale.

I'm pretty sure you can already load modules lazily using webpack's chunk/bundle splitting. This is very likely not related to Vue.

In fact there's even a tutorial (which is one of the first results on Google): https://alligator.io/vuejs/lazy-loading-vue-cli-3-webpack/

Assuming you're using Babel for the ES6+ syntax, the this issue you're mentioning appears to happen because you're nesting the context inside of the object's context. It could be due to ES6 behavior which is to be expected. You could probably use a closure to retain reference to the parent Vue context there.

I appreciate the quick reply but i'm afraid it is in fact related to Vue.

You are correct that there is no issue with using webpack's code splitting features to lazy load bundles for child components. However that is not the issue I posted.

You are also correct that the closure is working according to the ECMA spec, which is why this was posted as a "feature enhancement" and not a bug.

I'm very familiar with the concept of lazy loading via webpack's code splitting feature. I'm also very comfortable with the concept of ES6 closures and block scopes.

As you can see in my code example, I'm not inheriting the block scope context by using an arrow function, which in this case would be the component constructor itself. Instead, I'm using ES6 shorthand to retain the context if the instance scope. This is even the syntax used in the example you posted. Since components is an object, In this case, the caller (Vue) has to bind the instance.

As I clearly articulated above, the issue is that Vue needs to bind the vm as the context when calling the component promise function.

EDIT: here you can see it demonstrated in a simplified form: https://jsfiddle.net/justinhelmer/wc1gd4L9/

EDIT: here you can see it demonstrated with vanilla js, agnostic of vue: https://jsfiddle.net/k1v7cLbj/7/

I spent a bit of time trying to find the place in the codebase to add the enhancement, but figured someone more comfortable with the source would be able to quickly identify where the change would go.

<template>
  <div id="feature">
    <component :is="child"/>
    <div>some other feature html</div>
  </div>
</template>

<script>
  export default {
    inject: ['flavorOfChild'],
    created () {
      this.child = this.getChild()
    },
    methods: {
      getChild () {
        if (this.flavorOfChild === 'flavor 2') {
          return import('./flavor-2')
        }

        return import('./flavor-1')
      }
    }
  }
</script>

@KaelWD - Thank you taking the time to provide input.

Your solution doesn't work for a couple reasons:

  • this.child must be the name of a registered component, or a component's options objects. See the docs on dynamic components. Vue will not know what to do with a promise.

  • Your code could be slightly modified as:

async created () {
  this.child = await this.getChild();
},

However, the created() function is non-blocking and therefore would inhibit the ability to render server-side, which is another requirement.

EDIT: Here is a fiddle illustrating the different syntactical approaches to this design, and comments explaining why they are flawed (w.r.t. solving this particular design issue).

I will update the original description to make it clear that the application is universal (isomorphic) for the purpose of SEO and TTI. We do selective client lazy-loading where SEO is not important, but the typical use-case for code splitting the javascript is for the purpose of performance at-scale (with the contingency that SEO cannot be impacted).

I appreciate your response.

Thank you for such a detailed description! I took some time to read this and think it over before providing my response below. I think Vue can solve your issue as-is but until a core dev can provide a response, it's anyone's guess.

I've solved the scenario you're describing by using a computed property in a wrapper component. Using your example scenario I think you would write something like this:

<!-- "compnent-split.vue" -->
<template>
  <div id="feature">
    <component :is="childFeature">
    <div>some other feature html</div>
  </div>
</template>

<script>
export default {
  inject: ["flavorOfChild"],
  computed: {
    childFeature() {
      const feature = this.flavorOfChild.name
      return () => import(`./features/${feature}.vue`)
    }
  }
}
</script>

The most important part to this is that the computed property is returning a Function that starts the import process. Webpack will place the code-split there. Nothing more will be downloaded except what is required to fulfill that request. The added benefit you also get is that if flavorOfChild.name is a reactive object and its value changes, your computed property will "just work" and the new component will be downloaded and displayed.

You can take it a step further by returning an async loading component definition instead (as seen in Handling Loading State):

import LoadingSpinner from "./components/loading.vue"

export default {
  // ...snipped
  computed: {
    childFeature() {
      const feature = this.flavorOfChild.name
      return () => ({
        component: import(`./features/${feature}.vue`),
        loading: LoadingSpinner
      })
    }
  }
}

Overall, I think the idea behind the components object in a component definition is that those functions should remain stateless and pure because Vue will cache that result indefinitely. If your component definition depends on external state, a dynamic <component :is=""> is the best solution because it tells the person reading your code (or just future "you") that something here depends on the state of something else.

Update: Note that it's not required to use the component name as the file. This is a structure that I use for my larger applications. You can structure the files however you like. The important piece to keep in mind is that you can return a function and pass that function directly to <component :is=""> and let Vue handle the rest.

@sirlancelot - Great solution. I'm grateful you clearly took the time to understand my problem and provide a thoughtful response.

In my mind, computed() values had to be resolved synchronously in order to get the SEO juice. For a standard computed value, you cannot return a promise or promise function. The piece of the puzzle I was missing is that the computed property bound to :is happens to be special - it accepts _any_ form of a component definition, including a promise function.

I tested this solution with javascript disabled and it effectively blocks the render cycle before resolving the component definition. 🎉 Your solution is much cleaner and more portable. This is now what I will be going with as a solution unless our core friends can step in and save the day.

Our use-case is that these properties are "configurations" (do not need to be reactive), and therefore it seems more in-line with the components object for local registration. I agree with your assessment of what seems to be the difference between components vs <component :is="">. I can imagine it will not be an issue that we are treating it as a computed property in this case, because the value will never change. However I don't know enough about vue internals to be sure.

The fact that there _is_ a difference makes me think there is a reason to keep this issue open. If there's a valid reason (i.e. performance), there is still value in conditionally loading the definitions (which requires the vm to be bound).

Separately, there are other potential use-cases that could take advantage of the design opportunities opened by binding the context to the component function.

I would otherwise close this issue because @sirlancelot has solved our use-case! Cheers.


@sirlancelot - I have one unrelated question for you. How is that:

import(`./features/${feature}.vue`)

...is not causing all of ./features/* to get bundled? It was my understanding that this would be evaluated at run-time, which is why webpack would have to bundle everything up to the dynamic part (as explained here).

This is why we have rules like import/no-dynamic-require in place. What piece of the puzzle am I missing here?

That's why I did it in created, I just forgot that the promise has to be wrapped in a function.

@justinhelmer, Your assumption is correct. At compile-time, Webpack will bundle everything that matches the parameters of the dynamic require. The easiest way to accommodate this is to structure your folders in a way that the analyzer will only bundle the modules that you want.

Another option is to use Webpack's magic comments to craft your own RegExp which will match only the files that you want to include in the bundle.

See import() module methods. More specifically, /* webpackInclude, webpackExclude */

Closing since there is a userland solution and binding this actually complicates caching and could potentially leads to hard to detect bugs.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

franciscolourenco picture franciscolourenco  ·  3Comments

seemsindie picture seemsindie  ·  3Comments

6pm picture 6pm  ·  3Comments

robertleeplummerjr picture robertleeplummerjr  ·  3Comments

gkiely picture gkiely  ·  3Comments