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

2016-11-10 18:41:58

本文來自電子工業出版社出版的《Vue.js權威指南》一書,由出版社授權『前端之巔』發佈。該書作者為張耀春、黃軼、王靜、蘇偉等。


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

如何追蹤變化

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


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



模型和視圖關聯關係圖

圖中的模型(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方法的源碼定義如下:


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


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

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


observe方法首先判斷value是否已經添加了ob屬性,它是一個Observer對象的實例。如果是就直接用,否則在value滿足一些條件(數組或對象、可擴展、非vue組件等)的情況下創建一個Observer對象。接下來我們看一下Observer這個類,它的源碼定義如下:


Observer類的構造函數主要做了這麼幾件事:首先創建了一個Dep對象實例(關於Dep對象我們稍後作介紹);然後把自身this添加到value的ob屬性上;最後對value的類型進行判斷,如果是數組則觀察數組,否則觀察單個元素。其實observeArray方法就是對數組進行遍歷,遞歸調用observe方法,最終都會調用walk方法觀察單個元素。接下來我們看一下walk方法,它的源碼定義如下:


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


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這個類,它的源碼定義如下:


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

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


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



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')
  }

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


vm._compile編譯主要流程圖

這個過程通過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方法的實現,它的源碼定義如下:


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


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


parseText解析文本節點結果

解析後的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對象。我們看一下調用後的結果,如下圖所示。


processTextToken解析文本節點結果

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

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


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

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


compile方法返回了compositeLinkFn,它在Vue.prototype._compile方法執行時,是通過compile(el, options)(this, el)調用的。compositeLinkFn方法執行了linkAndCapture方法,它的功能是通過調用compile過程中生成的link方法創建指令對象,再對指令對象做一些綁定操作。linkAndCapture方法的源碼定義如下:


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


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指令,我們可以看一下它的相關操作,源碼定義如下:


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類的實現,它的源碼定義如下:


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

passExpression執行結果

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


res.get方法打印結果

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


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


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


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


Watcher.prototype.addDep方法就是把dep添加到Watcher實例的依賴中,同時又通過dep.addSub(this)把Watcher實例添加到dep的訂閲者中。addSub方法的源碼定義如下:


至此,指令完成了依賴收集,並且通過Watcher完成了對數據變化的訂閲。

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


這裏會調用dep.notify()方法,它會遍歷所有的訂閲者,也就是Watcher實例。然後調用Watcher實例的update方法,源碼定義如下:


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


pushWatcher方法把Watcher推入隊列中,通過nextTick方法在下一個事件循環週期處理Watcher隊列,這是Vue.js的一種性能優化手段。因為如果同時觀察的數據多次變化,比如同步執行3次vm.time++,同步調用watcher.run就會觸發3次DOM操作。而推入隊列中等待下一個事件循環週期再操作隊列裏的Watcher,因為是同一個Watcher,它只會調用一次watcher.run,從而只觸發一次DOM操作。接下來我們看一下flushBatcherQueue方法,它的源碼定義如下:


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


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



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權威指南》一書。


Vue.js是一個用來開發Web界面的前端庫。本書致力於普及國內Vue.js技術體系,讓更多喜歡前端的人員瞭解和學習Vue.js。如果你對Vue.js基礎知識感興趣,如果你對源碼解析感興趣,如果你對Vue.js 2.0感興趣,如果你對主流打包工具感興趣,如果你對如何實踐感興趣,本書都是一本不容錯過的以示例代碼為引導、知識涵蓋全面的最佳選擇。全書一共30章,由淺入深地講解了Vue.js基本語法及源碼解析。主要內容包括數據綁定、指令、表單控件綁定、過濾器、組件、表單驗證、服務通信、路由和視圖、vue-cli、測試開發和調試、源碼解析及主流打包構建工具等。該書內容全面,講解細緻,示例豐富,適用於各層次的開發者。

今日薦文

Vue相關內容推薦:

  • 餓了麼基於Vue 2.0的通用組件庫開發之路

  • Vue作者尤雨溪:Vue 2.0,漸進式前端解決方案

  • Vue 2.0 快速上手指南

  • 更輕更快的Vue.js 2.0與其他框架對比

  • Vue.js作者尤雨溪加盟Weex項目擔任技術顧問


滴滴公共前端團隊分享內容推薦:

  • 滴滴張耀春:如何打造公司級公共前端團隊

  • 滴滴WebApp實踐經驗分享

  • 滴滴:公司級組件庫以及MIS系統的技術實踐




長按二維碼關注

        前  端  之  巔

        緊 跟 前 端 發 展
        共 享 一 線 技 術
        不 斷 學 習 進 步
        攀 登 前 端 之 巔



在看



熱點新聞