Explore Vue higher-order components

HOC is a common term in the React ecosystem. The main way to reuse code in React is to use HOC, which is officially recommended. The main way to reuse code in Vue is to use mixins, and the concept of high-order components is rarely mentioned in Vue. This is because it is not as easy to implement high-order components in Vue as in React. The reason is that React and Vue have different design ideas. This is not to say that higher-order components cannot be used in a Vue, but the benefits of using higher-order components in a Vue are not substantially different from mixins. This article mainly describes the implementation of Vue higher-order components from a technical point of view, and will analyze React and Vue from both perspectives.

Start with the React

React also used mixins to reuse code. For example, purerendermixins could be mixed into components to avoid unnecessary repeated rendering:

const PureRenderMixin = require('react-addons-pure-render-mixin')
const MyComponent = React.createClass({
  mixins: [PureRenderMixin]
})
Copy the code

React abandoned this approach and instead used shallowCompare:

const shallowCompare = require('react-addons-shallow-compare')
const Button = React.createClass({
  shouldComponentUpdate: function(nextProps, nextState) {
    return shallowCompare(this, nextProps, nextState);
  }
})
Copy the code

ShouldComponentUpdate () {shouldComponentUpdate () {shouldComponentUpdate () {shouldComponentUpdate () {shouldComponentUpdate () {shouldComponentUpdate () {shouldComponentUpdate ();

In order to prevent developers from writing the same code in their components, React recommended using react. PureComponent. In short, React was gradually moving away from mixins. They argue that mixins are not a good model for the React ecosystem (note: mixins are not bad, just for the React ecosystem), as follows:

1. Mixins introduce implicit dependencies. 2. Mixins are prone to name conflicts between mixins and mixins, and between mixins and components. As the demand grows, mixins become more complex, leading to a snowballing complexity.

For details, you can check out the article Mixins Considered Harmful. But HOC isn’t a silver bullet, and it certainly brings its own problems. If you’re interested, check out this video: Michael Jackson – Never Write Another HOC. Using normal components with Render Prop can do anything HOC can do.

This article won’t discuss whether mixins or HOC are good or bad, just as technology itself isn’t good or bad. React and Vue do the same 🙂.

Let’s go back to higher-order components. High-order components are actually higher-order functions. React and Vue prove one thing: a function is a component. A high-order component is a function that returns a function. We know that writing high-order components in React is writing high-order functions. Isn’t it as easy to implement high-order components in Vue? In fact, Vue is a bit complicated, and even requires you to have a good understanding of Vue. Next, let’s implement higher-order components in Vue. Later in the article, we will analyze why Vue can’t easily implement higher-order components like React.

This is why it is important to understand the React higher-order components before implementing them. See the React code below:

function WithConsole (WrappedComponent) { return class extends React.Component { componentDidMount () { console.log('with console: componentDidMount') } render () { return <WrappedComponent {... this.props}/> } } }Copy the code

WithConsole is an advanced component that has the following features:

1. HOC should be a pure function with no side effects and should not modify the original component

As you can see, WithConsole is a pure function that takes a component as an argument and returns a new one. In the render function of the new component, it just renders the WrappedComponent (WrappedComponent), without intrudingly modifying it.

HOC doesn’t care what data you’re delivering (props), and the WrappedComponent doesn’t care where the data came from

This is essential to ensure that the higher-order components and the packaged components fit together perfectly

3. The props received by HOC should be passed transparently to the WrappedComponent.

Higher-order components can absolutely add, remove, and modify props, but in addition to that, pass through the rest of the props, otherwise it will block the props in deeper nested relationships (a common problem with higher-order components).

HOC should not be created in the render function; HOC should not be created in the render function. Higher-order components (HOC) also need to replicate static methods in components; The ref in HOC refers to the outermost container component instead of the WrappedComponent, and so on.

Higher-order components in Vue

With this in mind, we can now start implementing the higher-order components of Vue. To give you an intuitive feeling, I will still use React and Vue as a comparison. Let’s start with a basic Vue component, which we call WrappedComponent. Let’s say our component is called BaseComponent:

base-component.vue

<template>
  <div>
    <span @click="handleClick">props: {{test}}</span>
  </div>
</template>

<script>
export default {
  name: 'BaseComponent',
  props: {
    test: Number
  },
  methods: {
    handleClick () {
      this.$emit('customize-click')
    }
  }
}
</script>
Copy the code

When we look at a Vue component, we look at three things: props, Event, and slots. For the BaseComponent component, it receives props of type test and emits a custom event with the name customize-click, no slots. We use this component like this:

<base-component @customize-click="handleCustClick" :test="100" />
Copy the code

/ / Add a base-Component to the list. / / Add a base-component to the list. / / Add a base-component to the list.

export default consoleMixin {
  mounted () {
    console.log('I have already mounted')
  }
}
Copy the code

Then mix consoleMixin into the BaseComponent:

export default {
  name: 'BaseComponent',
  props: {
    test: Number
  },
  mixins: [ consoleMixin ]
  methods: {
    handleClick () {
      this.$emit('customize-click')
    }
  }
}
Copy the code

BaseComponent: I have already mounted BaseComponent: I have already mounted BaseComponent: BaseComponent: I have already mounted Take a component as an argument, return a new component, and the question is, what is a component in Vue? Some of you might wonder, isn’t it a function? Yes, there is no problem with components being functions in Vue, but that is the end result. For example, our component definition in a single-file component is actually a plain option object, as follows:

export default { name: 'BaseComponent', props: {... }, mixins: [...]  methods: {... }}Copy the code

Isn’t that just a pure object? So when we import a component from a single file:

import BaseComponent from './base-component.vue'
console.log(BaseComponent)
Copy the code

Think about it, what’s a BaseComponent here? Is it a function? No, although the single-file component is processed by vue-Loader, the resulting BaseComponent is still a normal JSON object, but once you register the object as a component, Vue will end up with the object as a parameter to create a constructor, the constructor is to produce a component instance constructor, so in the Vue component is indeed a function, but it was the final result, it is fair to say that before in the Vue components can also be a common object, like a single object derived from the file component.

Based on this, we know that in Vue a component can exist as a pure object, so a higher-order component in Vue can be defined as receiving a pure object and returning a new pure object, as follows:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    template: '<wrapped v-on="$listeners" v-bind="$attrs"/>',
    components: {
      wrapped: WrappedComponent
    },
    mounted () {
      console.log('I have already mounted')
    }
  }
}
Copy the code

WithConsole is a higher-order component that takes a component as an argument: WrappedComponent, and returns a new component. In the new component definition, we register WrappedComponent as wrapped and render it in the template. We add mounted hooks and print I have already Mounted.

This does the same thing as mixins, but this time we use higher-order components, so it’s non-invasive. Instead of modifying the original component (WrappedComponent), we render the original component in the new component without making any changes to the original component. Note the $listeners and $attrs

'<wrapped v-on="$listeners" v-bind="$attrs"/>'
Copy the code

This is mandatory. This is equivalent to passing props in React:

<WrappedComponent {... this.props}/>Copy the code

Otherwise, the WrappedComponent will not receive props and events when using higher-order components.

Is this really the perfect solution to the problem? No, the template option is only available in the full Vue, not in the run-time version, so at the very least we should use the render function instead of template, as follows:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
      })
    }
  }
}
Copy the code

