詳解Vue中的自定義渲染器和異步渲染
自定義渲染器的原理
渲染器是圍繞 Virtual DOM 而存在的,在 Web 平臺下它能夠把 Virtual DOM 渲染為瀏覽器中的真實 DOM 對象,通過前面幾章的講解,相信你已經(jīng)能夠認識到渲染器的實現(xiàn)原理,為了能夠?qū)?Virtual DOM 渲染為真實 DOM,渲染器內(nèi)部需要調(diào)用瀏覽器提供的 DOM 編程接口,下面羅列了在出上一章中我們曾經(jīng)使用到的那些瀏覽器為我們提供的 DOM 編程接口:
- document.createElement / createElementNS:創(chuàng)建標簽元素。
- document.createTextNode:創(chuàng)建文本元素。
- el.nodeValue:修改文本元素的內(nèi)容。
- el.removeChild:移除 DOM 元素。
- el.insertBefore:插入 DOM 元素。
- el.appendChild:追加 DOM 元素。
- el.parentNode:獲取父元素。
- el.nextSibling:獲取下一個兄弟元素。
- document.querySelector:掛載 Portal 類型的 VNode 時,用它查找掛載點。
這些 DOM 編程接口完成了 Web 平臺(或者說瀏覽器)下對 DOM 的增加、刪除、查找的工作,它是 Web 平臺獨有的,所以如果渲染器自身強依賴于這些方法(函數(shù)),那么這個渲染器也只能夠運行在瀏覽器中,它不具備跨平臺的能力。換句話說,如果想要實現(xiàn)一個平臺無關(guān)的渲染器,那么渲染器自身必須不能強依賴于任何一個平臺下特有的接口,而是應該提供一個抽象層,將 “DOM” 的增加、刪除、查找等操作使用抽象接口實現(xiàn),具體到某個平臺下時,由開發(fā)者決定如何使用該平臺下的接口實現(xiàn)這個抽象層,這就是自定義渲染器的本質(zhì)。
tip
在下文中,我們將使用 “元素” 一詞指代所有平臺中的元素對象,例如在 Web 平臺下 “元素” 一詞指的就是 DOM 元素。
渲染器除了負責對元素的增加、刪除、查找之外,它還負責修改某個特定元素自身的屬性/特性,例如 Web 平臺中元素具有 id、href 等屬性/特性。在上一章中,我們使用 patchData 函數(shù)來完成元素自身屬性/特性的更新,如下代碼用于修改一個元素的類名列表(class):
// patchData.js case 'class': el.className = nextValue break
這段代碼同樣也只能運行在瀏覽器中,為了渲染器能夠跨平臺,那么修改一個元素自身的屬性/特性的工作也應該作為可自定義的一部分才行,因此,一個跨平臺的渲染器應該至少包含兩個可自定義的部分:可自定義元素的增加、刪除、查找等操作、可自定義元素自身屬性/特性的修改操作。這樣對于任何一個元素來說,它的增刪改查都已經(jīng)變成了可自定義的部分,我們只需要“告知”渲染器在對元素進行增刪改查時應該做哪些具體的操作即可。
接下來我們就著手將一個普通渲染器修改為擁有自定義能力的渲染器,在之前的講解中,我們將渲染器的代碼存放在了 render.js 文件中,如下是整個 render.js 文件的核心代碼:
export default function createRenderer(options) { function render(vnode, container) { /* ... */ } // ========== 掛載 ========== function mount(vnode, container, isSVG, refNode) { /* ... */ } function mountElement(vnode, container, isSVG, refNode) { /* ... */ } function mountText(vnode, container) { /* ... */ } function mountFragment(vnode, container, isSVG) { /* ... */ } function mountPortal(vnode, container) { /* ... */ } function mountComponent(vnode, container, isSVG) { /* ... */ } function mountStatefulComponent(vnode, container, isSVG) { /* ... */ } function mountFunctionalComponent(vnode, container, isSVG) { /* ... */ } // ========== patch ========== function patch(prevVNode, nextVNode, container) { /* ... */ } function replaceVNode(prevVNode, nextVNode, container) { /* ... */ } function patchElement(prevVNode, nextVNode, container) { /* ... */ } function patchChildren( prevChildFlags, nextChildFlags, prevChildren, nextChildren, container ) { /* ... */ } function patchText(prevVNode, nextVNode) { /* ... */ } function patchFragment(prevVNode, nextVNode, container) { /* ... */ } function patchPortal(prevVNode, nextVNode) { /* ... */ } function patchComponent(prevVNode, nextVNode, container) { /* ... */ } return { render } } // https://en.wikipedia.org/wiki/Longest_increasing_subsequence function lis(arr) { /* ... */ }
createRenderer 函數(shù)的返回值就是之前的 render 函數(shù),也就是說調(diào)用 createRenderer 函數(shù)可以創(chuàng)建一個渲染器。createRenderer 函數(shù)接收一個參數(shù) options,該參數(shù)的作用是為了允許外界有能力將操作元素的具體實現(xiàn)以選項的方式傳遞進來。
那么 options 參數(shù)中應該包含哪些選項呢?其實前面我們已經(jīng)分析過了,只要是需要自定義的部分就應該作為選項傳遞進來,所以參數(shù) options 中至少要包含兩部分:一部分是元素的增加、刪除、查找;另外一部分是元素的修改,即 patchData 函數(shù)。如下代碼所示:
const { render } = createRenderer({ // nodeOps 是一個對象,該對象包含了所有用于操作節(jié)點的方法 nodeOps: { createElement() { /* ... */ }, createText() { /* ... */ } // more... }, patchData })
基于此,在 createRenderer 函數(shù)內(nèi)部我們就可以通過解構(gòu)的方式從 options 參數(shù)中得到具體的方法:
export default function createRenderer(options) { // options.nodeOps 選項中包含了本章開頭羅列的所有操作 DOM 的方法 // options.patchData 選項就是 patchData 函數(shù) const { nodeOps: { createElement: platformCreateElement, createText: platformCreateText, setText: platformSetText, // 等價于 Web 平臺的 el.nodeValue appendChild: platformAppendChild, insertBefore: platformInsertBefore, removeChild: platformRemoveChild, parentNode: platformParentNode, nextSibling: platformNextSibling, querySelector: platformQuerySelector }, patchData: platformPatchData } = options function render(vnode, container) { /* ... */ } // ========== 掛載 ========== // 省略... // ========== patch ========== // 省略... return { render } }
如上代碼所示,options.nodeOps 選項是一個對象,它包含了所有用于對元素進行增、刪、查的操作,options.patchData 選項是一個函數(shù),用于處理某個特定元素上的屬性/特性,這些內(nèi)容都是在創(chuàng)建渲染器時由外界來決定的。
接下來我們要做的就是將渲染器中原本使用了 Web 平臺進行 DOM 操作的地方修改成使用通過解構(gòu)得到的函數(shù)進行替代,例如在創(chuàng)建 DOM 元素時,原來的實現(xiàn)如下:
function mountElement(vnode, container, isSVG, refNode) { isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG const el = isSVG ? document.createElementNS('http://www.w3.org/2000/svg', vnode.tag) : document.createElement(vnode.tag) // 省略... }
現(xiàn)在我們應該使用 platformCreateElement 函數(shù)替代 document.createElement(NS):
function mountElement(vnode, container, isSVG, refNode) { isSVG = isSVG || vnode.flags & VNodeFlags.ELEMENT_SVG const el = platformCreateElement(vnode.tag, isSVG) // 省略... }
類似的,其他所有涉及 DOM 操作的地方都應該使用這些通過解構(gòu)得到的抽象接口替代。當這部分工作完成之后,接下來要做的就是對這些用于操作節(jié)點的抽象方法進行實現(xiàn),如下代碼所示,我們實現(xiàn)了 Web 平臺下創(chuàng)建 DOM 節(jié)點的方法:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag) } } })
再舉一個例子,下面這條語句是我們之前實現(xiàn)的渲染器中用于移除舊 children 中節(jié)點的代碼:
container.removeChild(prevChildren.el)
現(xiàn)在我們將之替換為 platformRemoveChild 函數(shù):
platformRemoveChild(container, prevVNode.el)
為了讓這段代碼在 Web 平臺正常工作,我們需要在創(chuàng)建渲染器時實現(xiàn) nodeOps.removeChild 函數(shù):
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag) }, removeChild(parent, child) { parent.removeChild(child) } } })
也許你已經(jīng)想到了,當我們實現(xiàn)了所有 nodeOps 下的規(guī)定的抽象接口之后,實際上就完成了一個面向 Web 平臺的渲染器,如下代碼所示:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag) }, removeChild(parent, child) { parent.removeChild(child) }, createText(text) { return document.createTextNode(text) }, setText(node, text) { node.nodeValue = text }, appendChild(parent, child) { parent.appendChild(child) }, insertBefore(parent, child, ref) { parent.insertBefore(child, ref) }, parentNode(node) { return node.parentNode }, nextSibling(node) { return node.nextSibling }, querySelector(selector) { return document.querySelector(selector) } } })
當然了,如上代碼所創(chuàng)建的渲染器只能夠完成 Web 平臺中對 DOM 的增加、刪除和查找的功能,為了能夠修改 DOM 元素自身的屬性和特性,我們還需要在創(chuàng)建渲染器時將 patchData 函數(shù)作為選項傳遞過去,好在我們之前已經(jīng)封裝了 patchData 函數(shù),現(xiàn)在直接拿過來用即可:
import { patchData } from './patchData' const { render } = createRenderer({ nodeOps: { // 省略... }, patchData })
以上我們就完成了對渲染器的抽象,使它成為一個平臺無關(guān)的工具。并基于此實現(xiàn)了一個 Web 平臺的渲染器,專門用于瀏覽器環(huán)境。
自定義渲染器的應用
Vue3 提供了一個叫做 @vue/runtime-test 的包,其作用是方便開發(fā)者在無 DOM 環(huán)境時有能力對組件的渲染內(nèi)容進行測試,這實際上就是對自定義渲染器的應用。本節(jié)我們嘗試來實現(xiàn)與 @vue/runtime-test 具有相同功能的渲染器。
原理其實很簡單,如下代碼所示,這是用于 Web 平臺下創(chuàng)建真實 DOM 元素的代碼:
const { render } = createRenderer({ nodeOps: { createElement(tag, isSVG) { return isSVG ? document.createElementNS('http://www.w3.org/2000/svg', tag) : document.createElement(tag) } } })
其中 nodeOps.createElement 函數(shù)會返回一個真實的 DOM 對象,在其內(nèi)部調(diào)用的是瀏覽器為我們提供的 document.createElement/NS 函數(shù)。實際上 nodeOps.createElement 函數(shù)的真正意圖是:創(chuàng)建一個元素,然而并沒有規(guī)定這個元素應該由誰來創(chuàng)建,或這個元素應該具有什么樣的特征,這就是自定義的核心所在。因此,我們完全使 nodeOps.createElement 函數(shù)返回一個普通對象來代指一個元素,后續(xù)的所有操作都是基于我們所規(guī)定的元素而進行,如下代碼所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag } return customElement } } })
在這段代碼中,我們自行規(guī)定了 nodeOps.createElement 函數(shù)所返回的元素的格式,即 customElement 對象,它包含兩個屬性,分別是 用來代表元素類型的 type 屬性以及用來代表元素名稱的 tag 屬性。雖然看上去很奇怪,但這確實是一個完全符合要求的實現(xiàn)。這么做的結(jié)果就是:nodeOps.createElement 函數(shù)所創(chuàng)建的元素不來自于瀏覽器的 DOM 編程接口,更不來自于任何其他平臺的 API,因此,如上代碼所創(chuàng)建的渲染器也將是一個平臺無關(guān)的渲染器。這就是為什么 @vue/runtime-test 可以運行在 NodeJs 中的原因。
當然了,如上代碼中 customElement 只有兩個屬性,實際上這并不能滿足需求,即使元素的格式由我們自行定義,但還是要有一定的限制,例如元素會有子節(jié)點,子節(jié)點也需要保存對父節(jié)點的引用,元素自身也會有屬性/特性等等。一個最小且完整的元素定義應該包含以下屬性:
const customElement = { type, // 元素的類型:ELEMENT ---> 標簽元素;TEXT ---> 文本 tag, // 當 type === 'ELEMENT' 時,tag 屬性為標簽名字 parentNode, // 對父節(jié)點的引用 children, // 子節(jié)點 props, // 當 type === 'ELEMENT' 時,props 中存儲著元素的屬性/特性 eventListeners, // 當 type === 'ELEMENT' 時,eventListeners 中存儲著元素的事件信息 text // 當 type === 'TEXT' 時,text 存儲著文本內(nèi)容 }
現(xiàn)在 customElement 就是一個能完全代替真實 DOM 對象的模擬實現(xiàn)了,我們用它修改之前的代碼:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag, parentNode: null, children: [], props: {}, eventListeners: {}, text: null } return customElement } } })
如上代碼所示,由于 nodeOps.createElement 函數(shù)用于創(chuàng)建元素節(jié)點,因此 type 屬性的值為 'ELEMENT';剛剛創(chuàng)建的元素還不能確定其父節(jié)點,因此 parentNode 為 null;用于存儲子節(jié)點的 children 屬性被初始化為一個數(shù)組,props 屬性和 eventListeners 被初始化為空對象;最后的 text 為 null,因為它不是一個文本節(jié)點。
現(xiàn)在創(chuàng)建元素節(jié)點的功能已經(jīng)實現(xiàn),那么創(chuàng)建文本節(jié)點呢?如下:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = {/* 省略... */} return customElement }, createText(text) { const customElement = { type: 'TEXT', parentNode: null, text: text } return customElement } } })
文本元素的 type 類型值為 'TEXT',parentNode 同樣被初始化為 null,text 屬性存儲著文本節(jié)點的內(nèi)容。由于文本元素沒有子節(jié)點、屬性/特性、事件等信息,因此不需要其他描述信息。
文本節(jié)點與元素節(jié)點的創(chuàng)建都已經(jīng)實現(xiàn),接下來我們看看當元素被追加時應該如何處理,即 nodeOps.appendChild 函數(shù)的實現(xiàn):
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = {/* 省略... */} return customElement }, createText(text) { const customElement = {/* 省略... */} return customElement }, appendChild(parent, child) { // 簡歷父子關(guān)系 child.parentNode = parent parent.children.push(child) } } })
如上高亮代碼所示,追加節(jié)點時我們要做的就是建立節(jié)點間正確的父子關(guān)系,在 Web 平臺下,當我們調(diào)用 el.appendChild 函數(shù)時,父子關(guān)系是由瀏覽器負責建立的,但在模擬實現(xiàn)中,這個關(guān)系需要我們自己來維護。不過好在這很簡單,讓子元素的 parentNode 指向父元素,同時將子元素添加到父元素的 children 數(shù)組中即可。
類似的,如下是 nodeOps.removeChild 函數(shù)的實現(xiàn):
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, createText(text) {/* 省略... */}, appendChild(parent, child) { // 簡歷父子關(guān)系 child.parentNode = parent parent.children.push(child) }, removeChild(parent, child) { // 找到將要移除的元素 child 在父元素的 children 中的位置 const i = parent.children.indexOf(child) if (i > -1) { // 如果找到了,則將其刪除 parent.children.splice(i, 1) } else { // 沒找到,說明渲染器出了問題,例如沒有在 nodeOps.appendChild 函數(shù)中維護正確的父子關(guān)系等 // 這時需要打印錯誤信息,以提示開發(fā)者 console.error('target: ', child) console.error('parent: ', parent) throw Error('target 不是 parent 的子節(jié)點') } // 清空父子鏈 child.parentNode = null } } })
如上高亮代碼所示,在移除節(jié)點時,思路也很簡單,首先需要在父節(jié)點的 children 屬性中查找即將要被移除的節(jié)點的位置索引,如果找到了,那么就直接將其從父節(jié)點的 children 數(shù)組中移除即可。如果沒有找到則說明渲染器出問題了,例如在你實現(xiàn)自定義渲染器時沒有在 nodeOps.appendChild 函數(shù)或 nodeOps.insertBefore 函數(shù)中維護正確的父子關(guān)系,這時我們需要打印錯誤信息以提示開發(fā)者。最后不要忘記清空父子鏈。
通過如上的講解,你可能已經(jīng)領(lǐng)會到了,我們所做的其實就是在模擬 Web 平臺在操作元素時的行為,并且這個模擬的思路也及其簡單。實際上,當我們實現(xiàn)了所有 nodeOps 下的抽象函數(shù)之后,那么這個類似于 @vue/runtime-test 的自定義渲染器就基本完成了。當然,不要忘記的是我們還需要實現(xiàn) patchData 函數(shù),這可能比你想象的要簡單的多,如下高亮代碼所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, createText(text) {/* 省略... */}, appendChild(parent, child) {/* 省略... */}, removeChild(parent, child) {/* 省略... */} // 其他 nodeOps 函數(shù)的實現(xiàn) }, patchData( el, key, prevValue, nextValue ) { // 將屬性添加到元素的 props 對象下 el.props[key] = nextValue // 我們將屬性名字中前兩個字符是 'o' 和 'n' 的屬性認為是事件綁定 if (key[0] === 'o' && key[1] === 'n') { // 如果是事件,則將事件添加到元素的 eventListeners 對象下 const event = key.slice(2).toLowerCase() ;(el.eventListeners || (el.eventListeners = {}))[event] = nextValue } } })
在創(chuàng)建渲染器時我們需要實現(xiàn) patchData 函數(shù)的功能,它的功能是用來更新元素自身的屬性/特性的,在之前的講解中我們實現(xiàn)了 Web 平臺中 patchData 函數(shù),然而在這個模擬實現(xiàn)中,我們要做的事情就少了很多。只需要把元素的屬性添加到元素的 props 對象中即可,同時如果是事件的話,我們也只需要將其添加到元素的 eventListeners 對象中就可以了。
實際上,本節(jié)我們所實現(xiàn)的自定義渲染器,就能夠滿足我們對組件測試的需求,我們可以利用它來測試組件所渲染內(nèi)容的正確性。如果你想要進一步提升該自定義渲染器的能力,例如希望該渲染器有能力在控制臺中打印出操作元素的信息,也很簡單,我們以創(chuàng)建元素為例,如下代碼所示:
const { render } = createRenderer({ nodeOps: { createElement(tag) { const customElement = { type: 'ELEMENT', tag, parentNode: null, children: [], props: {}, eventListeners: {}, text: null } console.table({ type: 'CREATE ELEMENT', targetNode: customElement }) ??????? return customElement } } })
只需要在 nodeOps.createElement 函數(shù)中調(diào)用 console.table 進行打印你想要的信息即可,例如我們打印了一個對象,該對象包含 type 屬性用于指示當前操作元素的類型,所以對于創(chuàng)建元素來說,我們?yōu)?type 屬性賦值了字符串 'CREATE ELEMENT',同時將目標節(jié)點也打印了出來(即 targetNode)。類似的,追加節(jié)點可以打印如下信息:
const { render } = createRenderer({ nodeOps: { createElement(tag) {/* 省略... */}, appendChild(parent, child) { // 建立父子關(guān)系 child.parentNode = parent parent.children.push(child) ??????? console.table({ type: 'APPEND', targetNode: child, parentNode: parent }) } } })
怎么樣,是不是很簡單。當然了這只是自定義渲染器的應用之一,對于自定義渲染器來說,它可發(fā)揮的空間還是非常大的,舉幾個例子:
- 渲染到 PDF,我們可以實現(xiàn)一個自定義渲染器如 vue-pdf-renderer,它能夠?qū)?Vue 組件渲染為 PDF 文件。
- 渲染到文件系統(tǒng),我們可以實現(xiàn)一個 vue-file-renderer,它可以根據(jù) VNode 的結(jié)構(gòu)在本地渲染與該結(jié)構(gòu)相同的文件目錄。
- canvas 渲染器,我們可以實現(xiàn)一個 vue-canvas-renderer,它可以從渲染器的層面渲染 canvas,而非組件層面。
這僅僅是簡單的列了幾個小想法,實際上由于自定義渲染器本身就是平臺無關(guān)的,很多事情需要看特定平臺的能力,渲染器為你提供的就是在組件層面的抽象能力以及虛擬 DOM 的更新算法,剩下的就靠社區(qū)的想象力和實現(xiàn)能力了。
以上就是詳解Vue中的自定義渲染器和異步渲染的詳細內(nèi)容,更多關(guān)于Vue自定義渲染器和異步渲染的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
element表格el-table實現(xiàn)虛擬滾動解決卡頓問題
當頁面數(shù)據(jù)過多,前端渲染大量的DOM時,會造成頁面卡死問題,本文主要介紹了element表格el-table實現(xiàn)虛擬滾動解決卡頓問題,具有一定的參考價值,感興趣的可以了解一下2023-10-10