Vue 原始碼解析:深入響應式原理

滴滴出行·DDFE發表於2016-11-11

作者:滴滴公共前端團隊 - 黃軼

本文來自《Vue.js 權威指南》原始碼篇的一個章節,現在分享出來給大家

Vue.js 最顯著的功能就是響應式系統,它是一個典型的 MVVM 框架,模型(Model)只是普通的 JavaScript 物件,修改它則檢視(View)會自動更新。這種設計讓狀態管理變得非常簡單而直觀,不過理解它的原理也很重要,可以避免一些常見問題。下面讓我們深挖 Vue.js 響應式系統的細節,來看一看 Vue.js 是如何把模型和檢視建立起關聯關係的。

如何追蹤變化

我們先來看一個簡單的例子。程式碼示例如下:

<div id="main">
  <h1>count: {{times}}</h1>
</div>
<script src="vue.js"></script>
<script>
  var vm = new Vue({
    el: '#main',
    data: function () {
      return {
        times: 1
      };
    },
    created: function () {
      var me = this;
      setInterval(function () {
        me.times++;
      }, 1000);
    }
  });
</script>複製程式碼

執行後,我們可以從頁面中看到,count 後面的 times 每隔 1s 遞增 1,檢視一直在更新。在程式碼中僅僅是通過 setInterval 方法每隔 1s 來修改 vm.times 的值,並沒有任何 DOM 操作。那麼 Vue.js 是如何實現這個過程的呢?我們可以通過一張圖來看一下,如下圖所示:

Vue 原始碼解析:深入響應式原理
image

圖中的模型(Model)就是 data 方法返回的{times:1},檢視(View)是最終在瀏覽器中顯示的DOM。模型通過Observer、Dep、Watcher、Directive等一系列物件的關聯,最終和檢視建立起關係。歸納起來,Vue.js在這裡主要做了三件事:

  • 通過 Observer 對 data 做監聽,並且提供了訂閱某個資料項變化的能力。
  • 把 template 編譯成一段 document fragment,然後解析其中的 Directive,得到每一個 Directive 所依賴的資料項和update方法。
  • 通過Watcher把上述兩部分結合起來,即把Directive中的資料依賴通過Watcher訂閱在對應資料的 Observer 的 Dep 上。當資料變化時,就會觸發 Observer 的 Dep 上的 notify 方法通知對應的 Watcher 的 update,進而觸發 Directive 的 update 方法來更新 DOM 檢視,最後達到模型和檢視關聯起來。

接下來我們就結合 Vue.js 的原始碼來詳細介紹這三個過程。

Observer

首先來看一下 Vue.js 是如何給 data 物件新增 Observer 的。我們知道,Vue 例項建立的過程會有一個生命週期,其中有一個過程就是呼叫 vm.initData 方法處理 data 選項。initData 方法的原始碼定義如下:

<!-原始碼目錄:src/instance/internal/state.js-->
Vue.prototype._initData = function () {
    var dataFn = this.$options.data
    var data = this._data = dataFn ? dataFn() : {}
    if (!isPlainObject(data)) {
      data = {}
      process.env.NODE_ENV !== 'production' && warn(
        'data functions should return an object.',
        this
      )
    }
    var props = this._props
    // proxy data on instance
    var keys = Object.keys(data)
    var i, key
    i = keys.length
    while (i--) {
      key = keys[i]
      // there are two scenarios where we can proxy a data key:
      // 1. it's not already defined as a prop
      // 2. it's provided via a instantiation option AND there are no
      //    template prop present
      if (!props || !hasOwn(props, key)) {
        this._proxy(key)
      } else if (process.env.NODE_ENV !== 'production') {
        warn(
          'Data field "' + key + '" is already defined ' +
          'as a prop. To provide default value for a prop, use the "default" ' +
          'prop option; if you want to pass prop values to an instantiation ' +
          'call, use the "propsData" option.',
          this
        )
      }
    }
    // observe data
    observe(data, this)
  }複製程式碼

在 initData 中我們要特別注意 proxy 方法,它的功能就是遍歷 data 的 key,把 data 上的屬性代理到 vm 例項上。_proxy 方法的原始碼定義如下:

<!-原始碼目錄:src/instance/internal/state.js-->
Vue.prototype._proxy = function (key) {
    if (!isReserved(key)) {
      // need to store ref to self here
      // because these getter/setters might
      // be called by child scopes via
      // prototype inheritance.
      var self = this
      Object.defineProperty(self, key, {
        configurable: true,
        enumerable: true,
        get: function proxyGetter () {
          return self._data[key]
        },
        set: function proxySetter (val) {
          self._data[key] = val
        }
      })
    }
  }複製程式碼

