从零开始,采用Vue的思想,开发一个本身的JS框架(三):update和diff

题外话

本章节咱们的主题是update和diff,这一章节可能理论部分会比较多。在开始这一块内容前,我以为有必要先大体看一下Vue和React实现这一部分的流程的:update->diff->patch->updateDOM。在开始更新后,会进行diff算法的比对,比对后会生成一个patch补丁包,而后再根据这个补丁包进行DOM的更新。补丁包中会经过id(或者序号)之类的标识来标识真实DOM的位置,定位到位置后,再经过修改的类型(如:新增节点、删除节点、修改节点等),来对不一样的状况进行DOM更新。vue

diff概述

何为diff?diff即为比较两颗VNode树之间的差别,对于我目前的开发阶段而言,实际上就是比较更新前和更新后的VNode(你也能够直接拿更新前的DOM和更新后的VNode比较),转化到数据结构来讲,其实就是对两颗树的比较。而传统的DFS(深度优先遍历)算法,其自己的时间复杂度达到O(n2),由于要将树1中每一个节点都与树2中的每一个节点进行比对。而在咱们当前的场景下,即对比两颗VNode树,咱们除了遍历比较之外,还须要选择出一个最优操做,因此时间复杂度从O(n2)上升到了O(n3),因此一个传统的diff过程,它的时间复杂度为O(n3)。node

面对如此高的复杂度,diff算法应运而生。了解过react和vue的diff算法的人应该都知道,他们的时间复杂度为O(n),他们diff的核心基本能够总结为如下几点:react

  1. 不作跨层级的比较(这也是咱们要尽可能减小跨层级操做的缘由)
  2. 对于不一样的标签元素,他们的子元素确定是不一样的
  3. 对于相同的标签元素,只会对其进行更新
  4. 同一层级的子节点,他们均可以经过key来区分

经过分析以上四点,咱们能够了解到react和vue的diff算法在DOM层面而言,其实并非最优的,可是它经过增大一部分DOM的开销,来使得时间复杂度大大下降,以一种还算过得去的修改DOM的性能(主要体如今一、2两点),来使时间复杂度达到尽可能低的阶段(O(n),只需一次遍历便可)。git

createElement和cloneNode

这里插一点与本文主题无关的内容,由于想到了就写一下。在长列表(很长很长的那种)的初次渲染中,咱们常常会遇到性能优化问题(这也是比较常见的面试题)。一个比较经常使用的解决方案是,使用Node.cloneNode替代document.createElement。不知道有没有人和我同样,一开始的时候会认为Node.cloneNode的效率比document.createElement的效率更高。本着探索未知领域的原则,我特地进行了测试:github

const app = document.querySelector('#app');
console.time('create');
for(let i = 0; i < 1000000; i++) {
  const newEl = document.createElement('div');
  app.appendChild(newEl);
}
console.timeEnd('create');
// create: 3148.89501953125ms
复制代码

接下来是对cloneNode的测试:面试

const app = document.querySelector('#app');
console.time('clone');
const newEl = document.createElement('div');
app.appendChild(newEl);
for(let i = 0; i < 1000000 - 1; i++) {
  // true表示深拷贝
  const newEl_CP = newEl.cloneNode(true);
  app.appendChild(newEl_CP);
}
console.timeEnd('clone');
// clone: 2832.31982421875ms
复制代码

能够看到,一样是渲染100万个节点,二者的时间差其实并无很大。看到这里,你们应该都有疑惑,实际上,两种方法的效率并相差不了多少,不是吗?一开始,我也抱有一样的疑惑,后来仔细想一想,是否是思路上出现了问题?既然选择了clone,那我为何要一个一个clone呢?一组一组地clone效率不是更高吗?咱们能够先生成一个1000个子节点的元素,而后再循环clone 1000 - 1次不是效率更高吗?为了验证这一猜测,让咱们用代码说话:算法

const app = document.querySelector('#app');
console.time('newClone');
// fragment是一段文档片断,能够将其子节点加入到DOM树中,且不产生额外的节点
// 用过Vue的<template>标签和React的Fragments会很好理解
let fragment = document.createDocumentFragment();
for(let i = 0; i < 1000; i++) {
  const newEl = document.createElement('div');
  fragment.appendChild(newEl);
}
// 这里为何要先拷贝一份?
// 由于fragment中的子元素在插入到DOM树后,能够理解为是一个转移的过程,fragment全部的子元素都被移动到DOM树中,
// 因此当调用app.appendChild(fragment)方法后,fragment就只是一个空的文档片断
const sourceFragment = fragment.cloneNode(true);
app.appendChild(fragment);
for(let i = 0; i < 1000 - 1; i++) {
  const newEl_CP = sourceFragment.cloneNode(true);
  app.appendChild(newEl_CP);
}
console.timeEnd('newClone');
// newClone: 945.964599609375ms
复制代码

