從零開始,採用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框架(四):組件化和路由組件

相關文章
相關標籤/搜索