一文搞懂虚拟DOM和DOM-diff
Posted Vue中文社区
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文搞懂虚拟DOM和DOM-diff相关的知识,希望对你有一定的参考价值。
Keep Moving
神奇的虚拟DOM
实现一下虚拟DOM
// 全局安装
npm i create-react-app -g
// 生成项目
create-react-app dom-diff
// 进入项目目录
cd dom-diff
// 编译
npm run start
创建虚拟DOM
// element.js
// 虚拟DOM元素的类,构建实例对象,用来描述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
// 首先引入对应的方法来创建虚拟DOM
import { createElement } from './element';
let virtualDom = createElement( 'ul', { class: 'list'}, [
createElement( 'li', { class: 'item'}, [ '周杰伦']),
createElement( 'li', { class: 'item'}, [ '林俊杰']),
createElement( 'li', { class: 'item'}, [ '王力宏'])
]);
console.log(virtualDom);
-
参数分析 -
type: 指定元素的标签类型,如'li', 'div', 'a'等 -
props: 表示指定元素身上的属性,如class, style, 自定义属性等 -
children: 表示指定元素是否有子节点,参数以数组的形式传入
渲染虚拟DOM
// element.js
class Element {
// 省略
}
functioncreateElement() {
// 省略
}
// render方法可以将虚拟DOM转化成真实DOM
function render(domObj) {
// 根据type类型来创建对应的元素
let el = document.createElement(domObj.type);
// 再去遍历props属性对象,然后给创建的元素el设置属性
for ( let key in domObj.props) {
// 设置属性的方法
setAttr(el, key, domObj.props[key]);
}
// 遍历子节点
// 如果是虚拟DOM,就继续递归渲染
// 不是就代表是文本节点,直接创建
domObj.children.forEach( child => {
child = (child instanceof Element) ? render(child) : document.createTextNode(child);
// 添加到对应元素内
el.appendChild(child);
});
return el;
}
// 设置属性
functionsetAttr(node, key, value) {
switch(key) {
case 'value':
// node是一个input或者textarea就直接设置其value即可
if (node.tagName.toLowerCase() === 'input' ||
node.tagName.toLowerCase() === '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 {
Element,
createElement,
render,
setAttr,
renderDom
};
调用render方法
// index.js
// 引入createElement、render和renderDom方法
import { createElement, render, renderDom } from './element';
let virtualDom = createElement( 'ul', { class: 'list'}, [
createElement( 'li', { class: 'item'}, [ '周杰伦']),
createElement( 'li', { class: 'item'}, [ '林俊杰']),
createElement( 'li', { class: 'item'}, [ '王力宏'])
]);
console.log(virtualDom);
// +++
let el = render(virtualDom); // 渲染虚拟DOM得到真实的DOM结构
console.log(el);
// 直接将DOM添加到页面内
renderDom(el, document.getElementById( 'root'));
// +++
DOM-diff闪亮登场
// diff.js
function diff(oldTree, newTree) {
// 声明变量patches用来存放补丁的对象
let patches = {};
// 第一次比较应该是树的第0个索引
let index = 0;
// 递归树 比较后的结果放到补丁里
walk(oldTree, newTree, index, patches);
return patches;
}
function walk(oldNode, newNode, index, patches) {
// 每个元素都有一个补丁
let current = [];
if (!newNode) { // rule1
current.push({ type: 'REMOVE', index });
} elseif (isString(oldNode) && isString(newNode)) {
// 判断文本是否一致
if (oldNode !== newNode) {
current.push({ type: 'TEXT', text: newNode });
}
} elseif (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);
} else { // 说明节点被替换了
current.push({ type: 'REPLACE', newNode});
}
// 当前元素确实有补丁存在
if (current.length) {
// 将元素和补丁对应起来,放到大补丁包中
patches[index] = current;
}
}
function isString(obj) {
return typeof obj === 'string';
}
function diffAttr(oldAttrs, newAttrs) {
let patch = {};
// 判断老的属性中和新的属性的关系
for ( let key in oldAttrs) {
if (oldAttrs[key] !== newAttrs[key]) {
patch[key] = newAttrs[key]; // 有可能还是undefined
}
}
for ( let key in newAttrs) {
// 老节点没有新节点的属性
if (!oldAttrs.hasOwnProperty(key)) {
patch[key] = newAttrs[key];
}
}
return patch;
}
// 所有都基于一个序号来实现
let num = 0;
function diffChildren(oldChildren, newChildren, patches) {
// 比较老的第一个和新的第一个
oldChildren.forEach( (child, index) => {
walk(child, newChildren[index], ++num, patches);
});
}
// 默认导出
export default diff;
比较规则
-
新的DOM节点不存在{type: 'REMOVE', index} -
文本的变化{type: 'TEXT', text: 1} -
当节点类型相同时,去看一下属性是否相同,产生一个属性的补丁包{type: 'ATTR', attr: {class: 'list-group'}} -
节点类型不相同,直接采用替换模式{type: 'REPLACE', newNode}
-
每个元素都有一个补丁,所以需要创建一个放当前补丁的数组 -
如果没有new节点的话,就直接将type为REMOVE的类型放到当前补丁里
if (!newNode) {
current.push({ type: 'REMOVE', index });
}
-
如果新老节点是文本的话,判断一下文本是否一致,再指定类型TEXT并把新节点放到当前补丁
elseif (isString(oldNode) && isString(newNode)) {
if (oldNode !== newNode) {
current.push({ type: 'TEXT', text: newNode });
}
}
-
如果新老节点的类型相同,那么就来比较一下他们的属性props -
属性比较 -
diffAttr -
去比较新老Attr是否相同 -
把newAttr的键值对赋给patch对象上并返回此对象 -
然后如果有子节点的话就再比较一下子节点的不同,再调一次walk -
diffChildren -
遍历oldChildren,然后递归调用walk再通过child和newChildren[index]去diff
elseif (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;
}
patch补丁更新
import { Element, render, setAttr } from './element';
let allPatches;
let index = 0; // 默认哪个需要打补丁
function patch(node, patches) {
allPatches = patches;
// 给某个元素打补丁
walk(node);
}
function walk(node) {
let current = allPatches[index++];
let childNodes = node.childNodes;
// 先序深度,继续遍历递归子节点
childNodes.forEach( child => walk(child));
if (current) {
doPatch(node, current); // 打上补丁
}
}
functiondoPatch(node, patches) {
// 遍历所有打过的补丁
patches.forEach( patch => {
switch (patch.type) {
case 'ATTR':
for ( let key in patch.attr) {
let value = patch.attr[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;
newNode = (newNode instanceof Element) ? render(newNode) : document.createTextNode(newNode);
node.parentNode.replaceChild(newNode, node);
break;
case 'REMOVE':
node.parentNode.removeChild(node);
break;
default:
break;
}
});
}
export default patch;
patch做了什么?
-
用一个变量来得到传递过来的所有补丁allPatches -
patch方法接收两个参数(node, patches) -
在方法内部调用walk方法,给某个元素打上补丁 -
walk方法里获取所有的子节点 -
给子节点也进行先序深度优先遍历,递归walk -
如果当前的补丁是存在的,那么就对其打补丁(doPatch) -
doPatch打补丁方法会根据传递的patches进行遍历 -
判断补丁的类型来进行不同的操作
-
属性ATTR for in去遍历attrs对象,当前的key值如果存在,就直接设置属性setAttr;如果不存在对应的key值那就直接删除这个key键的属性 -
文字TEXT 直接将补丁的text赋值给node节点的textContent即可 -
替换REPLACE 新节点替换老节点,需要先判断新节点是不是Element的实例,是的话调用render方法渲染新节点;
-
删除REMOVE 直接调用父级的removeChild方法删除该节点
-
将patch方法默认导出方便调用
回归
// index.js
import { createElement, render, renderDom } from './element';
// +++ 引入diff和patch方法
import diff from './diff';
import patch from './patch';
// +++
let virtualDom = createElement( 'ul', { class: 'list'}, [
createElement( 'li', { class: 'item'}, [ '周杰伦']),
createElement( 'li', { class: 'item'}, [ '林俊杰']),
createElement( 'li', { class: 'item'}, [ '王力宏'])
]);
let el = render(virtualDom);
renderDom(el, window.root);
// +++
// 创建另一个新的虚拟DOM
let virtualDom2 = createElement( 'ul', { class: 'list-group'}, [
createElement( 'li', { class: 'item active'}, [ '七里香']),
createElement( 'li', { class: 'item'}, [ '一千年以后']),
createElement( 'li', { class: 'item'}, [ '需要人陪'])
]);
// diff一下两个不同的虚拟DOM
let patches = diff(virtualDom, virtualDom2);
console.log(patches);
// 将变化打补丁,更新到el
patch(el, patches);
// +++
四句话
-
用JS对象模拟DOM(虚拟DOM) -
把此虚拟DOM转成真实DOM并插入页面中(render) -
如果有事件发生修改了虚拟DOM,比较两棵虚拟DOM树的差异,得到差异对象(diff) -
把差异对象应用到真正的DOM树上(patch)
以上是关于一文搞懂虚拟DOM和DOM-diff的主要内容,如果未能解决你的问题,请参考以下文章