目前前端使用最多的就是 vue 或 react 了,我们在学习这两个框架的过程中,总有一个绕不开的话题:vnode,也就是虚拟 DOM。什么是虚拟 DOM,引用一段 vue 官方的解释就是:
“一个用 JavaScript 生成名为 Virtual Dom 的 DOM 副本。
也就是说,虚拟 DOM 只是一个普通的 js 对象。我们都知道,对于 DOM 频繁的进行增删改查,成本很高,既然虚拟 DOM 只是一个 js 对象,那我们用操作对象的方式来代替操作 DOM,最后一次性的更改 DOM,那么一定程度上就能使得我们的 UI 更新更高效。
既然虚拟 DOM 的最终任务就是用计算出来的结果来修改 DOM,那么更新 DOM 还是不更新 DOM,怎么更新 DOM。这就需要借助 diff 算法来给出最终答案。
“diff 算法是用来计算新老 DOM 之间差异性的一种计算算法。
“Snabbdom 是一个虚拟 DOM 库,专注提供简单、模块性的体验,以及强大的功能和性能。
可能很多人都没听说过这个库,其实 vue 中虚拟 DOM 这一块就是借鉴的 Snabbdom,但是,它相比 vue 更加简单和纯粹,所以学习 Snabbdom 也能帮助我们理解 vue 中虚拟 DOM 相关的知识。
下面先来看看 Snabbdom 的简单的使用
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
// 创建一个 vnode
var vnode = h('div#container.two.classes', {on: {click: someFn}}, [
h('span', {style: {fontWeight: 'bold'}}, '这只是普通文本'),
' and this is just normal text',
h('a', {props: {href: '/foo'}}, '这是一个链接!')
]);
// 选择容器
var container = document.getElementById('container');
// 将 vnode patch 到容器中
patch(container, vnode);
// 生成一个新的 vnode
var newVnode = h('div#container.two.classes', {on: {click: anotherEventHandler}}, [
h('span', {style: {fontWeight: 'normal', fontStyle: 'italic'}}, '现在是斜体类型文本'),
' and this is still just normal text',
h('a', {props: {href: '/bar'}}, '这是一个链接!')
]);
// 用新 DOM 替换老 DOM
patch(vnode, newVnode);
上面的 demo 演示的流程就是:首先创建一个 vnode,patch 到选择的容器中,然后再创建一个新的 vnode,用老的 vnode 去替换新的 vnode。这里要注意的是,patch
方法的第一个参数,可以是一个 vnode 类型,也可以是一个 Element 类型,下面会介绍对这两种参数类型的处理。
其实上面这种初始化容器中的 DOM 和新老 DOM 的替换,我们在使用 vue 的过程,也是大量用到的,只不过 vue 替我们解决了繁琐的计算过程。
首先我们先来看看 vnode 生成过程,也就是下面这一块:
var vnode = h('div#container.two.classes', {on: {click: someFn}}, [
h('span', {style: {fontWeight: 'bold'}}, '这是加粗的类型'),
' and this is just normal text',
h('a', {props: {href: '/foo'}}, '这是一个链接!')
]);
上面的核心点就是 h 函数,它接受三个参数,第一个是标签,第二个是属性,第三个是子节点,其中子节点也可以是一个 h 函数的返回值。
下面贴上 h 函数的源码:
export function h(sel: any, b?: any, c?: any): VNode {
let data: VNodeData = {};
let children: any;
let text: any;
let i: number;
if (c !== undefined) {
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
} else if (is.primitive(c)) {
text = c.toString();
} else if (c && c.sel) {
children = [c];
}
} else if (b !== undefined && b !== null) {
// c 是 undefined,b 有可能是 children 类型
if (is.array(b)) {
children = b;
} else if (is.primitive(b)) {
text = b.toString();
} else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
// 这里 children 有可能是一个 h 函数的返回值,也有可能是一个 text 文本
if (is.primitive(children[i]))
children[i] = vnode(
undefined,
undefined,
undefined,
children[i],
undefined
);
}
}
// 对于 svg 标签的处理
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
}
上面这段代码首先是做了一些函数传参的处理,它允许传入的参数更加灵活。其次,调用 vnode 方法生成生成子节点的 vnode 和自己本身的 vnode,下面再接着看一下 vnode 函数的实现逻辑。
export function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined
): VNode {
const key = data === undefined ? undefined : data.key;
return { sel, data, children, text, elm, key };
}
vnode 函数的实现的函数很简单,就是返回一个 js 对象,所以本质上一个 vnode 就是一个如下结构的 js 对象。
{
sel: string | undefined;
data: any | undefined;
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined,
key: string | undefined
}
每一个 children 也可能是一个 vnode,这样就组装成了一个 vnode 树形结构。
了解完了 vnode 的生成过程,我们接下来就是将 vnode patch 到容器中,也就是下面的这两段代码。
patch(container, vnode); // 将 vnode patch 到容器中
和
patch(vnode, newVnode); // 用新 DOM 替换老 DOM
patch 函数其实就是 init 方法的返回值,所以找到 init 的源码:
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// dom 操作 api
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
function emptyNodeAt() {}
function createRmCb() {}
// 根据 vnode 创建一个 dom 树
function createElm() {}
function addVnodes() {}
function invokeDestroyHook() {}
function removeVnodes() {}
function updateChildren() {}
function patchVnode() {}
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let elm: Node, parent: Node;
// 如果不是 vnode(第一次调用 patch 时),
// 那么就是 Element 类型,直接创建一个空对象
// 空对象的 elm 值等于 oldVnode
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
// 如果满足 sameVnode
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
createElm(vnode);
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
return vnode;
};
}
这里有一部分关于 hooks 的执行时机的代码,由于本文主要是研究 diff 过程的,所以关于 hooks 的执行逻辑,代码中就没有贴进来了,感兴趣的同学可以研究下官方的源码。可以看到上面的代码就是返回 patch 函数,当我们调用 patch(container, vnode) 或 patch(vnode, newVnode) 的时候,就会执行该方法;
再看一下 patch 函数的实现逻辑:
1、对 vnode,也就是第一个参数类型做了判断,因为 vnode 有可能是一个 VNode 实例,也有可能是一个 Element 实例。
2、比较新老节点的根节点是否相同,这里比较逻辑的是判断新老节点的 key、sel 和 data.is 这三者都相等,才认为相同。
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
return isSameSel && isSameKey && isSameIs;
}
3、相同的逻辑我们先跳过,先看 sameVnode(oldVnode, vnode) 返回 false 的情况。先调用 createElm(vnode, insertedVnodeQueue) 创建 DOM 树,然后再将新创建的 DOM 树直接 append 到容器的父节点下,直接替换子节点。
最后,为了帮助大家理解整个 patch 的过程,我用一张图来描述这个过程:
上面 patch 函数的实现逻辑中,当 sameVnode(oldVnode, vnode) 返回 true 时,会执行 patchVnode 方法,patchVnode 其实就是用来比较新老节点的差异,计算出一个新的节点的。一起来看看 patchVnode 的比较实现:
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
// 如果新老节点相等,则不用替换
if (oldVnode === vnode) return;
// 如果新节点的子节点不是 textNode 类型。
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 如果新节点的子节点类型和老节点的子节点类型都是 children 类型
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
// 如果新节点的子节点类型是 children 类型,老节点的类型是 text 类型
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果老节点的子节点类型是 children 类型,新节点没有子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果老节点的子节点类型是 text 类型,新节点没有子节点
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
// 如果新节点的子节点是 text 类型。
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
api.setTextContent(elm, vnode.text!);
}
}
这里首先是判断 oldVnode 与 vnode 是否相等,如果相等,那就不用继续比较了;如果不相等,先执行新节点的 update 生命周期。因为根节点通过 sameVnode 方法比较过后, Snabbdom 就认为 vnode 和 oldVnode 的根节点是相等的,就不用更新根节点了。所以接下来就直接处理子节点的更新,这里为了方便大家理解,我先放一张子节点的比较的流程图。
上面我们在生成 vnode 的时候,根据节点类型,分别给 text 和 children 的值做了计算。所以,一个 vnode 的子节点 text 和 children 属性不可能同时有值。所以根据流程图,子节点对比的过程如下:
1、新节点的 children 为空,直接拿新节点的 text 和老节点的 text (不存在则为空)做比较,相等不动,不相等直接替换。
2、新节点的节点 children 不为空,老节点的 children 为空,直接用新节点替换老节点。
3、新节点的 children 和老节点的 children 皆不为空,执行 updateChildren()。
上面在新节点的 children 和老节点的 children 皆不为空的情况下,执行 updateChildren(elm, oldCh, ch, insertedVnodeQueue) 进行了深度比较,下面再来分析子节点的比较。
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
// 老节点开始位置为 null
oldStartVnode = oldCh[++oldStartIdx];
} else if (oldEndVnode == null) {
// 老节点结束位置为 null
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
// 新节点开始位置为 null
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
// 新节点结束位置为 null
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 老节点开始位置与新节点开始位置 diff
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 老节点结束位置与新节点结束位置 diff
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 老节点结束位置与新节点开始位置 diff
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(
parentElm,
oldStartVnode.elm!,
api.nextSibling(oldEndVnode.elm!)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 老节点开始位置与新节点结束位置 diff
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
// 将老节点转换成 map 结构
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 在 map 中找新节点的开始位置
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) {
// 如果没找到,新节点开始位置是新元素
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else { // 如果找到了,则比较两个节点的选择器是否相等
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
// 如果不相等,新节点开始位置是新元素
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else { // 如果相等,则认为是相同的元素
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
// 新节点开始位置右移
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
// 如果遍历完了
if (oldStartIdx > oldEndIdx) {
// 如果有残余的新节点未参与 diff,则全部 append 到父元素中
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else {
// 如果有残余的老节点未参与 diff,则全部移除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
子节点的 diff 应该算是整个整个 diff 过程当中最复杂的环节了,所以还是按照惯例,先上图:
结合代码和流程图来看:
1、首先是判断新老节点的开始和结束位置是否为 null;如果为 null,则将对应节点的位置左移(开始位置)或者右移(结束位置)。
2、然后依次拿新节点的开始位置与老节点的开始位置、新节点的结束位置与老节点的结束位置、新节点的结束位置与老节点的开始位置和新节点的开始位置与老节点的结束位置做对比,对比的逻辑依然是前面提到的 sameVnode 函数。如果匹配到,则再次进入 patchVnode 中,并且,对应的位置分别左移(开始位置)或右移(结束位置);如果匹配不到,则在所有老的子节点中寻找新节点开始位置的 vnode。
3、当循环条件 oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx 不满足时,检查新子节点中是否有残余的子节点未参与 diff 过程,如果有,则插入所有残余新子节点;检查老的子节点,是否有残余的子节点未参与 diff 过程,如果有,则删除所有残余老子节点。
由于本文只是讨论 Snabbdom 的 vnode 的生成和 diff 过程,其他还有很多地方没有深挖,但是建议大家有空可以去看看 Snabbdom 的源码,里面有很多非常棒的设计思路值得我们去学习的。
本文由哈喽比特于2年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/AYAIaGObmRRdJSDHqw8JFA
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。