proxy 方法主要通過 Object.defineProperty 的 getter 和 setter 方法實現了代理。在前面的例子中,我們呼叫 vm.times 就相當於訪問了 vm.data.times。

在 _initData 方法的最後,我們呼叫了 observe(data, this) 方法來對 data 做監聽。observe 方法的原始碼定義如下:

<!-原始碼目錄:src/observer/index.js-->
export function observe (value, vm) {
  if (!value || typeof value !== 'object') {
    return
  }
  var ob
  if (
    hasOwn(value, '__ob__') &&
    value.__ob__ instanceof Observer
  ) {
    ob = value.__ob__
  } else if (
    shouldConvert &&
    (isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    ob = new Observer(value)
  }
  if (ob && vm) {
    ob.addVm(vm)
  }
  return ob
}複製程式碼

observe 方法首先判斷 value 是否已經新增了 ob 屬性,它是一個 Observer 物件的例項。如果是就直接用,否則在 value 滿足一些條件(陣列或物件、可擴充套件、非 vue 元件等)的情況下建立一個 Observer 物件。接下來我們看一下 Observer 這個類,它的原始碼定義如下:

<!-原始碼目錄:src/observer/index.js-->
export function Observer (value) {
  this.value = value
  this.dep = new Dep()
  def(value, '__ob__', this)
  if (isArray(value)) {
    var augment = hasProto
      ? protoAugment
      : copyAugment
    augment(value, arrayMethods, arrayKeys)
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}複製程式碼

Observer 類的建構函式主要做了這麼幾件事:首先建立了一個 Dep 物件例項(關於 Dep 物件我們稍後作介紹);然後把自身 this 新增到 value 的 ob 屬性上;最後對 value 的型別進行判斷,如果是陣列則觀察陣列,否則觀察單個元素。其實 observeArray 方法就是對陣列進行遍歷,遞迴呼叫 observe 方法,最終都會呼叫 walk 方法觀察單個元素。接下來我們看一下 walk 方法,它的原始碼定義如下:

<!-原始碼目錄:src/observer/index.js-->
Observer.prototype.walk = function (obj) {
  var keys = Object.keys(obj)
  for (var i = 0, l = keys.length; i < l; i++) {
    this.convert(keys[i], obj[keys[i]])
  }
}複製程式碼

walk 方法是對 obj 的 key 進行遍歷,依次呼叫 convert 方法,對 obj 的每一個屬性進行轉換,讓它們擁有 getter、setter 方法。只有當 obj 是一個物件時,這個方法才能被呼叫。接下來我們看一下 convert 方法,它的原始碼定義如下:

<!-原始碼目錄:src/observer/index.js-->
Observer.prototype.convert = function (key, val) {
  defineReactive(this.value, key, val)
}複製程式碼

convert 方法很簡單,它呼叫了 defineReactive 方法。這裡 this.value 就是要觀察的 data 物件,key 是 data 物件的某個屬性,val 則是這個屬性的值。defineReactive 的功能是把要觀察的 data 物件的每個屬性都賦予 getter 和 setter 方法。這樣一旦屬性被訪問或者更新,我們就可以追蹤到這些變化。接下來我們看一下 defineReactive 方法,它的原始碼定義如下:

<!-原始碼目錄:src/observer/index.js-->
export function defineReactive (obj, key, val) {
  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
  var childOb = observe(val)
  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 (isArray(value)) {
          for (var e, i = 0, l = value.length; i < l; i++) {
            e = value[i]
            e && e.__ob__ && e.__ob__.dep.depend()
          }
        }
      }
      return value
    },
    set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val
      if (newVal === value) {
        return
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = observe(newVal)
      dep.notify()
    }
  })
}複製程式碼

defineReactive 方法最核心的部分就是通過呼叫 Object.defineProperty 給 data 的每個屬性新增 getter 和setter 方法。當 data 的某個屬性被訪問時,則會呼叫 getter 方法,判斷當 Dep.target 不為空時呼叫 dep.depend 和 childObj.dep.depend 方法做依賴收集。如果訪問的屬性是一個陣列,則會遍歷這個陣列收集陣列元素的依賴。當改變 data 的屬性時,則會呼叫 setter 方法,這時呼叫 dep.notify 方法進行通知。這裡我們提到了 dep,它是 Dep 物件的例項。接下來我們看一下 Dep 這個類,它的原始碼定義如下:

