精讀《vue-lit 原始碼》

黃子毅發表於2022-02-14

vue-lit 基於 lit-html + @vue/reactivity 僅用 70 行程式碼就給模版引擎實現了 Vue Composition API,用來開發 web component。

概述

<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>

上面定義了 my-componentmy-child 元件,並將 my-child 作為 my-component 的預設子元素。

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

defineComponent 定義 custom element,第一個引數是自定義 element 元件名,必須遵循原生 API customElements.define 對元件名的規範,元件名必須包含中劃線。

reactive 屬於 @vue/reactivity 提供的響應式 API,可以建立一個響應式物件,在渲染函式中呼叫時會自動進行依賴收集,這樣在 Mutable 方式修改值時可以被捕獲,並自動觸發對應元件的重渲染。

htmllit-html 提供的模版函式,通過它可以用 Template strings 原生語法描述模版,是一個輕量模版引擎。

onMountedonUpdatedonUnmounted 是基於 web component lifecycle 建立的生命週期函式,可以監聽元件建立、更新與銷燬時機。

接下來看 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>` : ``}
  `
})

藉助模版引擎 lit-html 的能力,可以同時在模版中傳遞變數與函式,再借助 @vue/reactivity 能力,讓變數變化時生成新的模版,更新元件 dom。

精讀

閱讀原始碼可以發現,vue-lit 巧妙的融合了三種技術方案,它們配合方式是:

  1. 使用 @vue/reactivity 建立響應式變數。
  2. 利用模版引擎 lit-html 建立使用了這些響應式變數的 HTML 例項。
  3. 利用 web component 渲染模版引擎生成的 HTML 例項,這樣建立的元件具備隔離能力。

其中響應式能力與模版能力分別是 @vue/reactivitylit-html 這兩個包提供的,我們只需要從原始碼中尋找剩下的兩個功能:如何在修改值後觸發模版重新整理,以及如何構造生命週期函式的。

首先看如何在值修改後觸發模版重新整理。以下我把與重渲染相關程式碼摘出來了:

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)
      })
    }
  }
)

可以清晰的看到,首先 customElements.define 建立一個原生 web component,並利用其 API 在初始化時建立一個 closed 節點,該節點對外部 API 呼叫關閉,即建立的是一個不會受外部干擾的 web component。

然後在 effect 回撥函式內呼叫 html 函式,即在使用文件裡返回的模版函式,由於這個模版函式中使用的變數都採用 reactive 定義,所以 effect 可以精準捕獲到其變化,並在其變化後重新呼叫 effect 回撥函式,實現了 “值變化後重渲染” 的功能。

然後看生命週期是如何實現的,由於生命週期貫穿整個實現流程,因此必須結合全量原始碼看,下面貼出全量核心程式碼,上面介紹過的部分可以忽略不看,只看生命週期的實現:

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')

生命週期實現形如 this._bm && this._bm.forEach((cb) => cb()),之所以是迴圈,是因為比如 onMount(() => cb()) 可以註冊多次,因此每個生命週期都可能註冊多個回撥函式,因此遍歷將其依次執行。

而生命週期函式還有一個特點,即並不分元件例項,因此必須有一個 currentInstance 標記當前回撥函式是在哪個元件例項註冊的,而這個註冊的同步過程就在 defineComponent 回撥函式 factory 執行期間,因此才會有如下的程式碼:

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

這樣,我們就將 currentInstance 始終指向當前正在執行的元件例項,而所有生命週期函式都是在這個過程中執行的,因此當呼叫生命週期回撥函式時,currentInstance 變數必定指向當前所在的元件例項

接下來為了方便,封裝了 createLifecycleMethod 函式,在元件例項上掛載了一些形如 _bm_bu 的陣列,比如 _bm 表示 beforeMount_bu 表示 beforeUpdate

接下來就是在對應位置呼叫對應函式了:

首先在 attachShadow 執行之前執行 _bm - onBeforeMount,因為這個過程確實是準備元件掛載的最後一步。

然後在 effect 中呼叫了兩個生命週期,因為 effect 會在每次渲染時執行,所以還特意儲存了 isMounted 標記是否為初始化渲染:

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
  }
})

這樣就很容易看懂了,只有初始化渲染過後,從第二次渲染開始,在執行 render(該函式來自 lit-html 渲染模版引擎)之前呼叫 _bu - onBeforeUpdate,在執行了 render 函式後呼叫 _u - onUpdated

由於 render(template(), root) 根據 lit-html 的語法,會直接把 template() 返回的 HTML 元素掛載到 root 節點,而 root 就是這個 web component attachShadow 生成的 shadow dom 節點,因此這句話執行結束後渲染就完成了,所以 onBeforeUpdateonUpdated 一前一後。

最後幾個生命週期函式都是利用 web component 原生 API 實現的:

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

分別實現 mountunmount。這也說明了瀏覽器 API 分層的清晰之處,只提供建立和銷燬的回撥,而更新機制完全由業務程式碼實現,不管是 @vue/reactivityeffect 也好,還是 addEventListener 也好,都不關心,所以如果在這之上做完整的框架,需要自己根據實現 onUpdate 生命週期。

最後的最後,還利用 attributeChangedCallback 生命週期監聽自定義元件 html attribute 的變化,然後將其直接對映到對 this._props[name] 的變化,這是為什麼呢?

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

看下面的程式碼片段就知道原因了:

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

早在初始化時,就將 _props 建立為響應式變數,這樣只要將其作為 lit-html 模版表示式的引數(對應 factory.call(this, props) 這段,而 factory 就是 defineComponent('my-child', ['msg'], (props) => { .. 的第三個引數),這樣一來,只要這個引數變化了就會觸發子元件的重渲染,因為這個 props 已經經過 Reactive 處理了。

總結

vue-lit 實現非常巧妙,學習他的原始碼可以同時瞭解一下幾種概念:

  • reative。
  • web component。
  • string template。
  • 模版引擎的精簡實現。
  • 生命週期。

以及如何將它們串起來,利用 70 行程式碼實現一個優雅的渲染引擎。

最後,用這種模式建立的 web component 引入的 runtime lib 在 gzip 後只有 6kb,但卻能享受到現代化框架的響應式開發體驗,如果你覺得這個 runtime 大小可以忽略不計,那這就是一個非常理想的建立可維護 web component 的 lib。

討論地址是:精讀《vue-lit 原始碼》· Issue #396 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

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

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章