網上關於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…