深入剖析Vue原始碼 - 響應式系統構建(中)

不做祖國的韭菜發表於2019-07-08

為了深入介紹響應式系統的內部實現原理,我們花了一整節的篇幅介紹了資料(包括data, computed,props)如何初始化成為響應式物件的過程。有了響應式資料物件的知識,上一節的後半部分我們還在保留原始碼結構的基礎上構建了一個以data為資料的響應式系統,而這一節,我們繼續深入響應式系統內部構建的細節,詳細分析Vue在響應式系統中對data,computed的處理。

7.8 相關概念

在構建簡易式響應式系統的時候,我們引出了幾個重要的概念,他們都是響應式原理設計的核心,我們先簡單回顧一下:

  • Observer類,例項化一個Observer類會通過Object.defineProperty對資料的getter,setter方法進行改寫,在getter階段進行依賴的收集,在資料發生更新階段,觸發setter方法進行依賴的更新
  • watcher類,例項化watcher類相當於建立一個依賴,簡單的理解是資料在哪裡被使用就需要產生了一個依賴。當資料發生改變時,會通知到每個依賴進行更新,前面提到的渲染wathcer便是渲染dom時使用資料產生的依賴。
  • Dep類,既然watcher理解為每個資料需要監聽的依賴,那麼對這些依賴的收集和通知則需要另一個類來管理,這個類便是Dep,Dep需要做的只有兩件事,收集依賴和派發更新依賴。

這是響應式系統構建的三個基本核心概念,也是這一節的基礎,如果還沒有印象,請先回顧上一節對極簡風響應式系統的構建

7.9 data

7.9.1 問題思考

在開始分析data之前,我們先丟擲幾個問題讓讀者思考,而答案都包含在接下來內容分析中。

  • 前面已經知道,Dep是作為管理依賴的容器,那麼這個容器在什麼時候產生?也就是例項化Dep發生在什麼時候?

  • Dep收集了什麼型別的依賴?即watcher作為依賴的分類有哪些,分別是什麼場景,以及區別在哪裡?

  • Observer這個類具體對getter,setter方法做了哪些事情?

  • 手寫的watcher和頁面資料渲染監聽的watch如果同時監聽到資料的變化,優先順序怎麼排?

  • 有了依賴的收集是不是還有依賴的解除,依賴解除的意義在哪裡?

帶著這幾個問題,我們開始對data的響應式細節展開分析。

7.9.2 依賴收集

data在初始化階段會例項化一個Observer類,這個類的定義如下(忽略陣列型別的data):

// initData 
function initData(data) {
  ···
  observe(data, true)
}
// observe
function observe(value, asRootData) {
  ···
  ob = new Observer(value);
  return ob
}

// 觀察者類,物件只要設定成擁有觀察屬性,則物件下的所有屬性都會重寫getter和setter方法,而getter,setting方法會進行依賴的收集和派發更新
var Observer = function Observer (value) {
    ···
    // 將__ob__屬性設定成不可列舉屬性。外部無法通過遍歷獲取。
    def(value, '__ob__', this);
    // 陣列處理
    if (Array.isArray(value)) {
        ···
    } else {
      // 物件處理
      this.walk(value);
    }
  };

function def (obj, key, val, enumerable) {
  Object.defineProperty(obj, key, {
    value: val,
    enumerable: !!enumerable, // 是否可列舉
    writable: true,
    configurable: true
  });
}
複製程式碼

Observer會為data新增一個__ob__屬性, __ob__屬性是作為響應式物件的標誌,同時def方法確保了該屬性是不可列舉屬性,即外界無法通過遍歷獲取該屬性值。除了標誌響應式物件外,Observer類還呼叫了原型上的walk方法,遍歷物件上每個屬性進行getter,setter的改寫。

Observer.prototype.walk = function walk (obj) {
    // 獲取物件所有屬性,遍歷呼叫defineReactive###1進行改寫
    var keys = Object.keys(obj);
    for (var i = 0; i < keys.length; i++) {
        defineReactive###1(obj, keys[i]);
    }
};
複製程式碼

