Kedua-dua React dan Vue mempunyai DOM maya, jadi bagaimana kita harus memahami dan menguasai intipati DOM maya? Artikel berikut akan memberi anda pemahaman yang mendalam tentang DOM maya. Saya harap ia akan membantu anda!
Bagaimana untuk memahami dan menguasai intipati DOM maya? Saya mengesyorkan semua orang mempelajari projek Snabbdom.
Snabbdom ialah perpustakaan pelaksanaan DOM maya Sebab mengapa ia disyorkan ialah kod itu agak kecil dan kod terasnya hanya beberapa ratus baris sebab kedua ialah Vue menggunakan idea ini projek untuk melaksanakan DOM maya; sebab ketiga ialah idea Reka bentuk/pelaksanaan dan pengembangan ini patut dirujuk.
snabb /snab/, bahasa Sweden, bermaksud cepat.
Laraskan kepada posisi duduk yang selesa, ceria dan mari mulakan~ Untuk mempelajari DOM maya, kita mesti terlebih dahulu mengetahui pengetahuan asas DOM dan titik kesakitan mengendalikan DOM secara langsung dengan JS.
DOM (Document Object Model) ialah model objek dokumen yang menggunakan struktur pepohon objek untuk mewakili dokumen HTML/XML , hujung setiap cabang pokok ialah nod, dan setiap nod mengandungi objek. Kaedah API DOM membolehkan anda memanipulasi pokok ini dengan cara tertentu Dengan kaedah ini, anda boleh menukar struktur, gaya atau kandungan dokumen.
Semua nod dalam pepohon DOM adalah pertama Node
dan Node
ialah kelas asas. Element
, Text
dan Comment
semuanya mewarisi daripadanya.
Dalam erti kata lain, Element
, Text
dan Comment
ialah tiga jenis istimewa Node
, masing-masing dipanggil ELEMENT_NODE
, TEXT_NODE
dan COMMENT_NODE
, yang mewakili nod elemen (tag HTML), nod teks dan nod ulasan. Antaranya, Element
juga mempunyai subkategori HTMLElement
, jadi apakah perbezaan antara HTMLElement
dan Element
? HTMLElement
mewakili elemen dalam HTML, seperti: <span>
, <img>
, dsb., dan beberapa elemen bukan standard HTML, seperti <svg>
. Anda boleh menggunakan kaedah berikut untuk menentukan sama ada elemen ini HTMLElement
:
document.getElementById('myIMG') instanceof HTMLElement;
Mencipta DOM adalah mahal untuk penyemak imbas. Mari kita ambil contoh klasik. Kita boleh mencipta elemen p mudah melalui document.createElement('p')
dan mencetak semua atribut:
Anda boleh melihat bahawa terdapat banyak atribut yang dicetak . , masalah prestasi akan berlaku apabila kerap mengemas kini pepohon DOM yang kompleks. DOM maya menggunakan objek JS asli untuk menerangkan nod DOM, jadi mencipta objek JS jauh lebih murah daripada mencipta objek DOM.
VNode ialah struktur objek yang menerangkan DOM maya dalam Snabbdom Kandungannya adalah seperti berikut:
type Key = string | number | symbol; interface VNode { // CSS 选择器,比如:'p#container'。 sel: string | undefined; // 通过 modules 操作 CSS classes、attributes 等。 data: VNodeData | undefined; // 虚拟子节点数组,数组元素也可以是 string。 children: Array<VNode | string> | undefined; // 指向创建的真实 DOM 对象。 elm: Node | undefined; /** * text 属性有两种情况: * 1. 没有设置 sel 选择器,说明这个节点本身是一个文本节点。 * 2. 设置了 sel,说明这个节点的内容是一个文本节点。 */ text: string | undefined; // 用于给已存在的 DOM 提供标识,在同级元素之间必须唯一,有效避免不必要地重建操作。 key: Key | undefined; } // vnode.data 上的一些设置,class 或者生命周期函数钩子等等。 interface VNodeData { props?: Props; attrs?: Attrs; class?: Classes; style?: VNodeStyle; dataset?: Dataset; on?: On; attachData?: AttachData; hook?: Hooks; key?: Key; ns?: string; // for SVGs fn?: () => VNode; // for thunks args?: any[]; // for thunks is?: string; // for custom elements v1 [key: string]: any; // for any other 3rd party module }
Sebagai contoh, tentukan objek vnode seperti ini :
const vnode = h( 'p#container', { class: { active: true } }, [ h('span', { style: { fontWeight: 'bold' } }, 'This is bold'), ' and this is just normal text' ]);
Kami mencipta objek vnode melalui fungsi h(sel, b, c)
. h()
Pelaksanaan kod terutamanya menentukan sama ada parameter b dan c wujud, dan memprosesnya menjadi data dan kanak-kanak akhirnya akan dalam bentuk tatasusunan. Akhir sekali, fungsi vnode()
mengembalikan format jenis VNode
yang ditakrifkan di atas.
Mari kita mulakan dengan contoh rajah mudah proses berjalan dan mempunyai konsep proses umum:
Pemprosesan perbezaan ialah proses yang digunakan untuk mengira perbezaan antara nod baharu dan lama.
Mari kita lihat contoh kod operasi Snabbdom:
import { init, classModule, propsModule, styleModule, eventListenersModule, h, } from 'snabbdom'; const patch = init([ // 通过传入模块初始化 patch 函数 classModule, // 开启 classes 功能 propsModule, // 支持传入 props styleModule, // 支持内联样式同时支持动画 eventListenersModule, // 添加事件监听 ]); // <p id="container"></p> const container = document.getElementById('container'); const vnode = h( 'p#container.two.classes', { on: { click: someFn } }, [ h('span', { style: { fontWeight: 'bold' } }, 'This is bold'), ' and this is just normal text', h('a', { props: { href: '/foo' } }, "I'll take you places!"), ] ); // 传入一个空的元素节点。 patch(container, vnode); const newVnode = h( 'p#container.two.classes', { on: { click: anotherEventHandler } }, [ h( 'span', { style: { fontWeight: 'normal', fontStyle: 'italic' } }, 'This is now italic type' ), ' and this is still just normal text', h('a', { props: { href: ''/bar' } }, "I'll take you places!"), ] ); // 再次调用 patch(),将旧节点更新为新节点。 patch(vnode, newVnode);
Seperti yang dapat dilihat daripada gambar rajah proses dan kod sampel, proses operasi Snabbdom diterangkan seperti berikut:
Panggilan pertama init()
untuk pemulaan, anda perlu mengkonfigurasi modul yang perlu anda gunakan. Contohnya, modul classModule
digunakan untuk mengkonfigurasi atribut kelas elemen dalam bentuk objek; modul eventListenersModule
digunakan untuk mengkonfigurasi pendengar acara, dsb. init()
akan mengembalikan fungsi patch()
selepas dipanggil.
Buat objek vnod yang dimulakan melalui fungsi h()
, panggil fungsi patch()
untuk mengemas kininya dan akhirnya buat objek DOM sebenar melalui createElm()
.
Apabila ia perlu dikemas kini, cipta objek vnod baharu, panggil fungsi patch()
untuk mengemas kini dan lengkapkan kemas kini pembezaan nod ini dan nod anaknya melalui patchVnode()
dan updateChildren()
.
Snabbdom 是通过模块这种设计来扩展相关属性的更新而不是全部写到核心代码中。那这是如何设计与实现的?接下来就先来康康这个设计的核心内容,Hooks——生命周期函数。
Snabbdom 提供了一系列丰富的生命周期函数也就是钩子函数,这些生命周期函数适用在模块中或者可以直接定义在 vnode 上。比如我们可以在 vnode 上这样定义钩子的执行:
h('p.row', { key: 'myRow', hook: { insert: (vnode) => { console.log(vnode.elm.offsetHeight); }, }, });
全部的生命周期函数声明如下:
名称 | 触发节点 | 回调参数 |
---|---|---|
pre |
patch 开始执行 | none |
init |
vnode 被添加 | vnode |
create |
一个基于 vnode 的 DOM 元素被创建 | emptyVnode, vnode |
insert |
元素被插入到 DOM | vnode |
prepatch |
元素即将 patch | oldVnode, vnode |
update |
元素已更新 | oldVnode, vnode |
postpatch |
元素已被 patch | oldVnode, vnode |
destroy |
元素被直接或间接得移除 | vnode |
remove |
元素已从 DOM 中移除 | vnode, removeCallback |
post |
已完成 patch 过程 | none |
其中适用于模块的是:pre
, create
,update
, destroy
, remove
, post
。适用于 vnode 声明的是:init
, create
, insert
, prepatch
, update
,postpatch
, destroy
, remove
。
我们来康康是如何实现的,比如我们以 classModule
模块为例,康康它的声明:
import { VNode, VNodeData } from "../vnode"; import { Module } from "./module"; export type Classes = Record<string, boolean>; function updateClass(oldVnode: VNode, vnode: VNode): void { // 这里是更新 class 属性的细节,先不管。 // ... } export const classModule: Module = { create: updateClass, update: updateClass };
可以看到最后导出的模块定义是一个对象,对象的 key 就是钩子函数的名称,模块对象 Module
的定义如下:
import { PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook, } from "../hooks"; export type Module = Partial<{ pre: PreHook; create: CreateHook; update: UpdateHook; destroy: DestroyHook; remove: RemoveHook; post: PostHook; }>;
TS 中 Partial
表示对象中每个 key 的属性都是可以为空的,也就是说模块定义中你关心哪个钩子,就定义哪个钩子就好了。钩子的定义有了,在流程中是怎么执行的呢?接着我们来看 init()
函数:
// 模块中可能定义的钩子有哪些。 const hooks: Array<keyof Module> = [ "create", "update", "remove", "destroy", "pre", "post", ]; export function init( modules: Array<Partial<Module>>, domApi?: DOMAPI, options?: Options ) { // 模块中定义的钩子函数最后会存在这里。 const cbs: ModuleHooks = { create: [], update: [], remove: [], destroy: [], pre: [], post: [], }; // ... // 遍历模块中定义的钩子,并存起来。 for (const hook of hooks) { for (const module of modules) { const currentHook = module[hook]; if (currentHook !== undefined) { (cbs[hook] as any[]).push(currentHook); } } } // ... }
可以看到 init()
在执行时先遍历各个模块,然后把钩子函数存到了 cbs
这个对象中。执行的时候可以康康 patch()
函数里面:
export function init( modules: Array<Partial<Module>>, domApi?: DOMAPI, options?: Options ) { // ... return function patch( oldVnode: VNode | Element | DocumentFragment, vnode: VNode ): VNode { // ... // patch 开始了,执行 pre 钩子。 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); // ... } }
这里以 pre
这个钩子举例,pre
钩子的执行时机是在 patch 开始执行时。可以看到 patch()
函数在执行的开始处去循环调用了 cbs
中存储的 pre
相关钩子。其他生命周期函数的调用也跟这个类似,大家可以在源码中其他地方看到对应生命周期函数调用的地方。
这里的设计思路是观察者模式。Snabbdom 把非核心功能分布在模块中来实现,结合生命周期的定义,模块可以定义它自己感兴趣的钩子,然后 init()
执行时处理成 cbs
对象就是注册这些钩子;当执行时间到来时,调用这些钩子来通知模块处理。这样就把核心代码和模块代码分离了出来,从这里我们可以看出观察者模式是一种代码解耦的常用模式。
接下来我们来康康核心函数 patch()
,这个函数是在 init()
调用后返回的,作用是执行 VNode 的挂载和更新,签名如下:
function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode { // 为简单起见先不关注 DocumentFragment。 // ... }
oldVnode
参数是旧的 VNode 或 DOM 元素或文档片段,vnode
参数是更新后的对象。这里我直接贴出整理的流程描述:
调用模块上注册的 pre
钩子。
如果 oldVnode
是 Element
,则将其转换为空的 vnode
对象,属性里面记录了 elm
。
这里判断是不是 Element
是判断 (oldVnode as any).nodeType === 1
是完成的,nodeType === 1
表明是一个 ELEMENT_NODE,定义在 这里。
然后判断 oldVnode
和 vnode
是不是相同的,这里会调用 sameVnode()
来判断:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean { // 同样的 key。 const isSameKey = vnode1.key === vnode2.key; // Web component,自定义元素标签名,看这里: // https://developer.mozilla.org/zh-CN/docs/Web/API/Document/createElement const isSameIs = vnode1.data?.is === vnode2.data?.is; // 同样的选择器。 const isSameSel = vnode1.sel === vnode2.sel; // 三者都相同即是相同的。 return isSameSel && isSameKey && isSameIs; }
patchVnode()
做 diff 更新。createElm()
创建新的 DOM 节点;创建完毕后插入 DOM 节点并删除旧的 DOM 节点。调用上述操作中涉及的 vnode 对象中注册的 insert
钩子队列, patchVnode()
createElm()
都可能会有新节点插入 。至于为什么这样做,在 createElm()
中会说到。
最后调用模块上注册的 post
钩子。
流程基本就是相同的 vnode 就做 diff,不同的就创建新的删除旧的。接下来先看下 createElm()
是如何创建 DOM 节点的。
createElm()
是根据 vnode 的配置来创建 DOM 节点。流程如下:
调用 vnode 对象上可能存在的 init
钩子。
然后分一下几种情况来处理:
如果 vnode.sel === '!'
,这是 Snabbdom 用来删除原节点的方法,这样会新插入一个注释节点。因为在 createElm()
后会删除老节点,所以这样设置就可以达到卸载的目的。
如果 vnode.sel
选择器定义是存在的:
解析选择器,得到 id
、tag
和 class
。
调用 document.createElement()
或 document.createElementNS
创建 DOM 节点,并记录到 vnode.elm
中,并根据上一步的结果来设置 id
、tag
和 class
。
调用模块上的 create
钩子。
Proses tatasusunan nod anak children
:
Jika children
ialah tatasusunan, panggil createElm()
secara rekursif. panggil appendChild
Lekapkan di bawah vnode.elm
.
Jika children
bukan tatasusunan tetapi vnode.text
wujud, ini bermakna kandungan elemen ini ialah teks Pada masa ini, panggil createTextNode
untuk mencipta nod teks dan lekapkannya di bawah vnode.elm
.
memanggil cangkuk create
pada vnod. Dan tambahkan insert
cangkuk pada vnod pada baris gilir cangkuk insert
.
Situasi selebihnya ialah vnode.sel
tidak wujud, menunjukkan bahawa nod itu sendiri ialah teks, kemudian panggil createTextNode
untuk mencipta nod teks dan rekod ia kepada vnode.elm
.
Akhirnya kembali ke vnode.elm
.
Ia boleh dilihat daripada keseluruhan proses bahawa createElm()
memilih cara untuk mencipta nod DOM berdasarkan tetapan berbeza bagi pemilih sel
. Terdapat butiran untuk ditambahkan di sini: patch()
disebut dalam insert
baris gilir cangkuk. Sebab mengapa insert
baris gilir cangkuk ini diperlukan ialah ia perlu menunggu sehingga DOM benar-benar dimasukkan sebelum melaksanakannya, dan ia juga perlu menunggu sehingga semua nod keturunan dimasukkan, supaya kita boleh mengira saiz dan maklumat kedudukan daripada elemen dalam insert
untuk menjadi tepat. Digabungkan dengan proses mencipta nod kanak-kanak di atas, createElm()
mencipta nod kanak-kanak ialah panggilan rekursif, jadi baris gilir akan merekodkan nod kanak-kanak dahulu dan kemudiannya sendiri. Dengan cara ini pesanan boleh dijamin apabila melaksanakan baris gilir pada penghujung patch()
.
Seterusnya mari lihat cara Snabbdom menggunakan patchVnode()
untuk melakukan diff, yang merupakan teras DOM maya. Aliran pemprosesan patchVnode()
adalah seperti berikut:
Mula-mula laksanakan cangkuk prepatch
pada vnod.
Jika oldVnode dan vnode adalah rujukan objek yang sama, ia akan dikembalikan terus tanpa diproses.
Panggilan update
cangkuk pada modul dan vnod.
Jika vnode.text
tidak ditakrifkan, beberapa kes children
dikendalikan:
Jika kedua-duanya oldVnode.children
dan vnode.children
wujud dan tidak sama. Kemudian hubungi updateChildren
untuk mengemas kini.
vnode.children
wujud dan oldVnode.children
tidak wujud. Jika oldVnode.text
wujud, kosongkannya dahulu, kemudian panggil addVnodes
untuk menambah vnode.children
baharu.
vnode.children
tidak wujud dan oldVnode.children
wujud. Panggil removeVnodes
untuk mengalih keluar oldVnode.children
.
Jika tiada oldVnode.children
mahupun vnode.children
wujud. Kosong jika oldVnode.text
wujud.
jika vnode.text
ditakrifkan dan berbeza daripada oldVnode.text
. Panggil oldVnode.children
jelas jika removeVnodes
wujud. Kemudian tetapkan kandungan teks melalui textContent
.
Akhir sekali laksanakan cangkuk postpatch
pada vnod.
Seperti yang dapat dilihat daripada proses, perubahan pada atribut berkaitan nodnya sendiri dalam perbezaan, seperti class
, style
dan seumpamanya, dikemas kini oleh modul. Saya tidak akan menghuraikan lebih lanjut di sini Jika perlu, anda boleh melihat kod berkaitan modul. Pemprosesan teras utama bagi perbezaan tertumpu pada children
, dan kemudian perbezaan Kangkang memproses beberapa fungsi berkaitan children
.
Ini sangat mudah, mula-mula panggil createElm()
untuk mencipta, dan kemudian masukkan ke dalam induk yang sepadan.
Apabila mengalih keluar, cangkuk destory
dan remove
akan dipanggil dahulu Di sini kita memfokuskan pada logik panggilan dan perbezaan kedua-dua ini cangkuk .
destory
, mula-mula panggil cangkuk ini. Logiknya adalah untuk memanggil cangkuk pada objek vnode terlebih dahulu, dan kemudian memanggil cangkuk pada modul. Kemudian cangkuk ini dipanggil secara rekursif pada vnode.children
dalam susunan ini. remove
, cangkuk ini hanya akan dicetuskan apabila elemen semasa dialih keluar daripada induknya, elemen anak dalam elemen yang dialih keluar tidak akan dicetuskan, dan cangkuk ini pada modul dan objek vnode Akan dipanggil, perintahnya ialah memanggil modul dahulu dan kemudian memanggil vnode. Apa yang lebih istimewa ialah elemen itu tidak akan dialih keluar sehingga semua remove
dipanggil. Ini boleh mencapai beberapa keperluan pemadaman tertunda. Dapat dilihat daripada di atas bahawa logik panggilan kedua-dua cangkuk ini berbeza, terutamanya remove
hanya akan dipanggil pada elemen yang dipisahkan secara langsung daripada induk.
updateChildren()
是用来处理子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思想是对 oldCh
和 newCh
各设置头、尾一共四个指针,这四个指针分别是 oldStartIdx
、oldEndIdx
、newStartIdx
和 newEndIdx
。然后在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
循环中对两个数组进行对比,找到相同的部分进行复用更新,并且每次比较处理最多移动一对指针。详细的遍历过程按以下顺序处理:
如果这四个指针有任何一个指向的 vnode == null,则这个指针往中间移动,比如:start++ 或 end--,null 的产生在后面情况有说明。
如果新旧开始节点相同,也就是 sameVnode(oldStartVnode, newStartVnode)
返回 true,则用 patchVnode()
执行 diff,并且两个开始节点都向中间前进一步。
如果新旧结束节点相同,也采用 patchVnode()
处理,两个结束节点向中间后退一步。
如果旧开始节点与新结束节点相同,先用 patchVnode()
处理更新。然后需要移动 oldStart 对应的 DOM 节点,移动的策略是移动到 oldEndVnode
对应 DOM 节点的下一个兄弟节点之前。为什么是这样移动呢?首先,oldStart 与 newEnd 相同,说明在当前循环处理中,老数组的开始节点是往右移动了;因为每次的处理都是首尾指针往中间移动,我们是把老数组更新成新的,这个时候 oldEnd 可能还没处理,但这个时候 oldStart 已确定在新数组的当前处理中是最后一个了,所以移动到 oldEnd 的下一个兄弟节点之前是合理的。移动完毕后,oldStart++,newEnd--,分别向各自的数组中间移动一步。
如果旧结束节点与新开始节点相同,也是先用 patchVnode()
处理更新,然后把 oldEnd 对应的 DOM 节点移动 oldStartVnode
对应的 DOM 节点之前,移动理由同上一步一样。移动完毕后,oldEnd--,newStart++。
如果以上情况都不是,则通过 newStartVnode 的 key 去找在 oldChildren
的下标 idx,根据下标是否存在有两种不同的处理逻辑:
如果下标不存在,说明 newStartVnode 是新创建的。通过 createElm()
创建新的 DOM,并插入到 oldStartVnode
对应的 DOM 之前。
如果下标存在,也要分两种情况处理:
如果两个 vnode 的 sel 不同,也还是当做新创建的,通过 createElm()
创建新的 DOM,并插入到 oldStartVnode
对应的 DOM 之前。
如果 sel 是相同的,则通过 patchVnode()
处理更新,并把 oldChildren
对应下标的 vnode 设置为 undefined,这也是前面双指针遍历中为什么会出现 == null 的原因。然后把更新完毕后的节点插入到 oldStartVnode
对应的 DOM 之前。
以上操作完后,newStart++。
遍历结束后,还有两种情况要处理。一种是 oldCh
已经全部处理完成,而 newCh
中还有新的节点,需要对 newCh
剩下的每个都创建新的 DOM;另一种是 newCh
全部处理完成,而 oldCh
中还有旧的节点,需要将多余的节点移除。这两种情况的处理在 如下:
function updateChildren( parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue ) { // 双指针遍历过程。 // ... // newCh 中还有新的节点需要创建。 if (newStartIdx <= newEndIdx) { // 需要插入到最后一个处理好的 newEndIdx 之前。 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm; addVnodes( parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue ); } // oldCh 中还有旧的节点要移除。 if (oldStartIdx <= oldEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
我们用一个实际例子来看一下 updateChildren()
的处理过程:
初始状态如下,旧子节点数组为 [A, B, C],新节点数组为 [B, A, C, D]:
第一轮比较,开始和结束节点都不一样,于是看 newStartVnode 在旧节点中是否存在,找到了在 oldCh[1] 这个位置,那么先执行 patchVnode()
进行更新,然后把 oldCh[1] = undefined,并把 DOM 插入到 oldStartVnode
之前,newStartIdx
向后移动一步,处理完后状态如下:
Pusingan kedua perbandingan, oldStartVnode
dan newStartVnode
adalah sama, lakukan patchVnode()
kemas kini, oldStartIdx
dan newStartIdx
bergerak ke tengah, status selepas pemprosesan adalah seperti berikut:
Pusingan ketiga perbandingan, oldStartVnode == null
, oldStartIdx
bergerak ke tengah, statusnya dikemas kini seperti berikut:
Pusingan keempat perbandingan, oldStartVnode
dan newStartVnode
adalah sama, lakukan patchVnode()
kemas kini, oldStartIdx
dan newStartIdx
beralih ke tengah , status selepas pemprosesan adalah seperti berikut:
Pada masa ini, oldStartIdx
lebih besar daripada oldEndIdx
dan kitaran tamat. Pada masa ini, masih terdapat nod baharu dalam newCh
yang belum diproses, dan anda perlu memanggil addVnodes()
untuk memasukkan Status akhir adalah seperti berikut:
<.>
Video Pengaturcaraan! !
Atas ialah kandungan terperinci Bagaimana untuk memahami DOM maya? Semak artikel ini!. Untuk maklumat lanjut, sila ikut artikel berkaitan lain di laman web China PHP!