随着前端领域快速发展,越来越多的前端框架不断涌现,当下React、Vue两个前端框架已经是前端开发者必备技能。
曾经我面试经常被问到:你了解虚拟DOM吗?简单说一下diff算法?你研究过React/Vue框架源码它们层次源码Dom-Diff是怎么实现的吗?
接下来,我会一步一步的实现一个虚拟DOM,讲解其中核心逻辑以及算法。图文并茂,让需要的小伙伴能够轻而易举的看懂也总结自己对虚拟DOM的理解方便后期复习。
注释
本文来自于
作者:txm
https://juejin.im/post/5e7ac6365188255de700f7ed
什么是虚拟DOM
Virtual dom, 即虚拟DOM节点。它通过JS
的Object对象模拟DOM中的节点,然后再通过特定的render方法将其渲染成真实的DOM节点。
为什么操作 dom 性能开销大
从上图可见,真实的 DOM 元素是非常庞大的,因为浏览器的标准就把 DOM 设计的非常复杂。当我们频繁的去做 DOM 更新,会产生一定的性能问题。而 Virtual DOM 就是用一个原生的 JS 对象去描述一个 DOM 节点,所以它比创建一个 DOM 的代价要小很多。
真实DOM转换成虚拟DOM
虚拟DOM就是一个普通的JavaScript对象,包含了tag
、props
、children
三个属性。
<div id="app">
<p class="text">TXM to SFM</p>
</div>
上面的HTML元素转换为虚拟DOM:
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: 'p',
props: {
className: 'text'
},
chidren: [
'TXM to SFM'
]
}
]
}
接下来,我们详细的介绍一下上面的HTML是如何转换成下面的JS树形结构的虚拟DOM对象。
初始化项目
创建项目
我们使用React脚手架创建一个项目,方便调试、编译、开效果...
// 全局安装
sudo npm install -g create-react-app
// 生成项目
create-react-app dom-diff
// 进入项目目录
cd dom-diff
// 编译
npm run start
虚拟DOM
createElement核心方法
createElement 接受type
, props
,children
三个参数创建一个虚拟标签元素DOM的方法。
function createElement(type, props, children) {
return new Element(type, props, children);
}
为了提高代码高度的复用性,我们将创建虚拟DOM元素的核心逻辑代码放到Element
类中。
class Element {
constructor(type, props, children) {
this.type = type;
this.props = props;
this.children = children;
}
}
注意:将这些参数挂载到该对象的私有属性上,这样在new时也会有这些属性。
render核心方法
render方法接受一个虚拟节点对象参数,其作用是:将虚拟DOM转换成真实DOM。
function render(eleObj) {
let el = document.createElement(eleObj.type); // 创建元素
for(let key in eleObj.props) {
// 设置属性的方法
setAttr(el, key, eleObj.props[key])
}
eleObj.children.forEach(child => {
// 判断子元素是否是Element类型,是则递归,不是则创建文本节点
child = (child instanceof Element) ? render(child) : document.createTextNode(child);
el.appendChild(child);
});
return el;
}
注意:在将虚拟DOM转换为真实DOM的时,转换属性时要考虑多种情况。像value
、style
等属性需要做特殊处理,具体的处理逻辑请看下方元素设置属性小节。
元素设置属性
在给元素设置属性的公共方法中接受三个参数:node
, key
, value
分别表示给那个元素设置属性、设置的属性名、以及设置属性的值。
function setAttr(node, key, value) {
switch(key) {
case 'value': // node是一个input或者textarea
if(node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA') {
node.value = value;
} else { // 普通属性
node.setAttribute(key, value);
}
break;
case 'style':
node.style.cssText = value;
break;
default:
node.setAttribute(key, value);
break;
}
}
以上我们只考虑了三种情况的属性,当我们设置完属性,还要判断children
属性的情况,具体的处理逻辑请看下方递归设置儿子小节。
递归设置儿子
判断子元素是否是Element
元素类型,是则递归
,不是则创建文本节点。注意:我们只考虑了元素类型和文本类型两种。
eleObj.children.forEach(child => {
// 判断子元素是否是Element类型,是则递归,不是则创建文本节点
child = (child instanceof Element) ? render(child) : document.createTextNode(child);
el.appendChild(child);
});
真实DOM渲染到浏览器上
我们都知道,render
方法的作用就是虚拟DOM转换为真实DOM,但是浏览器上为了看到效果,我们需要将真实DOM添加到浏览器上。我们写一个方法接受el
真实DOM和target
渲染目标两个参数。
function renderDom(el, target) {
target.appendChild(el);
}
上诉步骤完成后,就可以将这几个方法导出去供其他使用即可。
export {
createElement,
render,
Element,
renderDom
}
DOM Diff
Dom diff 则是通过JS
层面的计算,返回一个patch对象,即补丁对象,在通过特定的操作解析patch
对象,完成页面的重新渲染。
Diff 算法
规则:同层比较
Diff算法中有很多种情况,接下来我们以常见的几种情况做下讨论:
- 当节点类型相同时,去看一下属性是否相同 产生一个属性的补丁包 {type:'ATTRS', attrs: {class: 'list-group'}}
- 新的dom节点不存在 {type: 'REMOVE', index: xxx}
- 节点类型不相同 直接采用替换模式 {type: 'REPLACE', newNode: newNode}
- 文本的变化:{type: 'TEXT', text: 1}
比较两颗虚拟DOM树的核心diff方法接受oldTree
旧DOM树、newTree
新DOM树两个参数,根据两个虚拟对象创建出补丁,描述改变的内容,将这个补丁用来更新DOM。该方法的核心在于walk递归树
,该方法将比较后的差异节点放到补丁包中,具体递归树
核心逻辑请看下方walk递归树小节。
function diff(oldTree, newTree) {
let patches = {};
let index = 0; // 默认先比较树的第一层
// 递归树 比较后的节点放到补丁包中
walk(oldTree, newTree, index, patches);
return patches;
}
注意:在比较两棵树之间的差异时,默认先比较树的第一层。
walk 递归树
该递归树方法接受oldNode
老节点, newNode
新节点, index
比较层数, patches
存放补丁包四个参数,返回多种判断情况的常见差异存放在补丁包中。
情形一:新节点中删除了子节点
currentPatch.push({type: REMOVE, index: index});
情形二:判断两个文本是否一样
currentPatch.push({type: TEXT, text: newNode});
注意:目前只判断了文本字符串的情况,也存在数字的情况。
在判断两个文本是否一致时,首先要判断是不是属于文本类型,为了程序的可扩展性,我们封装一个判断是否是字符串的公共方法:
function isString(node) {
return Object.prototype.toString.call(node) === '[object String]';
}
情形三:两个节点数的元素类型相同, 就比较属性
在比较属性是否有更新时,我们需要封装一个diffAttr
方法,具体核心逻辑请查看下方diffAttr 属性比较小节。
let attrs = diffAttr(oldNode.props, newNode.props);
// 判断变化的属性结果有没有值
if(Object.keys(attrs).length > 0) { // 属性有更改
currentPatch.push({type: ATTRS, attrs})
}
注意:在第一层比较完后,若存在儿子节点,需要遍历递归
儿子,找出两颗节点数中所有的不同的补丁包。我们需要封装一个diffChildren
方法,具体核心逻辑请查看下方diffChildren 遍历儿子小节。
diffChildren(oldNode.children, newNode.children, patches);
情形四:都不相同,节点被替换了
currentPatch.push({type: REPLACE, newNode});
以上情形都判断后,需要判断对应的当前元素确实有补丁,然后返回赋值给自定义的patches
补丁对象。
diffAttr 属性比较
该方法接受oldAttrs
旧节点属性, newAttrs
新节点属性两个参数,其作用是比较两个节点数的属性是否相同,把不同的存放在patch对象中。
在属性比较中,有两种情况来判新旧节点的属性是否有差异。
-
判断老的属性中和新的属性的关系
for(let key in oldAttrs) { if(oldAttrs[key] !== newAttrs[key]) { patch[key] = newAttrs[key]; // 将新属性存放在patch对象中, 有可能是undefined(如果新的属性中没有老的属性中的属性) } }
-
老的节点中没有新节点中的属性
for(let key in newAttrs) { // 老的节点中没有新节点中的属性 if(!oldAttrs.hasOwnProperty(key)) { patch[key] = newAttrs[key]; } }
diffChildren 遍历儿子
在diffChildren
方法中接受oldChildren
老的儿子节点, newChildren
新的儿子节点, patches
补丁对象三个参数。
function diffChildren(oldChildren, newChildren, patches) {
oldChildren.forEach((child, idx) => {
walk(child, newChildren[idx], ++Index, patches);
});
}
注意:索引递增不是遍历的idx和index,而是需要在全局定义一个Index = 0
。
Patch 打补丁
当我们通过Diff算法获取补丁,然后通过patch
打补丁来进行更新DOM,从而更新页面视图。
打补丁的核心方法就是patch
,它接受node
元素节点, patches
所有的补丁两个参数,其作用就是给元素打补丁,重新更新视图。该方法最核心的逻辑就在walk
方法,请继续阅读下方walk 给每个元素打补丁小节。
function patch(node, patches) {
console.log(node)
allPatches = patches;
// 给某个元素打补丁
walk(node);
}
walk 给每个元素打补丁
该方法接受node
元素节点一个参数,将补丁一次次执行,获取元素的子节点进行递归遍历。若每一层存在补丁,则执行doPatch
方法。该方法具体核心逻辑请阅读下方doPatch小节。
function walk(node) {
let currentPatch = allPatches[index++];
let childNodes = node.childNodes;
childNodes.forEach(child => walk(child));
if(currentPatch) {
doPatch(node, currentPatch);
}
}
注意:需要全局定义allPatches
、index
变量。
doPatch
doPatch方法接受node
那个节点、patches
那个补丁两个参数,后序遍历补丁,判断补丁的类型来进行不同的操作:
- 当补丁的
type
为ATTR属性时,遍历属性attrs对象获取值。若值存在则setAttr设置属性值,若值不存在则删除对应的属性。setAttr方法具体的核心逻辑请阅读下方setAttr 设置属性小节。 - 当补丁的
type
为TEXT属性时,直接将补丁的text赋值给对应节点的textContent
。 - 当补丁的
type
为REMOVE属性时,直接调用父级的removeChild
删除该节点。 - 当补丁的
type
为REPLACE属性时,首先需要判断新节点是否为Element
元素类型,若是,则直接调用render
方法重新渲染新节点;若不是,则通过createTextNode
创建一个文本节点。最后调用父级的replaceChild方法替换新的节点即可。
function doPatch(node, patches) {
patches.forEach(patch => {
switch(patch.type) {
case 'ATTRS':
for(let key in patch.attrs) {
let value = patch.attrs[key];
if(value) {
setAttr(node, key, value);
} else{
node.removeAttribute(key);
}
}
break;
case 'TEXT':
node.textContext = patch.text;
break;
case 'REMOVE':
node.parentNode.removeChild(node);
break;
case 'REPLACE':
let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode);
node.parentNode.replaceChild(newNode, node);
break;
default:
break;
}
})
}
setAttr 设置属性
setAttr方法设置属性的具体逻辑,请查看上方元素设置属性小节。
验证
在项目根目录创建index.js
文件,通过手写修改DOM结构来验证我们上方编写的diff算法逻辑是否正确。
import {createElement, render, renderDom} from './element';
import diff from './diff'
import patch from './patch'
let virtualDom = createElement('ul', {class: 'list'}, [
createElement('li', {class: 'item'}, ['a']),
createElement('li', {class: 'item'}, ['a']),
createElement('li', {class: 'item'}, ['b'])
]);
let virtualDom2 = createElement('ul', {class: 'list-group'}, [
createElement('li', {class: 'item'}, ['1']),
createElement('li', {class: 'item'}, ['a']),
createElement('div', {class: 'item'}, ['3'])
]);
let dom = render(virtualDom);
// 将虚拟DOM转换成了真实DOM渲染到页面上
renderDom(dom, window.root);
// console.log(dom);
let patches = diff(virtualDom, virtualDom2);
// 给元素打补丁,重新更新视图
patch(dom, patches);
总结
从头看到尾,相信很多小伙伴就会觉得DOM-Diff整个过程是很清晰明了的,具体步骤:
- 用JS对象模拟DOM(虚拟DOM)
- 把此虚拟DOM转成真实DOM并插入页面中(render)
- 如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff)
- 把差异对象应用到真正的DOM树上(patch)
若该文章比有需要的小伙伴有帮助的话,请帮忙点个红心加波关注。star~
本文项目仓库地址:https://github.com/tangmengcheng/dom-diff.git
如果本文对你有帮助得话,给本文点个赞❤️❤️❤️
欢迎大家加入,一起学习前端,共同进步!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。