<!-原始碼目錄:src/observer/dep.js-->
export default function Dep () {
  this.id = uid++
  this.subs = []
}
// the current target watcher being evaluated.
// this is globally unique because there could be only one
// watcher being evaluated at any time.
Dep.target = null複製程式碼

Dep 類是一個簡單的觀察者模式的實現。它的建構函式非常簡單,初始化了 id 和 subs。其中 subs 用來儲存所有訂閱它的 Watcher,Watcher 的實現稍後我們會介紹。Dep.target 表示當前正在計算的 Watcher,它是全域性唯一的,因為在同一時間只能有一個 Watcher 被計算。

前面提到了在 getter 和 setter 方法呼叫時會分別呼叫 dep.depend 方法和 dep.notify 方法,接下來依次介紹這兩個方法。depend 方法的原始碼定義如下:

<!-原始碼目錄:src/observer/dep.js-->
Dep.prototype.depend = function () {
  Dep.target.addDep(this)
}複製程式碼

depend 方法很簡單,它通過 Dep.target.addDep(this) 方法把當前 Dep 的例項新增到當前正在計算的Watcher 的依賴中。接下來我們看一下 notify 方法,它的原始碼定義如下:

<!-原始碼目錄:src/observer/dep.js-->
Dep.prototype.notify = function () {
  // stablize the subscriber list first
  var subs = toArray(this.subs)
  for (var i = 0, l = subs.length; i < l; i++) {
    subs[i].update()
  }
}複製程式碼

notify 方法也很簡單,它遍歷了所有的訂閱 Watcher,呼叫它們的 update 方法。

至此,vm 例項中給 data 物件新增 Observer 的過程就結束了。接下來我們看一下 Vue.js 是如何進行指令解析的。

Directive

Vue 指令型別很多,限於篇幅,我們不會把所有指令的解析過程都介紹一遍,這裡結合前面的例子只介紹 v-text 指令的解析過程,其他指令的解析過程也大同小異。
前面我們提到了 Vue 例項建立的生命週期,在給 data 新增 Observer 之後,有一個過程是呼叫 vm.compile 方法對模板進行編譯。compile 方法的原始碼定義如下:

<!-原始碼目錄:src/instance/internal/lifecycle.js--> 
Vue.prototype._compile = function (el) {
    var options = this.$options
    // transclude and init element
    // transclude can potentially replace original
    // so we need to keep reference; this step also injects
    // the template and caches the original attributes
    // on the container node and replacer node.
    var original = el
    el = transclude(el, options)
    this._initElement(el)
    // handle v-pre on root node (#2026)
    if (el.nodeType === 1 && getAttr(el, 'v-pre') !== null) {
      return
    }
    // root is always compiled per-instance, because
    // container attrs and props can be different every time.
    var contextOptions = this._context && this._context.$options
    var rootLinker = compileRoot(el, options, contextOptions)
    // resolve slot distribution
    resolveSlots(this, options._content)
    // compile and link the rest
    var contentLinkFn
    var ctor = this.constructor
    // component compilation can be cached
    // as long as it's not using inline-template
    if (options._linkerCachable) {
      contentLinkFn = ctor.linker
      if (!contentLinkFn) {
        contentLinkFn = ctor.linker = compile(el, options)
      }
    }
    // link phase
    // make sure to link root with prop scope!
    var rootUnlinkFn = rootLinker(this, el, this._scope)
    var contentUnlinkFn = contentLinkFn
      ? contentLinkFn(this, el)
      : compile(el, options)(this, el)
    // register composite unlink function
    // to be called during instance destruction
    this._unlinkFn = function () {
      rootUnlinkFn()
      // passing destroying: true to avoid searching and
      // splicing the directives
      contentUnlinkFn(true)
    }
    // finally replace original
    if (options.replace) {
      replace(original, el)
    }
    this._isCompiled = true
    this._callHook('compiled')
  }複製程式碼

我們可以通過下圖來看一下這個方法編譯的主要流程:

Vue 原始碼解析:深入響應式原理
image

這個過程通過 el = transclude(el, option) 方法把 template 編譯成一段 document fragment,拿到 el 物件。而指令解析部分就是通過 compile(el, options) 方法實現的。接下來我們看一下 compile 方法的實現,它的原始碼定義如下:

