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…

相关文章
相关标签/搜索