网上关于virtual dom(下面简称VD)的博客数不胜数,不少都写得很好,本文是我初学VD算法实现的总结,在回顾的同时,但愿对于一样初学的人有所启发,注意,这篇文章介绍实现的东西较少,见谅。javascript
不少代码来自github库:hyperapp,几百行代码的库,拥有了redux和react的某些特性,能够一看。html
本文也会实现一个简单的组件类,能够用来渲染试图。java
顾名思义,VD就是虚拟Dom,也就是不真实的。node
举例来讲,若是html内容为:react
<div id="container">
<p>This is content</p>
</div>
复制代码
对应的VD为:webpack
{
nodeName: 'div',
attributes: { id: 'container' }
children: [
{
nodeName: 'p',
attributes: {},
children: ['This is content']
}
]
}
复制代码
能够看出,VD就是用js对象描述出当前的dom的一些基本信息。git
默认假设你知道jsx的概念,不知道的能够google一下。github
组件类中咱们也但愿有个render函数,用来渲染视图,因此咱们须要将jsx语法转化成纯js语法。web
那么怎么编译转化呢?算法
使用React JSX transform进行编译转化
若是render代码以下:
import { e } from './vdom';
...
render() {
const { state } = this;
return (
<div id="container"> <p>{state.count}</p> <button onClick={() => this.setState({ count: state.count + 1 })}>+</button> <button onClick={() => this.setState({ count: state.count - 1 })}>-</button> </div>
);
}
复制代码
须要在webpack.config.js中配置:
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["es2015"],
plugins: [
["transform-react-jsx", { "pragma": "e" }]
]
}
}
]
},
复制代码
在loader的babel插件中添加transform-react-jsx,pragma定义的是你的VD生成函数名,这个函数下面会说到。
这样配置,webpack打包后的代码以下:
function render() {
var _this2 = this;
var state = this.state;
return (0, _vdom.e)(
'div',
{ className: 'container' },
(0, _vdom.e)(
'p',
null,
state.count
),
(0, _vdom.e)(
'button',
{ onClick: function onClick() {
return _this2.setState({ count: state.count + 1 });
} },
'+'
),
(0, _vdom.e)(
'button',
{ onClick: function onClick() {
return _this2.setState({ count: state.count - 1 });
} },
'-'
)
);
}
复制代码
这样就把jsx转化成了js逻辑,能够看到,这个函数里面有个_vdom.e函数,是咱们在webpack配置中指定的,这个函数的做用是用来生成符合本身指望的VD的结构,须要自定义
能够看到,在上述编译结果中有下面的代码:
(0, _vdom.e)('div');
复制代码
是什么意思呢?有什么做用?
尝试后发现(0, 变量1, 变量2)这样的语法在js中总会返回最后一项,因此上面代码等同:
_vdom.e('div');
复制代码
做用,咱们能够看下代码就知道了
const obj = {
method: function() { return this; }
};
obj.method() === obj; // true
(0, obj.method)() === obj; // false
复制代码
因此,这个写法的其中一个做用就是使用对象的方法的时候不传递这个对象做为this到函数中。
至于其余做用,你们自行google,我google到的还有一两种不一样场景的做用。
咱们但愿获得的结构是:
{
nodeName, // dom的nodeName
attributes, // 属性
children, // 子节点
}
复制代码
因此咱们的自定义函数为:
function e(nodeName, attributes, ...rest) {
const children = [];
const checkAndPush = (node) => {
if (node != null && node !== true && node !== false) {
children.push(node);
}
}
rest.forEach((item) => {
if (Array.isArray(item)) {
item.forEach(sub => checkAndPush(sub));
} else {
checkAndPush(item);
}
});
return typeof nodeName === "function"
? nodeName(attributes || {}, children)
: {
nodeName,
attributes: attributes || {},
children,
key: attributes && attributes.key
};
}
复制代码
代码比较简单,提一点就是,因为编译结果的子节点是所有做为参数依次传递进vdom.e中的,因此须要你本身进行收集,用了ES6的数组解构特性:
...rest
等同
const rest = [].slice.call(arguments, 2)
复制代码
页面以下图,咱们要实现本身的一个Component类:
需求:
点击"+"增长数字
点击"-"减小数字
须要完成的功能:
<p>{state.count}</p>
复制代码
<button onClick={() => this.setState({ count: state.count + 1 })}>+</button>
复制代码
设计得比较简单,主要是模仿React的写法,不过省略了生命周期,setState是同步的,整个核心代码是patch阶段,这个阶段对比了新旧VD,获得须要dom树中须要修改的地方,而后同步更新到dom树中。
组件类:
class Component {
constructor() {
this._mounted = false;
}
// 注入到页面中
mount(root) {
this._root = root;
this._oldNode = virtualizeElement(root);
this._render();
this._mounted = true;
}
// 更新数据
setState(newState = {}) {
const { state = {} } = this;
this.state = Object.assign(state, newState);
this._render();
}
// 渲染Virtual Dom
_render() {
const { _root, _oldNode } = this;
const node = this.render();
this._root = patch(_root.parentNode, _root, _oldNode, node);
this._oldNode = node;
}
}
复制代码
刚才上面咱们已经将render函数转化为纯js逻辑,而且实现了vdom.e函数,因此咱们经过render()
就能够获取到返回的VD:
{
nodeName: "div",
attributes: { id: "container" },
children: [
{
nodeName: "p",
attributes: {},
children: [0],
},
{
nodeName: "button",
attributes: { onClick: f },
children: ["+"]
},
{
nodeName: "button",
attributes: { onClick: f },
children: ["-"]
}
]
}
复制代码
有2种状况:
function virtualizeElement(element) {
const attributes = {};
for (let attr of element.attributes) {
const { name, value } = attr;
attributes[name] = value;
}
return {
nodeName: element.nodeName.toLowerCase(),
attributes,
children: [].map.call(element.childNodes, (childNode) => {
return childNode.nodeType === Node.TEXT_NODE
? childNode.nodeValue
: virtualizeElement(childNode)
}),
key: attributes.key,
}
}
复制代码
递归去转化子节点
html中:
<div id="contianer"></div>
复制代码
VD为:
{
nodeName: 'div',
attributes: { id: 'container' },
children: [],
}
复制代码
拿到新旧VD后,咱们就能够开始对比过程了
parent:对比节点的父节点
element:对比节点
oldNode:旧的virtual dom
node:新的virtual dom
复制代码
下面咱们就进入patch函数体了
这种状况说明dom无变化,直接返回
if (oldNode === node) {
return element;
}
复制代码
这两种状况都说明须要生成新的dom,并插入到dom树中,若是是nodeName发生变化,还须要将旧的dom移除。
if (oldNode == null || oldNode.nodeName !== node.nodeName) {
const newElement = createElement(node);
parent.insertBefore(newElement, element);
if (oldNode != null) {
removeElement(parent, element, oldNode);
}
return newElement;
}
复制代码
函数中createElement是将VD转化成真实dom的函数,是virtualizeElement的逆过程。removeElement,是删除节点,两个函数代码不上了,知道意思便可。
// 或者判断条件:oldNode.nodeName == null
if (typeof oldNode === 'string' || typeof oldNode === 'number') {
element.nodeValue = node;
return element;
}
复制代码
主要作两件事:
注意,这里把diff和patch过程合在一块儿了,其中,
attributes对比主要有:
children对比,这个是重点难点!!,dom的状况主要有:
updateElement(element, oldNode.attributes, node.attributes);
复制代码
updateElement:
function updateElement(element, oldAttributes = {}, attributes = {}) {
const allAttributes = { ...oldAttributes, ...attributes };
Object.keys(allAttributes).forEach((name) => {
const oldValue = name in element ? element[name] : oldAttributes[name];
if ( attributes[name] !== oldValue) ) {
updateAttribute(element, name, attributes[name], oldAttributes[name]);
}
});
}
复制代码
若是发现属性变化了,使用updateAttribute进行更新。判断属性变化的值分红普通的属性和像value、checked这样的影响dom的属性
updateAttribute:
function eventListener(event) {
return event.currentTarget.events[event.type](event)
}
function updateAttribute(element, name, newValue, oldValue) {
if (name === 'key') { // ignore key
} else if (name === 'style') { // 样式,这里略
} else {
// onxxxx都视为事件
const match = name.match(/^on([a-zA-Z]+)$/);
if (match) {
// event name
const name = match[1].toLowerCase();
if (element.events) {
if (!oldValue) {
oldValue = element.events[name];
}
} else {
element.events = {}
}
element.events[name] = newValue;
if (newValue) {
if (!oldValue) {
element.addEventListener(name, eventListener)
}
} else {
element.removeEventListener(name, eventListener)
}
} else if (name in element) {
element[name] = newValue == null ? '' : newValue;
} else if (newValue != null && newValue !== false) {
element.setAttribute(name, newValue)
}
if (newValue == null || newValue === false) {
element.removeAttribute(name)
}
}
}
复制代码
其余的状况不展开,你们看代码应该能够看懂,主要讲下事件的逻辑:
上面代码中,咱们看addEventListener和removeEventListener能够发现,绑定和解绑事件处理都是使用了eventListener这个函数,为何这么作呢?
看render函数:
render() {
...
<button onClick={() => this.setState({ count: state.count + 1 })}>+</button>
...
}
复制代码
onClick属性值是一个匿名函数,因此每次执行render的时候,onClick属性都是一个新的值这样会致使removeEventListener没法解绑旧处理函数。
因此你应该也想到了,咱们须要缓存这个匿名函数来保证解绑事件的时候能找到这个函数
咱们能够把绑定数据挂在dom上,这时候可能写成:
if (match) {
const eventName = match[1].toLowerCase();
if (newValue) {
const oldHandler = element.events && element.events[eventName];
if (!oldHandler) {
element.addEventListener(eventName, newValue);
element.events = element.events || {};
element.events[eventName] = newValue;
}
} else {
const oldHandler = element.events && element.events[eventName];
if (oldHandler) {
element.removeEventListener(eventName, oldHandler);
element.events[eventName] = null;
}
}
}
复制代码
这样在这个case里面其实也是正常工做的,可是有个bug,若是绑定函数更换了,什么意思呢?如:
<button onClick={state.count === 0 ? fn1 : fn2}>+</button>
复制代码
因此通通托管到一个固定函数
currentTarget始终是监听事件者,而target是事件的真正发出者
也就是说,若是一个dom绑定了click事件,若是你点击的是dom的子节点,这时候event.target就等于子节点,event.currentTarget就等于dom
这里只有element的diff,没有component的diff children的patch是一个list的patch,这里采用和React同样的思想,节点能够添加惟一的key进行区分, 先上代码:
function patchChildren(element, oldChildren = [], children = []) {
const oldKeyed = {};
const newKeyed = {};
const oldElements = [];
oldChildren.forEach((child, index) => {
const key = getKey(child);
const oldElement = oldElements[index] = element.childNodes[index];
if (key != null) {
oldKeyed[key] = [child, oldElement];
}
});
let n = 0;
let o = 0;
while (n < children.length) {
const oldKey = getKey(oldChildren[o]);
const newKey = getKey(children[n]);
if (newKey == null) {
if (oldKey == null) {
patch(element, oldElements[o], oldChildren[o], children[n]);
n++;
}
o++;
} else {
const keyedNode = oldKeyed[newKey] || [];
if (newKey === oldKey) {
// 说明两个dom的key相等,是同一个dom
patch(element, oldElements[o], oldChildren[o], children[n]);
o++;
} else if (keyedNode[0]) {
// 说明新的这个dom在旧列表里有,须要移动到移动到的dom前
const movedElement = element.insertBefore(keyedNode[1], oldElements[o]);
patch(element, movedElement, keyedNode[0], children[n]);
} else {
// 插入
patch(element, oldElements[o], null, children[n]);
}
newKeyed[newKey] = children[n];
n++;
}
}
while (o < oldChildren.length) {
if (getKey(oldChildren[o]) == null) {
removeElement(element, oldElements[o], oldChildren[o])
}
o++
}
for (let key in oldKeyed) {
if (!newKeyed[key]) {
removeElement(element, oldKeyed[key][0], oldKeyed[key][1])
}
}
}
复制代码
以下图是新旧VD的一个列表图, 咱们用这个列表带你们跑一遍代码:
上图中,字母表明VD的key,null表示没有key
咱们用n做为新列表的下标,o做为老列表的下标
let n = 0
let o = 0
复制代码
开始遍历新列表
while (newIndex < newChildren.length) {
...
}
复制代码
下面是在遍历里面作的事情:
newKey = 'E', oldKey = 'A'
newKey不为空,oldKey也不为空,oldKey !== newKey,且oldKeyed[newKey] == null,因此应该走到插入的代码:
patch(element, oldElements[o], null, children[n]);
复制代码
旧列表中的A node尚未对比,因此这里o不变,o = 0
新列表中E node参与对比了,因此n++, n = 1
开始下一个循环。
patch(element, oldElements[o], oldChildren[o], children[n]);
复制代码
旧列表A node对比了,因此o++,o = 1;
新列表A node对比了,因此n++,n = 2;
进入下一个循环。
patch(element, oldElements[o], null, children[n]);
复制代码
旧列表B node没有参与对比,因此o不变,o = 1;
新列表C node对比了,因此n++,n = 3;
进入下一个循环。
const movedElement = element.insertBefore(keyedNode[1], oldElements[o]);
patch(element, movedElement, keyedNode[0], children[n]);
复制代码
旧列表B node没有参与对比,因此o不变,o = 1;
新列表C node对比了,因此n++,n = 4;
进入下一个循环。
直接跳过这个旧节点,不参与对比
o++
复制代码
旧列表B node因为newKey为null不参与对比,o++,o = 2;
新列表的当前Node没有对比,n不变,n = 4
进入下一个循环。
patch(element, oldElements[o], oldChildren[o], children[n]);
复制代码
旧列表当前 node参与对比,o++,o = 3;
新列表的当前 node参与对比,n++,n = 5;
结束循环。
删除o坐标后,没有key的节点
while (o < oldChildren.length) {
if (oldChildren[o].key == null) {
removeElement(element, oldElements[o], oldChildren[o])
}
o++;
}
复制代码
删除残留的有key的节点
for (let key in oldKeyed) {
if (!newKeyed[key]) {
removeElement(element, oldKeyed[key][0], oldKeyed[key][1])
}
}
复制代码
newKeyed在刚才的遍历中,遇到有key的会记录下来
DEMO源码下载 pan.baidu.com/s/1VLCZc0fZ…