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-component
與 my-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 方式修改值時可以被捕獲,並自動觸發對應元件的重渲染。
html
是 lit-html 提供的模版函式,通過它可以用 Template strings 原生語法描述模版,是一個輕量模版引擎。
onMounted
、onUpdated
、onUnmounted
是基於 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 巧妙的融合了三種技術方案,它們配合方式是:
- 使用 @vue/reactivity 建立響應式變數。
- 利用模版引擎 lit-html 建立使用了這些響應式變數的 HTML 例項。
- 利用 web component 渲染模版引擎生成的 HTML 例項,這樣建立的元件具備隔離能力。
其中響應式能力與模版能力分別是 @vue/reactivity、lit-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 節點,因此這句話執行結束後渲染就完成了,所以 onBeforeUpdate
與 onUpdated
一前一後。
最後幾個生命週期函式都是利用 web component 原生 API 實現的:
connectedCallback() {
this._m && this._m.forEach((cb) => cb())
}
disconnectedCallback() {
this._um && this._um.forEach((cb) => cb())
}
分別實現 mount
、unmount
。這也說明了瀏覽器 API 分層的清晰之處,只提供建立和銷燬的回撥,而更新機制完全由業務程式碼實現,不管是 @vue/reactivity 的 effect
也好,還是 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 許可證)