<!-原始碼目錄:src/compiler/compile.js-->
    export function compile (el, options, partial) {
  // link function for the node itself.
  var nodeLinkFn = partial || !options._asComponent
    ? compileNode(el, options)
    : null
  // link function for the childNodes
  var childLinkFn =
    !(nodeLinkFn && nodeLinkFn.terminal) &&
    !isScript(el) &&
    el.hasChildNodes()
      ? compileNodeList(el.childNodes, options)
      : null
  /**
   * A composite linker function to be called on a already
   * compiled piece of DOM, which instantiates all directive
   * instances.
   *
   * @param {Vue} vm
   * @param {Element|DocumentFragment} el
   * @param {Vue} [host] - host vm of transcluded content
   * @param {Object} [scope] - v-for scope
   * @param {Fragment} [frag] - link context fragment
   * @return {Function|undefined}
   */
  return function compositeLinkFn (vm, el, host, scope, frag) {
    // cache childNodes before linking parent, fix #657
    var childNodes = toArray(el.childNodes)
    // link
    var dirs = linkAndCapture(function compositeLinkCapturer () {
      if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag)
      if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag)
    }, vm)
    return makeUnlinkFn(vm, dirs)
  }
}複製程式碼

compile 方法主要通過 compileNode(el, options) 方法完成節點的解析,如果節點擁有子節點,則呼叫 compileNodeList(el.childNodes, options) 方法完成子節點的解析。compileNodeList 方法其實就是遍歷子節點,遞迴呼叫 compileNode 方法。因為 DOM 元素本身就是樹結構,這種遞迴方法也就是常見的樹的深度遍歷方法,這樣就可以完成整個 DOM 樹節點的解析。接下來我們看一下 compileNode 方法的實現,它的原始碼定義如下:

<!-原始碼目錄:src/compiler/compile.js-->
function compileNode (node, options) {
  var type = node.nodeType
  if (type === 1 && !isScript(node)) {
    return compileElement(node, options)
  } else if (type === 3 && node.data.trim()) {
    return compileTextNode(node, options)
  } else {
    return null
  }
}複製程式碼

compileNode 方法對節點的 nodeType 做判斷,如果是一個非 script 普通的元素(div、p等);則呼叫 compileElement(node, options) 方法解析;如果是一個非空的文字節點,則呼叫 compileTextNode(node, options) 方法解析。我們在前面的例子中解析的是非空文字節點 count: {{times}},這實際上是 v-text 指令,它的解析是通過 compileTextNode 方法實現的。接下來我們看一下 compileTextNode 方法,它的原始碼定義如下:

<!-原始碼目錄:src/compiler/compile.js-->
function compileTextNode (node, options) {
  // skip marked text nodes
  if (node._skip) {
    return removeText
  }
  var tokens = parseText(node.wholeText)
  if (!tokens) {
    return null
  }
  // mark adjacent text nodes as skipped,
  // because we are using node.wholeText to compile
  // all adjacent text nodes together. This fixes
  // issues in IE where sometimes it splits up a single
  // text node into multiple ones.
  var next = node.nextSibling
  while (next && next.nodeType === 3) {
    next._skip = true
    next = next.nextSibling
  }
  var frag = document.createDocumentFragment()
  var el, token
  for (var i = 0, l = tokens.length; i < l; i++) {
    token = tokens[i]
    el = token.tag
      ? processTextToken(token, options)
      : document.createTextNode(token.value)
    frag.appendChild(el)
  }
  return makeTextNodeLinkFn(tokens, frag, options)
}複製程式碼

compileTextNode 方法首先呼叫了 parseText 方法對 node.wholeText 做解析。主要通過正規表示式解析 count: {{times}} 部分,我們看一下解析結果,如下圖所示:

Vue 原始碼解析:深入響應式原理
image

解析後的 tokens 是一個陣列,陣列的每個元素則是一個 Object。如果是 count: 這樣的普通文字,則返回的物件只有 value 欄位;如果是 {{times}} 這樣的插值,則返回的物件包含 html、onTime、tag、value 等欄位。

接下來建立 document fragment,遍歷 tokens 建立 DOM 節點插入到這個 fragment 中。在遍歷過程中,如果 token 無 tag 欄位,則呼叫 document.createTextNode(token.value) 方法建立 DOM 節點;否則呼叫processTextToken(token, options) 方法建立 DOM 節點和擴充套件 token 物件。我們看一下呼叫後的結果,如下圖所示:

Vue 原始碼解析:深入響應式原理
image

可以看到,token 欄位多了一個 descriptor 屬性。這個屬性包含了幾個欄位,其中 def 表示指令相關操作的物件,expression 為解析後的表示式,filters 為過濾器,name 為指令的名稱。

在compileTextNode 方法的最後,呼叫 makeTextNodeLinkFn(tokens, frag, options) 並返回該方法執行的結果。接下來我們看一下 makeTextNodeLinkFn 方法,它的原始碼定義如下:

