Virtual Dom算法實現筆記

前言

網上關於virtual dom(下面簡稱VD)的博客數不勝數,不少都寫得很好,本文是我初學VD算法實現的總結,在回顧的同時,但願對於一樣初學的人有所啓發,注意,這篇文章介紹實現的東西較少,見諒。javascript

不少代碼來自github庫:hyperapp,幾百行代碼的庫,擁有了redux和react的某些特性,能夠一看。html

本文也會實現一個簡單的組件類,能夠用來渲染試圖。java

什麼是VD?

顧名思義,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編譯轉化工具

默認假設你知道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, function)()的做用

能夠看到,在上述編譯結果中有下面的代碼:

(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到的還有一兩種不一樣場景的做用。

VD自定義函數

咱們但願獲得的結構是:

{ 
    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)
複製代碼

咱們以一個DEMO來說解VD算法實現過程

頁面以下圖,咱們要實現本身的一個Component類:

Alt pic

需求:

  • 點擊"+"增長數字

  • 點擊"-"減小數字

須要完成的功能:

  • 視圖中能更新數字:
<p>{state.count}</p>

複製代碼
  • 點擊事件綁定能力實現
<button onClick={() => this.setState({ count: state.count + 1 })}>+</button>
複製代碼
  • setState實現,執行後修改state而且應該觸發視圖更新

Component類工做流程設計

Alt pic

設計得比較簡單,主要是模仿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;
  }
}

複製代碼

獲取新的Virtual Dom

剛纔上面咱們已經將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: ["-"]
    }
  ]
}
複製代碼

獲取舊的Virtual Dom

有2種狀況:

  • 注入到document中的時候,這時候須要將container節點轉化爲VD
  • 更新數據的時候,直接拿到緩存起來的當前VD 附上將element轉化爲VD的函數:
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後,咱們就能夠開始對比過程了

function patch(parent, element, oldNode, node)

parent:對比節點的父節點
element:對比節點
oldNode:舊的virtual dom
node:新的virtual dom
複製代碼

下面咱們就進入patch函數體了

場景1: 新舊VD相等

這種狀況說明dom無變化,直接返回

if (oldNode === node) {
    return element;
}
複製代碼

場景2: oldNode不存在 or 節點的nodeName發生變化

這兩種狀況都說明須要生成新的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,是刪除節點,兩個函數代碼不上了,知道意思便可。

場景3: element是文本節點

// 或者判斷條件:oldNode.nodeName == null
if (typeof oldNode === 'string' || typeof oldNode === 'number') {
    element.nodeValue = node;
    return element;
  }
複製代碼

場景4: 若是以上場景都不符合,說明是擁有相同nodeName的節點的對比

主要作兩件事:

  1. attributes的patch
  2. children的patch

注意,這裏把diff和patch過程合在一塊兒了,其中,

attributes對比主要有:

  • 事件綁定、解綁
  • 普通屬性設置、刪除
  • 樣式設置
  • input的value、checked設置等

children對比,這個是重點難點!!,dom的狀況主要有:

  • 移除
  • 更新
  • 新增
  • 移動

attributes的patch

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>
複製代碼
  1. 那麼因爲第一次已經綁定了fn1,因此fn2就不會綁定了,這樣確定不對。
  2. 若是要修復,你須要從新綁定fn2,可是因爲你沒法判斷是換了函數,仍是隻是由於匿名函數而函數引用發生了變化,這樣每次都要從新解綁、綁定。
  3. 形成性能浪費

因此通通託管到一個固定函數

event.currentTarget和event.target

currentTarget始終是監聽事件者,而target是事件的真正發出者

也就是說,若是一個dom綁定了click事件,若是你點擊的是dom的子節點,這時候event.target就等於子節點,event.currentTarget就等於dom

children的patch:重點來了!!

這裏只有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的一個列表圖, 咱們用這個列表帶你們跑一遍代碼:

Alt pic

上圖中,字母表明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]);
複製代碼

Alt pic

舊列表中的A node尚未對比,因此這裏o不變,o = 0

新列表中E node參與對比了,因此n++, n = 1

開始下一個循環。

  • newKey = 'A', oldKey = 'A',newKey不爲空,oldKey也不爲空,newKey === oldKey,因此直接對比這兩個node
patch(element, oldElements[o], oldChildren[o], children[n]);
複製代碼

Alt pic

舊列表A node對比了,因此o++,o = 1;

新列表A node對比了,因此n++,n = 2;

進入下一個循環。

  • oldKey = 'B',newKey = 'C', newKey不爲空,oldKey也不爲空,oldKey !== newKey,且oldKeyed[newKey] == null,因此應該走到插入的代碼:
patch(element, oldElements[o], null, children[n]);
複製代碼

Alt pic

舊列表B node沒有參與對比,因此o不變,o = 1;

新列表C node對比了,因此n++,n = 3;

進入下一個循環。

  • oldKey = 'B',newKey = 'D', newKey不爲空,oldKey也不爲空,oldKey !== newKey,且oldKeyed[newKey] != null,移動舊dom,而且對比
const movedElement = element.insertBefore(keyedNode[1], oldElements[o]);
patch(element, movedElement, keyedNode[0], children[n]);
複製代碼

Alt pic

舊列表B node沒有參與對比,因此o不變,o = 1;

新列表C node對比了,因此n++,n = 4;

進入下一個循環。

  • oldKey = 'B',newKey = null, newKey == null,oldKey != null

直接跳過這個舊節點,不參與對比

o++
複製代碼

Alt pic

舊列表B node因爲newKey爲null不參與對比,o++,o = 2;

新列表的當前Node沒有對比,n不變,n = 4

進入下一個循環。

  • oldKey = null,newKey = null
patch(element, oldElements[o], oldChildren[o], children[n]);
複製代碼

Alt pic

舊列表當前 node參與對比,o++,o = 3;

新列表的當前 node參與對比,n++,n = 5;

結束循環。

  • 注意,舊列表中咱們在上述過程當中當oldKey != null, newKey == null的時候會跳過這個節點的對比,因此這時候列表中還存在一些多餘的節點,應該刪除,舊列表可能沒有遍歷完,也應該刪除

刪除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的會記錄下來

到這裏,children的對比就完成了,VD的patch是一個遞歸的過程,VD的算法實現到此結束,剩下的Component類你能夠本身添加不少東西來玩耍

DEMO源碼下載 pan.baidu.com/s/1VLCZc0fZ…

相關文章
相關標籤/搜索