defineReactive###1是響應式構建的核心,它會先例項化一個Dep類,即為每個資料都建立一個依賴的管理,之後利用Object.defineProperty重寫getter,setter方法。這裡我們只分析依賴收集的程式碼。

function defineReactive###1 (obj,key,val,customSetter,shallow) {
    // 每個資料例項化一個Dep類,建立一個依賴的管理
    var dep = new Dep();

    var property = Object.getOwnPropertyDescriptor(obj, key);
    // 屬性必須滿足可配置
    if (property && property.configurable === false) {
      return
    }
    // cater for pre-defined getter/setters
    var getter = property && property.get;
    var setter = property && property.set;
    // 這一部分的邏輯是針對深層次的物件,如果物件的屬性是一個物件,則會遞迴呼叫例項化Observe類,讓其屬性值也轉換為響應式物件
    var childOb = !shallow && observe(val);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: true,s
      get: function reactiveGetter () {
        var value = getter ? getter.call(obj) : val;
        if (Dep.target) {
          // 為當前watcher新增dep資料
          dep.depend();
          if (childOb) {
            childOb.dep.depend();
            if (Array.isArray(value)) {
              dependArray(value);
            }
          }
        }
        return value
      },
      set: function reactiveSetter (newVal) {}
    });
  }
複製程式碼

主要看getter的邏輯,我們知道當data中屬性值被訪問時,會被getter函式攔截,根據我們舊有的知識體系可以知道,例項掛載前會建立一個渲染watcher

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

與此同時,updateComponent的邏輯會執行例項的掛載,在這個過程中,模板會被優先解析為render函式,而render函式轉換成Vnode時,會訪問到定義的data資料,這個時候會觸發gettter進行依賴收集。而此時資料收集的依賴就是這個渲染watcher本身。

程式碼中依賴收集階段會做下面幾件事:

  1. 為當前的watcher(該場景下是渲染watcher)新增擁有的資料
  2. 為當前的資料收集需要監聽的依賴

如何理解這兩點?我們先看程式碼中的實現。getter階段會執行dep.depend(),這是Dep這個類定義在原型上的方法。

dep.depend();


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

Dep.target為當前執行的watcher,在渲染階段,Dep.target為元件掛載時例項化的渲染watcher,因此depend方法又會呼叫當前watcheraddDep方法為watcher新增依賴的資料。

Watcher.prototype.addDep = function addDep (dep) {
    var id = dep.id;
    if (!this.newDepIds.has(id)) {
      // newDepIds和newDeps記錄watcher擁有的資料
      this.newDepIds.add(id);
      this.newDeps.push(dep);
      // 避免重複新增同一個data收集器
      if (!this.depIds.has(id)) {
        dep.addSub(this);
      }
    }
  };
複製程式碼

其中newDepIds是具有唯一成員是Set資料結構,newDeps是陣列,他們用來記錄當前watcher所擁有的資料,這一過程會進行邏輯判斷,避免同一資料新增多次。

addSub為每個資料依賴收集器新增需要被監聽的watcher

Dep.prototype.addSub = function addSub (sub) {
  //將當前watcher新增到資料依賴收集器中
    this.subs.push(sub);
};
複製程式碼
  1. getter如果遇到屬性值為物件時,會為該物件的每個值收集依賴

這句話也很好理解,如果我們將一個值為基本型別的響應式資料改變成一個物件,此時新增物件裡的屬性,也需要設定成響應式資料。

  1. 遇到屬性值為陣列時,進行特殊處理,這點放到後面講。

通俗的總結一下依賴收集的過程,每個資料就是一個依賴管理器,而每個使用資料的地方就是一個依賴。當訪問到資料時,會將當前訪問的場景作為一個依賴收集到依賴管理器中,同時也會為這個場景的依賴收集擁有的資料。

7.9.3 派發更新

在分析依賴收集的過程中,可能會有不少困惑,為什麼要維護這麼多的關係?在資料更新時,這些關係會起到什麼作用?帶著疑惑,我們來看看派發更新的過程。 在資料發生改變時,會執行定義好的setter方法,我們先看原始碼。

