11

VUE the LIT- based the LIT-HTML + @ VUE / Reactivity only 70 lines of code template engine implements give Vue the API Composition , to develop web component.

Overview

<my-component></my-component>

<script type="module">
  import {
    defineComponent,
    reactive,
    html,
    onMounted,
    onUpdated,
    onUnmounted
  } from 'https://unpkg.com/@vue/lit'

  defineComponent('my-component', () => {
    const state = reactive({
      text: 'hello',
      show: true
    })
    const toggle = () => {
      state.show = !state.show
    }
    const onInput = e => {
      state.text = e.target.value
    }

    return () => html`
      <button @click=${toggle}>toggle child</button>
      <p>
      ${state.text} <input value=${state.text} @input=${onInput}>
      </p>
      ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}
    `
  })

  defineComponent('my-child', ['msg'], (props) => {
    const state = reactive({ count: 0 })
    const increase = () => {
      state.count++
    }

    onMounted(() => {
      console.log('child mounted')
    })

    onUpdated(() => {
      console.log('child updated')
    })

    onUnmounted(() => {
      console.log('child unmounted')
    })

    return () => html`
      <p>${props.msg}</p>
      <p>${state.count}</p>
      <button @click=${increase}>increase</button>
    `
  })
</script>

The components my-component and my-child are defined above, with my-child as the default child element of my-component .

import {
  defineComponent,
  reactive,
  html, 
  onMounted,
  onUpdated,
  onUnmounted
} from 'https://unpkg.com/@vue/lit'

defineComponent defines custom element. The first parameter is the name of the custom element component, which must follow the native API customElements.define specification for component names. The component name must contain a dash.

reactive belongs to the responsive API provided by @vue/reactivity , which can create a responsive object, which will automatically collect dependencies when called in the rendering function, so that when the value is modified in the Mutable method, it can be captured and automatically trigger the corresponding component Re-render.

html is a template function provided by lit-html , through which the template can be described with the native syntax of Template strings , which is a lightweight template engine.

onMounted , onUpdated , onUnmounted are lifecycle functions created based on web component lifecycle , which can monitor the timing of component creation, update and destruction.

Next, look at the content of defineComponent :

defineComponent('my-component', () => {
  const state = reactive({
    text: 'hello',
    show: true
  })
  const toggle = () => {
    state.show = !state.show
  }
  const onInput = e => {
    state.text = e.target.value
  }

  return () => html`
    <button @click=${toggle}>toggle child</button>
    <p>
    ${state.text} <input value=${state.text} @input=${onInput}>
    </p>
    ${state.show ? html`<my-child msg=${state.text}></my-child>` : ``}
  `
})

With the ability of the template engine lit-html , variables and functions can be passed in the template at the same time, and then with the help of the @vue/reactivity ability, a new template can be generated when the variable changes, and the component DOM can be updated.

intensive reading

Reading the source code, you can find that vue-lit cleverly integrates three technical solutions, and they cooperate in the following ways:

  1. Use @vue/reactivity to create reactive variables.
  2. Use the template engine lit-html to create HTML instances that use these reactive variables.
  3. Use the web component render the HTML instance generated by the template engine, so that the created component has the ability to isolate.

Among them, the responsive capability and the template capability are provided by the two packages @vue/reactivity , lit-html , and we only need to find the remaining two functions from the source code: how to trigger the template refresh after modifying the value , and how the lifecycle functions are constructed.

First, let's see how to trigger a template refresh after the value is modified. Below I have extracted the code related to re-rendering:

import {
  effect
} from 'https://unpkg.com/@vue/reactivity/dist/reactivity.esm-browser.js'

customElements.define(
  name,
  class extends HTMLElement {
    constructor() {
      super()
      const template = factory.call(this, props)
      const root = this.attachShadow({ mode: 'closed' })
      effect(() => {
        render(template(), root)
      })
    }
  }
)

It can be clearly seen that first, customElements.define creates a native web component, and uses its API to create a closed node during initialization. This node is closed to external API calls, that is, a web component that is not subject to external interference is created.

Then call the html function in the effect callback function, that is, use the template function returned in the document. Since the variables used in this template function are defined by reactive , effect can accurately capture its changes and call effect again after the changes. The callback function implements the function of "re-rendering after value changes".

Then look at how the life cycle is implemented. Since the life cycle runs through the entire implementation process, it must be viewed in combination with the full source code. The full core code is posted below. The parts introduced above can be ignored and only look at the implementation of the life cycle:

let currentInstance

export function defineComponent(name, propDefs, factory) {
  if (typeof propDefs === 'function') {
    factory = propDefs
    propDefs = []
  }

  customElements.define(
    name,
    class extends HTMLElement {
      constructor() {
        super()
        const props = (this._props = shallowReactive({}))
        currentInstance = this
        const template = factory.call(this, props)
        currentInstance = null
        this._bm && this._bm.forEach((cb) => cb())
        const root = this.attachShadow({ mode: 'closed' })
        let isMounted = false
        effect(() => {
          if (isMounted) {
            this._bu && this._bu.forEach((cb) => cb())
          }
          render(template(), root)
          if (isMounted) {
            this._u && this._u.forEach((cb) => cb())
          } else {
            isMounted = true
          }
        })
      }
      connectedCallback() {
        this._m && this._m.forEach((cb) => cb())
      }
      disconnectedCallback() {
        this._um && this._um.forEach((cb) => cb())
      }
      attributeChangedCallback(name, oldValue, newValue) {
        this._props[name] = newValue
      }
    }
  )
}

