Vue 響應式原理

Zuckjet發表於2022-03-07

Vue 最獨特的特性之一,是非侵入式的響應系統。資料模型僅僅是普通的 JavaScript 物件。而當你修改它們時,檢視會進行更新。聊到 Vue 響應式實現原理,眾多開發者都知道實現的關鍵在於利用 Object.defineProperty , 但具體又是如何實現的呢,今天我們來一探究竟。

為了通俗易懂,我們還是從一個小的示例開始:

<body>
  <div id="app">
    {{ message }}
  </div>
  <script>
    var app = new Vue({
      el: '#app',
      data: {
        message: 'Hello Vue!'
      }
    })
</script>
</body>

我們已經成功建立了第一個 Vue 應用!看起來這跟渲染一個字串模板非常類似,但是 Vue 在背後做了大量工作。現在資料和 DOM 已經被建立了關聯,所有東西都是響應式的。我們要怎麼確認呢?開啟你的瀏覽器的 JavaScript 控制檯 (就在這個頁面開啟),並修改 app.message的值,你將看到上例相應地更新。修改資料便會自動更新,Vue 是如何做到的呢?
通過 Vue 建構函式建立一個例項時,會有執行一個初始化的操作:

function Vue (options) {
    this._init(options);
}

這個 _init初始化函式內部會初始化生命週期、事件、渲染函式、狀態等等:

      initLifecycle(vm);
      initEvents(vm);
      initRender(vm);
      callHook(vm, 'beforeCreate');
      initInjections(vm);
      initState(vm);
      initProvide(vm);
      callHook(vm, 'created');

因為本文的主題是響應式原理,因此我們只關注 initState(vm) 即可。它的關鍵呼叫步驟如下:

function initState (vm) {
  initData(vm);
}

function initData(vm) {
  // data就是我們建立 Vue例項傳入的 {message: 'Hello Vue!'}
  observe(data, true /* asRootData */);
}

function observe (value, asRootData) {
  ob = new Observer(value);
}

var Observer = function Observer (value) {
  this.walk(value);
}

Observer.prototype.walk = function walk (obj) {
  var keys = Object.keys(obj);
  for (var i = 0; i < keys.length; i++) {
    // 實現響應式關鍵函式
    defineReactive$$1(obj, keys[i]);
  }
};
}

我們來總結一下上面 initState(vm)流程。初始化狀態的時候會對應用的資料進行檢測,即建立一個 Observer 例項,其建構函式內部會執行原型上的 walk方法。walk方法的主要作用便是 遍歷資料的所有屬性,並把每個屬性轉換成響應式,而這轉換的工作主要由 defineReactive$$1 函式完成。

function defineReactive$$1(obj, key, val) {
  var dep = new Dep();
  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get: function reactiveGetter() {
      var value = getter ? getter.call(obj) : val;
      if (Dep.target) {
        dep.depend();
        if (childOb) {
          childOb.dep.depend();
          if (Array.isArray(value)) {
            dependArray(value);
          }
        }
      }
      return value
    },
    set: function reactiveSetter(newVal) {
      var value = getter ? getter.call(obj) : val;
      /* eslint-disable no-self-compare */
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      /* eslint-enable no-self-compare */
      if (customSetter) {
        customSetter();
      }
      // #7981: for accessor properties without setter
      if (getter && !setter) { return }
      if (setter) {
        setter.call(obj, newVal);
      } else {
        val = newVal;
      }
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
  });
}

defineReactive$$1函式內部使用Object.defineProperty 來監測資料的變化。每當從 obj 的 key 中讀取資料時,get 函式被觸發;每當往 obj 的 key 中設定資料時,set 函式被觸發。我們說修改資料觸發 set 函式,那麼 set 函式是如何更新檢視的呢?拿本文開頭示例分析:

<div id="app">
    {{ message }}
</div>

該模板使用了資料 message, 當 message 的值發生改變的時候,應用中所有使用到 message 的檢視都能觸發更新。在 Vue 的內部實現中,先是收集依賴,即把用到資料 message 的地方收集起來,然後等資料發生改變的時候,把之前收集的依賴全部觸發一遍就可以了。也就是說我們在上述的 get 函式中收集依賴,在 set 函式中觸發檢視更新。那接下來的重點就是分析 get 函式和 set 函式了。先看 get 函式,其關鍵呼叫如下:

get: function reactiveGetter () {
        if (Dep.target) {
          dep.depend();
        }
 }
 
Dep.prototype.depend = function depend () {
    if (Dep.target) {
      Dep.target.addDep(this);
    }
 };
 
Watcher.prototype.addDep = function addDep (dep) {
  dep.addSub(this);
}

 Dep.prototype.addSub = function addSub (sub) {
    this.subs.push(sub);
 };
 其中 Dep 建構函式如下:
 var Dep = function Dep () {
   this.id = uid++;
   this.subs = [];
 };

上述程式碼中Dep.target的值是一個Watcher例項,稍後我們再分析它是何時被賦值的。我們用一句話總結 get 函式所做的工作:把當前 Watcher 例項(也就是Dep.target)新增到 Dep 例項的 subs 陣列中。在繼續分析 get 函式前,我們需要弄清楚 Dep.target 的值何時被賦值為 Watcher 例項,這裡我們需要從 mountComponent這個函式開始分析:

function mountComponent (vm, el, hydrating) {
  updateComponent = function () {
    vm._update(vm._render(), hydrating);
  };
  new Watcher(vm, updateComponent, noop, xxx);
}
// Wather建構函式下
var Watcher = function Watcher (vm, expOrFn, cb) {
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn;
  } else {
    this.getter = parsePath(expOrFn);
  }
   this.value = this.get();
}

Watcher.prototype.get = function get () {
   pushTarget(this);
   value = this.getter.call(vm, vm);
}

function pushTarget (target) {
    targetStack.push(target);
    Dep.target = target;
}

由上述程式碼我們知道mountComponent函式會建立一個 Watcher 例項,在其建構函式中最終會呼叫 pushTarget函式,把當前 Watcher 例項賦值給 Dep.target。另外我們注意到,建立 Watcher 例項這個動作是發生在函式mountComponent內部,也就是說 Watcher 例項是元件級別的粒度,而不是說任何用到資料的地方都新建一個 Watcher 例項。現在我們再來看看 set 函式的主要呼叫過程:

set: function reactiveSetter (newVal) {
  dep.notify();
}

Dep.prototype.notify = function notify () {
   var subs = this.subs.slice();
   for (var i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
}

Watcher.prototype.update = function update () {
  queueWatcher(this);
}

 Watcher.prototype.update = function update () {
   // queue是一個全域性陣列
   queue.push(watcher);
   nextTick(flushSchedulerQueue);
 }
 
 // flushSchedulerQueue是一個全域性函式
 function flushSchedulerQueue () {
    for (index = 0; index < queue.length; index++) {
      watcher = queue[index];
      watcher.run();
    }
 }
 
Watcher.prototype.run = function run () {
   var value = this.get();
}

set 函式內容有點長,但上述程式碼都是精簡過的,應該不難理解。當改變應用資料的時候,觸發 set 函式執行。它會呼叫 Dep 例項的 notify()方法,而 notify 方法又會把當前 Dep 例項收集的所有 Watcher 例項的 update 方法呼叫一遍,以達到更新所有用到該資料的檢視部分。我們繼續看 Watcher 例項的 update 方法做了什麼。update 方法會把當前的 watcher 新增到陣列 queue 中,然後把 queue 中每個 watcher 的 run 方法執行一遍。run 方法內部會執行 Wather 原型上的 get 方法,後續的呼叫在前文分析 mountComponent 函式中都有描述,在此就不再贅述。總結來說,最終 update 方法會觸發 updateComponent函式:

updateComponent = function () {
  vm._update(vm._render(), hydrating);
};

Vue.prototype._update = function (vnode, hydrating) {
  vm.$el = vm.__patch__(prevVnode, vnode);
}

這裡我們注意到 _update 函式的第一個引數是 vnode 。vnode 顧名思義是虛擬節點的意思,它是一個普通物件,該物件的屬性上儲存了生成 DOM 節點所需要資料。說到虛擬節點你是不是很容易就聯想到虛擬 DOM 了呢,沒錯 Vue 中也使用了虛擬 DOM。前文說到 Wather 是和元件相關的,元件內部的更新就用虛擬 DOM 進行對比和渲染。_update 函式內部呼叫了 patch 函式,通過該函式對比新舊兩個 vnode 之間的不同,然後根據對比結果找出需要更新的節點進行更新。

注:本文分析示例基於 Vue v2.6.14 版本。

相關文章