什么是 Virtual DOM
- Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,
所以叫 Virtual DOM
{
sel: "div",
data: {},
children: undefined,
text: "Hello Virtual DOM",
elm: undefined,
key: undefined
}
真实 DOM 成员
let element = document.querySelector('#app');
let s = '';
for (var key in element) {
s += key + ',';
}
console.log(s);
// 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,aut ocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,off setTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,onc opy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onc hange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondrag end,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchan ge,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypr ess,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown ,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup, onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,on resize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend ,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongot pointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointe rup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerl eave,onselectstart,onselectionchange,
为什么使用 Virtual DOM
手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是 Virtual DOM 出现了
Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
-
参考 github 上 virtual-dom 的描述
- 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
- 通过比较前后两次状态的差异更新真实 DOM
虚拟 DOM 的作用
维护视图和状态的关系
复杂视图情况下提升渲染性能
除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
常见的 Virtual DOM 库
Snabbdom 使用
// 安装
$ yarn add snabbdom
// 引入
import { init, h, thunk } from 'snabbdom'
- init()是一个高阶函数,返回 patch()
- h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过
new Vue({ router, store, render: h => h(App) }).$mount('#app');
- thunk() 是一种优化策略,可以在处理不可变数据时使用
基本使用
import { h, init } from 'snabbdom';
// patch是个核心函数
const patch = init([]);
// 创建虚拟dom
let vnode = h('div#container', 'Hello World');
const app = document.querySelector('#app');
let oldVnode = patch(app, vnode);
vnode = h('div', 'hello,glh');
patch(oldVnode, vnode);
模块
Snabbdom 的核心库并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块
常用模块
-
attributes
- 设置 DOM 元素的属性,使用 setAttribute ()
- 处理布尔类型的属性
-
props
- 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
- 不处理布尔类型的属性
-
class
- 切换类样式
- 注意:给元素设置类样式是通过 sel 选择器
-
dataset
- 设置 data-*的自定义属性
eventlisteners
注册和移除事件-
style
- 设置行内样式,支持动画
- delayed/remove/destroy
模块的使用
import { init, h } from 'snabbdom';
import style from 'snabbdom/modules/style';
import eventlisteners from 'snabbdom/modules/eventlisteners';
const patch = init([style, eventlisteners]);
let vnode = h(
'div',
{
style: {
backgroundColor: 'red',
},
on: {
click: eventHandler,
},
},
[h('h1', 'hello,Snabbdom')]
);
function eventHandler() {
console.log('点一下');
}
const app = document.querySelector('#app');
patch(app, vnode);
Snabbdom 源码解析
- h() 函数创建虚拟 Dom。源码位置:src/h.ts
// h 函数的重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {},
children: any,
text: any,
i: number; // 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况 // sel、data、children/text
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
}
// 如果 c 是字符串或者数字
else if (is.primitive(c)) {
text = c;
}
// 如果 c 是 VNode
else if (c && c.sel) {
children = [c];
}
} else if (b !== undefined && b !== null) {
// 处理两个参数的情况
// 如果 b 是数组
if (is.array(b)) {
children = b;
}
// 如果 b 是字符串或者数字
else if (is.primitive(b)) {
text = b;
}
// 如果 b 是 VNode
else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
// 处理 children 中的原始值(string/number)
for (i = 0; i < children.length; ++i) {
// 如果 child 是 string/number,创建文本节点
if (is.primitive(children[i]))
children[i] = vnode(
undefined,
undefined,
undefined,
children[i],
undefined
);
}
}
if (
sel[0] === 's' &&
sel[1] === 'v' &&
sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是 svg,添加命名空间 addNS(data, children, sel);
}
// 返回 VNode
return vnode(sel, data, children, text, undefined);
}
// 导出模块
export default h;
- VNode 源码位置:src/vnode.ts
export interface VNode {
// 选择器
: string | undefined;
// 节点数据:属性/样式/事件等
data: VNodeData | undefined;
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined; // 记录 vnode 对应的真实 DOM
elm: Node | undefined;
// 节点中的内容,和 children 只能互斥
text: string | undefined; // 优化用 key: Key | undefined;
}
export function vnode(sel: string | undefined, data: any | undefined, children: Array<VNode | string> | undefined, text: string | undefined, elm: Element | Text | undefined): VNode {
let key = data === undefined ? undefined : data.key;
return {sel, data, children, text, elm, key};
}
export default vnode;
- init 返回 patch 函数,依赖 modules/domApi/cbs 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建
源码位置:src/snabbdom.ts
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) { let i: number, j: number, cbs = ({} as ModuleHooks);
// 初始化 api
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 把传入的所有模块的钩子方法,统一存储到 cbs 对象中 // 最终构建的 cbs 对象的形式 cbs = [ create: [fn1, fn2], update: [], ... ]
for (i = 0; i < hooks.length; ++i) {
// cbs['create'] = []
cbs[hooks[i]] = []; for (j = 0; j < modules.length; ++j) {
// const hook = modules[0]['create']
const hook = modules[j][hooks[i]];
if (hook !== undefined) {
(cbs[hooks[i]] as Array<any>).push(hook);
}
}
}
…
…
…
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {}}
- patch()
- 功能:
- 传入新旧 VNode,对比差异,把差异渲染到 DOM
- 返回新的 VNode,作为下一次 patch() 的 oldVnode
- 执行过程:
- 首先执行模块中的钩子函数 pre
- 如果 oldVnode 和 vnode 相同(key 和 sel 相同)调用 patchVnode(),找节点的差异并更新 DOM
- 如果 oldVnode 是 DOM 元素,把 DOM 元素转换成 oldVnode
- 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
- 把刚创建的 DOM 元素插入到 parent 中
- 移除老节点
- 触发用户设置的 create 钩子函数
- 源码位置:src/snabbdom.ts
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node;
// 保存新插入节点的队列,为了触发钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 执行模块的 pre
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
if (!isVnode(oldVnode)) {
// 把 DOM 元素转换成空的 VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 如果新旧节点是相同节点(key 和 sel 相同)
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果新旧节点不同,vnode 创建对应的 DOM
// 获取当前的 DOM 元素
elm = oldVnode.elm!; parent = api.parentNode(elm);
// 触发 init/create 钩子函数,创建 DOM
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
// 移除老节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 执行用户设置的 insert 钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]); }
// 执行模块的 post 钩子函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
// 返回 vnode
return vnode;
};