<!-原始碼目錄:src/compiler/compile.js-->
function makeTextNodeLinkFn (tokens, frag) {
  return function textNodeLinkFn (vm, el, host, scope) {
    var fragClone = frag.cloneNode(true)
    var childNodes = toArray(fragClone.childNodes)
    var token, value, node
    for (var i = 0, l = tokens.length; i < l; i++) {
      token = tokens[i]
      value = token.value
      if (token.tag) {
        node = childNodes[i]
        if (token.oneTime) {
          value = (scope || vm).$eval(value)
          if (token.html) {
            replace(node, parseTemplate(value, true))
          } else {
            node.data = _toString(value)
          }
        } else {
          vm._bindDir(token.descriptor, node, host, scope)
        }
      }
    }
    replace(el, fragClone)
  }
}複製程式碼

makeTextNodeLinkFn 這個方法什麼也沒做,它僅僅是返回了一個新的方法 textNodeLinkFn。往前回溯,這個方法最終作為 compileNode 的返回值,被新增到 compile 方法生成的 childLinkFn 中。

我們回到 compile 方法,在 compile 方法的最後有這樣一段程式碼:

<!-原始碼目錄:src/compiler/compile.js-->
return function compositeLinkFn (vm, el, host, scope, frag) {
    // cache childNodes before linking parent, fix #657
    var childNodes = toArray(el.childNodes)
    // link
    var dirs = linkAndCapture(function compositeLinkCapturer () {
      if (nodeLinkFn) nodeLinkFn(vm, el, host, scope, frag)
      if (childLinkFn) childLinkFn(vm, childNodes, host, scope, frag)
    }, vm)
    return makeUnlinkFn(vm, dirs)
  }複製程式碼

compile 方法返回了 compositeLinkFn,它在 Vue.prototype._compile 方法執行時,是通過 compile(el, options)(this, el) 呼叫的。compositeLinkFn 方法執行了 linkAndCapture 方法,它的功能是通過呼叫 compile 過程中生成的 link 方法建立指令物件,再對指令物件做一些繫結操作。linkAndCapture 方法的原始碼定義如下:

<!-原始碼目錄:src/compiler/compile.js-->
function linkAndCapture (linker, vm) {
  /* istanbul ignore if */
  if (process.env.NODE_ENV === 'production') {
    // reset directives before every capture in production
    // mode, so that when unlinking we don't need to splice
    // them out (which turns out to be a perf hit).
    // they are kept in development mode because they are
    // useful for Vue's own tests.
    vm._directives = []
  }
  var originalDirCount = vm._directives.length
  linker()
  var dirs = vm._directives.slice(originalDirCount)
  dirs.sort(directiveComparator)
  for (var i = 0, l = dirs.length; i < l; i++) {
    dirs[i]._bind()
  }
  return dirs
}複製程式碼

linkAndCapture 方法首先呼叫了 linker 方法,它會遍歷 compile 過程中生成的所有 linkFn 並呼叫,本例中會呼叫到之前定義的 textNodeLinkFn。這個方法會遍歷 tokens,判斷如果 token 的 tag 屬性值為 true 且 oneTime 屬性值為 false,則呼叫 vm.bindDir(token.descriptor, node, host, scope) 方法建立指令物件。 vm._bindDir 方法的原始碼定義如下:

<!-原始碼目錄:src/instance/internal/lifecycle.js-->
Vue.prototype._bindDir = function (descriptor, node, host, scope, frag) {
    this._directives.push(
      new Directive(descriptor, this, node, host, scope, frag)
    )
  }複製程式碼

Vue.prototype._bindDir 方法就是根據 descriptor 例項化不同的 Directive 物件,並新增到 vm 例項 directives 陣列中的。到這一步,Vue.js 從解析模板到生成 Directive 物件的步驟就完成了。接下來回到 linkAndCapture 方法,它對建立好的 directives 進行排序,然後遍歷 directives 呼叫 dirs[i]._bind 方法對單個directive做一些繫結操作。dirs[i]._bind方法的原始碼定義如下:

