虚拟 DOM 原理
啥是虚拟 DOM
想象一下,我们要建一座房子,在真正动手盖之前,先在纸上画一个房子的模型,这个模型就相当于虚拟 DOM。在网页开发里,真实的 DOM 就像那座真正的房子,而虚拟 DOM 就是用 JavaScript 对象来描述真实 DOM 结构的一个“模型”。
为啥要用虚拟 DOM
直接操作真实 DOM 是很“贵”的,这里的“贵”指的是性能开销大。每次修改真实 DOM,浏览器都要重新计算样式、布局,然后再把新的界面绘制出来,这个过程很耗时。而虚拟 DOM 是在 JavaScript 里操作的,速度非常快。我们可以先在虚拟 DOM 上做各种修改,然后对比修改前后的虚拟 DOM,找出差异,最后只把这些差异应用到真实 DOM 上,这样就能减少对真实 DOM 的操作次数,提高性能。
虚拟 DOM 原理步骤
- 创建虚拟 DOM:用 JavaScript 对象来描述真实 DOM 的结构和属性。
- 修改虚拟 DOM:在 JavaScript 里对虚拟 DOM 对象进行修改,模拟页面的变化。
- 对比虚拟 DOM:通过 Diff 算法比较修改前后的虚拟 DOM,找出差异。
- 更新真实 DOM:把找出的差异应用到真实 DOM 上,只更新需要改变的部分。
Diff 算法的实现机制
啥是 Diff 算法
Diff 算法就是用来比较两个虚拟 DOM 树的差异的,就像有两个房子模型,我们要找出它们之间哪里不一样。
Diff 算法的比较策略
- 同级比较:只比较同一层级的节点,不会跨层级比较。就像比较两个房子模型,先比较第一层的房间,再比较第二层的房间,不会把第一层的房间和第二层的房间混在一起比较。
- 类型判断:如果两个节点的类型不同(比如一个是
<div>
,一个是<p>
),就认为这是两个完全不同的节点,直接替换。 - key 的使用:在列表中,给每个节点加上一个唯一的
key
,这样 Diff 算法就能更准确地判断哪些节点是新增的、哪些是删除的、哪些是移动的。
代码示例
// 1. 创建虚拟 DOM 节点的函数
function createElement(type, props, ...children) {return {type, // 节点类型,如 'div', 'p' 等props: {...props,children: children.map(child => typeof child === 'object'? child : createTextElement(child))}};
}// 创建文本节点的函数
function createTextElement(text) {return {type: 'TEXT_ELEMENT',props: {nodeValue: text,children: []}};
}// 2. 将虚拟 DOM 转换为真实 DOM
function render(vdom, container) {const dom = vdom.type === 'TEXT_ELEMENT'? document.createTextNode(vdom.props.nodeValue): document.createElement(vdom.type);// 设置属性const isProperty = key => key!== 'children';Object.keys(vdom.props).filter(isProperty).forEach(name => {dom[name] = vdom.props[name];});// 递归渲染子节点vdom.props.children.forEach(child => {render(child, dom);});// 将生成的真实 DOM 插入到容器中container.appendChild(dom);
}// 3. 简单的 Diff 算法示例
function diff(oldVdom, newVdom, container) {if (!oldVdom) {// 如果旧的虚拟 DOM 不存在,直接创建新的真实 DOMrender(newVdom, container);} else if (!newVdom) {// 如果新的虚拟 DOM 不存在,删除旧的真实 DOMcontainer.removeChild(getDom(oldVdom));} else if (oldVdom.type!== newVdom.type) {// 如果节点类型不同,替换旧的真实 DOMconst newDom = createDom(newVdom);container.replaceChild(newDom, getDom(oldVdom));} else {// 如果节点类型相同,更新属性updateDomProperties(getDom(oldVdom), oldVdom.props, newVdom.props);// 递归比较子节点const maxLength = Math.max(oldVdom.props.children.length,newVdom.props.children.length);for (let i = 0; i < maxLength; i++) {diff(oldVdom.props.children[i],newVdom.props.children[i],getDom(oldVdom));}}
}// 获取虚拟 DOM 对应的真实 DOM
function getDom(vdom) {if (vdom._dom) {return vdom._dom;}return vdom;
}// 更新真实 DOM 的属性
function updateDomProperties(dom, oldProps, newProps) {// 删除旧属性const isEvent = name => name.startsWith('on');Object.keys(oldProps).filter(isEvent).forEach(name => {const eventType = name.toLowerCase().substring(2);dom.removeEventListener(eventType, oldProps[name]);});// 添加新属性Object.keys(newProps).filter(isEvent).forEach(name => {const eventType = name.toLowerCase().substring(2);dom.addEventListener(eventType, newProps[name]);});// 更新其他属性const isProperty = key => key!== 'children' &&!isEvent(key);Object.keys(oldProps).filter(isProperty).forEach(name => {if (!newProps[name]) {dom[name] = '';}});Object.keys(newProps).filter(isProperty).forEach(name => {dom[name] = newProps[name];});
}// 示例使用
const oldVdom = createElement('div', { id: 'old' }, '旧的内容');
const newVdom = createElement('div', { id: 'new' }, '新的内容');
const container = document.getElementById('root');// 首次渲染
render(oldVdom, container);// 模拟更新,使用 Diff 算法更新 DOM
diff(oldVdom, newVdom, container);
代码解释
createElement
函数:用来创建虚拟 DOM 节点,接收节点类型、属性和子节点作为参数,返回一个 JavaScript 对象来描述这个节点。render
函数:将虚拟 DOM 转换为真实 DOM,并插入到指定的容器中。它会递归处理子节点,确保整个虚拟 DOM 树都被转换为真实 DOM。diff
函数:实现了简单的 Diff 算法,比较旧的虚拟 DOM 和新的虚拟 DOM,根据不同情况更新真实 DOM。getDom
函数:获取虚拟 DOM 对应的真实 DOM。updateDomProperties
函数:更新真实 DOM 的属性,包括事件监听器和其他属性。
通过这些步骤,我们就实现了虚拟 DOM 的创建、渲染和更新,利用 Diff 算法减少了对真实 DOM 的操作,提高了性能。
大白话用代码示例详细解释虚拟 DOM 原理
啥是虚拟 DOM
咱先说说啥是虚拟 DOM。在网页开发里,真实的 DOM 就像是一座真正的大楼,有各种各样的房间(元素)和装饰(属性)。直接去改动这座大楼可费劲了,每次改动都得重新规划、装修,很耗时间和精力。而虚拟 DOM 呢,就像是这座大楼的一个模型,用一些材料(JavaScript 对象)做出来的,在这个模型上做改动就容易多了,速度也快。改完模型后,我们再看看模型和原来的有啥不一样,只把这些不一样的地方用到真正的大楼上,这样就能减少对真大楼的改动次数,提高效率。
代码实现虚拟 DOM 原理
1. 创建虚拟 DOM 节点
// 创建虚拟 DOM 节点的函数
function createElement(type, props, ...children) {return {type, // 节点类型,比如 'div'、'p' 这些,就像大楼里不同类型的房间props: {...props,// 处理子节点,如果子节点是普通文本,就把它变成文本节点对象children: children.map(child => typeof child === 'object'? child : createTextElement(child))}};
}// 创建文本节点的辅助函数
function createTextElement(text) {return {type: 'TEXT_ELEMENT',props: {nodeValue: text, // 文本内容,就像房间里的标语children: []}};
}
解释:
createElement
函数就像是一个造房间的工人,它能根据你给的房间类型(type
)、房间的装修(props
)和里面的小房间(children
)来创建一个房间模型(虚拟 DOM 节点)。createTextElement
函数是专门造标语房间(文本节点)的,把你给的文本变成一个有特定格式的房间模型。
2. 把虚拟 DOM 变成真实 DOM
// 将虚拟 DOM 渲染为真实 DOM 的函数
function render(vdom, container) {// 如果是标语房间(文本节点),就创建一个真正的标语const dom = vdom.type === 'TEXT_ELEMENT'? document.createTextNode(vdom.props.nodeValue): document.createElement(vdom.type);// 给房间加上装修(设置属性)const isProperty = key => key!== 'children';Object.keys(vdom.props).filter(isProperty).forEach(name => {dom[name] = vdom.props[name];});// 把小房间(子节点)都放进大房间里,递归处理vdom.props.children.forEach(child => {render(child, dom);});// 把造好的房间放到大楼的指定位置(容器)container.appendChild(dom);
}
解释:
render
函数就像是一个建筑工人,它根据虚拟 DOM 这个房间模型,用真正的材料(document.createTextNode
或document.createElement
)造出一个真实的房间。- 然后给这个房间装上和模型一样的装修(属性)。
- 接着把模型里的小房间也都按照同样的方法造出来,放到大房间里。
- 最后把这个造好的房间放到大楼的指定位置(容器)。
3. 模拟页面更新和重新渲染
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8">
</head><body><div id="root"></div><script>// 创建一个虚拟 DOM 节点,就像设计一个大楼的房间布局const vdom = createElement('div', { id: 'example' }, createElement('h1', null, 'Hello, Virtual DOM!'),createElement('p', null, 'This is a simple example.'));// 找到大楼的指定位置(容器)const container = document.getElementById('root');// 第一次把设计好的房间布局变成真实的房间render(vdom, container);// 模拟页面状态更新,设计一个新的房间布局const newVdom = createElement('div', { id: 'example' }, createElement('h1', null, 'Updated: Hello, Virtual DOM!'),createElement('p', null, 'This is an updated example.'));// 这里先简单处理,清空原来的房间,再把新的房间布局变成真实的房间container.innerHTML = '';render(newVdom, container);</script>
</body></html>
解释:
- 先设计一个大楼的房间布局(创建虚拟 DOM),然后把这个布局变成真实的房间(渲染虚拟 DOM)。
- 过了一段时间,想改变一下房间布局(模拟页面状态更新),就设计一个新的布局(新的虚拟 DOM)。
- 这里简单处理,把原来的房间都拆了(清空容器),再按照新的布局造新的房间(重新渲染新的虚拟 DOM)。
4. 用 Diff 算法优化更新
// 简单的 Diff 算法实现
function diff(oldVdom, newVdom, container) {if (!oldVdom) {// 如果原来没有房间布局,就直接按照新的布局造房间render(newVdom, container);} else if (!newVdom) {// 如果新的布局没有了,就把原来的房间拆了container.removeChild(getDom(oldVdom));} else if (oldVdom.type!== newVdom.type) {// 如果房间类型不一样,就把原来的房间拆了,按照新的类型造房间const newDom = createDom(newVdom);container.replaceChild(newDom, getDom(oldVdom));} else {// 如果房间类型一样,就只更新房间的装修和里面的小房间updateDomProperties(getDom(oldVdom), oldVdom.props, newVdom.props);// 递归比较小房间的差异const maxLength = Math.max(oldVdom.props.children.length,newVdom.props.children.length);for (let i = 0; i < maxLength; i++) {diff(oldVdom.props.children[i],newVdom.props.children[i],getDom(oldVdom));}}
}// 获取虚拟 DOM 对应的真实 DOM
function getDom(vdom) {if (vdom._dom) {return vdom._dom;}return vdom;
}// 更新真实 DOM 的属性
function updateDomProperties(dom, oldProps, newProps) {// 把原来的装修(事件属性)都拆了const isEvent = name => name.startsWith('on');Object.keys(oldProps).filter(isEvent).forEach(name => {const eventType = name.toLowerCase().substring(2);dom.removeEventListener(eventType, oldProps[name]);});// 装上新的装修(事件属性)Object.keys(newProps).filter(isEvent).forEach(name => {const eventType = name.toLowerCase().substring(2);dom.addEventListener(eventType, newProps[name]);});// 更新其他装修(属性)const isProperty = key => key!== 'children' &&!isEvent(key);Object.keys(oldProps).filter(isProperty).forEach(name => {if (!newProps[name]) {dom[name] = '';}});Object.keys(newProps).filter(isProperty).forEach(name => {dom[name] = newProps[name];});
}// 创建真实 DOM 节点
function createDom(vdom) {const dom = vdom.type === 'TEXT_ELEMENT'? document.createTextNode(vdom.props.nodeValue): document.createElement(vdom.type);// 设置属性const isProperty = key => key!== 'children';Object.keys(vdom.props).filter(isProperty).forEach(name => {dom[name] = vdom.props[name];});// 递归渲染子节点vdom.props.children.forEach(child => {const childDom = createDom(child);dom.appendChild(childDom);});vdom._dom = dom;return dom;
}
解释:
diff
函数就像是一个装修监工,它会比较原来的房间布局(旧虚拟 DOM)和新的房间布局(新虚拟 DOM)。- 如果原来没有布局,就直接按照新的布局造房间。
- 如果新的布局没有了,就把原来的房间拆了。
- 如果房间类型不一样,就把原来的房间拆了,按照新的类型造房间。
- 如果房间类型一样,就只更新房间的装修(属性)和里面的小房间。
getDom
函数是用来找到虚拟 DOM 对应的真实房间的。updateDomProperties
函数是用来更新房间的装修(属性)的,包括事件属性和其他属性。createDom
函数和前面的render
函数类似,是用来根据虚拟 DOM 造真实房间的。
5. 使用 Diff 算法更新页面
// 使用 Diff 算法更新页面
diff(vdom, newVdom, container);
解释:
最后调用 diff
函数,让装修监工来比较新旧布局,只更新有变化的地方,而不是把整个大楼都拆了重新造,这样就能提高效率啦。
虚拟 DOM 中 diff 算法的具体步骤
整体概念
想象一下你有两个积木城堡模型,一个是旧的,一个是新的。Diff 算法就像是一个“找茬大师”,要找出这两个模型之间的不同之处,然后只对真实的城堡做那些有变化的修改,这样就能省不少力气。在虚拟 DOM 里,这两个模型就是新旧虚拟 DOM 树,真实的城堡就是真实的 DOM。
具体步骤
1. 开始比较(根节点比较)
Diff 算法会先从两个虚拟 DOM 树的根节点开始比较。这就好比“找茬大师”先看看两个积木城堡的最下面那层有没有不一样。
-
节点类型不同:如果两个根节点的类型不一样,比如说旧的根节点是个
<div>
,新的根节点变成了<p>
,那就相当于两个城堡最下面那层的形状完全不同。这时候,Diff 算法会直接把旧的根节点对应的真实 DOM 节点删掉,然后用新的根节点创建一个全新的真实 DOM 节点。就好像把旧城堡最下面那层拆了,重新按照新的形状搭一层。 -
节点类型相同:要是两个根节点类型一样,比如都是
<div>
,那就接着比较它们的属性和子节点。这就像两个城堡最下面那层形状一样,但还得看看这层的装饰(属性)和上面堆的小积木(子节点)有没有变化。
2. 比较属性
当两个节点类型相同时,Diff 算法会比较它们的属性。这就像检查两个形状相同的积木层,看看上面贴的贴纸、装的小窗户这些装饰是不是一样。
-
属性新增或修改:如果新节点有一些属性是旧节点没有的,或者相同属性的值不一样了,Diff 算法就会更新真实 DOM 节点的属性。比如新的
<div>
节点多了一个class="active"
的属性,或者某个属性的值从red
变成了blue
,那就会在真实的<div>
节点上加上或者修改这个属性。 -
属性删除:要是旧节点有某个属性,新节点却没有了,Diff 算法会把真实 DOM 节点上对应的属性删掉。就像把旧城堡那层上的某个贴纸撕下来。
3. 比较子节点
比较完属性后,Diff 算法会开始比较两个节点的子节点。这就像看看两个积木层上面堆的小积木有啥不同。这里面有几种不同的情况:
-
旧节点没有子节点,新节点有子节点:这就好比旧城堡那层上面啥都没堆,新城堡那层上面堆了好多小积木。Diff 算法会把新节点的子节点依次创建成真实 DOM 节点,然后添加到对应的真实 DOM 父节点下面。
-
旧节点有子节点,新节点没有子节点:和上面相反,旧城堡那层上面有小积木,新城堡那层空了。Diff 算法会把旧节点对应的真实 DOM 节点下面的子节点都删掉。
-
新旧节点都有子节点:这种情况比较复杂,Diff 算法会采用一些策略来高效地比较。常见的策略是使用
key
值。
4. 递归比较
对于每个子节点,Diff 算法会重复上面的步骤,也就是先比较节点类型,再比较属性,最后比较子节点。这就像“找茬大师”从城堡的最下面一层开始,一层一层往上找不同,每一层都仔细检查上面的小积木。通过这种递归的方式,Diff 算法会遍历整个虚拟 DOM 树,找出所有的差异。
5. 更新真实 DOM
当 Diff 算法找出所有的差异后,就会根据这些差异来更新真实的 DOM。它会把需要删除的节点从真实 DOM 中移除,把需要新增的节点添加进去,把需要修改属性的节点进行属性更新。这样,真实的 DOM 就和新的虚拟 DOM 树保持一致了,就像把积木城堡按照新的模型修改好了。
总结
Diff 算法通过从根节点开始,先比较节点类型,再比较属性和子节点,采用递归的方式遍历整个虚拟 DOM 树,找出新旧虚拟 DOM 树的差异,最后根据这些差异来更新真实 DOM。使用 key
值可以让 Diff 算法更高效地找出差异,避免不必要的操作,提高性能。