16.觸類旁通各種框架

框架在任何一種語言編程范疇中都扮演了舉足輕重的地位,前端尤是如此。目前流行的前端框架三駕馬車:Angular、React 和 Vue,它們各有特點和受眾,都值得開發(fā)者認(rèn)真思考和學(xué)習(xí)。那么我們在精力有限的情況下,如何做到「觸類旁通」、如何提取框架共性、提高學(xué)習(xí)和應(yīng)用效率呢?

我們這一講就來剖析這些框架的特點和本質(zhì),介紹如何學(xué)習(xí)并使用這些框架,進而了解前端框架的真諦。

相關(guān)知識點如下:


我把現(xiàn)代框架的關(guān)鍵詞進行提煉,掌握這些關(guān)鍵詞,是我們學(xué)習(xí)的重要環(huán)節(jié)。這些關(guān)鍵詞有:雙向綁定、依賴收集、發(fā)布訂閱模式、MVVM / MVC、虛擬 DOM、虛擬 DOM diff、模版編譯等。

響應(yīng)式框架基本原理

我們不再贅述響應(yīng)式或數(shù)據(jù)雙向綁定的基本概念,這里直接思考其行為:直觀上,數(shù)據(jù)在變化時,不再需要開發(fā)者去手動更新視圖,而視圖會根據(jù)變化的數(shù)據(jù)「自動」進行更新。想完成這個過程,我們需要:

  • 收集視圖依賴了哪些數(shù)據(jù)
  • 感知被依賴數(shù)據(jù)的變化
  • 數(shù)據(jù)變化時,自動「通知」需要更新的視圖部分,并進行更新

道理很簡單,這個思考過程換成對應(yīng)的技術(shù)概念就是:

  • 依賴收集
  • 數(shù)據(jù)劫持 / 數(shù)據(jù)代理
  • 發(fā)布訂閱模式

接下來,我們一步步拆解。

數(shù)據(jù)劫持與代理

感知數(shù)據(jù)變化的方法很直接,就是進行數(shù)據(jù)劫持或數(shù)據(jù)代理。我們往往通過 Object.defineProperty 實現(xiàn)。這個方法可以定義數(shù)據(jù)的 getter 和 setter,具體用法不再贅述。下面來看一個場景:

let data = {
   stage: 'GitChat',
   course: {
     title: '前端開發(fā)進階',
     author: 'Lucas',
     publishTime: '2018 年 5 月'
   }
 }

 Object.keys(data).forEach(key => {
   let currentValue = data[key]

   Object.defineProperty(data, key, {
     enumerable: true,
     configurable: false,
     get() {
       console.log(`getting ${key} value now, getting value is:`, currentValue)
       return currentValue
     },
     set(newValue) {
       currentValue = newValue
       console.log(`setting ${key} value now, setting value is`, currentValue)
     }
   })
 })
data.course

//  getting course value now, getting value is:
//  {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"}

data.course = '前端開發(fā)進階 2'
// setting course value now, setting value is 前端開發(fā)進階 2

但是這種實現(xiàn)有一個問題,例如:

data.course.title = '前端開發(fā)進階 2'

// getting course value now, getting value is: 
// {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"}

只會有 getting course value now, getting value is: {title: "前端開發(fā)進階", author: "Lucas", publishTime: "2018 年 5 月"} 的輸出,這是因為我們嘗試讀取了 data.course 信息。但是修改 data.course.title 的信息并沒有打印出來。

出現(xiàn)這個問題的原因是因為我們的實現(xiàn)代碼只進行了一層 Object.defineProperty,或者說只對 data 的第一層屬性進行了 Object.defineProperty,對于嵌套的引用類型數(shù)據(jù)結(jié)構(gòu):data.course,我們同樣應(yīng)該進行攔截。

為了達到深層攔截的目的,將 Object.defineProperty 的邏輯抽象為 observe 函數(shù),并改用遞歸實現(xiàn):

let data = {
   stage: 'GitChat',
   course: {
     title: '前端開發(fā)進階',
     author: 'Lucas',
     publishTime: '2018 年 5 月'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerable: true,
       configurable: false,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)

這樣一來,就實現(xiàn)了深層數(shù)據(jù)攔截:

data.course.title = '前端開發(fā)進階 2'

// getting course value now, getting value is: {// ...}
// setting title value now, setting value is 前端開發(fā)進階 2

請注意,我們在 set 代理中,并沒有對 newValue 再次遞歸進行

observe(newValue)。也就是說,如果賦值是一個引用類型:

data.course.title = {
     title: '前端開發(fā)進階 2'
 }

無法實現(xiàn)對 data.course.title 數(shù)據(jù)的觀察。這里為了簡化學(xué)習(xí)成本,默認(rèn)修改的數(shù)值符合語義,都是基本類型。

在嘗試對 data.course.title 賦值時,首先會讀取 data.course,因此輸出:getting course value now, getting value is: {// ...},賦值后,觸發(fā) data.course.title 的 setter,輸出:setting title value now, setting value is 前端開發(fā)進階 2。

因此我們總結(jié)出:對數(shù)據(jù)進行攔截并不復(fù)雜,這也是很多框架實現(xiàn)的第一步。

監(jiān)聽數(shù)組變化

如果上述數(shù)據(jù)中某一項變?yōu)閿?shù)組:

let data = {
   stage: 'GitChat',
   course: {
     title: '前端開發(fā)進階',
     author: ['Lucas', 'Ronaldo'],
     publishTime: '2018 年 5 月'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerable: true,
       configurable: false,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)


 data.course.author.push('Messi')
 // getting course value now, getting value is: {//...}
 // getting author value now, getting value is: (2) [(...), (...)]

我們只監(jiān)聽到了 data.course 以及 data.course.author 的讀取,而數(shù)組 push 行為并沒有被攔截。這是因為 Array.prototype 上掛載的方法并不能觸發(fā) data.course.author 屬性值的 setter,由于這并不屬于做賦值操作,而是 push API 調(diào)用操作。然而對于框架實現(xiàn)來說,這顯然是不滿足要求的,當(dāng)數(shù)組變化時我們應(yīng)該也有所感知。

Vue 同樣存在這樣的問題,它的解決方法是:將數(shù)組的常用方法進行重寫,進而覆蓋掉原生的數(shù)組方法,重寫之后的數(shù)組方法需要能夠被攔截。

實現(xiàn)邏輯如下:

const arrExtend = Object.create(Array.prototype)
 const arrMethods = [
   'push',
   'pop',
   'shift',
   'unshift',
   'splice',
   'sort',
   'reverse'
 ]

 arrMethods.forEach(method => {
   const oldMethod = Array.prototype[method]
   const newMethod = function(...args) {
     oldMethod.apply(this, args)
     console.log(`${method} 方法被執(zhí)行了`)
   }
   arrExtend[method] = newMethod
 })

對于數(shù)組原生的 7 個方法:

  • push
  • pop
  • shift
  • unshift
  • splice
  • sort
  • reverse

行重寫,核心操作還是調(diào)用原生方法:oldMethod.apply(this, args),除此之外可以在調(diào)用 oldMethod.apply(this, args) 前后加入我們需要的任何邏輯。示例代碼中加入了一行 console.log。使用時:

Array.prototype = Object.assign(Array.prototype, arrExtend)

 let array = [1, 2, 3]
 array.push(4)
 // push 方法被執(zhí)行了

對應(yīng)我們的代碼:

const arrExtend = Object.create(Array.prototype)
 const arrMethods = [
   'push',
   'pop',
   'shift',
   'unshift',
   'splice',
   'sort',
   'reverse'
 ]

 arrMethods.forEach(method => {
   const oldMethod = Array.prototype[method]
   const newMethod = function(...args) {
     oldMethod.apply(this, args)
     console.log(`${method} 方法被執(zhí)行了`)
   }
   arrExtend[method] = newMethod
 })

 Array.prototype = Object.assign(Array.prototype, arrExtend)


 let data = {
   stage: 'GitChat',
   course: {
     title: '前端開發(fā)進階',
     author: ['Lucas', 'Ronaldo'],
     publishTime: '2018 年 5 月'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerable: true,
       configurable: false,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)

 data.course.author.push('Messi')

// getting course value now, getting value is: {//...}
// getting author value now, getting value is: (2) [(...), (...)]
// push 方法被執(zhí)行了

將會輸出:

這種 monkey patch 本質(zhì)是重寫原生方法,這天生不是很安全,也很不優(yōu)雅,能有更好的實現(xiàn)嗎?

答案是有的,使用 ES Next 的新特性——Proxy,之前也介紹過,它可以完成對數(shù)據(jù)的代理。

那么這兩種方式有何區(qū)別呢?請繼續(xù)閱讀。

Object.defineProperty VS Proxy

我們首先嘗試使用 Proxy 來完成代碼重構(gòu):

let data = {
  stage: 'GitChat',
  course: {
    title: '前端開發(fā)進階',
    author: ['Lucas'],
    publishTime: '2018 年 5 月',
  },
};

const observe = (data) => {
  if (
    !data ||
    Object.prototype.toString.call(data) !== '[object Object]'
  ) {
    return;
  }

  Object.keys(data).forEach((key) => {
    let currentValue = data[key];
    // 事實上 proxy 也可以對函數(shù)類型進行代理。
    // 這里只對承載數(shù)據(jù)類型的 object 進行處理,讀者了解即可。
    if (typeof currentValue === 'object') {
      observe(currentValue);
      data[key] = new Proxy(currentValue, {
        set(target, property, value, receiver) {
          // 因為數(shù)組的 push 會引起 length 屬性的變化,
          // 所以 push 之后會觸發(fā)兩次 set 操作,
          // 我們只需要保留一次即可,property 為 length 時,忽略
          if (property !== 'length') {
            console.log(
              `setting ${key} value now, setting value is`,
              currentValue
            );
          }
          return Reflect.set(target, property, value, receiver);
        },
      });
    } else {
      Object.defineProperty(data, key, {
        enumerable: true,
        configurable: false,
        get() {
          console.log(
            `getting ${key} value now, getting value is:`,
            currentValue
          );
          return currentValue;
        },
        set(newValue) {
          currentValue = newValue;
          console.log(
            `setting ${key} value now, setting value is`,
            currentValue
          );
        },
      });
    }
  });
};

observe(data);

此時對數(shù)組進行操作:

data.course.author.push('messi')
 // setting author value now, setting value is ["Lucas"]

已經(jīng)符合我們的需求了。注意這里在使用 Proxy 進行代理時,并沒有對 getter 進行代理,因此上述代碼的輸出結(jié)果并不像之前使用 Object.defineProperty 那樣也會有 getting value 輸出。

整體實現(xiàn)并不難理解,需要讀者了解最基本的 Proxy 知識。簡單總結(jié)一下,對于數(shù)據(jù)鍵值為基本類型的情況,我們使用 Object.defineProperty;對于鍵值為對象類型的情況,繼續(xù)遞歸調(diào)用 observe 方法,并通過 Proxy 返回的新對象對 data[key] 重新賦值,這個新值的 getter 和 setter 已經(jīng)被添加了代理。

了解了 Proxy 實現(xiàn)之后,我們對 Proxy 實現(xiàn)數(shù)據(jù)代理和 Object.defineProperty 實現(xiàn)數(shù)據(jù)攔截進行對比,會發(fā)現(xiàn):

  • Object.defineProperty 不能監(jiān)聽數(shù)組的變化,需要進行數(shù)組方法的重寫
  • Object.defineProperty 必須遍歷對象的每個屬性,且對于嵌套結(jié)構(gòu)需要深層遍歷
  • Proxy 的代理是針對整個對象的,而不是對象的某個屬性,因此不同于Object.defineProperty 的必須遍歷對象每個屬性,Proxy 只需要做一層代理就可以監(jiān)聽同級結(jié)構(gòu)下的所有屬性變化,當(dāng)然對于深層結(jié)構(gòu),遞歸還是需要進行的
  • Proxy 支持代理數(shù)組的變化
  • Proxy 的第二個參數(shù)除了 set 和 get 以外,可以有 13 種攔截方法,比起 Object.defineProperty() 更加強大,這里不再一一列舉
  • Proxy 性能將會被底層持續(xù)優(yōu)化,而 Object.defineProperty 已經(jīng)不再是優(yōu)化重點

模版編譯原理介紹

到此,我們了解了如何監(jiān)聽數(shù)據(jù)的變化,那么下一步呢?以類 Vue 框架為例,我們看看一個典型的用法:

{{stage}} 平臺課程:{{course.title}}
{{course.title}} 是 {{course.author}} 發(fā)布的課程
發(fā)布時間為 {{course.publishTime}}

let vue = new Vue({
  ele: '#app',
  data: {
    stage: 'GitChat',
    course: {
      title: '前端開發(fā)進階',
      author: 'Lucas',
      publishTime: '2018 年 5 月',
    },
  },
});

其中模版變量使用了 {{}} 的表達方式輸出模版變量。最終輸出的 HTML 內(nèi)容應(yīng)該被合適的數(shù)據(jù)進行填充替換,因此還需要一步編譯過程,該過程任何框架或類庫中都是相通的,比如 React 中的 JSX,也是編譯為 React.createElement,并在生成虛擬 DOM 時進行數(shù)據(jù)填充。

我們這里簡化過程,將模版內(nèi)容:

{{stage}} 平臺課程:{{course.title}}
{{course.title}} 是 {{course.author}} 發(fā)布的課程
發(fā)布時間為 {{course.publishTime}}

輸出為真實 HTML 即可。

模版編譯實現(xiàn)

一提到這樣的「模版編譯」過程,很多開發(fā)者都會想到詞法分析,也許都會感到頭大。其實原理很簡單,就是使用正則 + 遍歷,有時也需要一些算法知識,我們來看現(xiàn)在的場景,只需要對 #app 節(jié)點下內(nèi)容進行替換,通過正則識別出模版變量,獲取對應(yīng)的數(shù)據(jù)即可:

compile(document.querySelector('#app'), data);

function compile(el, data) {
  let fragment = document.createDocumentFragment();

  while ((child = el.firstChild)) {
    fragment.appendChild(child);
  }

  // 對 el 里面的內(nèi)容進行替換
  function replace(fragment) {
    Array.from(fragment.childNodes).forEach((node) => {
      let textContent = node.textContent;
      let reg = /\{\{(.*?)\}\}/g;

      if (node.nodeType === 3 && reg.test(textContent)) {
        const nodeTextContent = node.textContent;
        const replaceText = () => {
          node.textContent = nodeTextContent.replace(
            reg,
            (matched, placeholder) => {
              return placeholder.split('.').reduce((prev, key) => {
                return prev[key];
              }, data);
            }
          );
        };

        replaceText();
      }

      // 如果還有子節(jié)點,繼續(xù)遞歸 replace
      if (node.childNodes && node.childNodes.length) {
        replace(node);
      }
    });
  }

  replace(fragment);

  el.appendChild(fragment);
  return el;
}

代碼分析:我們使用 fragment 變量儲存生成的真實 HTML 節(jié)點內(nèi)容。通過 replace 方法對 {{變量}} 進行數(shù)據(jù)替換,同時 {{變量}} 的表達只會出現(xiàn)在 nodeType === 3 的文本類型節(jié)點中,因此對于符合 node.nodeType === 3 && reg.test(textContent) 條件的情況,進行數(shù)據(jù)獲取和填充。我們借助字符串 replace 方法第二個參數(shù)進行一次性替換,此時對于形如 {{data.course.title}} 的深層數(shù)據(jù),通過 reduce 方法,獲得正確的值。

因為 DOM 結(jié)構(gòu)可能是多層的,所以對存在子節(jié)點的節(jié)點,依然使用遞歸進行 replace 替換。

這個編譯過程比較簡單,沒有考慮到邊界情況,只是單純完成模版變量到真實 DOM 的轉(zhuǎn)換,讀者只需體會簡單道理即可。

雙向綁定實現(xiàn)

上述實現(xiàn)是單向的,數(shù)據(jù)變化引起了視圖變化,那么如果頁面中存在一個輸入框,如何觸發(fā)數(shù)據(jù)變化呢?比如:

<input v-model="inputData"/>

我們需要在模版編譯中,對于存在 v-model 屬性的 node 進行事件監(jiān)聽,在輸入框輸入時,改變 v-model 屬性值對應(yīng)的數(shù)據(jù)即可(這里為 inputData),增加 compile 中的 replace 方法邏輯,對于 node.nodeType === 1 的 DOM 類型,偽代碼如下:

function replace(el, data) {
   // 省略...
   if (node.nodeType === 1) {

     let attributesArray = node.attributes

     Array.from(attributesArray).forEach(attr => {
       let attributeName = attr.name
       let attributeValue = attr.value

       if (name.includes('v-')) {
         node.value = data[attributeValue]
       }

       node.addEventListener('input', e => {
         let newVal = e.target.value
         data[attributeValue] = newVal
         // ...
         // 更改數(shù)據(jù)源,觸發(fā) setter
         // ...
       })
     })

   }

   if (node.childNodes && node.childNodes.length) {
     replace(node)
   }
 }

發(fā)布訂閱模式簡單應(yīng)用

作為前端開發(fā)人員,我們對于所謂的「事件驅(qū)動」理念——即「事件發(fā)布訂閱模式(Pub/Sub 模式)」一定再熟悉不過了。這種模式在 JavaScript 里面有與生俱來的基因:我們可以認(rèn)為 JavaScript 本身就是事件驅(qū)動型語言,比如,應(yīng)用中對一個 button 進行了事件綁定,用戶點擊之后就會觸發(fā)按鈕上面的 click 事件。這是因為此時有特定程序正在監(jiān)聽這個事件,隨之觸發(fā)了相關(guān)的處理程序。

這個模式的一個好處之一在于能夠解耦,實現(xiàn)「高內(nèi)聚、低耦合」的理念。這種模式對于我們框架的設(shè)計同樣也不可或缺。請思考:通過前面內(nèi)容的學(xué)習(xí),我們了解了如何監(jiān)聽數(shù)據(jù)的變化。如果最終想實現(xiàn)響應(yīng)式 MVVM,或所謂的雙向綁定,那么還需要根據(jù)這個數(shù)據(jù)變化作出相應(yīng)的視圖更新。這個邏輯和我們在頁面中對 button 綁定事件處理函數(shù)是多么相近。

那么這樣一個「熟悉的」模式應(yīng)該怎么實現(xiàn)呢,又該如何在框架中具體應(yīng)用呢?看代碼:

class Notify {
  constructor() {
    this.subscribers = [];
  }
  add(handler) {
    this.subscribers.push(handler);
  }
  emit() {
    this.subscribers.forEach((subscriber) => subscriber());
  }
}

使用:

let notify = new Notify();

notify.add(() => {
  console.log('emit here');
});

notify.emit();
// emit here

這就是一個簡單實現(xiàn)的「事件發(fā)布訂閱模式」,當(dāng)然代碼只是啟發(fā)思路,真實應(yīng)用還比較「粗糙」,沒有進行事件名設(shè)置,APIs 也并不豐富,但完全能夠說明問題了。其實讀者翻看 Vue 源碼,也能了解 Vue 中的發(fā)布訂閱模式很簡單。

MVVM 融會貫通
回顧一下前面的基本內(nèi)容:數(shù)據(jù)攔截和代理、發(fā)布訂閱模式、模版編譯,那么如何根據(jù)這些概念實現(xiàn)一個 MVVM 框架呢?其實不管是 Vue 還是其他類庫或框架,其解決思想都是建立在前文所述概念之上的。

我們來進行串聯(lián),整個過程是:首先對數(shù)據(jù)進行深度攔截或代理,對每一個屬性的 getter 和 setter 進行「加工」,該「加工」具體做些什么后面馬上會有說明。在模版初次編譯時,解析指令(如 v-model),并進行依賴收集({{變量}}),訂閱數(shù)據(jù)的變化。

這里的依賴收集過程具體指:當(dāng)調(diào)用 compiler 中的 replace 方法時,我們會讀取數(shù)據(jù)進行模版變量的替換,這時候「讀取數(shù)據(jù)時」需要做一個標(biāo)記,用來表示「我依賴這一項數(shù)據(jù)」,因此我要訂閱這個屬性值的變化。Vue 中定義一個 Watcher 類來表示觀察訂閱依賴。這就實現(xiàn)了整套流程,換個思路再復(fù)述一遍:我們知道模版編譯過程中會讀取數(shù)據(jù),進而觸發(fā)數(shù)據(jù)源屬性值的 getter,因此上面所說的數(shù)據(jù)代理的「加工」就是在數(shù)據(jù)監(jiān)聽的 getter 中記錄這個依賴,同時在 setter 觸發(fā)數(shù)據(jù)變化時,執(zhí)行依賴對應(yīng)的相關(guān)操作,最終觸發(fā)模版中數(shù)據(jù)的變化。

我們抽象成流程圖來理解:

這也是 Vue 框架(類庫)的基本架構(gòu)圖。由此看出,Vue 的實現(xiàn),或者大部分 MVVM 的實現(xiàn),就是我們本節(jié)課程介紹的概念組合應(yīng)用。

關(guān)于框架的對比剖析,更多話題我們留在《第 4-7 課:從框架和類庫,我們該學(xué)到什么》一課中介紹。

揭秘虛擬 DOM

我們來看現(xiàn)代框架中另一個重頭戲——虛擬 DOM。虛擬 DOM 這個概念其實并沒有那么新,甚至在前端三大框架問世之前,虛擬 DOM 就已經(jīng)存在了,只不過 React 創(chuàng)造性的應(yīng)用了虛擬 DOM,為前端發(fā)展帶來了變革。Vue 2.0 也很快跟進,使得虛擬 DOM 徹底成為現(xiàn)代框架的重要基因。簡單來說,虛擬 DOM 就是用數(shù)據(jù)結(jié)構(gòu)表示 DOM 結(jié)構(gòu),它并沒有真實 append 到 DOM 上,因此稱之為「虛擬」。

應(yīng)用虛擬 DOM 的收益也很直觀:操作數(shù)據(jù)結(jié)構(gòu)遠比和瀏覽器交互去操作 DOM 快很多。請讀者準(zhǔn)確理解這句話:操作數(shù)據(jù)結(jié)構(gòu)是指改變對象(虛擬 DOM),這個過程比修改真實 DOM 快很多。但虛擬 DOM 也最終是要掛載到瀏覽器上成為真實 DOM 節(jié)點,因此使用虛擬 DOM 并不能使得操作 DOM 的數(shù)量減少,但能夠精確地獲取最小的、最必要的操作 DOM 的集合。

這樣一來,我們抽象表示 DOM,每次通過 DOM diff 計算出視圖前后更新的最小差異,再去把最小差異應(yīng)用到真實 DOM 上的做法,無疑更為可靠,性能更有保障。

那我們該如何表示虛擬 DOM 呢?又該如何產(chǎn)出虛擬 DOM 呢?

直觀上我們看這樣一段 DOM 結(jié)構(gòu):

<ul id="chapterList">
  <li class="chapter">chapter1</li>
  <li class="chapter">chapter2</li>
  <li class="chapter">chapter3</li>
</ul>

如果用 JavaScript 來表示,我們采用對象結(jié)構(gòu):

const chapterListVirtualDom = {
   tagName: 'ul',
   attributes: {
     id: 'chapterList'
   },
   children: [
     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter1'] },
     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter2'] },
     { tagName: 'li', attributes: { class: 'chapter' }, children: ['chapter3'] },
   ]
 }

很好理解:tagName 表示虛擬 DOM 對應(yīng)的真實 DOM 標(biāo)簽類型;attributes 是一個對象,表示真實 DOM 節(jié)點上所有的屬性;children 對應(yīng)真實 DOM 的 childNodes,其中 childNodes 每一項又是類似的結(jié)構(gòu)。

我們來實現(xiàn)一個虛擬 DOM 生成類,用于生產(chǎn)虛擬 DOM:

class Element {
   constructor(tagName, attributes = {}, children = []) {
     this.tagName = tagName
     this.attributes = attributes
     this.children = children
   }
 }

 function element(tagName, attributes, children) {
   return new Element(tagName, attributes, children)
 }

上述虛擬 DOM 就可以這樣生成:

const chapterListVirtualDom = element('ul', { id: 'list' }, [
   element('li', { class: 'chapter' }, ['chapter1']),
   element('li', { class: 'chapter' }, ['chapter2']),
   element('li', { class: 'chapter' }, ['chapter3'])
 ])

如圖:


是不是很簡單?我們繼續(xù)完成虛擬 DOM 向真實 DOM 節(jié)點的生成。首先實現(xiàn)一個 setAttribute 方法,后續(xù)的代碼都將使用 setAttribute 方法來對 DOM 節(jié)點進行屬性設(shè)置。

const setAttribute = (node, key, value) => {
   switch (key) {
     case 'style':
       node.style.cssText = value
       break
     case 'value':
       let tagName = node.tagName || ''
       tagName = tagName.toLowerCase()
       if (
         tagName === 'input' || tagName === 'textarea'
       ) {
         node.value = value
       } else {
         // 如果節(jié)點不是 input 或者 textarea, 則使用 setAttribute 去設(shè)置屬性
         node.setAttribute(key, value)
       }
       break
     default:
       node.setAttribute(key, value)
       break
   }
 }

Element 類中加入 render 原型方法,該方法的目的是根據(jù)虛擬 DOM 生成真實 DOM 片段:

class Element {
   constructor(tagName, attributes = {}, children = []) {
     this.tagName = tagName
     this.attributes = attributes
     this.children = children
   }

   render () {
     let element = document.createElement(this.tagName)
     let attributes = this.attributes

      for (let key in attributes) {
         setAttribute(element, key, attributes[key])
      }

     let children = this.children

     children.forEach(child => {
       let childElement = child instanceof Element
         ? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
         : document.createTextNode(child)  // 若是字符串,直接創(chuàng)建文本節(jié)點
       element.appendChild(childElement)
     })

     return element
   }
 }

 function element (tagName, attributes, children) {
   return new Element(tagName, attributes, children)
 }

實現(xiàn)也不困難,我們借助工具方法:setAttribute 進行屬性的創(chuàng)建;對 children 每一項類型進行判斷,如果是 Element 實例,進行遞歸調(diào)用 child 的 render 方法;直到遇見文本節(jié)點類型,進行內(nèi)容渲染。

有了真實的 DOM 節(jié)點片段,我們趁熱打鐵,將真實的 DOM 節(jié)點渲染到瀏覽器上,實現(xiàn) renderDOM 方法:

const renderDom = (element, target) => {
   target.appendChild(element)
 }

執(zhí)行代碼:

const setAttribute = (node, key, value) => {
   switch (key) {
     case 'style':
       node.style.cssText = value
       break
     case 'value':
       let tagName = node.tagName || ''
       tagName = tagName.toLowerCase()
       if (
         tagName === 'input' || tagName === 'textarea'
       ) {
         node.value = value
       } else {
         // 如果節(jié)點不是 input 或者 textarea,則使用 setAttribute 去設(shè)置屬性
         node.setAttribute(key, value)
       }
       break
     default:
       node.setAttribute(key, value)
       break
   }
 }

 class Element {
   constructor(tagName, attributes = {}, children = []) {
     this.tagName = tagName
     this.attributes = attributes
     this.children = children
   }

   render () {
     let element = document.createElement(this.tagName)
     let attributes = this.attributes

      for (let key in attributes) {
         setAttribute(element, key, attributes[key])
      }

     let children = this.children

     children.forEach(child => {
       let childElement = child instanceof Element
         ? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
         : document.createTextNode(child)  // 若是字符串,直接創(chuàng)建文本節(jié)點
       element.appendChild(childElement)
     })

     return element
   }
 }

 function element (tagName, attributes, children) {
   return new Element(tagName, attributes, children)
 }

 const renderDom = (element, target) => {
   target.appendChild(element)
 }

 const chapterListVirtualDom = element('ul', { id: 'list' }, [
   element('li', { class: 'chapter' }, ['chapter1']),
   element('li', { class: 'chapter' }, ['chapter2']),
   element('li', { class: 'chapter' }, ['chapter3'])
 ])

 const dom = chapterListVirtualDom.render()

 renderDom(dom, document.body)

得到如圖:

虛擬 DOM diff

有了上述基礎(chǔ),我們可以產(chǎn)出一份虛擬 DOM,并渲染在瀏覽器中。當(dāng)用戶在特定操作后,會產(chǎn)出新的一份虛擬 DOM,如何得出前后兩份虛擬 DOM 的差異,并交給瀏覽器需要更新的結(jié)果呢?這就涉及到 DOM diff 的過程。

直觀上,因為虛擬 DOM 是個樹形結(jié)構(gòu),所以我們需要對兩份虛擬 DOM 進行遞歸比較,將變化存儲在一個變量 patches 中:

const diff = (oldVirtualDom, newVirtualDom) => {
     let patches = {}

     // 遞歸樹,比較后的結(jié)果放到 patches
     walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)

      // 返回 diff 結(jié)果
     return patches
 }

walkToDiff 前兩個參數(shù)是兩個需要比較的虛擬 DOM 對象;第三個參數(shù)記錄 nodeIndex,在刪除節(jié)點時使用,初始為 0;第四個參數(shù)是一個閉包變量,記錄 diff 結(jié)果:

let initialIndex = 0

 const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
   let diffResult = []

   // 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
   // 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
   if (!newVirtualDom) {
     diffResult.push({
       type: 'REMOVE',
       index
     })
   }
   // 如果新舊節(jié)點都是文本節(jié)點,是字符串
   else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
     // 比較文本是否相同,如果不同則記錄新的結(jié)果
     if (oldVirtualDom !== newVirtualDom) {
       diffResult.push({
         type: 'MODIFY_TEXT',
         data: newVirtualDom,
         index
       })
     }
   }
   // 如果新舊節(jié)點類型相同
   else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
     // 比較屬性是否相同
     let diffAttributeResult = {}

     for (let key in oldVirtualDom) {
       if (oldVirtualDom[key] !== newVirtualDom[key]) {
         diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     for (let key in newVirtualDom) {
       // 舊節(jié)點不存在的新屬性
       if (!oldVirtualDom.hasOwnProperty(key)) {
           diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     if (Object.keys(diffAttributeResult).length > 0) {
         diffResult.push({
           type: 'MODIFY_ATTRIBUTES',
           diffAttributeResult
         })
     }

     // 如果有子節(jié)點,遍歷子節(jié)點
     oldVirtualDom.children.forEach((child, index) => {
       walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
     })
   }
   // else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
   else {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (!oldVirtualDom) {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (diffResult.length) {
     patches[index] = diffResult
   }
 }

我們最后將所有代碼放在一起:

const setAttribute = (node, key, value) => {
   switch (key) {
     case 'style':
       node.style.cssText = value
       break
     case 'value':
       let tagName = node.tagName || ''
       tagName = tagName.toLowerCase()
       if (
         tagName === 'input' || tagName === 'textarea'
       ) {
         node.value = value
       } else {
         // 如果節(jié)點不是 input 或者 textarea,則使用 setAttribute 去設(shè)置屬性
         node.setAttribute(key, value)
       }
       break
     default:
       node.setAttribute(key, value)
       break
   }
 }

 class Element {
   constructor(tagName, attributes = {}, children = []) {
     this.tagName = tagName
     this.attributes = attributes
     this.children = children
   }

   render () {
     let element = document.createElement(this.tagName)
     let attributes = this.attributes

      for (let key in attributes) {
         setAttribute(element, key, attributes[key])
      }

     let children = this.children

     children.forEach(child => {
       let childElement = child instanceof Element
         ? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
         : document.createTextNode(child)  // 若是字符串,直接創(chuàng)建文本節(jié)點
       element.appendChild(childElement)
     })

     return element
   }
 }

 function element (tagName, attributes, children) {
   return new Element(tagName, attributes, children)
 }

 const renderDom = (element, target) => {
   target.appendChild(element)
 }

 const diff = (oldVirtualDom, newVirtualDom) => {
   let patches = {}

   // 遞歸樹 比較后的結(jié)果放到 patches
   walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)

   return patches
 }

 let initialIndex = 0

 const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
   let diffResult = []

   // 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
   // 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
   if (!newVirtualDom) {
     diffResult.push({
       type: 'REMOVE',
       index
     })
   }
   // 如果新舊節(jié)點都是文本節(jié)點,是字符串
   else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
     // 比較文本是否相同,如果不同則記錄新的結(jié)果
     if (oldVirtualDom !== newVirtualDom) {
       diffResult.push({
         type: 'MODIFY_TEXT',
         data: newVirtualDom,
         index
       })
     }
   }
   // 如果新舊節(jié)點類型相同
   else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
     // 比較屬性是否相同
     let diffAttributeResult = {}

     for (let key in oldVirtualDom) {
       if (oldVirtualDom[key] !== newVirtualDom[key]) {
         diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     for (let key in newVirtualDom) {
       // 舊節(jié)點不存在的新屬性
       if (!oldVirtualDom.hasOwnProperty(key)) {
           diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     if (Object.keys(diffAttributeResult).length > 0) {
         diffResult.push({
           type: 'MODIFY_ATTRIBUTES',
           diffAttributeResult
         })
     }

     // 如果有子節(jié)點,遍歷子節(jié)點
     oldVirtualDom.children.forEach((child, index) => {
       walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
     })
   }
   // else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
   else {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (!oldVirtualDom) {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (diffResult.length) {
     patches[index] = diffResult
   }
 }

我們對 diff 進行測試:

const chapterListVirtualDom = element('ul', { id: 'list' }, [
   element('li', { class: 'chapter' }, ['chapter1']),
   element('li', { class: 'chapter' }, ['chapter2']),
   element('li', { class: 'chapter' }, ['chapter3'])
 ])

 const chapterListVirtualDom1 = element('ul', { id: 'list2' }, [
   element('li', { class: 'chapter2' }, ['chapter4']),
   element('li', { class: 'chapter2' }, ['chapter5']),
   element('li', { class: 'chapter2' }, ['chapter6'])
 ])

 diff(chapterListVirtualDom, chapterListVirtualDom1)

得到如圖 diff 數(shù)組:


最小化差異應(yīng)用

大功告成之前,我們來看看都做了哪些事情:通過 Element class 生成了虛擬 DOM,通過 diff 方法對任意兩個虛擬 DOM 進行比對,得到差異。那么這個差異如何更新到現(xiàn)有的 DOM 節(jié)點中呢?看上去需要一個 patch 方法來完成:

const patch = (node, patches) => {
   let walker = { index: 0 }
   walk(node, walker, patches)
 }

patch 方法接受一個真實的 DOM 節(jié)點,它是現(xiàn)有的瀏覽器中需要進行更新的 DOM 節(jié)點,同時接受一個最小化差異集合,該集合對接 diff 方法返回的結(jié)果。在 patch 方法內(nèi)部,我們調(diào)用了 walk 函數(shù):

const walk = (node, walker, patches) => {
   let currentPatch = patches[walker.index]

   let childNodes = node.childNodes

   childNodes.forEach(child => {
     walker.index++
     walk(child, walker, patches)
   })

   if (currentPatch) {
     doPatch(node, currentPatch)
   }
 }

walk 進行自身遞歸,對于當(dāng)前節(jié)點的差異調(diào)用 doPatch 方法進行更新:

const doPatch = (node, patches) => {
   patches.forEach(patch => {
     switch (patch.type) {
       case 'MODIFY_ATTRIBUTES':
         const attributes = patch.diffAttributeResult.attributes
         for (let key in attributes) {
             if (node.nodeType !== 1) return
             const value = attributes[key]
             if (value) {
               setAttribute(node, key, value)
             } else {
               node.removeAttribute(key)
             }
         }
         break
       case 'MODIFY_TEXT':
         node.textContent = patch.data
         break
       case 'REPLACE':
         let newNode = (patch.newNode instanceof Element) 
              ? render(patch.newNode) 
              : document.createTextNode(patch.newNode)
         node.parentNode.replaceChild(newNode, node)
         break
       case 'REMOVE':
         node.parentNode.removeChild(node)
         break
       default:
         break
     }
   })
 }

doPatch 對四種類型的 diff 進行處理,最終進行測試:

var element = chapterListVirtualDom.render()
 renderDom(element, document.body)

 const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)

 patch(element, patches)

全部代碼放在一起:

const setAttribute = (node, key, value) => {
   switch (key) {
     case 'style':
       node.style.cssText = value
       break
     case 'value':
       let tagName = node.tagName || ''
       tagName = tagName.toLowerCase()
       if (
         tagName === 'input' || tagName === 'textarea'
       ) {
         node.value = value
       } else {
         // 如果節(jié)點不是 input 或者 textarea, 則使用 setAttribute 去設(shè)置屬性
         node.setAttribute(key, value)
       }
       break
     default:
       node.setAttribute(key, value)
       break
   }
 }

 class Element {
   constructor(tagName, attributes = {}, children = []) {
     this.tagName = tagName
     this.attributes = attributes
     this.children = children
   }

   render () {
     let element = document.createElement(this.tagName)
     let attributes = this.attributes

      for (let key in attributes) {
         setAttribute(element, key, attributes[key])
      }

     let children = this.children

     children.forEach(child => {
       let childElement = child instanceof Element
         ? child.render() // 若 child 也是虛擬節(jié)點,遞歸進行
         : document.createTextNode(child)  // 若是字符串,直接創(chuàng)建文本節(jié)點
       element.appendChild(childElement)
     })

     return element
   }
 }

 function element (tagName, attributes, children) {
   return new Element(tagName, attributes, children)
 }

 const renderDom = (element, target) => {
   target.appendChild(element)
 }

 const diff = (oldVirtualDom, newVirtualDom) => {
   let patches = {}

   // 遞歸樹 比較后的結(jié)果放到 patches
   walkToDiff(oldVirtualDom, newVirtualDom, 0, patches)

   return patches
 }

 let initialIndex = 0

 const walkToDiff = (oldVirtualDom, newVirtualDom, index, patches) => {
   let diffResult = []

   // 如果 newVirtualDom 不存在,說明該節(jié)點被移除,
   // 我們將 type 為 REMOVE 的對象推進 diffResult 變量,并記錄 index
   if (!newVirtualDom) {
     diffResult.push({
       type: 'REMOVE',
       index
     })
   }
   // 如果新舊節(jié)點都是文本節(jié)點,是字符串
   else if (typeof oldVirtualDom === 'string' && typeof newVirtualDom === 'string') {
     // 比較文本是否相同,如果不同則記錄新的結(jié)果
     if (oldVirtualDom !== newVirtualDom) {
       diffResult.push({
         type: 'MODIFY_TEXT',
         data: newVirtualDom,
         index
       })
     }
   }
   // 如果新舊節(jié)點類型相同
   else if (oldVirtualDom.tagName === newVirtualDom.tagName) {
     // 比較屬性是否相同
     let diffAttributeResult = {}

     for (let key in oldVirtualDom) {
       if (oldVirtualDom[key] !== newVirtualDom[key]) {
         diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     for (let key in newVirtualDom) {
       // 舊節(jié)點不存在的新屬性
       if (!oldVirtualDom.hasOwnProperty(key)) {
           diffAttributeResult[key] = newVirtualDom[key]
       }
     }

     if (Object.keys(diffAttributeResult).length > 0) {
         diffResult.push({
           type: 'MODIFY_ATTRIBUTES',
           diffAttributeResult
         })
     }

     // 如果有子節(jié)點,遍歷子節(jié)點
     oldVirtualDom.children.forEach((child, index) => {
       walkToDiff(child, newVirtualDom.children[index], ++initialIndex, patches)
     })
   }
   // else 說明節(jié)點類型不同,被直接替換了,我們直接將新的結(jié)果 push
   else {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (!oldVirtualDom) {
     diffResult.push({
       type: 'REPLACE',
       newVirtualDom
     })
   }

   if (diffResult.length) {
     patches[index] = diffResult
   }
 }

 const chapterListVirtualDom = element('ul', { id: 'list' }, [
   element('li', { class: 'chapter' }, ['chapter1']),
   element('li', { class: 'chapter' }, ['chapter2']),
   element('li', { class: 'chapter' }, ['chapter3'])
 ])

 const chapterListVirtualDom1 = element('ul', { id: 'list2' }, [
   element('li', { class: 'chapter2' }, ['chapter4']),
   element('li', { class: 'chapter2' }, ['chapter5']),
   element('li', { class: 'chapter2' }, ['chapter6'])
 ])

 const patch = (node, patches) => {
   let walker = { index: 0 }
   walk(node, walker, patches)
 }

 const walk = (node, walker, patches) => {
   let currentPatch = patches[walker.index]

   let childNodes = node.childNodes

   childNodes.forEach(child => {
     walker.index++
     walk(child, walker, patches)
   })

   if (currentPatch) {
     doPatch(node, currentPatch)
   }
 }

 const doPatch = (node, patches) => {
   patches.forEach(patch => {
     switch (patch.type) {
       case 'MODIFY_ATTRIBUTES':
         const attributes = patch.diffAttributeResult.attributes
         for (let key in attributes) {
             if (node.nodeType !== 1) return
             const value = attributes[key]
             if (value) {
               setAttribute(node, key, value)
             } else {
               node.removeAttribute(key)
             }
         }
         break
       case 'MODIFY_TEXT':
         node.textContent = patch.data
         break
       case 'REPLACE':
         let newNode = (patch.newNode instanceof Element) 
              ? render(patch.newNode) 
              : document.createTextNode(patch.newNode)
         node.parentNode.replaceChild(newNode, node)
         break
       case 'REMOVE':
         node.parentNode.removeChild(node)
         break
       default:
         break
     }
   })
 }

先執(zhí)行:

var element = chapterListVirtualDom.render()
 renderDom(element, document.body)

再執(zhí)行:

const patches = diff(chapterListVirtualDom, chapterListVirtualDom1)
patch(element, patches)

生成結(jié)果符合預(yù)期。

短短不到兩百行代碼,就實現(xiàn)了虛擬 DOM 思想的全部流程。當(dāng)然其中還有一些優(yōu)化手段,一些邊界情況并沒有進行特別處理,但是我們?nèi)シ匆恍┲奶摂M DOM 庫:snabbdom、etch 等,其實現(xiàn)思想和上述教例完全一致。

總結(jié)

現(xiàn)代框架無疑極大程度上解放了前端生產(chǎn)力,其設(shè)計思想相互借鑒,存在非常多的共性。本講我們通過分析前端框架中的共性,梳理概念原理,希望達到「任何一種框架變得不再神秘」的目的。掌握了這些基本思想,我們不僅能觸類旁通,更快地上手框架,更能學(xué)習(xí)進階,吸取優(yōu)秀框架的精華。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容