<!-原始碼目錄:src/directive.js-->
Directive.prototype._bind = function () {
  var name = this.name
  var descriptor = this.descriptor
  // remove attribute
  if (
    (name !== 'cloak' || this.vm._isCompiled) &&
    this.el && this.el.removeAttribute
  ) {
    var attr = descriptor.attr || ('v-' + name)
    this.el.removeAttribute(attr)
  }
  // copy def properties
  var def = descriptor.def
  if (typeof def === 'function') {
    this.update = def
  } else {
    extend(this, def)
  }
  // setup directive params
  this._setupParams()
  // initial bind
  if (this.bind) {
    this.bind()
  }
  this._bound = true
  if (this.literal) {
    this.update && this.update(descriptor.raw)
  } else if (
    (this.expression || this.modifiers) &&
    (this.update || this.twoWay) &&
    !this._checkStatement()
  ) {
    // wrapped updater for context
    var dir = this
    if (this.update) {
      this._update = function (val, oldVal) {
        if (!dir._locked) {
          dir.update(val, oldVal)
        }
      }
    } else {
      this._update = noop
    }
    var preProcess = this._preProcess
      ? bind(this._preProcess, this)
      : null
    var postProcess = this._postProcess
      ? bind(this._postProcess, this)
      : null
    var watcher = this._watcher = new Watcher(
      this.vm,
      this.expression,
      this._update, // callback
      {
        filters: this.filters,
        twoWay: this.twoWay,
        deep: this.deep,
        preProcess: preProcess,
        postProcess: postProcess,
        scope: this._scope
      }
    )
    // v-model with inital inline value need to sync back to
    // model instead of update to DOM on init. They would
    // set the afterBind hook to indicate that.
    if (this.afterBind) {
      this.afterBind()
    } else if (this.update) {
      this.update(watcher.value)
    }
  }
}複製程式碼

Directive.prototype._bind 方法的主要功能就是做一些指令的初始化操作,如混合 def 屬性。def 是通過 this.descriptor.def 獲得的,this.descriptor 是對指令進行相關描述的物件,而 this.descriptor.def 則是包含指令相關操作的物件。比如對於 v-text 指令,我們可以看一下它的相關操作,原始碼定義如下:

<!-原始碼目錄:src/directives/public/text.js-->
export default {
  bind () {
    this.attr = this.el.nodeType === 3
      ? 'data'
      : 'textContent'
  },
  update (value) {
    this.el[this.attr] = _toString(value)
  }
}複製程式碼

v-text 的 def 包含了 bind 和 update 方法,Directive 在初始化時通過 extend(this, def) 方法可以對例項擴充套件這兩個方法。Directive 在初始化時還定義了 this.update 方法,並建立了 Watcher,把 this.update 方法作為 Watcher 的回撥函式。這裡把 Directive 和 Watcher 做了關聯,當 Watcher 觀察到指令表示式值變化時,會呼叫 Directive 例項的 _update 方法,最終呼叫 v-text 的 update 方法更新 DOM 節點。

至此,vm 例項中編譯模板、解析指令、繫結 Watcher 的過程就結束了。接下來我們看一下 Watcher 的實現,瞭解 Directive 和 Observer 之間是如何通過 Watcher 關聯的。

Watcher

我們先來看一下 Watcher 類的實現,它的原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
export default function Watcher (vm, expOrFn, cb, options) {
  // mix in options
  if (options) {
    extend(this, options)
  }
  var isFn = typeof expOrFn === 'function'
  this.vm = vm
  vm._watchers.push(this)
  this.expression = expOrFn
  this.cb = cb
  this.id = ++uid // 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.prevError = null // for async error stacks
  // parse expression for getter/setter
  if (isFn) {
    this.getter = expOrFn
    this.setter = undefined
  } else {
    var res = parseExpression(expOrFn, this.twoWay)
    this.getter = res.get
    this.setter = res.set
  }
  this.value = this.lazy
    ? undefined
    : this.get()
  // state for avoiding false triggers for deep and Array
  // watchers during vm._digest()
  this.queued = this.shallow = false
}複製程式碼

Directive 例項在初始化 Watche r時,會傳入指令的 expression。Watcher 建構函式會通過 parseExpression(expOrFn, this.twoWay) 方法對 expression 做進一步的解析。在前面的例子中, expression 是times,passExpression 方法的功能是把 expression 轉換成一個物件,如下圖所示:

Vue 原始碼解析:深入響應式原理
image

可以看到 res 有兩個屬性,其中 exp 為表示式字串;get 是通過 new Function 生成的匿名方法,可以把它列印出來,如下圖所示:

Vue 原始碼解析:深入響應式原理
image