Object.defineProperty(obj,key, {
  ···
  set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val;
      // 新值和舊值相等時,跳出操作
      if (newVal === value || (newVal !== newVal && value !== value)) {
        return
      }
      ···
      // 新值為物件時,會為新物件進行依賴收集過程
      childOb = !shallow && observe(newVal);
      dep.notify();
    }
})
複製程式碼

派發更新階段會做以下幾件事:

  • 判斷資料更改前後是否一致,如果資料相等則不進行任何派發更新操作
  • 新值為物件時,會對該值的屬性進行依賴收集過程
  • 通知該資料收集的watcher依賴,遍歷每個watcher進行資料更新,這個階段是呼叫該資料依賴收集器的dep.notify方法進行更新的派發。
Dep.prototype.notify = function notify () {
    var subs = this.subs.slice();
    if (!config.async) {
      // 根據依賴的id進行排序
      subs.sort(function (a, b) { return a.id - b.id; });
    }
    for (var i = 0, l = subs.length; i < l; i++) {
      // 遍歷每個依賴,進行更新資料操作。
      subs[i].update();
    }
  };
複製程式碼
  • 更新時會將每個watcher推到佇列中,等待下一個tick到來時取出每個watcher進行run操作
 Watcher.prototype.update = function update () {
    ···
    queueWatcher(this);
  };
複製程式碼

queueWatcher方法的呼叫,會將資料所收集的依賴依次推到queue陣列中,陣列會在下一個事件迴圈'tick'中根據緩衝結果進行檢視更新。而在執行檢視更新過程中,難免會因為資料的改變而在渲染模板上新增新的依賴,這樣又會執行queueWatcher的過程。所以需要有一個標誌位來記錄是否處於非同步更新過程的佇列中。這個標誌位為flushing,當處於非同步更新過程時,新增的watcher會插入到queue中。

function queueWatcher (watcher) {
    var id = watcher.id;
    // 保證同一個watcher只執行一次
    if (has[id] == null) {
      has[id] = true;
      if (!flushing) {
        queue.push(watcher);
      } else {
        var i = queue.length - 1;
        while (i > index && queue[i].id > watcher.id) {
          i--;
        }
        queue.splice(i + 1, 0, watcher);
      }
      ···
      nextTick(flushSchedulerQueue);
    }
  }
複製程式碼

nextTick的原理和實現先不講,概括來說,nextTick會緩衝多個資料處理過程,等到下一個事件迴圈tick中再去執行DOM操作,它的原理,本質是利用事件迴圈的微任務佇列實現非同步更新

當下一個tick到來時,會執行flushSchedulerQueue方法,它會拿到收集的queue陣列(這是一個watcher的集合),並對陣列依賴進行排序。為什麼進行排序呢?原始碼中解釋了三點:

  • 元件建立是先父後子,所以元件的更新也是先父後子,因此需要保證父的渲染watcher優先於子的渲染watcher更新。
  • 使用者自定義的watcher,稱為user watcheruser watcherrender watcher執行也有先後,由於user watchersrender watcher要先建立,所以user watcher要優先執行
  • 如果一個元件在父元件的 watcher 執行階段被銷燬,那麼它對應的 watcher 執行都可以被跳過。
