写在前面
最近在整理vue知识的时候,就深入了去学习了dom diff算法,对于我前端刚刚满一年的菜鸟来说,学习源码是一件很困难的事情,但是为了以后迎娶白富美,所以现在必须努力
前言
1.虚拟DOM是什么?
所谓的虚拟DOM就是用JS去按照DOM结构来实现的树形结构对象,也可以叫做DOM对象。
2. diff的比较方式?
在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。算法也是采用了先序遍历
dom diff流程又是怎么样的呢
dom diff的实现需要分为下面的4个步骤,废话不多说,让我们来实现一个简版的dom diff吧!
- 用JavaScript对象模拟DOM
- 虚拟DOM转成真实DOM并插入页面中
- 两棵虚拟DOM树的差异,得到差异对象
- 异对象应用到真正的DOM树上
项目目录结构
先发个图,来看一下整个目录结构是什么样子的
这个目录结构是用create-react-app脚手架直接生成的,也是为了方便编译调试
// 全局安装
npm i create-react-app -g
// 生成项目
create-react-app dom-diff
// 进入项目目录
cd dom-diff
// 编译
npm run start
创建虚拟DOM
首先实现一下如何创建虚拟DOM,看代码:
// element.js
// 虚拟DOM元素类
class Element {
constructor(type, props, children) {
this.type = type;
this.props = props;
this.children = children;
}
}
// 创建虚拟DOM,返回虚拟节点(object)
function createElement(type, props, children) {
return new Element(type, props, children);
}
export {
Element,
createElement
}
写好了我们下面把createElement引入到index.js来看看结果吧
完成第一步,调用createElement方法把JavaScript对象模拟DOM
import {createElement} from './element';
let vertualDom1 = createElement('ul', { class: 'list' }, [ createElement('li', { class: 'item' }, ['1']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item ' }, ['3'])
]);
let vertualDom2 = createElement('ul', { class: 'list-group' }, [ createElement('li', { class: 'item' }, ['3']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item item3' }, ['1']),
]);
console.log('vertualDom1',vertualDom1)
createElement方法也是vue用来创建虚拟DOM的方法,接收三个参数,分别是type,props和children
参数分析
- type: 指定元素的标签类型,如'li', 'div', 'a'等
- props: 表示指定元素身上的属性,如class, style, 自定义属性等
- children: 表示指定元素是否有子节点,参数以数组的形式传入
下面来看一下打印出来的虚拟DOM,如下图
完成第二步 虚拟DOM转成真实DOM并插入页面中
// element.js
class Element {
// 省略
}
function createElement() {
// 省略
}
// render方法可以将虚拟DOM转化成真实DOM
function render(eleObj) {
let el = document.createElement(eleObj.type);
for (let key in eleObj.props) {
// 设置属性方法
setAttr(el, key, eleObj.props[key]);
}
// 遍历子节点,如果是虚拟DOM继续渲染,不是就代表的是文本节点
eleObj.children.forEach((child) => {
child =
child instanceof Element ? render(child) : document.createTextNode(child);
el.appendChild(child);
});
return el;
}
// 设置属性
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;
}
}
// 将元素插入到页面内
function renderDom(el, target) {
target.appendChild(el);
}
export { createElement, render, renderDom, Element };
下面我们在index.js去调用render和renderDom方法吧
// index.js
// 引入createElement、render和renderDom方法
import { createElement, render, renderDom } from './element';
let vertualDom1 = createElement('ul', { class: 'list' }, [
createElement('li', { class: 'item' }, ['1']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item ' }, ['3'])
]);
let vertualDom2 = createElement('ul', { class: 'list-group' }, [
createElement('li', { class: 'item' }, ['3']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item item3' }, ['1']),
]);
console.log(vertualDom1);
let el = render(virtualDom); // 渲染虚拟DOM得到真实的DOM结构
console.log(el);
// 直接将DOM添加到页面内
renderDom(el, document.getElementById('root'));
下图为打印后的结果:
实现第三步 DOM-diff比较策略
// diff.js
const ATTRS = "ATTRS";
const TEXT = "TEXT";
const REMOVE = "REMOVE";
const REPLACE = "REPLACE";
let Index = 0;
function diff(oldTree, newTree) {
let patchers = {};
let index = 0; // 这个index表示是第几层的dom
walk(oldTree, newTree, index, patchers);
return patchers;
}
// 判断是否是字符串类型
function isString(node) {
return Object.prototype.toString.call(node) === "[object String]";
}
function diffAttr(oldAttrs, newAttrs) {
let patch = {};
// 判断老的属性和新的属性的关系
for (let key in oldAttrs) {
if (oldAttrs[key] !== newAttrs[key]) {
patch[key] = newAttrs[key]; // newAttrs[key]有可能为undefined
}
}
// 老节点没有新节点的属性
for (let key in newAttrs) {
if (!oldAttrs.hasOwnProperty(key)) {
patch[key] = newAttrs[key];
}
}
return patch;
}
function diffChildren(oldChildren, newChildren, patches) {
// 比较老的第一个和新的第一个
oldChildren.forEach((child, idx) => {
// 索引不应该是index了 ------------------
// index 每次传递给waklk时 index是递增的,所有的都基于一个序号来实现
walk(child,newChildren[idx],++Index,patches)
});
}
// index被私有化到walk作用域内
function walk(oldNode, newNode, index, patches) {
let currentPatch = []; // 每个元素都有一个补丁对象
if (!newNode) {
// 没有新节点就删除
currentPatch.push({ type: REMOVE, index });
} else if (isString(oldNode) && isString(newNode)) {
// 判断是文本节点
if (oldNode !== newNode) {
currentPatch.push({ type: TEXT, text: newNode });
}
} else if (oldNode.type === newNode.type) {
// 比较属性是否有更改
let attrs = diffAttr(oldNode.props, newNode.props);
if (Object.keys(attrs).length>0) {
currentPatch.push({ type: ATTRS, attrs });
}
// 如果有子节点 遍历子节点
diffChildren(oldNode.children, newNode.children, patches);
} else {
// 节点被替换情况
currentPatch.push({ type: REPLACE, newNode });
}
if (currentPatch.length > 0) {
// 当前元素有补丁包
// 将元素和补丁对应起来 放到大补丁包中
patches[index] = currentPatch;
}
}
export default diff;
比较规则
- 新的DOM节点不存在{type: 'REMOVE', index}
- 文本的变化{type: 'TEXT', text: 1}
- 当节点类型相同时,去看一下属性是否相同,产生一个属性的补丁包{type: 'ATTR', attr: {class: 'list-group'}}
- 节点类型不相同,直接采用替换模式{type: 'REPLACE', newNode}
walk方法都做了什么?
每个元素都有一个补丁,所以需要创建一个放当前补丁的数组如果没有新节点的话,就直接将type为REMOVE的类型放到当前补丁里
if (!newNode) {
current.push({ type: 'REMOVE', index });
}
如果新老节点是文本的话,判断一下文本是否一致,再指定类型TEXT并把新节点放到当前补丁
else if (isString(oldNode) && isString(newNode)) {
if (oldNode !== newNode) {
current.push({ type: 'TEXT', text: newNode });
}
}
如果新老节点的类型相同,那么就来比较一下他们的属性props,通过diffAttr函数来实现
- 去比较新老Attr是否相同
- 把newAttr的键值对赋给patch对象上并返回此对象
然后如果有子节点的话就再比较一下子节点的不同,再调一次walk
遍历oldChildren,然后递归调用walk再通过child和newChildren[index]去diff
else if (oldNode.type === newNode.type) {
// 比较属性是否有更改
let attr = diffAttr(oldNode.props, newNode.props);
if (Object.keys(attr).length > 0) {
current.push({ type: 'ATTR', attr });
}
// 如果有子节点,遍历子节点
diffChildren(oldNode.children, newNode.children, patches);
}
上面三个如果都没有发生的话,那就表示节点单纯的被替换了,type为REPLACE,直接用newNode替换即可
else {
current.push({ type: 'REPLACE', newNode});
}
当前补丁里确实有值的情况,就将对应的补丁放进大补丁包里
if (current.length > 0) {
// 将元素和补丁对应起来,放到大补丁包中
patches[index] = current;
}
下面我们把diff方法引入index.js文件来看一下结果吧
import {createElement,render,renderDom} from './element';
import diff from './diff'
let vertualDom1 = createElement('ul', { class: 'list' }, [
createElement('li', { class: 'item' }, ['1']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item ' }, ['3'])
]);
let vertualDom2 = createElement('ul', { class: 'list-group' }, [
createElement('li', { class: 'item' }, ['3']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item item3' }, ['1']),
]);
console.log('vertualDom1',vertualDom1)
let el = render(vertualDom1);
console.log('el',el)
let patches = diff(vertualDom1,vertualDom2);
console.log('patches',patches);
renderDom(el, window.root);
如图:
实现第四步 最后我们来完成patch方法吧dom diff的结果补丁到真实的dom里面去
// patch.js
import {Element,render} from './element';
let allPathes;
let index = 0; // 默认哪个需要打补丁
function patch(node,patches) {
allPathes = patches;
walk(node);
// 给某个元素打补丁
}
function walk(node) {
let currentPatch = allPathes[index++];
let childNodes = node.childNodes;
childNodes.forEach(child =>walk(child));
if(currentPatch){
doPatch(node, currentPatch);
}
}
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;
}
}
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.textContent = patch.text;
break;
case 'REPLACE':
let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode);
node.parentNode.replaceChild(newNode,node);
break;
case 'REMOVE':
node.parentNode.removeChild(node);
break;
default:
break;
}
});
}
export default patch;
patch做了什么?
-
用一个变量来得到传递过来的所有补丁allPatches
-
patch方法接收两个参数(node, patches)
1.在方法内部调用walk方法,给某个元素打上补丁
-
walk方法里获取所有的子节点
1.给子节点也进行先序深度优先遍历,递归walk
2.如果当前的补丁是存在的,那么就对其打补丁(doPatch)
-
doPatch打补丁方法会根据传递的patches进行遍历,判断补丁的类型来进行不同的操作
1.属性ATTR for in去遍历attrs对象,当前的key值如果存在,就直接设置属性setAttr; 如果不存在对应的key值那就直接删除这个key键的属性
2.文字TEXT 直接将补丁的text赋值给node节点的textContent即可
3.替换REPLACE 新节点替换老节点,需要先判断新节点是不是Element的实例,是的话调用render方法渲染新节点;不是的话就表明新节点是个文本节点,直接创建一个文本节点就OK了。
4.删除REMOVE 直接调用父级的removeChild方法删除该节点
最后让我们测试一下吧
import {createElement,render,renderDom} from './element';
import diff from './diff'
import patch from './patch'
let vertualDom1 = createElement('ul', { class: 'list' }, [
createElement('li', { class: 'item' }, ['1']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item ' }, ['3'])
]);
let vertualDom2 = createElement('ul', { class: 'list-group' }, [
createElement('li', { class: 'item' }, ['3']),
createElement('li', { class: 'item' }, ['2']),
createElement('li', { class: 'item item3' }, ['1']),
]);
console.log('vertualDom1',vertualDom1)
let el = render(vertualDom1);
console.log('el',el)
renderDom(el, window.root);
let patches = diff(vertualDom1,vertualDom2);
console.log('patches',patches);
// 给元素打补丁 重新更新视图
patch(el, patches);
如图:
总结
我们已经把一个简单的dom diff已经撸出来了(小撸怡情,大撸伤身,强撸灰飞烟灭),不容易呀,最后附上整个dom diff的流程图
如果想看整个项目代码的话,可以通过github.com/xiehaitao02… 地址去下载