動手寫一個簡易的 Virtual DOM,加強(qiáng)閱讀源碼的能力
你可能聽說過Virtual DOM(以及Shadow DOM)。甚至可能使用過它(JSX基本上是VDOM的語法糖)。如果你想了解更多,那么就看看今天這篇文章。
什么是虛擬DOM?
DOM操作很貴。做一次時,差異可能看起來很小(分配一個屬性給一個對象之間大約0.4毫秒的差異),但它會隨著時間的推移而增加。
- // 將屬性賦值給對象1000次
- let obj = {};
- console.time("obj");
- for (let i = 0; i < 1000; i++) {
- obj[i] = i;
- }
- console.timeEnd("obj");
- // 操縱dom 1000次
- console.time("dom");
- for (let i = 0; i < 1000; i++) {
- document.querySelector(".some-element").innerHTML += i;
- }
- console.timeEnd("dom");
當(dāng)我運(yùn)行上面的代碼片段時,我發(fā)現(xiàn)第一個循環(huán)花費(fèi)了約3ms,而第二個循環(huán)花費(fèi)了約41ms。
我們舉一個更真實(shí)的例子。
- function generateList(list) {
- let ul = document.createElement('ul');
- document.getElementByClassName('.fruits').appendChild(ul);
- list.forEach(function (item) {
- let li = document.createElement('li');
- ul.appendChild(li);
- li.innerHTML += item;
- });
- return ul;
- }
- document.querySelector("ul.some-selector").innerHTML = generateList(["Banana", "Apple", "Orange"])
到目前為止,一切都好?,F(xiàn)在,如果數(shù)組改變,我們需要重新渲染,我們這樣做:
- document.querySelector("ul.some-selector").innerHTML = generateList(["Banana", "Apple", "Mango"])
看看出了什么問題?
即使只需要改變一個元素,我們也會改變整個元素,因?yàn)槲覀兒軕小?/p>
這就是為什么創(chuàng)建了虛擬DOM的原因。那什么是虛擬 Dom?
Virtual DOM是DOM作為對象的表示。假設(shè)我們有下面的 HTML:
- <div class="contents">
- <p>Text here</p>
- <p>Some other <b>Bold</b> content</p>
- </div>
它可以寫作以下VDOM對象:
- let vdom = {
- tag: "div",
- props: { class: 'contents' },
- children: [
- {
- tag: "p",
- children: "Text here"
- },
- {
- tag: "p",
- children: ["Some other ", { tag: "b", children: "Bold" }, " content"]
- }
- ]
- }
請注意,實(shí)際開發(fā)中可能存在更多屬性,這是一個簡化的版本。
VDOM是一個對象,帶有:
- 一個名為tag(有時也稱為type)的屬性,它表示標(biāo)簽的名稱
- 一個名為props的屬性,包含所有 props
- 如果內(nèi)容只是文本,則為字符串
- 如果內(nèi)容包含元素,則vdom數(shù)組
我們這樣使用 VDOM:
- 我們改變了vdom而不是dom
- 函數(shù)檢查DOM和VDOM之間的所有差異,只更改變化的部分
- 改變VDOM被標(biāo)記為最新的改變,這樣我們下次比較VDOM時就可以節(jié)省更多的時間。
有什么好處?
知道了什么是 VDOM,我們來改進(jìn)一下前面的 generateList函數(shù)。
- function generateList(list) {
- // VDOM 生成過程,待下補(bǔ)上
- }
- patch(oldUL, generateList(["Banana", "Apple", "Orange"]));
不要介意patch函數(shù),它的作用是就將更改的部分附加到DOM中。以后再改變DOM時:
- patch(oldUL, generateList(["Banana", "Apple", "Mango"]));
patch函數(shù)發(fā)現(xiàn)只有第三個li發(fā)生了變化,,而不是所有三個元素都發(fā)生了變化,所以只會操作第三個 li 元素。
構(gòu)建 VDOM!
我們需要做4件事:
- 創(chuàng)建一個虛擬節(jié)點(diǎn)(vnode)
- 掛載 VDOM
- 卸載 VDOM
- Patch (比較兩個vnode,找出差異,然后掛載)
創(chuàng)建 vnode
- function createVNode(tag, props = {}, children = []) {
- return { tag, props, children}
- }
在Vue(和許多其他地方)中,此函數(shù)稱為h,hyperscript 的縮寫。
掛載 VDOM
通過掛載,將vnode附加到任何容器,如#app或任何其他應(yīng)該掛載它的地方。
這個函數(shù)將遞歸遍歷所有節(jié)點(diǎn)的子節(jié)點(diǎn),并將它們掛載到各自的容器中。
注意,下面的所有代碼都放在掛載函數(shù)中。
- function mount(vnode, container) { ... }
創(chuàng)建DOM元素
- const element = (vnode.element = document.createElement(vnode.tag))
你可能會想這個vnode.element是什么。它只是一個內(nèi)部設(shè)置的屬性,我們可以根據(jù)它知道哪個元素是vnode的父元素。
從props 對象設(shè)置所有屬性。我們可以對它們進(jìn)行循環(huán)
- Object.entries(vnode.props || {}).forEach([key, value] => {
- element.setAttribute(key, value)
- })
掛載子元素,有兩種情況需要處理:
- children 只是文本
- children 是 vnode 數(shù)組
- if (typeof vnode.children === 'string') {
- element.textContent = vnode.children
- } else {
- vnode.children.forEach(child => {
- mount(child, element) // 遞歸掛載子節(jié)點(diǎn)
- })
- }
最后,我們必須將內(nèi)容添加到DOM中:
- container.appendChild(element)
最終的結(jié)果:
- function mount(vnode, container) {
- const element = (vnode.element = document.createElement(vnode.tag))
- Object.entries(vnode.props || {}).forEach([key, value] => {
- element.setAttribute(key, value)
- })
- if (typeof vnode.children === 'string') {
- element.textContent = vnode.children
- } else {
- vnode.children.forEach(child => {
- mount(child, element) // Recursively mount the children
- })
- }
- container.appendChild(element)
- }
卸載 vnode
卸載就像從DOM中刪除一個元素一樣簡單:
- function unmount(vnode) {
- vnode.element.parentNode.removeChild(vnode.element)
- }
patch vnode.
這是我們必須編寫的(相對而言)最復(fù)雜的函數(shù)。要做的事情就是找出兩個vnode之間的區(qū)別,只對更改部分進(jìn)行 patch。
- function patch(VNode1, VNode2) {
- // 指定父級元素
- const element = (VNode2.element = VNode1.element);
- // 現(xiàn)在我們要檢查兩個vnode之間的區(qū)別
- // 如果節(jié)點(diǎn)具有不同的標(biāo)記,則說明整個內(nèi)容已經(jīng)更改。
- if (VNode1.tag !== VNode2.tag) {
- // 只需卸載舊節(jié)點(diǎn)并掛載新節(jié)點(diǎn)
- mount(VNode2, element.parentNode)
- unmount(Vnode1)
- } else {
- // 節(jié)點(diǎn)具有相同的標(biāo)簽
- // 所以我們要檢查兩個部分
- // - Props
- // - Children
- // 這里不打算檢查 Props,因?yàn)樗鼤黾哟a的復(fù)雜性,我們先來看怎么檢查 Children 就行啦
- // 檢查 Children
- // 如果新節(jié)點(diǎn)的 children 是字符串
- if (typeof VNode2.children == "string") {
- // 如果兩個孩子完全不同
- if (VNode2.children !== VNode1.children) {
- element.textContent = VNode2.children;
- }
- } else {
- // 如果新節(jié)點(diǎn)的 children 是一個數(shù)組
- // - children 的長度是一樣的
- // - 舊節(jié)點(diǎn)比新節(jié)點(diǎn)有更多的子節(jié)點(diǎn)
- // - 新節(jié)點(diǎn)比舊節(jié)點(diǎn)有更多的子節(jié)點(diǎn)
- // 檢查長度
- const children1 = VNode1.children;
- const children2 = VNode2.children;
- const commonLen = Math.min(children1.length, children2.length)
- // 遞歸地調(diào)用所有公共子節(jié)點(diǎn)的patch
- for (let i = 0; i < commonLen; i++) {
- patch(children1[i], children2[i])
- }
- // 如果新節(jié)點(diǎn)的children 比舊節(jié)點(diǎn)的少
- if (children1.length > children2.length) {
- children1.slice(children2.length).forEach(child => {
- unmount(child)
- })
- }
- // 如果新節(jié)點(diǎn)的children 比舊節(jié)點(diǎn)的多
- if (children2.length > children1.length) {
- children2.slice(children1.length).forEach(child => {
- mount(child, element)
- })
- }
- }
- }
- }
這是vdom實(shí)現(xiàn)的一個基本版本,方便我們快速掌握這個概念。當(dāng)然還有一些事情要做,包括檢查 props 和一些性能方面的改進(jìn)。
現(xiàn)在讓我們渲染一個vdom!
回到generateList例子。對于我們的vdom實(shí)現(xiàn),我們可以這樣做
- function generateList(list) {
- let children = list.map(child => createVNode("li", null, child));
- return createVNode("ul", { class: 'fruits-ul' }, children)
- }
- mount(generateList(["apple", "banana", "orange"]), document.querySelector("#app")/* any selector */)
線上示例:https://codepen.io/SiddharthShyniben/pen/MWpQrwM
~完,我是小智,SPA 走一波,下期見!
作者:Siddharth
譯者:前端小智 來源:dev原文:https://dev.to/siddharthshyniben/what-is-the-virtual-dom-let-s-build-it-5070