能够看到,咱们使用这种fragment+cloneNode方式能够极大地下降生产插入DOM的这一过程。我想这才是在应对长列表的渲染中,cloneNode优于createElement的真正缘由吧。缓存

我实现的diff算法

我这里实现的diff算法,没有那么复杂,我暂时不考虑对key的状况作相应的处理(即第4点),而且,个人diff算法秉承简单粗暴的原则,直接在diff的过程当中就对DOM进行了修改,因此也没有patch补丁包。性能优化

首先回忆一下我以前对初次渲染的时候,涉及到DOM的这一部分实际上都经过VNode类和Element类来实现。因此咱们的diff核心就是围绕着这两个类执行:经过VNode来比对出哪些须要变更,Element则是对这些变更进行执行,Element充当着一个执行者的角色。理清了这一部分关系后,让咱们回到咱们最初等待解决的问题上,咱们须要一个update方法,以供Watcher在更新时调用:数据结构

xm.$watcher = new Watcher(() => {
  xm._callHook.call(xm, 'beforeUpdate');
  const newVnodeTree = parseJsxObj(xm.$render());
  // update方法返回一个新的vnodeTree,
  xm.$vnodeTree = update(xm, newVnodeTree, xm.$vnodeTree);
}, 'render');
复制代码

看一下update方法,很简单,就是调用diff,返回处理后的newVNode

export const update = function(xm, newVNode, oldVNode) {
  // 差别比对
  diff(xm, newVNode, oldVNode);
  // 这里返回的newVNode是在diff过程当中通过处理的,添加了每一个VNode对应的Element
  return newVNode;
}
复制代码

接下来就是diff的过程,我的感受如下内容可能不大好理解,请慢慢阅读:

/** * @param { Xue } xm Xue实例,用于在事件相关上绑定this * @param { VNode } newVNode 新的vnodeTree * @param { VNode } oldVNode 旧的vnodeTree * @param { VNode } parentVNode newVNode的父级vnode * @param { VNode } nextBroNode 当前newVNode的下一个兄弟节点,主要用在insertBefore的场景 */
export const diff = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
  // 定义变化的类型
  let diffType = '';
  // 旧节点不存在
  // 或者旧节点为null,新节点不为null
  if(!oldVNode || (oldVNode.tag === null && newVNode.tag !== null)) {
    // 有节点新增
    diffType = 'addNode';
  }
  // 新节点不存在
  // 或者新节点为null,旧节点不为null
  else if(!newVNode || (oldVNode.tag !== null && newVNode.tag === null)) {
    // 有节点删除
    diffType = 'delNode';
  }
  // 节点标签不同,直接替换
  else if(oldVNode.tag !== newVNode.tag) {
    // 替换节点
    diffType = 'replaceNode';
  }
  // 文本节点时,直接用新的文本节点替换旧的文本节点
  else if(newVNode.tag === '') {
    diffType = 'replaceText';
  }
  // 比较属性和事件
  else {
    diffType = 'updateAttrsAndEvents';
  }
  // 根据diffType调用不一样的处理函数
  diffUpdateHandler(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode);
  // 递归处理子节点,这里其实就是同一层级结构比较的过程
  // 对于条件渲染的状况,为了正确处理,若是当前条件为falsy(虚值),则必须返回一个null节点
  // 让VNode知道这里有一个null的占位空节点,此节点不会被渲染
  for(let i = 0; i < newVNode.children.length; i++) {
    // 下一个兄弟节点,为了在新增节点时,插入至正确的位置
    const nextBroNode = (i === newVNode.children.length - 1) ? null : oldVNode.children[i + 1];
    // 缓存旧节点
    let oldVNodeParam = oldVNode && oldVNode.children[i];
    // 对于新增长的节点或者替换后的节点来讲,它们的子节点在oldVNode中都被认为是不存在的值,子节点都被直接插入至新的节点
    // 其实就是对应于不一样节点的子节点都是不一样的
    if(diffType === 'addNode') oldVNodeParam = undefined;
    // 递归
    diff(xm, newVNode.children[i], oldVNodeParam, newVNode, nextBroNode);
  }
}
复制代码

接下来再看diffUpdateHandler的逻辑,这一块相对来讲比较简单,就是根据不一样的diffType值调用不一样的处理函数:

export const diffUpdateHandler = function(diffType, xm, newVNode, oldVNode, parentVNode, nextBroNode) {
  switch(diffType) {
    case 'addNode': diffAddNode(xm, newVNode, oldVNode, parentVNode, nextBroNode); break;
    case 'delNode': diffDelNode(xm, newVNode, oldVNode, parentVNode); break;
    case 'replaceNode': diffReplaceNode(xm, newVNode, oldVNode, parentVNode); break;
    case 'replaceText': diffUpdateText(xm, newVNode, oldVNode, parentVNode); break;
    case 'updateAttrsAndEvents': diffAttribute(xm, newVNode, oldVNode); diffEvent(xm, newVNode, oldVNode); break;
    default: warn(`error diffType: ${ diffType }`);
  }
}
复制代码

在看这几天处理函数以前,先来看一下完善以后的Element类,添加了许多处理更新DOM的方法:

class Element {
  constructor(vnode, xm) {
    this.xm = xm;
    // 若是为null的话,则不作任何处理
    if(vnode.tag === null) return;
    // 非文本节点
    if(vnode.tag !== '') {
      this.el = document.createElement(vnode.tag);
      // 绑定属性
      Object.entries(vnode.attrs).forEach(([key, value]) => {
        this.setAttribute(key, value);
      });
      // 绑定事件
      Object.keys(vnode.events).forEach(key => {
        // 缓存bind后的函数,用于以后的函数移除
        vnode.events[key] = vnode.events[key].bind(xm);
        this.addEventListener(key, vnode.events[key]);
      });
    }
    // 文本节点
    else this.el = document.createTextNode(vnode.text);

  }
  // 添加子节点
  appendChild(element) {
    this.el && element.el && this.el.appendChild(element.el);
  }
  // 移除子节点
  removeChild(element) {
    this.el && element.el && this.el.removeChild(element.el);
  }
  // 添加属性,对className和style作特殊处理
  // class是保留字,style接受一个对象
  setAttribute(name, value) {
    if(name === 'className') {
      this.el.setAttribute('class', value);
    }
    else if(name === 'style') {
      Object.entries(value).forEach(([styleKey, styleValue]) => {
        this.el.style[styleKey] = styleValue;
      })
    }
    else {
      this.el.setAttribute(name, value);
    }
  }
  // 添加事件监听
  addEventListener(name, handler) {
    this.el.addEventListener(name, handler);
  }
  // 移除事件监听
  removeEventListener(name, handler) {
    this.el.removeEventListener(name, handler);
  }
  // 更新文本内容
  updateTextContent(text) {
    this.el.textContent = text;
  }
  // 替换子节点
  replaceChild(newElement, oldElement) {
    this.el.replaceChild(newElement.el, oldElement.el);
  }
  // 在referenceElement前插入newElement,父节点为this.el
  insertBefore(newElement, referenceElement) {
    // insertBefore这个方法还有一个巧妙的用法:当须要插入的节点自己就在DOM树中时,这个节点会被移动到插入的位置
    // 即在将节点附加到其余节点以前,不须要从其父节点删除该节点
    // 能够把这一特性应用到含key值的列表项的处理
    this.el.insertBefore(newElement.el, referenceElement && referenceElement.el);
  }
}
复制代码

看完了Element类,让咱们接着刚才的流程,重上往下一个个看这几个处理函数:

diffAddNode

// 添加节点
export const diffAddNode = function(xm, newVNode, oldVNode, parentVNode, nextBroNode) {
  // 建立新的Element,同时也建立了DOM对象
  const newElement = new Element(newVNode, xm);
  // 父vnode对应的element--即为newElement须要插入到的父节点
  // nextBroNode为newElement的下一个兄弟节点,为空时会直接插入到父节点的子列表的末尾
  parentVNode.element.insertBefore(newElement, nextBroNode && nextBroNode.element);
  // 当前的newVNode指定新的newElement
  newVNode.addElement(newElement);
}
复制代码

diffDelNode

// 删除旧节点
export const diffDelNode = function(xm, newVNode, oldVNode, parentVNode) {
  // 调用父节点的removeChild方法删除当前节点
  parentVNode.element.removeChild(oldVNode.element);
  // 当前的newVNode指定空的element占位对象
  newVNode.addElement(new Element(new VNode(null), xm));
}
复制代码

diffReplaceNode

// 替换旧节点
export const diffReplaceNode = function(xm, newVNode, oldVNode, parentVNode) {
  // 新建节点
  const newElement = new Element(newVNode, xm);
  // 把旧节点替换为新节点
  parentVNode.element.replaceChild(newElement, oldVNode.element);
  // 为newVNode指定element
  newVNode.addElement(newElement);
}
复制代码