function flushSchedulerQueue () {
    currentFlushTimestamp = getNow();
    flushing = true;
    var watcher, id;
    // 對queue的watcher進行排序
    queue.sort(function (a, b) { return a.id - b.id; });
    // 迴圈執行queue.length,為了確保由於渲染時新增新的依賴導致queue的長度不斷改變。
    for (index = 0; index < queue.length; index++) {
      watcher = queue[index];
      // 如果watcher定義了before的配置,則優先執行before方法
      if (watcher.before) {
        watcher.before();
      }
      id = watcher.id;
      has[id] = null;
      watcher.run();
      // in dev build, check and stop circular updates.
      if (has[id] != null) {
        circular[id] = (circular[id] || 0) + 1;
        if (circular[id] > MAX_UPDATE_COUNT) {
          warn(
            'You may have an infinite update loop ' + (
              watcher.user
                ? ("in watcher with expression \"" + (watcher.expression) + "\"")
                : "in a component render function."
            ),
            watcher.vm
          );
          break
        }
      }
    }

    // keep copies of post queues before resetting state
    var activatedQueue = activatedChildren.slice();
    var updatedQueue = queue.slice();
    // 重置恢復狀態,清空佇列
    resetSchedulerState();

    // 檢視改變後,呼叫其他鉤子
    callActivatedHooks(activatedQueue);
    callUpdatedHooks(updatedQueue);

    // devtool hook
    /* istanbul ignore if */
    if (devtools && config.devtools) {
      devtools.emit('flush');
    }
  }
複製程式碼

flushSchedulerQueue階段,重要的過程可以總結為四點:

  • queue中的watcher進行排序,原因上面已經總結。
  • 遍歷watcher,如果當前watcherbefore配置,則執行before方法,對應前面的渲染watcher:在渲染watcher例項化時,我們傳遞了before函式,即在下個tick更新檢視前,會呼叫beforeUpdate生命週期鉤子。
  • 執行watcher.run進行修改的操作。
  • 重置恢復狀態,這個階段會將一些流程控制的狀態變數恢復為初始值,並清空記錄watcher的佇列。
new Watcher(vm, updateComponent, noop, {
  before: function before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate');
    }
  }
}, true /* isRenderWatcher */);
複製程式碼

重點看看watcher.run()的操作。

Watcher.prototype.run = function run () {
    if (this.active) {
      var value = this.get();
      if ( value !== this.value || isObject(value) || this.deep ) {
        // 設定新值
        var oldValue = this.value;
        this.value = value;
        // 針對user watcher,暫時不分析
        if (this.user) {
          try {
            this.cb.call(this.vm, value, oldValue);
          } catch (e) {
            handleError(e, this.vm, ("callback for watcher \"" + (this.expression) + "\""));
          }
        } else {
          this.cb.call(this.vm, value, oldValue);
        }
      }
    }
  };
複製程式碼

首先會執行watcher.prototype.get的方法,得到資料變化後的當前值,之後會對新值做判斷,如果判斷滿足條件,則執行cb,cb為例項化watcher時傳入的回撥。

在分析get方法前,回頭看看watcher建構函式的幾個屬性定義

var watcher = function Watcher(
  vm, // 元件例項
  expOrFn, // 執行函式
  cb, // 回撥
  options, // 配置
  isRenderWatcher // 是否為渲染watcher
) {
  this.vm = vm;
    if (isRenderWatcher) {
      vm._watcher = this;
    }
    vm._watchers.push(this);
    // options
    if (options) {
      this.deep = !!options.deep;
      this.user = !!options.user;
      this.lazy = !!options.lazy;
      this.sync = !!options.sync;
      this.before = options.before;
    } else {
      this.deep = this.user = this.lazy = this.sync = false;
    }
    this.cb = cb;
    this.id = ++uid$2; // uid for batching
    this.active = true;
    this.dirty = this.lazy; // for lazy watchers
    this.deps = [];
    this.newDeps = [];
    this.depIds = new _Set();
    this.newDepIds = new _Set();
    this.expression = expOrFn.toString();
    // parse expression for getter
    if (typeof expOrFn === 'function') {
      this.getter = expOrFn;
    } else {
      this.getter = parsePath(expOrFn);
      if (!this.getter) {
        this.getter = noop;
        warn(
          "Failed watching path: \"" + expOrFn + "\" " +
          'Watcher only accepts simple dot-delimited paths. ' +
          'For full control, use a function instead.',
          vm
        );
      }
    }
    // lazy為計算屬性標誌,當watcher為計算watcher時,不會理解執行get方法進行求值
    this.value = this.lazy
      ? undefined
      : this.get();
  
}
複製程式碼

方法get的定義如下:

Watcher.prototype.get = function get () {
    pushTarget(this);
    var value;
    var vm = this.vm;
    try {
      value = this.getter.call(vm, vm);
    } catch (e) {
     ···
    } finally {
      ···
      // 把Dep.target恢復到上一個狀態,依賴收集過程完成
      popTarget();
      this.cleanupDeps();
    }
    return value
  };
複製程式碼

get方法會執行this.getter進行求值,在當前渲染watcher的條件下,getter會執行檢視更新的操作。這一階段會重新渲染頁面元件

new Watcher(vm, updateComponent, noop, { before: () => {} }, true);

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

執行完getter方法後,最後一步會進行依賴的清除,也就是cleanupDeps的過程。

關於依賴清除的作用,我們列舉一個場景: 我們經常會使用v-if來進行模板的切換,切換過程中會執行不同的模板渲染,如果A模板監聽a資料,B模板監聽b資料,當渲染模板B時,如果不進行舊依賴的清除,在B模板的場景下,a資料的變化同樣會引起依賴的重新渲染更新,這會造成效能的浪費。因此舊依賴的清除在優化階段是有必要。

// 依賴清除的過程
  Watcher.prototype.cleanupDeps = function cleanupDeps () {
    var i = this.deps.length;
    while (i--) {
      var dep = this.deps[i];
      if (!this.newDepIds.has(dep.id)) {
        dep.removeSub(this);
      }
    }
    var tmp = this.depIds;
    this.depIds = this.newDepIds;
    this.newDepIds = tmp;
    this.newDepIds.clear();
    tmp = this.deps;
    this.deps = this.newDeps;
    this.newDeps = tmp;
    this.newDeps.length = 0;
  };
複製程式碼

把上面分析的總結成依賴派發更新的最後兩個點

  • 執行run操作會執行getter方法,也就是重新計算新值,針對渲染watcher而言,會重新執行updateComponent進行檢視更新
  • 重新計算getter後,會進行依賴的清除

7.10 computed

計算屬性設計的初衷是用於簡單運算的,畢竟在模板中放入太多的邏輯會讓模板過重且難以維護。在分析computed時,我們依舊遵循依賴收集和派發更新兩個過程進行分析。

7.10.1 依賴收集

computed的初始化過程,會遍歷computed的每一個屬性值,併為每一個屬性例項化一個computed watcher,其中{ lazy: true}computed watcher的標誌,最終會呼叫defineComputed將資料設定為響應式資料,對應原始碼如下:

function initComputed() {
  ···
  for(var key in computed) {
    watchers[key] = new Watcher(
        vm,
        getter || noop,
        noop,
        computedWatcherOptions
      );
  }
  if (!(key in vm)) {
    defineComputed(vm, key, userDef);
  }
}

// computed watcher的標誌,lazy屬性為true
var computedWatcherOptions = { lazy: true };
複製程式碼

defineComputed的邏輯和分析data的邏輯相似,最終呼叫Object.defineProperty進行資料攔截。具體的定義如下:

function defineComputed (target,key,userDef) {
  // 非服務端渲染會對getter進行快取
  var shouldCache = !isServerRendering();
  if (typeof userDef === 'function') {
    // 
    sharedPropertyDefinition.get = shouldCache
      ? createComputedGetter(key)
      : createGetterInvoker(userDef);
    sharedPropertyDefinition.set = noop;
  } else {
    sharedPropertyDefinition.get = userDef.get
      ? shouldCache && userDef.cache !== false
        ? createComputedGetter(key)
        : createGetterInvoker(userDef.get)
      : noop;
    sharedPropertyDefinition.set = userDef.set || noop;
  }
  if (sharedPropertyDefinition.set === noop) {
    sharedPropertyDefinition.set = function () {
      warn(
        ("Computed property \"" + key + "\" was assigned to but it has no setter."),
        this
      );
    };
  }
  Object.defineProperty(target, key, sharedPropertyDefinition);
}
複製程式碼