可以看到 res.get 方法很簡單,它接受傳入一個 scope 變數,返回 scope.times。對於傳入的 scope 值,稍後我們會進行介紹。在 Watcher 建構函式的最後呼叫了 this.get 方法,它的原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
Watcher.prototype.get = function () {
  this.beforeGet()
  var scope = this.scope || this.vm
  var value
  try {
    value = this.getter.call(scope, scope)
  } catch (e) {
    if (
      process.env.NODE_ENV !== 'production' &&
      config.warnExpressionErrors
    ) {
      warn(
        'Error when evaluating expression ' +
        '"' + this.expression + '": ' + e.toString(),
        this.vm
      )
    }
  }
  // "touch" every property so they are all tracked as
  // dependencies for deep watching
  if (this.deep) {
    traverse(value)
  }
  if (this.preProcess) {
    value = this.preProcess(value)
  }
  if (this.filters) {
    value = scope._applyFilters(value, null, this.filters, false)
  }
  if (this.postProcess) {
    value = this.postProcess(value)
  }
  this.afterGet()
  return value
}複製程式碼

Watcher.prototype.get 方法的功能就是對當前 Watcher 進行求值,收集依賴關係。它首先執行 this.beforeGet 方法,原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
Watcher.prototype.beforeGet = function () {
  Dep.target = this
}複製程式碼

Watcher.prototype.beforeGet 很簡單,設定 Dep.target 為當前 Watcher 例項,為接下來的依賴收集做準備。我們回到 get 方法,接下來執行 this.getter.call(scope, scope) 方法,這裡的 scope 是 this.vm,也就是當前 Vue 例項。這個方法實際上相當於獲取 vm.times,這樣就觸發了物件的 getter。在第一小節我們給 data 新增 Observer 時,通過 Object.defineProperty 給 data 物件的每一個屬性新增 getter 和 setter。回顧一下程式碼:

<!-原始碼目錄:src/observer/index.js-->
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 (isArray(value)) {
          for (var e, i = 0, l = value.length; i < l; i++) {
            e = value[i]
            e && e.__ob__ && e.__ob__.dep.depend()
          }
        }
      }
      return value
    },
       …
  })複製程式碼

當獲取 vm.times 時,會執行到 get 方法體內。由於我們在之前已經設定了 Dep.target 為當前 Watcher 例項,所以接下來就呼叫 dep.depend() 方法完成依賴收集。它實際上是執行了 Dep.target.addDep(this),相當於執行了 Watcher 例項的 addDep 方法,把 Dep 例項新增到 Watcher 例項的依賴中。addDep 方法的原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
Watcher.prototype.addDep = function (dep) {
  var id = dep.id
  if (!this.newDepIds.has(id)) {
    this.newDepIds.add(id)
    this.newDeps.push(dep)
    if (!this.depIds.has(id)) {
      dep.addSub(this)
    }
  }
}複製程式碼

Watcher.prototype.addDep 方法就是把 dep 新增到 Watcher 例項的依賴中,同時又通過 dep.addSub(this) 把 Watcher 例項新增到 dep 的訂閱者中。addSub 方法的原始碼定義如下:

<!-原始碼目錄:src/observer/dep.js-->
Dep.prototype.addSub = function (sub) {
  this.subs.push(sub)
}複製程式碼

至此,指令完成了依賴收集,並且通過 Watcher 完成了對資料變化的訂閱。

接下來我們看一下,當 data 發生變化時,檢視是如何自動更新的。在前面的例子中,我們通過 setInterval 每隔 1s 執行一次 vm.times++,資料改變會觸發物件的 setter,執行 set 方法體的程式碼。回顧一下程式碼:

<!-原始碼目錄:src/observer/index.js-->
Object.defineProperty(obj, key, {
     …
   set: function reactiveSetter (newVal) {
      var value = getter ? getter.call(obj) : val
      if (newVal === value) {
        return
      }
      if (setter) {
        setter.call(obj, newVal)
      } else {
        val = newVal
      }
      childOb = observe(newVal)
      dep.notify()
    }
  })複製程式碼

這裡會呼叫 dep.notify() 方法,它會遍歷所有的訂閱者,也就是 Watcher 例項。然後呼叫 Watcher 例項的 update 方法,原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
Watcher.prototype.update = function (shallow) {
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync || !config.async) {
    this.run()
  } else {
    // if queued, only overwrite shallow with non-shallow,
    // but not the other way around.
    this.shallow = this.queued
      ? shallow
        ? this.shallow
        : false
      : !!shallow
    this.queued = true
    // record before-push error stack in debug mode
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.debug) {
      this.prevError = new Error('[vue] async stack trace')
    }
    pushWatcher(this)
  }
}複製程式碼

Watcher.prototype.update 方法在滿足某些條件下會直接呼叫 this.run 方法。在多數情況下會呼叫 pushWatcher(this) 方法把 Watcher 例項推入佇列中,延遲 this.run 呼叫的時機。pushWatcher 方法的原始碼定義如下:

