Virtual Dom算法实现笔记

2,107 阅读6分钟

前言

网上关于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…