Virtual Dom演算法實現筆記

frontdog發表於2019-03-03

前言

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

很多程式碼來自github庫:hyperapp,幾百行程式碼的庫,擁有了redux和react的某些特性,可以一看。

本文也會實現一個簡單的元件類,可以用來渲染試圖。

什麼是VD?

顧名思義,VD就是虛擬Dom,也就是不真實的。

舉例來說,如果html內容為:

<div id="container">
    <p>This is content</p>
</div>
複製程式碼

對應的VD為:

{
    nodeName: 'div',
    attributes: { id: 'container' }
    children: [
        {
            nodeName: 'p',
            attributes: {},
            children: ['This is content']
        }
    ]
}
複製程式碼

可以看出,VD就是用js物件描述出當前的dom的一些基本資訊。

使用jsx編譯轉化工具

預設假設你知道jsx的概念,不知道的可以google一下。

元件類中我們也希望有個render函式,用來渲染檢視,所以我們需要將jsx語法轉化成純js語法。

那麼怎麼編譯轉化呢?

使用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…

相關文章