<!-原始碼目錄:src/batcher.js-->
export function pushWatcher (watcher) {
  const id = watcher.id
  if (has[id] == null) {
    // push watcher into appropriate queue
    const q = watcher.user
      ? userQueue
      : queue
    has[id] = q.length
    q.push(watcher)
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushBatcherQueue)
    }
  }
}複製程式碼

pushWatcher 方法把 Watcher 推入佇列中,通過 nextTick 方法在下一個事件迴圈週期處理 Watcher 佇列,這是 Vue.j s的一種效能優化手段。因為如果同時觀察的資料多次變化,比如同步執行 3 次 vm.time++,同步呼叫 watcher.run 就會觸發 3 次 DOM 操作。而推入佇列中等待下一個事件迴圈週期再操作佇列裡的 Watcher,因為是同一個 Watcher,它只會呼叫一次 watcher.run,從而只觸發一次 DOM 操作。接下來我們看一下 flushBatcherQueue 方法,它的原始碼定義如下:

<!-原始碼目錄:src/batcher.js-->
function flushBatcherQueue () {
  runBatcherQueue(queue)
  runBatcherQueue(userQueue)
  // user watchers triggered more watchers,
  // keep flushing until it depletes
  if (queue.length) {
    return flushBatcherQueue()
  }
  // dev tool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
  resetBatcherState()
}複製程式碼

flushBatcherQueue 方法通過呼叫 runBatcherQueue 來 run Watcher。這裡我們看到 Watcher 佇列分為內部 queue 和 userQueue,其中 userQueue 是通過 $watch() 方法註冊的 Watcher。我們優先 run 內部queue 來保證指令和 DOM 節點優先更新,這樣當使用者自定義的 Watcher 的回撥函式觸發時 DOM 已更新完畢。接下來我們看一下 runBatcherQueue 方法,它的原始碼定義如下:

<!-原始碼目錄:src/batcher.js-->
function runBatcherQueue (queue) {
  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (let i = 0; i < queue.length; i++) {
    var watcher = queue[i]
    var id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > config._maxUpdateCount) {
        warn(
          'You may have an infinite update loop for watcher ' +
          'with expression "' + watcher.expression + '"',
          watcher.vm
        )
        break
      }
    }
  }
  queue.length = 0
}複製程式碼

runBatcherQueued 的功能就是遍歷 queue 中 Watcher 的 run 方法。接下來我們看一下 Watcher 的 run 方法,它的原始碼定義如下:

<!-原始碼目錄:src/watcher.js-->
Watcher.prototype.run = function () {
  if (this.active) {
    var value = this.get()
    if (
      value !== this.value ||
      // Deep watchers and watchers on Object/Arrays should fire even
      // when the value is the same, because the value may
      // have mutated; but only do so if this is a
      // non-shallow update (caused by a vm digest).
      ((isObject(value) || this.deep) && !this.shallow)
    ) {
      // set new value
      var oldValue = this.value
      this.value = value
      // in debug + async mode, when a watcher callbacks
      // throws, we also throw the saved before-push error
      // so the full cross-tick stack trace is available.
      var prevError = this.prevError
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' &&
          config.debug && prevError) {
        this.prevError = null
        try {
          this.cb.call(this.vm, value, oldValue)
        } catch (e) {
          nextTick(function () {
            throw prevError
          }, 0)
          throw e
        }
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
    this.queued = this.shallow = false
  }
}複製程式碼

Watcher.prototype.run 方法再次對 Watcher 求值,重新收集依賴。接下來判斷求值結果和之前 value 的關係。如果不變則什麼也不做,如果變了則呼叫 this.cb.call(this.vm, value, oldValue) 方法。這個方法是 Directive 例項建立 Watcher 時傳入的,它對應相關指令的 update 方法來真實更新 DOM。這樣就完成了資料更新到對應檢視的變化過程。 Watcher 巧妙地把 Observer 和 Directive 關聯起來,實現了資料一旦更新,檢視就會自動變化的效果。儘管 Vue.js 利用 Object.defineProperty 這個核心技術實現了資料和檢視的繫結,但仍然會存在一些資料變化檢測不到的問題,接下來我們看一下這部分內容。

今天我們就講到這裡,更多精彩內容關注我們的書籍《Vue.js 權威指南》


歡迎關注DDFE
GITHUB:github.com/DDFE
微信公眾號:微信搜尋公眾號“DDFE”或掃描下面的二維碼

Vue 原始碼解析:深入響應式原理

相關文章