在非服務端渲染的情形,計算屬性的計算結果會被快取,快取的意義在於,只有在相關響應式資料發生變化時,computed才會重新求值,其餘情況多次訪問計算屬性的值都會返回之前計算的結果,這就是快取的優化computed屬性有兩種寫法,一種是函式,另一種是物件,其中物件的寫法需要提供gettersetter方法。

當訪問到computed屬性時,會觸發getter方法進行依賴收集,看看createComputedGetter的實現。

function createComputedGetter (key) {
    return function computedGetter () {
      var watcher = this._computedWatchers && this._computedWatchers[key];
      if (watcher) {
        if (watcher.dirty) {
          watcher.evaluate();
        }
        if (Dep.target) {
          watcher.depend();
        }
        return watcher.value
      }
    }
  }
複製程式碼

createComputedGetter返回的函式在執行過程中會先拿到屬性的computed watcher,dirty是標誌是否已經執行過計算結果,如果執行過則不會執行watcher.evaluate重複計算,這也是快取的原理。

Watcher.prototype.evaluate = function evaluate () {
    // 對於計算屬性而言 evaluate的作用是執行計算回撥
    this.value = this.get();
    this.dirty = false;
  };
複製程式碼

get方法前面介紹過,會呼叫例項化watcher時傳遞的執行函式,在computer watcher的場景下,執行函式是計算屬性的計算函式,他可以是一個函式,也可以是物件的getter方法。

列舉一個場景避免和data的處理脫節,computed在計算階段,如果訪問到data資料的屬性值,會觸發data資料的getter方法進行依賴收集,根據前面分析,dataDep收集器會將當前watcher作為依賴進行收集,而這個watcher就是computed watcher,並且會為當前的watcher新增訪問的資料Dep

回到計算執行函式的this.get()方法,getter執行完成後同樣會進行依賴的清除,原理和目的參考data階段的分析。get執行完畢後會進入watcher.depend進行依賴的收集。收集過程和data一致,將當前的computed watcher作為依賴收集到資料的依賴收集器Dep中。

這就是computed依賴收集的完整過程,對比data的依賴收集,computed會對運算的結果進行快取,避免重複執行運算過程。

7.10.2 派發更新

派發更新的條件是data中資料發生改變,所以大部分的邏輯和分析data時一致,我們做一個總結。

  • 當計算屬性依賴的資料發生更新時,由於資料的Dep收集過computed watch這個依賴,所以會呼叫depnotify方法,對依賴進行狀態更新。
  • 此時computed watcher和之前介紹的watcher不同,它不會立刻執行依賴的更新操作,而是通過一個dirty進行標記。我們再回頭看依賴更新的程式碼。
Dep.prototype.notify = function() {
  ···
   for (var i = 0, l = subs.length; i < l; i++) {
      subs[i].update();
    }
}

Watcher.prototype.update = function update () {
  // 計算屬性分支  
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    queueWatcher(this);
  }
};
複製程式碼

由於lazy屬性的存在,update過程不會執行狀態更新的操作,只會將dirty標記為true

  • 由於data資料擁有渲染watcher這個依賴,所以同時會執行updateComponent進行檢視重新渲染,而render過程中會訪問到計算屬性,此時由於this.dirty值為true,又會對計算屬性重新求值。

7.11 小結

我們在上一節的理論基礎上深入分析了Vue如何利用data,computed構建響應式系統。響應式系統的核心是利用Object.defineProperty對資料的getter,setter進行攔截處理,處理的核心是在訪問資料時對資料所在場景的依賴進行收集,在資料發生更改時,通知收集過的依賴進行更新。這一節我們詳細的介紹了data,computed對響應式的處理,兩者處理邏輯存在很大的相似性但卻各有的特性。原始碼中會computed的計算結果進行快取,避免了在多個地方使用時頻繁重複計算的問題。由於篇幅有限,對於使用者自定義的watcher我們會放到下一小節分析。文章還留有一個疑惑,依賴收集時如果遇到的資料是陣列時應該怎麼處理,這些疑惑都會在之後的文章一一解開。


相關文章