diffUpdateText

// 比较文本节点
export const diffUpdateText = function(xm, newVNode, oldVNode, parentVNode) {
  if(newVNode.text !== oldVNode.text) {
    // 更新文本的时候不须要建立新的文本节点,直接利用旧节点便可
    oldVNode.element.updateTextContent(newVNode.text);
  }
  // 为newVNode指定element
  newVNode.addElement(oldVNode.element);
}
复制代码

diffAttribute

// 比较属性
export const diffAttribute = function(xm, newVNode, oldVNode) {
  // 经过Set建立须要比较的全部属性,将新旧vnode的属性结合
  const attrs = new Set(Object.keys(newVNode.attrs).concat(Object.keys(oldVNode.attrs)));
  // 对属性值不一样的更新属性值
  attrs.forEach(attr => newVNode.attrs[attr] !== oldVNode.attrs[attr] && oldVNode.element.setAttribute(attr, newVNode.attrs[attr]));
  // 为newVNode指定element
  newVNode.addElement(oldVNode.element);
}
复制代码

diffEvent

// 比较事件
export const diffEvent = function(xm, newVNode, oldVNode) {
  // 拿到须要比较的全部事件
  const events = new Set(Object.keys(newVNode.events).concat(Object.keys(oldVNode.events)));
  events.forEach(event => {
    // 当newVNode和oldVNode事件不一样时
    if(newVNode.events[event] !== oldVNode.events[event]) {
      // 移除旧事件的响应函数
      oldVNode.element.removeEventListener(event, oldVNode.events[event]);
      // 若是新事件的响应函数存在,则添加
      if(newVNode.events[event]) {
        // 保存新的绑定this后的处理函数
        const handler = newVNode.events[event] = newVNode.events[event].bind(xm);
        oldVNode.element.addEventListener(event, handler);
      }
    }
  });
  // 为newVNode指定element
  newVNode.addElement(oldVNode.element);
}
复制代码

demo测试

到此为止update这一块的内容都已经结束了,接下来,让咱们写一个demo试试到目前为止的效果

import Xue from './src/main';

new Xue({
  root: '#app',
  data() {
    return {
      test1: 'i am text1',
      test2: {
        a: 'i am text2 attr a'
      }
    }
  },
  methods: {
    fn1() {
      console.log(this)
      console.log('i am fn1')
    },
    fn2() {
      console.log(this)
      console.log('i am fn2')
    }
  },
  render() {
    return (<div> { this.test1 } <br /> { this.test2.a } <br /> { this.test1 === 'i am text1' ? 'text1 === i am text1' : 'text1 === i am text1 change' } <br /> { this.test1 === 'i am text1' ? null : <div>i have been rendered when test1 !== i am text1 </div> } { this.test1 === 'i am text1' ? <div>i have been rendered when test1 === i am text1 </div>: null } { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1<span> i am inner</span></a> : <span>i am a node when text1 === i am text1 change</span> } <br /> { this.test1 === 'i am text1' ? <a>i am a node when text1 === i am text1</a> : <span>i am a node when text1 === i am text1 change<span> i am inner</span></span> } <br /> <div onClick={this.test === 'i am text1' ? this.fn1 : this.fn2} className={this.test === 'i am text1' ? 'cls1' : 'cls2'} id='id1'> my attrs and events will be change </div> </div>);
  },
  beforeCreate() {
    setTimeout(() => {
      this.test1 = 'i am text1 change';
      this.test2.a = 'i am text2 attr a change';
      console.log('settimeout');
    }, 3000);
    setTimeout(() => {
      this.test1 = 'i am text1';
      this.test2.a = 'i am text2 attr a';
      console.log('settimeout');
    }, 5000)
  }
});
复制代码

初次渲染

avatar
3s后页面更新
avatar
点击触发fn2,能够看到this的指向也是正确的
avatar
5s后页面更新回初此渲染时的状态
avatar
点击触发fn1,能够看到this的指向也是正确的
avatar

下一章节内容:组件化,敬请期待......另外,下周可能拖更,缘由干这行的都懂,难顶。

github项目地址:点此跳转

第一章:从零开始,采用Vue的思想,开发一个本身的JS框架(一):基本架构的搭建

第二章:从零开始,采用Vue的思想,开发一个本身的JS框架(二):首次渲染

第四章:从零开始,采用Vue的思想,开发一个本身的JS框架(四):组件化和路由组件

相关文章
相关标签/搜索