function createLifecycleMethod(name) {
  return (cb) => {
    if (currentInstance) {
      ;(currentInstance[name] || (currentInstance[name] = [])).push(cb)
    }
  }
}

export const onBeforeMount = createLifecycleMethod('_bm')
export const onMounted = createLifecycleMethod('_m')
export const onBeforeUpdate = createLifecycleMethod('_bu')
export const onUpdated = createLifecycleMethod('_u')
export const onUnmounted = createLifecycleMethod('_um')

The life cycle implementation is in the form of this._bm && this._bm.forEach((cb) => cb()) . The reason for the cycle is that, for example, onMount(() => cb()) can be registered multiple times, so each life cycle may register multiple callback functions, so the traversal will execute them in sequence.

Another feature of the life cycle function is that it is not divided into component instances, so there must be a currentInstance mark which component instance the current callback function is registered in, and the synchronization process of this registration is during the execution of the defineComponent callback function factory . There will be the following code:

currentInstance = this
const template = factory.call(this, props)
currentInstance = null

In this way, we always point currentInstance to the currently executing component instance, and all life cycle functions are executed during this process. Therefore, when the life cycle callback function is currentInstance must point to the current component instance .

Next, for convenience, the createLifecycleMethod function is encapsulated, and some arrays such as _bm and _bu are mounted on the component instance. For example, _bm means beforeMount , and _bu means beforeUpdate .

The next step is to call the corresponding function in the corresponding position:

First execute _bm - onBeforeMount before attachShadow executes, as this process is indeed the last step in preparing the component mount.

Then two life cycles are called in effect , because effect will be executed every time it is rendered, so it is also specially stored whether the isMounted mark is initialized rendering:

effect(() => {
  if (isMounted) {
    this._bu && this._bu.forEach((cb) => cb())
  }
  render(template(), root)
  if (isMounted) {
    this._u && this._u.forEach((cb) => cb())
  } else {
    isMounted = true
  }
})

This is easy to understand, only after the initial rendering, from the second rendering, call _u - onUpdated before executing render (this function comes from the lit-html rendering template engine), and call _bu - onBeforeUpdate after executing the render function.

Since render(template(), root) according to lit-html syntax directly to template() returned HTML elements mounted to root node, and root is this the Component Web attachShadow generated shadow dom node, thus rendering is performed after the end of this sentence is complete, so onBeforeUpdate and onUpdated One after the other.

The last few lifecycle functions are implemented using the web component native API:

connectedCallback() {
  this._m && this._m.forEach((cb) => cb())
}
disconnectedCallback() {
  this._um && this._um.forEach((cb) => cb())
}

Implement mount and unmount respectively. This also shows the clarity of the browser API layering. It only provides callbacks for creation and destruction, and the update mechanism is completely implemented by business code, whether it is 06209b7afc818e or addEventListener of @vue/reactivity , or effect . Don't care, so if you do a complete framework on top of this, you need to implement onUpdate life cycle yourself.

In the end, it also uses the attributeChangedCallback life cycle to monitor the change of the html attribute of the custom component, and then directly maps it to the change to this._props[name] . Why is this?

attributeChangedCallback(name, oldValue, newValue) {
  this._props[name] = newValue
}

See the code snippet below to see why:

const props = (this._props = shallowReactive({}))
const template = factory.call(this, props)
effect(() => {
  render(template(), root)
})

As early as initialization, _props is created as a responsive variable, so as long as it is used as a parameter of the lit-html template expression (corresponding to factory.call(this, props) , and factory is the third parameter of defineComponent('my-child', ['msg'], (props) => { .. ), so, As long as this parameter changes, it will trigger the re-rendering of the child component, because this props has been processed by Reactive.

Summarize

The implementation of vue-lit is very clever. Learning his source code can understand several concepts at the same time:

  • reative。
  • web component。
  • string template。
  • A stripped-down implementation of the template engine.
  • life cycle.

And how to string them together to implement an elegant rendering engine in 70 lines of code.

Finally, the runtime lib introduced by web components created with this mode is only 6kb after gzip, but it can enjoy the responsive development experience of modern frameworks. If you think this runtime size is negligible, then this is a very ideal. Create libs that maintain web components.

The discussion address is: Intensive Reading "vue-lit Source Code" Issue #396 dt-fe/weekly

If you want to join the discussion, please click here , there are new topics every week, published on weekends or Mondays. Front-end intensive reading - help you filter reliable content.

Pay attention to front-end intensive reading WeChat public number

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

Copyright notice: Free to reprint - non-commercial - non-derivative - keep attribution ( Creative Commons 3.0 License )

黄子毅
7k 声望9.5k 粉丝