How to understand virtual DOM? Check out this article!

青灯夜游
Release: 2022-07-21 20:21:34
forward
1893 people have browsed it

Both React and Vue have virtual DOM, so how should we understand and master the essence of virtual DOM? The following article will give you an in-depth understanding of virtual DOM. I hope it will be helpful to you!

How to understand virtual DOM? Check out this article!

How to understand and master the essence of virtual DOM? I recommend everyone to learn the Snabbdom project.

Snabbdom is a virtual DOM implementation library. The reason for recommendation is that the code is relatively small and the core code is only a few hundred lines; the second is that Vue draws on the ideas of this project to implement the virtual DOM; the third is that this project Design/implementation and expansion ideas are worth referencing.

snabb /snab/, Swedish, means fast.

Adjust your comfortable sitting posture, cheer up and let’s get started~ To learn virtual DOM, we must first know the basic knowledge of DOM and the pain points of directly operating DOM with JS.

The role and type structure of DOM

DOM (Document Object Model) is a document object model that uses an object tree structure to represent an HTML/XML document , the end of each branch of the tree is a node, and each node contains objects. The methods of the DOM API allow you to manipulate this tree in specific ways. With these methods, you can change the structure, style, or content of the document.

All nodes in the DOM tree are first aNode, andNodeis a base class.Element,TextandCommentall inherit from it.
In other words,Element,TextandCommentare three specialNode, they are calledELEMENT_NODE respectively,
TEXT_NODEandCOMMENT_NODErepresent element nodes (HTML tags), text nodes and comment nodes. Among them,Elementalso has a subclass calledHTMLElement. What is the difference betweenHTMLElementandElement?HTMLElementrepresents elements in HTML, such as:,, etc. However, some elements are not HTML standard, such as. You can use the following method to determine whether this element isHTMLElement:

document.getElementById('myIMG') instanceof HTMLElement;
Copy after login

Why do you need a virtual DOM?

It is very "expensive" for the browser to create DOM. Let’s take a classic example. We can create a simple p element throughdocument.createElement('p')and print out all the attributes:

You can see that there are a lot of printed attributes. When updating complex DOM trees frequently, performance problems will occur. Virtual DOM uses a native JS object to describe a DOM node, so creating a JS object is much less expensive than creating a DOM object.

VNode

VNode is an object structure describing the virtual DOM in Snabbdom. The content is as follows:

type Key = string | number | symbol; interface VNode { // CSS 选择器,比如:'p#container'。 sel: string | undefined; // 通过 modules 操作 CSS classes、attributes 等。 data: VNodeData | undefined; // 虚拟子节点数组,数组元素也可以是 string。 children: Array | 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 }
Copy after login

For example, define a vnode object like this:

const vnode = h( 'p#container', { class: { active: true } }, [ h('span', { style: { fontWeight: 'bold' } }, 'This is bold'), ' and this is just normal text' ]);
Copy after login

We create the vnode object through theh(sel, b, c)function.h()The code implementation mainly determines whether the b and c parameters exist, and processes them into data and children. Children will eventually be in the form of an array. Finally, theVNodetype format defined above is returned through thevnode()function.

Snabbdom’s running process

Let’s start with a simple example diagram of the running process, and have a general process concept:

diff processing is a process used to calculate the difference between new and old nodes.

Let’s look at a sample code of Snabbdom operation:

import { init, classModule, propsModule, styleModule, eventListenersModule, h, } from 'snabbdom'; const patch = init([ // 通过传入模块初始化 patch 函数 classModule, // 开启 classes 功能 propsModule, // 支持传入 props styleModule, // 支持内联样式同时支持动画 eventListenersModule, // 添加事件监听 ]); // 

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);
Copy after login

As can be seen from the process diagram and sample code, the operation process of Snabbdom is described as follows:

  • First callinit()for initialization. During initialization, you need to configure the modules you need to use. For example, theclassModulemodule is used to configure the class attribute of elements in the form of objects; theeventListenersModulemodule is used to configure event listeners, etc.init()will return thepatch()function after being called.

  • Create the initialized vnode object through theh()function, call thepatch()function to update it, and finally usecreateElm()Create a real DOM object.

  • When you need to update, create a new vnode object and call thepatch()function to update. AfterpatchVnode()andupdateChildren()Complete the differential update of this node and child nodes.

    Snabbdom 是通过模块这种设计来扩展相关属性的更新而不是全部写到核心代码中。那这是如何设计与实现的?接下来就先来康康这个设计的核心内容,Hooks——生命周期函数。

Hooks

Snabbdom 提供了一系列丰富的生命周期函数也就是钩子函数,这些生命周期函数适用在模块中或者可以直接定义在 vnode 上。比如我们可以在 vnode 上这样定义钩子的执行:

h('p.row', { key: 'myRow', hook: { insert: (vnode) => { console.log(vnode.elm.offsetHeight); }, }, });
Copy after login

全部的生命周期函数声明如下:

名称 触发节点 回调参数
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; function updateClass(oldVnode: VNode, vnode: VNode): void { // 这里是更新 class 属性的细节,先不管。 // ... } export const classModule: Module = { create: updateClass, update: updateClass };
Copy after login

可以看到最后导出的模块定义是一个对象,对象的 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; }>;
Copy after login

TS 中Partial表示对象中每个 key 的属性都是可以为空的,也就是说模块定义中你关心哪个钩子,就定义哪个钩子就好了。钩子的定义有了,在流程中是怎么执行的呢?接着我们来看init()函数:

// 模块中可能定义的钩子有哪些。 const hooks: Array = [ "create", "update", "remove", "destroy", "pre", "post", ]; export function init( modules: Array>, 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); } } } // ... }
Copy after login

可以看到init()在执行时先遍历各个模块,然后把钩子函数存到了cbs这个对象中。执行的时候可以康康patch()函数里面:

export function init( modules: Array>, 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](); // ... } }
Copy after login

这里以pre这个钩子举例,pre钩子的执行时机是在 patch 开始执行时。可以看到patch()函数在执行的开始处去循环调用了cbs中存储的pre相关钩子。其他生命周期函数的调用也跟这个类似,大家可以在源码中其他地方看到对应生命周期函数调用的地方。

这里的设计思路是观察者模式。Snabbdom 把非核心功能分布在模块中来实现,结合生命周期的定义,模块可以定义它自己感兴趣的钩子,然后init()执行时处理成cbs对象就是注册这些钩子;当执行时间到来时,调用这些钩子来通知模块处理。这样就把核心代码和模块代码分离了出来,从这里我们可以看出观察者模式是一种代码解耦的常用模式。

patch()

接下来我们来康康核心函数patch(),这个函数是在init()调用后返回的,作用是执行 VNode 的挂载和更新,签名如下:

function patch(oldVnode: VNode | Element | DocumentFragment, vnode: VNode): VNode { // 为简单起见先不关注 DocumentFragment。 // ... }
Copy after login

oldVnode参数是旧的 VNode 或 DOM 元素或文档片段,vnode参数是更新后的对象。这里我直接贴出整理的流程描述:

  • 调用模块上注册的pre钩子。

  • 如果oldVnodeElement,则将其转换为空的vnode对象,属性里面记录了elm

    这里判断是不是Element是判断(oldVnode as any).nodeType === 1是完成的,nodeType === 1表明是一个 ELEMENT_NODE,定义在 这里。

  • 然后判断oldVnodevnode是不是相同的,这里会调用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; }
    Copy after login
    • 如果相同,则调用patchVnode()做 diff 更新。
    • 如果不同,则调用createElm()创建新的 DOM 节点;创建完毕后插入 DOM 节点并删除旧的 DOM 节点。
  • 调用上述操作中涉及的 vnode 对象中注册的insert钩子队列,patchVnode()createElm()都可能会有新节点插入 。至于为什么这样做,在createElm()中会说到。

  • 最后调用模块上注册的post钩子。

流程基本就是相同的 vnode 就做 diff,不同的就创建新的删除旧的。接下来先看下createElm()是如何创建 DOM 节点的。

createElm()

createElm()是根据 vnode 的配置来创建 DOM 节点。流程如下:

  • 调用 vnode 对象上可能存在的init钩子。

  • 然后分一下几种情况来处理:

    • 如果vnode.sel === '!',这是 Snabbdom 用来删除原节点的方法,这样会新插入一个注释节点。因为在createElm()后会删除老节点,所以这样设置就可以达到卸载的目的。

    • 如果vnode.sel选择器定义是存在的:

      • 解析选择器,得到idtagclass

      • 调用document.createElement()document.createElementNS创建 DOM 节点,并记录到vnode.elm中,并根据上一步的结果来设置idtagclass

      • 调用模块上的create钩子。

      • ProcessingchildrenChild node array:

        • Ifchildrenis an array, call recursivelycreateElm()After creating the child node, callappendChildto mount it undervnode.elm.

        • Ifchildrenis not an array butvnode.textexists, it means that the content of this element is text, andcreateTextNode# is called at this time. ## Create a text node and mount it undervnode.elm.

      • Call the

        createhook on vnode. And add theinserthook on vnode to theinserthook queue.

    • The remaining situation is that

      vnode.seldoes not exist, indicating that the node itself is text, then callcreateTextNodeCreate a text node and log it tovnode.elm.

  • Finally returns

    vnode.elm.

It can be seen from the whole process that

createElm()chooses how to create a DOM node based on the different settings of theselselector. There is a detail to add here:inserthook queue mentioned inpatch(). The reason why thisinserthook queue is needed is that it needs to wait until the DOM is actually inserted before executing it, and also wait until all descendant nodes are inserted, so that we can calculate the elements ininsertThe size and location information is accurate. Combined with the process of creating child nodes above,createElm()is a recursive call to create child nodes, so the queue will record the child nodes first and then itself. This way the order can be guaranteed when executing this queue at the end ofpatch().

patchVnode()

Next let’s see how Snabbdom uses

patchVnode()to do diff, which is the core of virtual DOM. The processing flow ofpatchVnode()is as follows:

  • First execute the

    prepatchhook on vnode.

  • If oldVnode and vnode are the same object reference, they will be returned directly without processing.

  • Call the

    updatehook on the module and vnode.

  • If

    vnode.textis not defined, handle several situations ofchildren:

    • If

      oldVnode.childrenandvnode.childrenboth exist and are not the same. Then callupdateChildrento update.

    • vnode.childrenexists andoldVnode.childrendoes not exist. IfoldVnode.textexists, clear it first, and then calladdVnodesto add a newvnode.children.

    • vnode.childrendoes not exist whileoldVnode.childrendoes. CallremoveVnodesto removeoldVnode.children.

    • If neither

      oldVnode.childrennorvnode.childrenexist. Empty ifoldVnode.textexists.

  • If

    vnode.textis defined and is different fromoldVnode.text. IfoldVnode.childrenexists, callremoveVnodesto clear. Then set the text content throughtextContent.

  • Finally execute the

    postpatchhook on the vnode.

It can be seen from the process that changes to the related attributes of its own nodes in diff, such as

class,style, etc., rely on modules I'm going to update it, but I've just expanded it here. If you need to, you can take a look at the module-related code. The main core processing of diff is focused onchildren, and then Kangkang diff processes several related functions ofchildren.

addVnodes()

This is very simple, first call

createElm()to create it, and then insert it into the corresponding parent.

removeVnodes()

When removing, the

destoryandremovehooks will be called first. Here we focus on this The calling logic and differences between the two hooks.

  • destroy, first call this hook. The logic is to call the hook on the vnode object first, and then call the hook on the module. Then call this hook recursively onvnode.childrenin this order.
  • remove, this hook will only be triggered when the current element is deleted from its parent, and the child elements in the removed element will not be triggered, and the module and vnode objects This hook will be called on the module, and the order is to first call the module and then call the vnode. What's more special is that the element will not be actually removed until allremoveare called. This can achieve some delayed deletion requirements.
It can be seen from the above that the calling logic of these two hooks is different, especially

removewill only be called on elements that are directly separated from the parent.

updateChildren()

updateChildren()是用来处理子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思想是对oldChnewCh各设置头、尾一共四个指针,这四个指针分别是oldStartIdxoldEndIdxnewStartIdxnewEndIdx。然后在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); } }
Copy after login

我们用一个实际例子来看一下updateChildren()的处理过程:

  • 初始状态如下,旧子节点数组为 [A, B, C],新节点数组为 [B, A, C, D]:

  • 第一轮比较,开始和结束节点都不一样,于是看 newStartVnode 在旧节点中是否存在,找到了在 oldCh[1] 这个位置,那么先执行patchVnode()进行更新,然后把 oldCh[1] = undefined,并把 DOM 插入到oldStartVnode之前,newStartIdx向后移动一步,处理完后状态如下:

  • The second round of comparison,oldStartVnodeis the same asnewStartVnode, executepatchVnode()to update,oldStartIdxandnewStartIdxmove to the middle, and the status after processing is as follows:

  • ##The third round of comparison,

    oldStartVnode == null,oldStartIdxmoves to the middle, the status is updated as follows:

  • The fourth round of comparison,

    oldStartVnodeis the same asnewStartVnode, executepatchVnode()update,oldStartIdxandnewStartIdxMove to the middle, the status after processing is as follows:

    ##At this time
  • oldStartIdx

    is greater thanoldEndIdx, the loop ends. At this time, there are still new nodes that have not been processed innewCh. You need to calladdVnodes()to insert. The final status is as follows:

Summary

The core content of virtual DOM has been sorted out here. I think the design and implementation principles of Snabbdom are quite good. You can go to Kangkang when you have time. If you look closely at the details of the source code, the ideas are worth learning.

For more programming related knowledge, please visit:

Programming Video

! !

The above is the detailed content of How to understand virtual DOM? Check out this article!. For more information, please follow other related articles on the PHP Chinese website!

Related labels:
source:segmentfault.com
Statement of this Website
The content of this article is voluntarily contributed by netizens, and the copyright belongs to the original author. This site does not assume corresponding legal responsibility. If you find any content suspected of plagiarism or infringement, please contact admin@php.cn
Latest Downloads
More>
Web Effects
Website Source Code
Website Materials
Front End Template
About us Disclaimer Sitemap
php.cn:Public welfare online PHP training,Help PHP learners grow quickly!