react 和 vue 中都有虚拟 dom,我们应该如何理解和掌握虚拟 DOM 的精髓呢?我推荐大家学习Snabbdom 这个项目。Snabbdom 是一个虚拟 DOM 实现库,推荐的原因一是代码比较少,核心代码只有几百行;二是 Vue 就是借鉴此项目的思路来实现虚拟 DOM 的;三是这个项目的设计/实现和扩展思路值得参考。
snabb /snab/,瑞典语,意思是快速的。
调整好舒服的坐姿,打起精神我们要开始啦~ 要学习虚拟 DOM,我们得先知道 DOM 的基础知识和用 JS 直接操作 DOM 的痛点在哪里。
DOM(Document Object Model)是一种文档对象模型,用一个对象树的结构来表示一个 html/XML 文档,树的每个分支的终点都是一个节点(node),每个节点都包含着对象。DOM api 的方法让你可以用特定方式操作这个树,用这些方法你可以改变文档的结构、样式或者内容。
DOM 树中的所有节点首先都是一个 Node , Node 是一个基类。 Element , Text 和 Comment 都继承于它。
换句话说, Element , Text 和 Comment 是三种特殊的 Node ,它们分别叫做 ELEMENT_NODE ,
TEXT_NODE 和 COMMENT_NODE ,代表的是元素节点(HTML 标签)、文本节点和注释节点。其中 Element 还有一个子类是 HTMLElement ,那 HTMLElement 和 Element 有什么区别呢? HTMLElement 代表 HTML 中的元素,如: <span> 、 <img> 等,而有些元素并不是 HTML 标准的,比如 <svg> 。可以用下面的方法来判断这个元素是不是 HTMLElement :
document.getElementById('myIMG') instanceof HTMLElement;
浏览器创建 DOM 是很“昂贵”的。来一个经典示例,我们可以通过 document.createElement('div') 创建一个简单的 div 元素,将属性都打印出来康康:
可以看到打印出来的属性非常多,当频繁地去更新复杂的 DOM 树时,会产生性能问题。虚拟 DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以创建一个 JS 对象比创建一个 DOM 对象的代价要小很多。
Vnode 就是 Snabbdom 中描述虚拟 DOM 的一个对象结构,内容如下:
type Key = string | number | symbol;
interface VNode {
// css 选择器,比如:'div#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
}
例如这样定义一个 vnode 的对象:
const vnode = h(
'div#container',
{ class: { active: true } },
[
h('span', { style: { fontWeight: 'bold' } }, 'This is bold'),
' and this is just normal text'
]);
我们通过 h(sel, b, c) 函数来创建 vnode 对象。 h() 代码实现中主要是判断了 b 和 c 参数是否存在,并处理成 data 和 children,children 最终会是数组的形式。最后通过 vnode() 函数返回上面定义的 VNode 类型格式。
先来一张运行流程的简单示例图,先有个大概的流程概念:
diff 处理是用来计算新老节点之间差异的处理过程。
再来看一段 Snabbdom 运行的示例代码:
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from 'snabbdom';
const patch = init([
// 通过传入模块初始化 patch 函数
classModule, // 开启 classes 功能
propsModule, // 支持传入 props
styleModule, // 支持内联样式同时支持动画
eventListenersModule, // 添加事件监听
]);
// <div id="container"></div>
const container = document.getElementById('container');
const vnode = h(
'div#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(
'div#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);
从流程示意图和示例代码可以看出,Snabbdom 的运行流程描述如下:
当需要更新时,创建一个新的 vnode 对象,调用 patch() 函数去更新,经过 patchVnode() 和 updateChildren() 完成本节点和子节点的差异更新。
Snabbdom 是通过模块这种设计来扩展相关属性的更新而不是全部写到核心代码中。那这是如何设计与实现的?接下来就先来康康这个设计的核心内容,Hooks——生命周期函数。
Snabbdom 提供了一系列丰富的生命周期函数也就是钩子函数,这些生命周期函数适用在模块中或者可以直接定义在 vnode 上。比如我们可以在 vnode 上这样定义钩子的执行:
h('div.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 参数是更新后的对象。这里我直接贴出整理的流程描述:
如果 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()
createElm()
流程基本就是相同的 vnode 就做 diff,不同的就创建新的删除旧的。接下来先看下 createElm() 是如何创建 DOM 节点的。
createElm() 是根据 vnode 的配置来创建 DOM 节点。流程如下:
然后分一下几种情况来处理:
如果 vnode.sel 选择器定义是存在的:
处理 children 子节点数组:
整个过程可以看出 createElm() 是根据 sel 选择器的不同设置来选择如何创建 DOM 节点。这里有个细节是补一下: patch() 中提到的 insert 钩子队列。需要这个 insert 钩子队列的原因是需要等到 DOM 真正被插入后才执行,而且也要等到所有子孙节点都插入完成,这样我们可以在 insert 中去计算元素的大小位置信息才是准确的。结合上面创建子节点的过程, createElm() 创建子节点是递归调用,所以队列会先记录子节点,再记录自身。这样在 patch() 的结尾执行这个队列时就可以保证这个顺序。
接下来我们来看 Snabbdom 如何用 patchVnode() 来做 diff 的,这是虚拟 DOM 的核心。 patchVnode() 的处理流程如下:
如果没有定义 vnode.text ,则处理 children 的几种情况:
从过程可以看出,diff 中对于自身节点的相关属性的改变比如 class 、 style 之类的是依靠模块去更新的,这里不过多展开了大家有需要可以去看下模块相关的代码。diff 的主要核心处理是集中在 children 上,接下来康康 diff 处理 children 的几个相关函数。
这个很简单,先调用 createElm() 创建,然后插入到对应的 parent 中。
移除的时候会先调用 destory 和 remove 钩子,这里重点讲讲这两个钩子的调用逻辑和区别。
以上可以看出这两个钩子调用逻辑不同的地方,特别是 remove 只在直接脱离父级的元素上才会被调用。
updateChildren() 是用来处理子节点 diff 的,也是 Snabbdom 中比较复杂的一个函数。总的思想是对 oldCh 和 newCh 各设置头、尾一共四个指针,这四个指针分别是 oldStartIdx 、 oldEndIdx 、 newStartIdx 和 newEndIdx 。然后在 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) 循环中对两个数组进行对比,找到相同的部分进行复用更新,并且每次比较处理最多移动一对指针。详细的遍历过程按以下顺序处理:
如果以上情况都不是,则通过 newStartVnode 的 key 去找在 oldChildren 的下标 idx,根据下标是否存在有两种不同的处理逻辑:
如果下标存在,也要分两种情况处理:
遍历结束后,还有两种情况要处理。一种是 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() 的处理过程:
到这里虚拟 DOM 的核心内容已经梳理完毕,Snabbdom 的设计和实现原理我觉得挺好的,大家有空可以去康康源码的细节再细品下,其中的思想很值得学习。
欢迎关注我的 JS 博客:小声比比 JavaScript
原文 https://segmentfault.com/a/1190000042195199
DOM是很慢的,其元素非常庞大,页面的性能问题鲜有由JS引起的,大部分都是由DOM操作引起的。虚拟的DOM的核心思想是:对复杂的文档DOM结构,提供一种方便的工具,进行最小化地DOM操作。
浏览器解析HTML文档生成DOM树的过程,以下是一段HTML代码,以此为例来分析解析HTML文档的原理.解析HTML文档构建DOM树的理解过程可分为两个主要模块构成,即标签解析、DOM树构建
javascript获取DOM对象的多种方法:通过id获取 、通过class获取、通过标签名获取、通过name属性获取、通过querySelector获取、通过querySelectorAll获取等
遍历DOM节点常用一般用节点的 childNodes, firstChild, lastChild, nodeType, nodeName, nodeValue属性。在获取节点nodeValue时要注意,元素节点的子文本节点的nodeValue才是元素节点中文本的内容。
要构建自己的虚拟DOM,需要知道两件事。你甚至不需要深入 React 的源代码或者深入任何其他虚拟DOM实现的源代码,因为它们是如此庞大和复杂——但实际上,虚拟DOM的主要部分只需不到50行代码。
事件冒泡: 即事件开始时由最具体的元素(文档中嵌套层数最深的那个点)接收,事件捕获:不太具体的节点应该更早接收到事件,而最具体的节点应该最后接收到事件.与此同时,我们还需要了解dom事件绑定处理的几种方式:
先列出我的理解,然后再从具体的例子中说明:DOM操作本身应该是同步的(当然,我说的是单纯的DOM操作,不考虑ajax请求后渲染等);DOM操作之后导致的渲染等是异步的(在DOM操作简单的情况下,是难以察觉的)
早期由于浏览器厂商对于浏览器市场的争夺,各家浏览器厂商对同一功能的JavaScript的实现都不进相同,本节内容介绍JavaScript的DOM事件模型及事件处理程序的分类。
设置定义属性值 :data-value=.., 2.直接获取 3.通过this.$refs.***获取 1.目标DOM定义ref值: 2.通过 【this.$refs.***.属性名】 获取相关属性的值: this.$refs.*** 获取到对应的元素 ...
框架用多了,你还记得那些操作 DOM 的纯 JS 语法吗?看看这篇文章,来回顾一下~ 操作 className,addClass给元素增加 class,使用 classList 属性
内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!