< div style = “color: red; color: red; color: red; Why don’t you get it? Attrs refers to properties that are not declared as props, so we need to add props parameters to the render function:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}
Copy the code

Is that ok? $props = this.$props () {this.$props = this.$props () {this. Simply set the props of the higher-order component to the same as the props of the wrapped component:

hoc.js

export default function WithConsole (WrappedComponent) {
  return {
    mounted () {
      console.log('I have already mounted')
    },
    props: WrappedComponent.props,
    render (h) {
      return h(WrappedComponent, {
        on: this.$listeners,
        attrs: this.$attrs,
        props: this.$props
      })
    }
  }
}
Copy the code

It is now a slightly fully usable higher-level component. Watch your words: a little, Nani? Can’t you change it like this? Of course, the higher-order components above do the following:

Pass through the properties of props that are not declared as props. 3

Don’t you think there’s something missing? As we mentioned earlier, the three important elements of a Vue component are props, events, and slots. The first two are all worked out, but slots is not. We modify the BaseComponent component to add a named slot and a default slot as follows:

base-component.vue

<template> <div> <span @click="handleClick">props: {{test}}</span> <slot name="slot1"/> <! -- Named slot --> <p>===========</p> <slot/> <! </div> </template> <script> export default {... } </script>Copy the code

Then we write the following test code:

<template>
  <div>
    <base-component>
      <h2 slot="slot1">BaseComponent slot</h2>
      <p>default slot</p>
    </base-component>
    <enhanced-com>
      <h2 slot="slot1">EnhancedComponent slot</h2>
      <p>default slot</p>
    </enhanced-com>
  </div>
</template>

<script>
  import BaseComponent from './base-component.vue'
  import hoc from './hoc.js'

  const EnhancedCom = hoc(BaseComponent)

  export default {
    components: {
      BaseComponent,
      EnhancedCom
    }
  }
</script>
Copy the code

Render the result as follows:

The blue box in the image above is the content rendered by the BaseComponent component, which is normal. The red box is the content rendered by the higher-order component. You can see that both the named slot and the default slot are missing. The reason is simply that we don’t pass the distributed slot content to the WrappedComponent in the higher-order component, so we try to modify the higher-order component:

hoc.js

function WithConsole (WrappedComponent) { return { mounted () { console.log('I have already mounted') }, props: Wrappedcomponent. props, render (h) {// Format this.$slots as an array, because the third argument to his a child node, Reduce ((arr, key) => arr. Concat (this.$slots[key]), []) return h(WrappedComponent, { on: this.$listeners, attrs: this.$attrs, props: This.$props}, slots) // Use slots as the third argument to function h}}}Copy the code

There you go. Refresh the page as follows:

What 😱? We noticed that the distribution was rendered, but not in the right order… The blue box is normal, and there is a dividing line between the named slot and the default slot (===========), while all slots in the red box are rendered below the dividing line (===========), so it looks like the named slot is also being treated as the default slot. What’s going on here?

To figure this out, we come back to the point I made at the beginning of this article, that you need to know something about how Vue works, otherwise there is no solution. Next, we will explain how to solve this problem from the principle of triggering. The root of the problem is that Vue takes scoping into account when dealing with named slots. It doesn’t matter if you don’t understand, let’s analyze it bit by bit.

First, a quick reminder: Vue compiles a template into a render function, such as the following:

<div>
  <h2 slot="slot1">BaseComponent slot</h2>
</div>
Copy the code

Will be compiled into the following render function:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c("div", [
    _c("h2", {
      attrs: { slot: "slot1" },
      slot: "slot1"
    }, [
      _vm._v("BaseComponent slot")
    ])
  ])
}
Copy the code

To view the compiled rendering function of a component’s template, simply visit this.$options.render. Looking at the rendering function above, we can see that the normal DOM is created using the _c function to create the corresponding VNode. Now we modify the template. In addition to the normal DOM, there are components in the template as follows:

<div>
  <base-component>
    <h2 slot="slot1">BaseComponent slot</h2>
    <p>default slot</p>
  </base-component>
</div>
Copy the code

The generated render function looks like this:

var render = function() {
  var _vm = this
  var _h = _vm.$createElement
  var _c = _vm._self._c || _h
  return _c(
    "div",
    [
      _c("base-component", [
        _c("h2", { attrs: { slot: "slot1" }, slot: "slot1" }, [
          _vm._v("BaseComponent slot")
        ]),
        _vm._v(" "),
        _c("p", [_vm._v("default slot")])
      ])
    ],
    1
  )
}
Copy the code

We find that both the normal DOM and the component are created using the _c function to create their corresponding VNodes. _c inside Vue is createElement. The createElement function automatically checks if the first parameter is a normal DOM tag. If it is not, createElement treats it as a component and creates an instance of the component. Note that the component instance is created at this point. But there is a problem with creating a component instance: the component needs to know whether and how many slots are passed in the parent template, whether they are passed named or unnamed, and so on. So how does a child component know this information? This is simple if the component template looks like this:

<div>
  <base-component>
    <h2 slot="slot1">BaseComponent slot</h2>
    <p>default slot</p>
  </base-component>
</div>
Copy the code

The parent component’s template will eventually generate the corresponding VNode of the parent component. Therefore, all the vNodes corresponding to the template are owned by the parent component. Can we obtain the contents of the slot by obtaining the parent component’s VNode when creating the child component instance? Get the VNode corresponding to the following template from the parent component:

<base-component>
  <h2 slot="slot1">BaseComponent slot</h2>
  <p>default slot</p>
</base-component>
Copy the code

This.$VNode is the parent component of the template, so the child component knows which slot to render. This.$VNode is the parent component of the template.

This.$vnode is not documented in Vue. The child component gets the slot that needs to be rendered and enters the crucial step, which is the cause of the high order component passing through the slot to the BaseComponent but not rendering correctly, as shown below:

This.$vnode is used in the context of the component in which the vnode was created. Since the vnode object in this. So the context in this.$vnode refers to the parent instance. The two contexts labeled in the theoretical figure above should be equal:

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // true
Copy the code

One important thing Vue does internally is that the above expression must be valid in order to handle the named slot correctly, otherwise even the named slot is not considered and is used as the default slot. This is why slot cannot be rendered correctly in higher-order components.

So why is the expression above not true in higher-order components? This.$vnode is not a vnode segment of the parent component, but a vnode segment of the higher-order component. This.$vnode.context refers to a higher-order component, but slot is passed through. The context of the vnode in slot refers to the original parent component instance, so the following expression is false:

console.log(this.$vnode.context === this.$vnode.componentOptions.children[0].context) // false
Copy the code

As a result, the named slot is used as the default slot, rendering incorrectly.

To resolve this problem, manually set the context value of the VNode in slot to a higher-order component instance.

hoc.js

function WithConsole (WrappedComponent) { return { mounted () { console.log('I have already mounted') }, props: WrappedComponent.props, render (h) { const slots = Object.keys(this.$slots) .reduce((arr, key) => arr.concat(this.$slots[key]), []) // Manually correct context.map (vnode => {vnode.context = this._self return vnode}) return h(WrappedComponent, {on: this.$listeners, props: this.$props, attrs: this.$attrs }, slots) } } }Copy the code

Now, all render normally, as shown below:

The key point here is that in addition to understanding how Vue handles slot, you also need to know that the instance itself is accessed via the current instance _self attribute, rather than using this directly, since this is a proxy object.

Now that seems to be all right, but there’s one thing we forgot about scopedSlots. ScopedSlots is not implemented the same way as slot. ScopedSlots is essentially a function that takes data as an argument and renders VNode. So there is no concept of context, so just pass through:

hoc.js

function WithConsole (WrappedComponent) { return { mounted () { console.log('I have already mounted') }, props: WrappedComponent.props, render (h) { const slots = Object.keys(this.$slots) .reduce((arr, key) => arr.concat(this.$slots[key]), []) .map(vnode => { vnode.context = this._self return vnode }) return h(WrappedComponent, { on: this.$listeners, props: $props, // scopedSlots scopedSlots: this.$props, attrs: this.$attrs}, slots)}}}Copy the code

By now, the basic functionality of a higher-level component has been achieved, but this is just the beginning. There are many things to consider before implementing a fully robust higher-level Vue component, such as:

Use the second argument of the Render function instead of this in functional components. Above we have only discussed Vue components in the form of pure objects, but functions can also exist in addition to pure objects. Many of the steps to create the Render function can be encapsulated. Handle more options for higher-order function components themselves (not just a simple lifecycle hook as in the above example)

I think I need to put up two reference links about higher-order components for reference exchange:

Discussion: Best Way to create a HOC github.com/jackmellis/…

Why is it difficult to implement higher-order components in Vue

Earlier, we looked at why implementing higher-order components is more complex in Vue and simpler in React. This is mainly due to their different design ideas and objectives. Writing components in React is just writing functions, and functions have all the functional components. Vue is more like a highly encapsulated function. At a higher level, Vue allows you to do things easily, but the opposite of a high level of encapsulation is the loss of flexibility. You need to follow certain rules to make the system work better.

There’s a good saying:

Can not difficult, difficult not

Complex or simple is a relative term. Finally, I hope you can play React and appreciate Vue. Here are two pictures that I agree with for your discussion: