vue響應式系統原始碼解析

輝衛無敵發表於2018-12-24

vue和react是現在前端框架的雙子星。vue以其簡單好用而聞名。vue以資料驅動檢視,資料響應系統是vue的核心。這篇文章主要是結合原始碼分析vue響應式系統的原理和實現。

代理

下面這段程式碼是vue使用的典型方式:

<div id="app-5">
  <p>{{ message }}</p>
  <button v-on:click="reverseMessage">逆轉訊息</button>
</div>
複製程式碼
var app5 = new Vue({
  el: '#app-5',
  data: {
    message: 'Hello Vue.js!'
  },
  methods: {
    reverseMessage: function () {
      this.message = this.message.split('').reverse().join('')
    }
  }
})
複製程式碼

vue響應式系統原始碼解析

我們可以看到當我們給this.message賦值時,檢視會自動更新。這就是vue資料響應系統做的事情,當然vue做的事情遠比這個複雜地多,但這是核心理念。原理也很簡單,vue幫我們代理了資料的賦值操作,在資料賦值時進行了DOM的更新,這些對於vue使用者是不可見的,也是無需考慮的。

實現資料代理在js中有兩種方式,一個是ES5的gettersetter方法;一個是ES6的proxyapi。vue2.5及以下采用的是gettersetter方法;即將出來的vue3.0全部改為proxy方式來實現。

getter和setter

Object提供一個方法definePropery可以讓我們給一個物件的屬性定義gettersetter,從而代理物件屬性的取值和賦值操作,用法如下:

var o = {};
Object.defineProperty(o, "b", {
  get: function(){
    alert('我在取值');
  },
  set: function(newValue){
    alert('我在賦值');
  },
  enumerable : true,
  configurable : true
});
var a = o.b; // 彈出“我在取值”
o.b=5; // 彈出“我在賦值”
複製程式碼

有了這個特性,我們就可以實現最簡單的雙向資料繫結。比如vue中的v-model效果。

<input type="text" id="name"></input>
複製程式碼
var data = {};
var nameDom = document.querySelector('#name');
Object.defineProperty(data, 'value', {
  get: function(){
    return nameDom.value;
  },
  set: function(value){
    nameDom.value = value;
  }
})
複製程式碼

vue響應式系統原始碼解析
由此我們便實現了最簡單的雙向資料繫結。當然,vue實現響應式資料的思路和上面不一樣,要複雜的多,但是基本理念就是通過劫持資料的賦值和取值操作來完成的。

觀察者模式

我們要想實現vue的響應式資料,就要給vue初始化物件的data和props的所有屬性設定setter和getter函式。vue原始碼src/core/instance/state.jsinitData函式有如下程式碼,其中observe都是迴圈遍歷data物件,給每個屬性都設定setter和getter。

  // src/core/instance/state.js
  // observe data
  observe(data, true /* asRootData */)
複製程式碼

我們可以知道的是setter函式中的操作肯定是要更新DOM的,那每個資料繫結的DOM不同,繫結的屬性也不同,如果像我們上面那樣把每個響應式資料和具體DOM的屬性繫結起來,就太麻煩和複雜了。Vue採取的策略是虛擬DOM,每次資料setter操作,都會根據你寫的template或者render生成虛擬DOM,然後和之前的虛擬DOM進行比較,如果有不同,則進行DOM更新,而且只更新有變化的部分;如果相同就不做操作。這種方式可以解決我們的問題,效能也沒有問題。在vue例項mount的過程中會執行以下程式碼:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
複製程式碼

這句就是用來執行DOM的更新渲染的,我們在響應式資料的setter中應該執行updateComponent就能達到我們的目的了。看起來很簡單是吧,但是現在有兩個問題:

  1. 我們在data和props中宣告的屬性不一定都繫結到Dom上了,如果是沒有繫結到Dom上的資料,在進行setter的時候,也要DOM更新操作,雖然不會引起真正的DOM更新,但也是很浪費效能。
  2. 我們資料setter可能會不止有Dom更新的任務,比如watch了一個屬性,那麼這個屬性就有Dom更新和watch繫結的回撥兩個任務。

資料setter繫結不同的任務,在資料改變時,執行所有繫結的任務,這個不就是觀察者模式嘛。。。

觀察者模式一個典型的例子就是DOM元素的事件繫結

var btnDom = document.getElementById('btn');
btnDom.addEventListener('click', function(){
  console.log('click事件發生了,做點啥...');
});
複製程式碼

我們給btnDom繫結click事件,就相當於在觀察btnDom,當btnDom被點選,就會呼叫我們繫結的事件。現在我們的響應式資料(data和props)就是我們觀察的物件,我們把需要執行的任務放入響應式資料的setter裡,在響應式資料被賦值的時候,執行這些任務。觀察者模式這個名字是和現實的一個類比,有的有觀察者例項,有的直接註冊回撥函式,其實本質是一樣的,這些觀察者例項或者回撥函式都在觀察的動作中被放入被觀察者的例項中的,在被觀察者發生改變時,執行註冊在自己身上的回撥函式或者通知觀察者。下面是一段典型的觀察者模式實現:


//被觀察者
class Subject{
 constructor(){
   this.observerList = [];
 }

 addObserver(observer){
   this.observerList.push(observer);
 }

 removeObserver(observer){
   const index = this.observerList.findIndex(item => item === observer);
   if(index !== -1){
     this.observerList.splice(index, 1);
   }
 }

 notify(context){
   const length = this.observerList.length;
   for(let i = 0; i<length; i++){
     const observer = this.observerList[i];
     observer.notify(context);
   }
 }
}

//觀察者
class Observer{
 constructor(){
   this.notify = function(){
     // ...
   };
 }
}
複製程式碼

Subject例項通過addObserverremoveObserver來新增和刪除觀察者,然後在合適的時機通過notify方法通知所有的觀察者。vue也是類似的實現機制,不過Vue的設計比較巧妙,實現形式有所不同。vue有3個類是用來處理響應式資料的觀察者模式:ObserverWatcherDep

  • Observer, 該類主要作用是用來定義屬性的getter和setter方法
  • Watcher, 觀察者類,且同時用於$watch例項方法和watch指令
  • Dep, 觀察者容器,每一個響應式資料的屬性都擁有一個自己獨立的Dep例項,盛放自己的觀察者

我們上面寫的觀察者模式或者是事件繫結,需要我們主動去新增觀察者,那麼在響應式資料這個模式當中我們應該在何時去收集屬性自己的觀察者那?答案是在響應式資料的getter中收集。因為被觀察的資料在求值的時候肯定會觸發getter函式,這是一個很好的時機,而且也能避免沒有參與DOM更新的屬性被繫結DOM更新的觀察者。所以,vue採用的方式是在getter中收集觀察者,在setter中通知觀察者

Observer型別中有一個defineReactive函式,這個函式主要是用來定義屬性的getter和setter方法,下面是一個簡化版的defineReactive函式,去掉了一個邊界情況的資料,只考慮物件這種響應式資料。

/**
* Define a reactive property on an Object.
*/
export function defineReactive (
 obj: Object,
 key: string,
 shallow?: boolean
) {
 const dep = new Dep()
 val = obj[key];
 let childOb = !shallow && observe(val)
 Object.defineProperty(obj, key, {
   enumerable: true,
   configurable: true,
   get: function reactiveGetter () {
     if (Dep.target) {
       dep.depend()
     }
     return val
   },

   set: function reactiveSetter (newVal) {
     /* eslint-disable no-self-compare */
     if (newVal === val || (newVal !== newVal && val !== val)) {
       return
     }
     val = newVal
     childOb = !shallow && observe(newVal)
     dep.notify()
   }
 })
}
複製程式碼

上面程式碼還是很清楚簡單的,在getter中的dep.depend就是收集觀察者;在setterdep.notify就是通知觀察者。每一個響應式屬性都擁有自己的閉包Dep例項,這個Dep例項中裝載這所有該屬性的觀察者。那麼Dep.target是什麼東西那?它就是我們所要收集的觀察者。這裡Dep.target可能會有點迷糊,我們先來考慮一下vue的$watch例項方法或者watch指令是怎麼用的:

vm.$watch(expOrFn, function(){...});
複製程式碼

expOrFn的值傳送變化時,執行回撥函式。而$watch方法就是新建了一個Watcher例項:

Vue.prototype.$watch = function (
  expOrFn: string | Function,
  cb: any,
  options?: Object
): Function {
  const vm: Component = this
  if (isPlainObject(cb)) { // 用於處理watch指令cb是一個物件,帶有immediate或者deep引數的情況,createWatcher中是整理引數,建立Watcher例項
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {}
  const watcher = new Watcher(vm, expOrFn, cb, options)
  if (options.immediate) {
    cb.call(vm, watcher.value)
  }
  return function unwatchFn () {
    watcher.teardown()
  }
}
複製程式碼

現在我們需要明確的一點就是,其實通過改變資料來更新DOM這個操作,其實就是建立了一個渲染函式的Watcher例項,跟我們使用$watch方法去觀察一個資料是一樣的,只不過這個操作是Vue主動做的,只不過它觀察的是所有<template>或者render中的資料。下面這段程式碼就是建立渲染函式的Watcher例項:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)
複製程式碼

updateComponent函式上面已經說過,是用來更新DOM操作的。我們先來看一下Watcher類建構函式的引數,Watcher(vm, expOrFn, cb, options)一共有4個:

  • vm, Vue例項
  • expOrFn, 被觀察的表示式或者函式,用來求值同時觸發被觀察資料的getter函式,用於收集該觀察者,所以我們上面說的Dep.target就是在expOrFn求值之前被賦值為該觀察者例項的
  • cd,回撥函式,被觀察資料改變時,執行的回撥函式
  • options, 一些引數設定
  • isRenderWatcher,是否為渲染函式的觀察者

那我們看這個渲染函式的觀察者例項的構造引數就有些奇怪,因為它的回撥是noop,noop是定義了一個空函式。這不是很奇怪嗎,在資料變化的時候什麼都不做,怎麼更新DOM?實際上在資料發生變化的時候,Watcher例項都要對expOrFn重新求一遍值,這樣才能知道expOrFn的值有沒有變化,進而決定是否要執行cb;而updateComponent這個更新DOM的函式同時滿足觸發getter和更新DOM的需求,所以在這裡就不需要設定cb了,同樣如果我們在編碼時,有這樣同時滿足收集依賴和滿足回撥的函式,也可以這樣用。

避免重複收集觀察者

那每次資料發生變化的時候,觀察者都對expOrFn求值,豈不是每次都會觸發getter函式,造成依賴重複收集?的確會,而且即便在一次求值過程中,也可能觸發同一個資料多次(比如同一個屬性出現在模板多個地方),不過Vue已經實現了避免收集重複依賴的處理:

class Watcher{
  addDep (dep: Dep) {
    const id = dep.id
    if (!this.newDepIds.has(id)) {
      this.newDepIds.add(id)
      this.newDeps.push(dep)
      if (!this.depIds.has(id)) {
        dep.addSub(this)
      }
    }
  }
}

class Dep{
  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }
}
複製程式碼

上述程式碼就是Vue用來避免收集重複依賴的,我們知道,在響應式資料的getter中,我們會呼叫該屬性所擁有的Dep例項的depend方法來收集觀察者。我們可以看到在depend方法中呼叫了觀察者例項方法addDep,而在addDep方法中我們可以看到又呼叫了Dep的例項方法addSubDep這個容器加入觀察者。有點繞,兩個類來回撥用,目的只有一個,就是避免收集重複的觀察者

我們可以看到addDep方法中有3個值,決定了是否新增觀察者,我們先來說明一下這3個值的作用:

  • newDepIds,本次求值所收集的Dep例項Id列表,用來避免本次求值的重複收集,在每次求值完成之後都會被賦值給depIds,然後被清空
  • depIds,上次求值所收集的Dep例項Id列表,用來避免兩次求值之間的重複收集以及去除廢棄的觀察者
  • newDeps, 本次求值所收集的Dep例項列表, 在每次求值完成之後都會被賦值給deps,然後被清空

這樣看程式碼的邏輯就很清楚了,先判斷在本次求值中是否已經收集了該Dep例項,如果沒有,則將該Dep的id新增到newDepIds,然後再判斷,該Dep例項是否存在於上次求值的Dep例項Id列表,如果沒有,則將該觀察者放入Dep例項中,作為該Dep例項所屬的響應式資料所擁有的觀察者

在每次求值之後,都會執行cleanupDeps,用於給newDepIds賦值給depIds,清空newDepIds, 並且去除廢棄的觀察者,下面這段程式碼就是去除廢棄的觀察者

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) {
      dep.removeSub(this)
    }
  }
  // 省略...
}
複製程式碼

凡是在上次求值過程中存在的Dep例項,在本次求值中不存在了,說明該Dep例項已經被廢棄了,更直白的說法就是該Dep例項所屬的響應式資料已經不在本次求值過程中了,需要把該觀察者Dep例項中去除。

非同步更新佇列

在同一個js任務佇列中,我們可能改變多個模板中的響應式資料,這樣會造成多次觸發渲染函式的觀察者,造成多次重複地渲染DOM,造成效能浪費。解決這個問題的辦法在於我們要有一個合適的時機統一處理一個js任務佇列中所有被觸發的觀察者,對於重複的觀察者只執行一次。這個合適的時機就是微任務,在js任務佇列執行完之後,會立即執行在本次任務佇列中產生的所有微任務。且在兩次js任務佇列之間會穿插著DOM更新,所以在微任務中把所有相關的資料更新,是最優的。下面queueWatcher是非同步模式下觀察者被通知時執行的操作,queue存放在本次任務佇列中所有被通知的觀察者nextTick是Vue實現的微任務機制(在不支援微任務的情況下,回退到巨集任務),flushSchedulerQueue則是用來執行queue中的觀察者,並清空queue

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    queue.push(watcher)
    // queue the flush
    if (!waiting) {
      waiting = true

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}
複製程式碼

相關文章