1. 程式人生 > >超詳細Vue2.1.7原始碼學習

超詳細Vue2.1.7原始碼學習

Vue2.1.7原始碼學習

原本文章的名字叫做《原始碼解析》,不過後來想想,還是用“原始碼學習”來的合適一點,在沒有徹底掌握原始碼中的每一個字母之前,“解析”就有點標題黨了。建議在看這篇文章之前,最好開啟2.1.7的原始碼對照著看,這樣可能更容易理解。另外本人水平有限,文中有錯誤或不妥的地方望大家多多指正共同成長。

補充:Vue 2.2 剛剛釋出,作為一個系列文章的第一篇,本篇文章主要從Vue程式碼的組織,Vue建構函式的還原,原型的設計,以及引數選項的處理和已經被寫爛了的資料繫結與如何使用 Virtual DOM 更新檢視入手。從整體的大方向觀察框架,這麼看來 V2.1.7 對於理解 V2.2

的程式碼不會有太大的影響。該系列文章的後續文章,都會從最新的原始碼入手,並對改動的地方做相應的提示。

很久之前寫過一篇文章:JavaScript實現MVVM之我就是想監測一個普通物件的變化,文章開頭提到了我寫部落格的風格,還是那句話,只寫努力讓小白,甚至是小學生都能看明白的文章。這不免會導致對於某些同學來說這篇文章有些墨跡,所以大家根據自己的喜好,可以詳細的看,也可以跳躍著看。

一、從瞭解一個開源專案入手

要看一個專案的原始碼,不要一上來就看,先去了解一下專案本身的元資料和依賴,除此之外最好也瞭解一下 PR 規則,Issue Reporting 規則等等。特別是“前端”開源專案,我們在看原始碼之前第一個想到的應該是:package.json

檔案。

package.json 檔案中,我們最應該關注的就是 scripts 欄位和 devDependencies 以及 dependencies 欄位,通過 scripts 欄位我們可以知道專案中定義的指令碼命令,通過 devDependenciesdependencies 欄位我們可以瞭解專案的依賴情況。

瞭解了這些之後,如果有依賴我們就 npm install 安裝依賴就ok了。

除了 package.json 之外,我們還要閱讀專案的貢獻規則文件,瞭解如何開始,一個好的開源專案肯定會包含這部分內容的,Vue也不例外:https://github.com/vuejs/vue/blob/dev/.github/CONTRIBUTING.md

,在這個文件裡說明了一些行為準則,PR指南,Issue Reporting 指南,Development Setup 以及 專案結構。通過閱讀這些內容,我們可以瞭解專案如何開始,如何開發以及目錄的說明,下面是對重要目錄和檔案的簡單介紹,這些內容你都可以去自己閱讀獲取:

├── build --------------------------------- 構建相關的檔案,一般情況下我們不需要動
├── dist ---------------------------------- 構建後文件的輸出目錄
├── examples ------------------------------ 存放一些使用Vue開發的應用案例
├── flow ---------------------------------- 型別宣告,使用開源專案 [Flow](https://flowtype.org/)
├── package.json -------------------------- 不解釋
├── test ---------------------------------- 包含所有測試檔案
├── src ----------------------------------- 這個是我們最應該關注的目錄,包含了原始碼
│   ├── entries --------------------------- 包含了不同的構建或包的入口檔案
│   │   ├── web-runtime.js ---------------- 執行時構建的入口,輸出 dist/vue.common.js 檔案,不包含模板(template)到render函式的編譯器,所以不支援 `template` 選項,我們使用vue預設匯出的就是這個執行時的版本。大家使用的時候要注意
│   │   ├── web-runtime-with-compiler.js -- 獨立構建版本的入口,輸出 dist/vue.js,它包含模板(template)到render函式的編譯器
│   │   ├── web-compiler.js --------------- vue-template-compiler 包的入口檔案
│   │   ├── web-server-renderer.js -------- vue-server-renderer 包的入口檔案
│   ├── compiler -------------------------- 編譯器程式碼的存放目錄,將 template 編譯為 render 函式
│   │   ├── parser ------------------------ 存放將模板字串轉換成元素抽象語法樹的程式碼
│   │   ├── codegen ----------------------- 存放從抽象語法樹(AST)生成render函式的程式碼
│   │   ├── optimizer.js ------------------ 分析靜態樹,優化vdom渲染
│   ├── core ------------------------------ 存放通用的,平臺無關的程式碼
│   │   ├── observer ---------------------- 反應系統,包含資料觀測的核心程式碼
│   │   ├── vdom -------------------------- 包含虛擬DOM建立(creation)和打補丁(patching)的程式碼
│   │   ├── instance ---------------------- 包含Vue建構函式設計相關的程式碼
│   │   ├── global-api -------------------- 包含給Vue建構函式掛載全域性方法(靜態方法)或屬性的程式碼
│   │   ├── components -------------------- 包含抽象出來的通用元件
│   ├── server ---------------------------- 包含服務端渲染(server-side rendering)的相關程式碼
│   ├── platforms ------------------------- 包含平臺特有的相關程式碼
│   ├── sfc ------------------------------- 包含單檔案元件(.vue檔案)的解析邏輯,用於vue-template-compiler包
│   ├── shared ---------------------------- 包含整個程式碼庫通用的程式碼

大概瞭解了重要目錄和檔案之後,我們就可以檢視 Development Setup 中的常用命令部分,來了解如何開始這個專案了,我們可以看到這樣的介紹:

# watch and auto re-build dist/vue.js
$ npm run dev





watch and auto re-run unit tests in Chrome

$ npm run dev:test

現在,我們只需要執行 npm run dev 即可監測檔案變化並自動重新構建輸出 dist/vue.js,然後執行 npm run dev:test 來測試。不過為了方便,我會在 examples 目錄新建一個例子,然後引用 dist/vue.js 這樣,我們可以直接拿這個例子一邊改Vue原始碼一邊看自己寫的程式碼想怎麼玩怎麼玩。


二、看原始碼的小提示

在真正步入原始碼世界之前,我想簡單說一說看原始碼的技巧:


注重大體框架,從巨集觀到微觀

當你看一個專案程式碼的時候,最好是能找到一條主線,先把大體流程結構摸清楚,再深入到細節,逐項擊破,拿Vue舉個栗子:假如你已經知道Vue中資料狀態改變後會採用virtual DOM的方式更新DOM,這個時候,如果你不瞭解virtual DOM,那麼聽我一句“暫且不要去研究內部具體實現,因為這會是你喪失主線”,而你僅僅需要知道virtual DOM分為三個步驟:

一、createElement(): 用 JavaScript物件(虛擬樹) 描述 真實DOM物件(真實樹)
二、diff(oldNode, newNode) : 對比新舊兩個虛擬樹的區別,收集差異
三、patch() : 將差異應用到真實DOM樹

有的時候 第二步 可能與 第三步 合併成一步(Vue 中的patch就是這樣),除此之外,還比如 src/compiler/codegen 內的程式碼,可能你不知道他寫了什麼,直接去看它會讓你很痛苦,但是你只需要知道 codegen 是用來將抽象語法樹(AST)生成render函式的就OK了,也就是生成類似下面這樣的程式碼:

function anonymous() {
    with(this){return _c('p',{attrs:{"id":"app"}},[_v("\n      "+_s(a)+"\n      "),_c('my-com')])}
}

當我們知道了一個東西存在,且知道它存在的目的,那麼我們就很容易抓住這條主線,這個系列的第一篇文章就是圍繞大體主線展開的。瞭解大體之後,我們就知道了每部分內容都是做什麼的,比如 codegen 是生成類似上面貼出的程式碼所示的函式的,那麼再去看codegen下的程式碼時,目的性就會更強,就更容易理解。

三、Vue 的建構函式是什麼樣的

balabala一大堆,開始來乾貨吧。我們要做的第一件事就是搞清楚 Vue 建構函式到底是什麼樣子的。

我們知道,我們要使用 new 操作符來呼叫 Vue,那麼也就是說 Vue 應該是一個建構函式,所以我們第一件要做的事兒就是把建構函式先扒的一清二楚,如何尋找 Vue 建構函式呢?當然是從 entry 開始啦,還記的我們執行 npm run dev 命令後,會輸出 dist/vue.js 嗎,那麼我們就去看看 npm run dev 幹了什麼:

"dev": "TARGET=web-full-dev rollup -w -c build/config.js",

首先將 TARGET 得值設定為 ‘web-full-dev’,然後,然後,然後如果你不瞭解 rollup 就應該簡單去看一下啦……,簡單的說就是一個JavaScript模組打包器,你可以把它簡單的理解為和 webpack 一樣,只不過它有他的優勢,比如 Tree-shaking (webpack2也有),但同樣,在某些場景它也有他的劣勢。。。廢話不多說,其中 -w 就是watch,-c 就是指定配置檔案為 build/config.js ,我們開啟這個配置檔案看一看:

// 引入依賴,定義 banner
...

// builds 物件
const builds = {
    ...
    // Runtime+compiler development build (Browser)
    'web-full-dev': {
        entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
        dest: path.resolve(__dirname, '../dist/vue.js'),
        format: 'umd',
        env: 'development',
        alias: { he: './entity-decoder' },
        banner
    },
    ...
}

// 生成配置的方法
function genConfig(opts){
    ...
}

if (process.env.TARGET) {
  module.exports = genConfig(builds[process.env.TARGET])
} else {
  exports.getBuild = name => genConfig(builds[name])
  exports.getAllBuilds = () => Object.keys(builds).map(name => genConfig(builds[name]))
}

上面的程式碼是簡化過的,當我們執行 npm run dev 的時候 process.env.TARGET 的值等於 ‘web-full-dev’,所以

module.exports = genConfig(builds[process.env.TARGET])

這句程式碼相當於:

module.exports = genConfig({
    entry: path.resolve(__dirname, '../src/entries/web-runtime-with-compiler.js'),
    dest: path.resolve(__dirname, '../dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
})

最終,genConfig 函式返回一個 config 物件,這個config物件就是Rollup的配置物件。那麼我們就不難看到,入口檔案是:

src/entries/web-runtime-with-compiler.js

我們開啟這個檔案,不要忘了我們的主題,我們在尋找Vue建構函式,所以當我們看到這個檔案的第一行程式碼是:

import Vue from './web-runtime'

這個時候,你就應該知道,這個檔案暫時與你無緣,你應該開啟 web-runtime.js 檔案,不過當你開啟這個檔案時,你發現第一行是這樣的:

import Vue from 'core/index'

依照此思路,最終我們尋找到Vue建構函式的位置應該是在 src/core/instance/index.js 檔案中,其實我們猜也猜得到,上面介紹目錄的時候說過:instance 是存放Vue建構函式設計相關程式碼的目錄。總結一下,我們尋找的過程是這樣的:

尋找 Vue 建構函式路線

我們回頭看一看 src/core/instance/index.js 檔案,很簡單:

import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

引入依賴,定義 Vue 建構函式,然後以Vue建構函式為引數,呼叫了五個方法,最後匯出 Vue。這五個方法分別來自五個檔案:init.js state.js render.js events.js 以及 lifecycle.js

開啟這五個檔案,找到相應的方法,你會發現,這些方法的作用,就是在 Vue 的原型 prototype 上掛載方法或屬性,經歷了這五個方法後的Vue會變成這樣:

// initMixin(Vue)    src/core/instance/init.js **************************************************
Vue.prototype._init = function (options?: Object) {}

// stateMixin(Vue)    src/core/instance/state.js **************************************************
Vue.prototype.$data
Vue.prototype.$set = set
Vue.prototype.$delete = del
Vue.prototype.$watch = function(){}

// renderMixin(Vue)    src/core/instance/render.js **************************************************
Vue.prototype.$nextTick = function (fn: Function) {}
Vue.prototype._render = function (): VNode {}
Vue.prototype._s = _toString
Vue.prototype._v = createTextVNode
Vue.prototype._n = toNumber
Vue.prototype._e = createEmptyVNode
Vue.prototype._q = looseEqual
Vue.prototype._i = looseIndexOf
Vue.prototype._m = function(){}
Vue.prototype._o = function(){}
Vue.prototype._f = function resolveFilter (id) {}
Vue.prototype._l = function(){}
Vue.prototype._t = function(){}
Vue.prototype._b = function(){}
Vue.prototype._k = function(){}

// eventsMixin(Vue)    src/core/instance/events.js **************************************************
Vue.prototype.$on = function (event: string, fn: Function): Component {}
Vue.prototype.$once = function (event: string, fn: Function): Component {}
Vue.prototype.$off = function (event?: string, fn?: Function): Component {}
Vue.prototype.$emit = function (event: string): Component {}

// lifecycleMixin(Vue)    src/core/instance/lifecycle.js **************************************************
Vue.prototype._mount = function(){}
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {}
Vue.prototype._updateFromParent = function(){}
Vue.prototype.$forceUpdate = function () {}
Vue.prototype.$destroy = function () {}

這樣就結束了嗎?並沒有,根據我們之前尋找 Vue 的路線,這只是剛剛開始,我們追溯路線往回走,那麼下一個處理 Vue 建構函式的應該是 src/core/index.js 檔案,我們開啟它:

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'

initGlobalAPI(Vue)

Object.defineProperty(Vue.prototype, '$isServer', {
  get: isServerRendering
})

Vue.version = '__VERSION__'

export default Vue

這個檔案也很簡單,從 instance/index 中匯入已經在原型上掛載了方法和屬性後的 Vue,然後匯入 initGlobalAPIisServerRendering,之後將Vue作為引數傳給 initGlobalAPI ,最後又在 Vue.prototype 上掛載了 $isServer ,在 Vue 上掛載了 version 屬性。

initGlobalAPI 的作用是在 Vue 建構函式上掛載靜態屬性和方法,Vue 在經過 initGlobalAPI 之後,會變成這樣:

// src/core/index.js / src/core/global-api/index.js
Vue.config
Vue.util = util
Vue.set = set
Vue.delete = del
Vue.nextTick = util.nextTick
Vue.options = {
    components: {
        KeepAlive
    },
    directives: {},
    filters: {},
    _base: Vue
}
Vue.use
Vue.mixin
Vue.cid = 0
Vue.extend
Vue.component = function(){}
Vue.directive = function(){}
Vue.filter = function(){}

Vue.prototype.$isServer
Vue.version = '__VERSION__'

其中,稍微複雜一點的就是 Vue.options,大家稍微分析分析就會知道他的確長成那個樣子。下一個就是 web-runtime.js 檔案了,web-runtime.js 檔案主要做了三件事兒:

1、覆蓋 Vue.config 的屬性,將其設定為平臺特有的一些方法
2、Vue.options.directivesVue.options.components 安裝平臺特有的指令和元件
3、在 Vue.prototype 上定義 __patch__$mount

經過 web-runtime.js 檔案之後,Vue 變成下面這個樣子:

// 安裝平臺特定的utils
Vue.config.isUnknownElement = isUnknownElement
Vue.config.isReservedTag = isReservedTag
Vue.config.getTagNamespace = getTagNamespace
Vue.config.mustUseProp = mustUseProp
// 安裝平臺特定的 指令 和 元件
Vue.options = {
    components: {
        KeepAlive,
        Transition,
        TransitionGroup
    },
    directives: {
        model,
        show
    },
    filters: {},
    _base: Vue
}
Vue.prototype.__patch__
Vue.prototype.$mount

這裡大家要注意的是 Vue.options 的變化。另外這裡的 $mount 方法很簡單:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return this._mount(el, hydrating)
}

首先根據是否是瀏覽器環境決定要不要 query(el) 獲取元素,然後將 el 作為引數傳遞給 this._mount()

最後一個處理 Vue 的檔案就是入口檔案 web-runtime-with-compiler.js 了,該檔案做了兩件事:

1、快取來自 web-runtime.js 檔案的 $mount 函式

const mount = Vue.prototype.$mount

然後覆蓋覆蓋了 Vue.prototype.$mount

2、在 Vue 上掛載 compile

Vue.compile = compileToFunctions

compileToFunctions 函式的作用,就是將模板 template 編譯為render函式。

至此,我們算是還原了 Vue 建構函式,總結一下:

1、Vue.prototype 下的屬性和方法的掛載主要是在 src/core/instance 目錄中的程式碼處理的

2、Vue 下的靜態屬性和方法的掛載主要是在 src/core/global-api 目錄下的程式碼處理的

3、web-runtime.js 主要是新增web平臺特有的配置、元件和指令,web-runtime-with-compiler.js 給Vue的 $mount 方法新增 compiler 編譯器,支援 template

四、一個貫穿始終的例子

在瞭解了 Vue 建構函式的設計之後,接下來,我們一個貫穿始終的例子就要登場了,掌聲有請:

let v = new Vue({
    el: '#app',
    data: {
        a: 1,
        b: [1, 2, 3]
    }
})

好吧,我承認這段程式碼你家沒滿月的孩子都會寫了。這段程式碼就是我們貫穿始終的例子,它就是這篇文章的主線,在後續的講解中,都會以這段程式碼為例,當講到必要的地方,會為其新增選項,比如講計算屬性的時候當然要加上一個 computed 屬性了。不過在最開始,我只傳遞了兩個選項 el 以及 data,“我們看看接下來會發生什麼,讓我們拭目以待“ —- NBA球星在接受採訪時最喜歡說這句話。

當我們按照例子那樣編碼使用Vue的時候,Vue都做了什麼?

想要知道Vue都幹了什麼,我們就要找到 Vue 初始化程式,檢視 Vue 建構函式:

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

我們發現,_init() 方法就是Vue呼叫的第一個方法,然後將我們的引數 options 透傳了過去。在呼叫 _init() 之前,還做了一個安全模式的處理,告訴開發者必須使用 new 操作符呼叫 Vue。根據之前我們的整理,_init() 方法應該是在 src/core/instance/init.js 檔案中定義的,我們開啟這個檔案檢視 _init() 方法:

  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++
    // a flag to avoid this being observed
    vm._isVue = true
    // merge options
    if (options && options._isComponent) {
      // optimize internal component instantiation
      // since dynamic options merging is pretty slow, and none of the
      // internal component options needs special treatment.
      initInternalComponent(vm, options)
    } else {
      vm.$options = mergeOptions(
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }
    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    callHook(vm, 'beforeCreate')
    initState(vm)
    callHook(vm, 'created')
    initRender(vm)
  }

_init() 方法在一開始的時候,在 this 物件上定義了兩個屬性:_uid_isVue,然後判斷有沒有定義 options._isComponent,在使用 Vue 開發專案的時候,我們是不會使用 _isComponent 選項的,這個選項是 Vue 內部使用的,按照本節開頭的例子,這裡會走 else 分支,也就是這段程式碼:

  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )

這樣 Vue 第一步所做的事情就來了:使用策略物件合併引數選項

可以發現,Vue使用 mergeOptions 來處理我們呼叫Vue時傳入的引數選項(options),然後將返回值賦值給 this.$options (vm === this),傳給 mergeOptions 方法三個引數,我們分別來看一看,首先是:resolveConstructorOptions(vm.constructor),我們檢視一下這個方法:

export function resolveConstructorOptions (Ctor: Class<Component>) {
  let options = Ctor.options
  if (Ctor.super) {
    const superOptions = Ctor.super.options
    const cachedSuperOptions = Ctor.superOptions
    const extendOptions = Ctor.extendOptions
    if (superOptions !== cachedSuperOptions) {
      // super option changed
      Ctor.superOptions = superOptions
      extendOptions.render = options.render
      extendOptions.staticRenderFns = options.staticRenderFns
      extendOptions._scopeId = options._scopeId
      options = Ctor.options = mergeOptions(superOptions, extendOptions)
      if (options.name) {
        options.components[options.name] = Ctor
      }
    }
  }
  return options
}

這個方法接收一個引數 Ctor,通過傳入的 vm.constructor 我們可以知道,其實就是 Vue 建構函式本身。所以下面這句程式碼:

let options = Ctor.options

相當於:

let options = Vue.options

大家還記得 Vue.options 嗎?在尋找Vue建構函式一節裡,我們整理了 Vue.options 應該長成下面這個樣子:

Vue.options = {
    components: {
        KeepAlive,
        Transition,
        TransitionGroup
    },
    directives: {
        model,
        show
    },
    filters: {},
    _base: Vue
}

之後判斷是否定義了 Vue.super ,這個是用來處理繼承的,我們後續再講,在本例中,resolveConstructorOptions 方法直接返回了 Vue.options。也就是說,傳遞給 mergeOptions 方法的第一個引數就是 Vue.options

傳給 mergeOptions 方法的第二個引數是我們呼叫Vue建構函式時的引數選項,第三個引數是 vm 也就是 this 物件,按照本節開頭的例子那樣使用 Vue,最終執行的程式碼應該如下:

  vm.$options = mergeOptions(
      // Vue.options
    {
        components: {
            KeepAlive,
            Transition,
            TransitionGroup
        },
        directives: {
            model,
            show
        },
        filters: {},
        _base: Vue
    },
    // 呼叫Vue建構函式時傳入的引數選項 options
    {
        el: '#app',
        data: {
            a: 1,
            b: [1, 2, 3]
        }
    },
    // this
    vm
  )

瞭解了這些,我們就可以看看 mergeOptions 到底做了些什麼了,根據引用尋找到 mergeOptions 應該是在 src/core/util/options.js 檔案中定義的。這個檔案第一次看可能會頭大,下面是我處理後的簡略展示,大家看上去應該更容易理解了:

// 1、引用依賴
import Vue from '../instance/index'
其他引用...

// 2、合併父子選項值為最終值的策略物件,此時 strats 是一個空物件,因為 config.optionMergeStrategies = Object.create(null)
const strats = config.optionMergeStrategies
// 3、在 strats 物件上定義與引數選項名稱相同的方法
strats.el = 
strats.propsData = function (parent, child, vm, key){}
strats.data = function (parentVal, childVal, vm)

config._lifecycleHooks.forEach(hook => {
  strats[hook] = mergeHook
})

config._assetTypes.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

strats.watch = function (parentVal, childVal)

strats.props =
strats.methods =
strats.computed = function (parentVal: ?Object, childVal: ?Object)
// 預設的合併策略,如果有 `childVal` 則返回 `childVal` 沒有則返回 `parentVal`
const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

// 4、mergeOptions 中根據引數選項呼叫同名的策略方法進行合併處理
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {

  // 其他程式碼
  ...

  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options

}

上面的程式碼中,我省略了一些工具函式,例如 mergeHookmergeAssets 等等,唯一需要注意的是這段程式碼:

config._lifecycleHooks.forEach(hook => {
  strats[hook] = mergeHook
})

config._assetTypes.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

config 物件引用自 src/core/config.js 檔案,最終的結果就是在 strats 下添加了相應的生命週期選項的合併策略函式為 mergeHook,新增指令(directives)、元件(components)、過濾器(filters)等選項的合併策略函式為 mergeAssets

這樣看來就清晰多了,拿我們貫穿本文的例子來說:

let v = new Vue({
    el: '#app',
    data: {
        a: 1,
        b: [1, 2, 3]
    }
})

其中 el 選項會使用 defaultStrat 預設策略函式處理,data 選項則會使用 strats.data 策略函式處理,並且根據 strats.data 中的邏輯,strats.data 方法最終會返回一個函式:mergedInstanceDataFn

這裡就不詳細的講解每一個策略函式的內容了,後續都會講到,這裡我們還是抓住主線理清思路為主,只需要知道Vue在處理選項的時候,使用了一個策略物件對父子選項進行合併。並將最終的值賦值給例項下的 $options</code> 屬性即:<code>this.$options,那麼我們繼續檢視 _init() 方法在合併完選項之後,又做了什麼:

合併完選項之後,Vue 第二部做的事情就來了:初始化工作與Vue例項物件的設計

前面講了 Vue 建構函式的設計,並且整理了 Vue原型屬性與方法Vue靜態屬性與方法,而 Vue 例項物件就是通過建構函式創造出來的,讓我們來看一看 Vue 例項物件是如何設計的,下面的程式碼是 _init() 方法合併完選項之後的程式碼:

    /* istanbul ignore else */
    if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

    // expose real self
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    callHook(vm, 'beforeCreate')
    initState(vm)
    callHook(vm, 'created')
    initRender(vm)

根據上面的程式碼,在生產環境下會為例項新增兩個屬性,並且屬性值都為例項本身:

vm._renderProxy = vm
vm._self = vm

然後,呼叫了四個 init* 方法分別為:initLifecycleinitEventsinitStateinitRender,且在 initState 前後分別回調了生命週期鉤子 beforeCreatecreated,而 initRender 是在 created 鉤子執行之後執行的,看到這裡,也就明白了為什麼 created 的時候不能操作DOM了。因為這個時候還沒有渲染真正的DOM元素到文件中。created 僅僅代表資料狀態的初始化完成。

根據四個 init* 方法的引用關係開啟對應的檔案檢視對應的方法,我們發現,這些方法是在處理Vue例項物件,以及做一些初始化的工作,類似整理Vue建構函式一樣,我同樣針對Vue例項做了屬性和方法的整理,如下:

// 在 Vue.prototype._init 中新增的屬性         **********************************************************
this._uid = uid++
this._isVue = true
this.$options = {
    components,
    directives,
    filters,
    _base,
    el,
    data: mergedInstanceDataFn()
}
this._renderProxy = this
this._self = this

// 在 initLifecycle 中新增的屬性        **********************************************************
this.$parent = parent
this.$root = parent ? parent.$root : this

this.$children = []
this.$refs = {}

this._watcher = null
this._inactive = false
this._isMounted = false
this._isDestroyed = false
this._isBeingDestroyed = false

// 在 initEvents     中新增的屬性         **********************************************************
this._events = {}
this._updateListeners = function(){}

// 在 initState 中新增的屬性        **********************************************************
this._watchers = []
    // initData
    this._data

// 在 initRender     中新增的屬性     **********************************************************
this.$vnode = null // the placeholder node in parent tree
this._vnode = null // the root of the child tree
this._staticTrees = null
this.$slots
this.$scopedSlots
this._c
this.$createElement

以上就是一個Vue例項所包含的屬性和方法,除此之外要注意的是,在 initEvents 中除了新增屬性之外,如果有 vm.$options._parentListeners 還要呼叫 vm._updateListeners() 方法,在 initState 中又呼叫了一些其他init方法,如下:

export function initState (vm: Component) {
  vm._watchers = []
  initProps(vm)
  initMethods(vm)
  initData(vm)
  initComputed(vm)
  initWatch(vm)
}

最後在 initRender 中如果有 vm.$options.el</code> 還要呼叫 <code>vm.$mount(vm.$options.el),如下:

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }

這就是為什麼如果不傳遞 el 選項就需要手動 mount 的原因了。

那麼我們依照我們本節開頭的的例子,以及初始化的先後順序來逐一看一看都發生了什麼。我們將 initState 中的 init* 方法展開來看,執行順序應該是這樣的(從上到下的順序執行):

initLifecycle(vm)
initEvents(vm)
callHook(vm, 'beforeCreate')
initProps(vm)
initMethods(vm)
initData(vm)
initComputed(vm)
initWatch(vm)
callHook(vm, 'created')
initRender(vm)

首先是 initLifecycle,這個函式的作用就是在例項上新增一些屬性,然後是 initEvents,由於 vm.$options._parentListeners</code> 的值為 <code>undefined</code> 所以也僅僅是在例項上新增屬性, <code>vm._updateListeners(listeners)</code> 並不會執行,由於我們只傳遞了 <code>el</code> 和 <code>data</code>,所以 <code>initProps</code>、<code>initMethods</code>、<code>initComputed</code>、<code>initWatch</code> 這四個方法什麼都不會做,只有 <code>initData</code> 會執行。最後是 <code>initRender</code>,除了在例項上新增一些屬性外,由於我們傳遞了 <code>el</code> 選項,所以會執行 <code>vm.$mount(vm.$options.el)

綜上所述:按照我們的例子那樣寫,初始化工作只包含兩個主要內容即:initDatainitRender

五、通過 initData 看Vue的資料響應系統

Vue的資料響應系統包含三個部分:ObserverDepWatcher。關於資料響應系統的內容真的已經被文章講爛了,所以我就簡單的說一下,力求大家能理解就ok,我們還是先看一下 initData 中的程式碼:

function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? data.call(vm)
    : data || {}
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // proxy data on instance
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    if (props && hasOwn(props, keys[i])) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${keys[i]}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else {
      proxy(vm, keys[i])
    }
  }
  // observe data
  observe(data)
  data.__ob__ && data.__ob__.vmCount++
}

首先,先拿到 data 資料:let data = vm.$options.data</code>,大家還記得此時 <code>vm.$options.data 的值應該是通過 mergeOptions 合併處理後的 mergedInstanceDataFn 函式嗎?所以在得到 data 後,它又判斷了 data 的資料型別是不是 ‘function’,最終的結果是:data 還是我們傳入的資料選項的 data,即:

data: {
    a: 1,
    b: [1, 2, 3]
}

然後在例項物件上定義 _data 屬性,該屬性與 data 是相同的引用。

然後是一個 while 迴圈,迴圈的目的是在例項物件上對資料進行代理,這樣我們就能通過 this.a 來訪問 data.a 了,程式碼的處理是在 proxy 函式中,該函式非常簡單,僅僅是在例項物件上設定與 data 屬性同名的訪問器屬性,然後使用 _data 做資料劫持,如下:

function proxy (vm: Component, key: string) {
  if (!isReserved(key)) {
    Object.defineProperty(vm, key, {
      configurable: true,
      enumerable: true,
      get: function proxyGetter () {
        return vm._data[key]
      },
      set: function proxySetter (val) {
        vm._data[key] = val
      }
    })
  }
}

做完資料的代理,就正式進入響應系統,

observe(data)

我們說過,資料響應系統主要包含三部分:ObserverDepWatcher,程式碼分別存放在:observer/index.jsobserver/dep.js 以及 observer/watcher.js 檔案中,這回我們換一種方式,我們先不看其原始碼,大家先跟著我的思路來思考,最後回頭再去看程式碼,你會有一種:”奧,不過如此“的感覺。

假如,我們有如下程式碼:

var data = {
    a: 1,
    b: {
        c: 2
    }
}

observer(data)

new Watch('a', () => {
    alert(9)
})
new Watch('a', () => {
    alert(90)
})
new Watch('b.c', () => {
    alert(80)
})

這段程式碼目的是,首先定義一個數據物件 data,然後通過 observer 對其進行觀測,之後定義了三個觀察者,當資料有變化時,執行相應的方法,這個功能使用Vue的實現原來要如何去實現?其實就是在問 observer 怎麼寫?Watch 建構函式又怎麼寫?接下來我們逐一實現。

首先,observer 的作用是:將資料物件data的屬性轉換為訪問器屬性:

class Observer {
    constructor (data) {
        this.walk(data)
    }
    walk (data) {
        // 遍歷 data 物件屬性,呼叫 defineReactive 方法
        let keys = Object.keys(data)
        for(let i = 0; i < keys.length; i++){
            defineReactive(data, keys[i], data[keys[i]])
        }
    }
}

// defineReactive方法僅僅將data的屬性轉換為訪問器屬性
function defineReactive (data, key, val) {
    // 遞迴觀測子屬性
    observer(val)

    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            // 對新值進行觀測
            observer(newVal)
        }
    })
}

// observer 方法首先判斷data是不是純JavaScript物件,如果是,呼叫 Observer 類進行觀測
function observer (data) {
    if(Object.prototype.toString.call(data) !== '[object Object]') {
        return
    }
    new Observer(data)
}

上面的程式碼中,我們定義了 observer 方法,該方法檢測了資料data是不是純JavaScript物件,如果是就呼叫 Observer 類,並將 data 作為引數透傳。在 Observer 類中,我們使用 walk 方法對資料data的屬性迴圈呼叫 defineReactive 方法,defineReactive 方法很簡單,僅僅是將資料data的屬性轉為訪問器屬性,並對資料進行遞迴觀測,否則只能觀測資料data的直屬子屬性。這樣我們的第一步工作就完成了,當我們修改或者獲取data屬性值的時候,通過 getset 即能獲取到通知。

我們繼續往下看,來看一下 Watch

new Watch('a', () => {
    alert(9)
})

現在的問題是,Watch 要怎麼和 observer 關聯???????我們看看 Watch 它知道些什麼,通過上面呼叫 Watch 的方式,傳遞給 Watch 兩個引數,一個是 ‘a’ 我們可以稱其為表示式,另外一個是回撥函式。所以我們目前只能寫出這樣的程式碼:

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
    }
}

那麼要怎麼關聯呢,大家看下面的程式碼會發生什麼:

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
        data[exp]
    }
}

多了一句 data[exp],這句話是在幹什麼?是不是在獲取 data 下某個屬性的值,比如 exp 為 ‘a’ 的話,那麼 data[exp] 就相當於在獲取 data.a 的值,那這會放生什麼?大家不要忘了,此時資料 data 下的屬性已經是訪問器屬性了,所以這麼做的結果會直接觸發對應屬性的 get 函式,這樣我們就成功的和 observer 產生了關聯,但這樣還不夠,我們還是沒有達到目的,不過我們已經無限接近了,我們繼續思考看一下可不可以這樣:

既然在 Watch 中對錶達式求值,能夠觸發 observerget,那麼可不可以在 get 中收集 Watch 中函式呢?

答案是可以的,不過這個時候我們就需要 Dep 出場了,它是一個依賴收集器。我們的思路是:data 下的每一個屬性都有一個唯一的 Dep 物件,在 get 中收集僅針對該屬性的依賴,然後在 set 方法中觸發所有收集的依賴,這樣就搞定了,看如下程式碼:

class Dep {
    constructor () {
        this.subs = []
    }
    addSub () {
        this.subs.push(Dep.target)
    }
    notify () {
        for(let i = 0; i < this.subs.length; i++){
            this.subs[i].fn()
        }
    }
}
Dep.target = null
function pushTarget(watch){
    Dep.target = watch
}

class Watch {
    constructor (exp, fn) {
        this.exp = exp
        this.fn = fn
        pushTarget(this)
        data[exp]
    }
}

上面的程式碼中,我們在 Watch 中增加了 pushTarget(this),可以發現,這句程式碼的作用是將 Dep.target 的值設定為該Watch物件。在 pushTarget 之後我們才對表示式進行求值,接著,我們修改 defineReactive 程式碼如下

function defineReactive (data, key, val) {
    observer(val)
    let dep = new Dep()        // 新增
    Object.defineProperty(data, key, {
        enumerable: true,
        configurable: true,
        get: function () {
            dep.addSub()    // 新增
            return val
        },
        set: function (newVal) {
            if(val === newVal){
                return
            }
            observer(newVal)
            dep.notify()    // 新增
        }
    })
}

如標註,新增了三句程式碼,我們知道,Watch 中對錶達式求值會觸發 get 方法,我們在 get 方法中呼叫了 dep.addSub,也就執行了這句程式碼:this.subs.push(Dep.target),由於在這句程式碼執行之前,Dep.target 的值已經被設定為一個 Watch 物件了,所以最終結果就是收集了一個 Watch 物件,然後在 set 方法中我們呼叫了 dep.notify,所以當data屬性值變化的時候,就會通過 dep.notify 迴圈呼叫所有收集的Watch物件中的回撥函式:

notify () {
    for(let i = 0; i < this.subs.length; i++){
        this.subs[i].fn()
    }
}

這樣 observerDepWatch 三者就聯絡成為一個有機的整體,實現了我們最初的目標,完整的程式碼可以戳這裡:observer-dep-watch。這裡還給大家挖了個坑,因為我們沒有處理對陣列的觀測,由於比較複雜並且這又不是我們討論的重點,如果大家想了解可以戳我的這篇文章:JavaScript實現MVVM之我就是想監測一個普通物件的變化,另外,在 Watch 中對錶達式求值的時候也只做了直接子屬性的求值,所以如果 exp 的值為 ‘a.b’ 的時候,就不可以用了,Vue的做法是使用 . 分割表示式字串為陣列,然後遍歷一下對其進行求值,大家可以檢視其原始碼。如下:

/**
 * Parse simple path.
 */
const bailRE = /[^\w.$]/
export function parsePath (path: string): any {
  if (bailRE.test(path)) {
    return
  } else {
    const segments = path.split('.')
    return function (obj) {
      for (let i = 0; i < segments.length; i++) {
        if (!obj) return
        obj = obj[segments[i]]
      }
      return obj
    }
  }
}

Vue 的求值程式碼是在 src/core/util/lang.js 檔案中 parsePath 函式中實現的。總結一下Vue的依賴收集過程應該是這樣的:

Vue的依賴收集過程

實際上,Vue並沒有直接在 get 中呼叫 addSub,而是呼叫的 dep.depend,目的是將當前的 dep 物件收集到 watch 物件中,如果要完整的流程,應該是這樣的:(大家注意資料的每一個欄位都擁有自己的 dep 物件和 get 方法。)

Vue完整的收集依賴的流程

這樣 Vue 就建立了一套資料響應系統,之前我們說過,按照我們的例子那樣寫,初始化工作只包含兩個主要內容即:initDatainitRender。現在 initData 我們分析完了,接下來看一看 initRender

六、通過 initRender 看Vue的 render(渲染) 與 re-render(重新渲染)

initRender 方法中,因為我們的例子中傳遞了 el 選項,所以下面的程式碼會執行:

  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }

這裡,呼叫了 $mount</code> 方法,在還原Vue建構函式的時候,我們整理過所有的方法,其中 <code>$mount 方法在兩個地方出現過:

1、在 web-runtime.js 檔案中:

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return this._mount(el, hydrating)
}

它的作用是通過 el 獲取相應的DOM元素,然後呼叫 lifecycle.js 檔案中的 _mount 方法。

2、在 web-runtime-with-compiler.js 檔案中:

// 快取了來自 web-runtime.js 的 $mount 方法
const mount = Vue.prototype.$mount
// 重寫 $mount 方法
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 根據 el 獲取相應的DOM元素
  el = el && query(el)
  // 不允許你將 el 掛載到 html 標籤或者 body 標籤
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  // 如果我們沒有寫 render 選項,那麼就嘗試將 template 或者 el 轉化為 render 函式
  if (!options.render) {
    let template = options.template
    if (template) {
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        warn,
        shouldDecodeNewlines,
        delimiters: options.delimiters
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  // 呼叫已經快取下來的 web-runtime.js 檔案中的 $mount 方法
  return mount.call(this, el, hydrating)
}

分析一下可知 web-runtime-with-compiler.js 的邏輯如下:

1、快取來自 web-runtime.js 檔案的 $mount 方法

2、判斷有沒有傳遞 render 選項,如果有直接呼叫來自 web-runtime.js 檔案的 $mount 方法

3、如果沒有傳遞 render 選項,那麼檢視有沒有 template 選項,如果有就使用 compileToFunctions 函式根據其內容編譯成 render 函式

4、如果沒有 template 選項,那麼檢視有沒有 el 選項,如果有就使用 compileToFunctions 函式將其內容(template = getOuterHTML(el))編譯成 render 函式

5、將編譯成的 render 函式掛載到 this.$options</code> 屬性下,並呼叫快取下來的 <code>web-runtime.js</code> 檔案中的 $mount 方法

簡單的用一張圖表示 mount 方法的呼叫關係,從上至下呼叫:

mount呼叫關係

不過不管怎樣,我們發現這些步驟的最終目的是生成 render 函式,然後再呼叫 lifecycle.js 檔案中的 _mount 方法,我們看看這個方法做了什麼事情,檢視 _mount 方法的程式碼,這是簡化過得:

  Vue.prototype._mount = function (
    el?: Element | void,
    hydrating?: boolean
  ): Component {
    const vm: Component = this

    // 在Vue例項物件上新增 $el 屬性,指向掛載點元素
    vm.$el = el

    // 觸發 beforeMount 生命週期鉤子
    callHook(vm, 'beforeMount')

    vm._watcher = new Watcher(vm, () => {
      vm._update(vm._render(), hydrating)
    }, noop)

    // 如果是第一次mount則觸發 mounted 生命週期鉤子
    if (vm.$vnode == null) {
      vm._isMounted = true
      callHook(vm, 'mounted')
    }
    return vm
  }

上面的程式碼很簡單,該註釋的都註釋了,唯一需要看的就是這段程式碼:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)

看上去很眼熟有沒有?我們平時使用Vue都是這樣使用 watch的:

this.$watch('a', (newVal, oldVal) => {

})
// 或者
this.$watch(function(){
    return this.a + this.b
}, (newVal, oldVal) => {

})

第一個引數是 表示式或者函式,第二個引數是回撥函式,第三個引數是可選的選項。原理是 Watch 內部對錶達式求值或者對函式求值從而觸發資料的 get 方法收集依賴。可是 _mount 方法中使用 Watcher 的時候第一個引數 vm 是什麼鬼。我們不妨去看看原始碼中 $watch</code> 函式是如何實現的,根據之前還原Vue建構函式中所整理的內容可知:<code>$warch 方法是在 src/core/instance/state.js 檔案中的 stateMixin 方法中定義的,原始碼如下:

  Vue.prototype.$watch = function (
    expOrFn: string | Function,
    cb: Function,
    options?: Object
  ): Function {
    const vm: Component = this
    options = options || {}
    options.user = true
    const watcher = new Watcher(vm, expOrFn, cb, options)
    if (options.immediate) {
      cb.call(vm, watcher.value)
    }
    return function unwatchFn () {
      watcher.teardown()
    }
  }

我們可以發現,$warch 其實是對 Watcher 的一個封裝,內部的 Watcher 的第一個引數實際上也是 vm 即:Vue例項物件,這一點我們可以在 Watcher 的原始碼中得到驗證,代開 observer/watcher.js 檔案檢視:

export default class Watcher {

  constructor (
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options?: Object = {}
  ) {

  }
}

可以發現真正的 Watcher 第一個引數實際上就是 vm。第二個引數是表示式或者函式,然後以此類推,所以現在再來看 _mount 中的這段程式碼:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)

忽略第一個引數 vm,也就說,Watcher 內部應該對第二個引數求值,也就是執行這個函式:

() => {
  vm._update(vm._render(), hydrating)
}

所以 vm._render() 函式被第一個執行,該函式在 src/core/instance/render.js 中,該方法中的程式碼很多,下面是簡化過的:

  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    // 解構出 $options 中的 render 函式
    const {
      render,
      staticRenderFns,
      _parentVnode
    } = vm.$options
    ...

    let vnode
    try {
      // 執行 render 函式
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      ...
    }

    // set parent
    vnode.parent = _parentVnode
    return vnode
  }

_render 方法首先從 vm.$options</code> 中解構出 <code>render</code> 函式,大家應該記得:<code>vm.$options.render 方法是在 web-runtime-with-compiler.js 檔案中通過 compileToFunctions 方法將 templateel 編譯而來的。解構出 render 函式後,接下來便執行了該方法:

vnode = render.call(vm._renderProxy, vm.$createElement)

其中使用 call 指定了 render 函式的作用域環境為 vm._renderProxy,這個屬性在我們整理例項物件的時候知道,他是在 Vue.prototype._init 方法中被新增的,即:vm._renderProxy = vm,其實就是Vue例項物件本身,然後傳遞了一個引數:vm.$createElement。那麼 render 函式到底是幹什麼的呢?讓我們根據上面那句程式碼猜一猜,我們已經知道 render 函式是從 templateel 編譯而來的,如果沒錯的話應該是返回一個虛擬DOM物件。我們不妨使用 console.log 列印一下 render 函式,當我們的模板這樣編寫時:

<ul id="app">
  <li>{{a}}</li>
</ul>

列印的 render 函式如下:

render函式1

我們修改模板為:

<ul id="app">
  <li v-for="i in b">{{a}}</li>
</ul>

打印出來的 render 函式如下:

render函式2

其實瞭解Vue2.x版本的同學都知道,Vue提供了 render 選項,作為 template 的代替方案,同時為JavaScript提供了完全程式設計的能力,下面兩種編寫模板的方式實際是等價的:

// 方案一:
new Vue({
    el: '#app',
    data: {
        a: 1
    },
    template: '<ul><li>{{a}}</li><li>{{a}}</li></ul>'
})

// 方案二:
new Vue({
    el: '#app',
    render: function (createElement) {
        createElement('ul', [
            createElement('li', this.a),
            createElement('li', this.a)
        ])
    }
})

現在我們再來看我們列印的 render 函式:

function anonymous() {
    with(this){
        return _c('ul', { 
            attrs: {"id": "app"}
        },[
            _c('li', [_v(_s(a))])
        ])
    }
}

是不是與我們自己寫 render 函式很像?因為 render 函式的作用域被繫結到了Vue例項,即:render.call(vm._renderProxy, vm.$createElement),所以上面程式碼中 _c_v_s 以及變數 a相當於Vue例項下的方法和變數。大家還記得諸如 _c_v_s 這樣的方法在哪裡定義的嗎?我們在整理Vue建構函式的時候知道,他們在 src/core/instance/render.js 檔案中的 renderMixin 方法中定義,除了這些之外還有諸如:_l_m_o 等等。其中 _l 就在我們使用 v-for 指令的時候出現了。所以現在大家知道為什麼這些方法都被定義在 render.js 檔案中了吧,因為他們就是為了構造出 render 函式而存在的。

現在我們已經知道了 render 函式的長相,也知道了 render 函式的作用域是Vue例項本身即:this(或vm)。那麼當我們執行 render 函式時,其中的變數如:a,就相當於:this.a,我們知道這是在求值,所以 _mount 中的這段程式碼:

vm._watcher = new Watcher(vm, () => {
  vm._update(vm._render(), hydrating)
}, noop)

vm._render 執行的時候,所依賴的變數就會被求值,並被收集為依賴。按照Vue中 watcher.js 的邏輯,當依賴的變數有變化時不僅僅回撥函式被執行,實際上還要重新求值,即還要執行一遍:

() => {
  vm._update(vm._render(), hydrating)
}

這實際上就做到了 re-render,因為 vm._update 就是文章開頭所說的虛擬DOM中的最後一步:patch

vm_render 方法最終返回一個 vnode 物件,即虛擬DOM,然後作為 vm_update 的第一個引數傳遞了過去,我們看一下 vm_update 的邏輯,在 src/core/instance/lifecycle.js 檔案中有這麼一段程式碼:

    if (!prevVnode) {
      // initial render
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
    } else {
      // updates
      vm.$el = vm.__patch__(prevVnode, vnode)
    }

如果還沒有 prevVnode 說明是首次渲染,直接建立真實DOM。如果已經有了 prevVnode 說明不是首次渲染,那麼就採用 patch 演算法進行必要的DOM操作。這就是Vue更新DOM的邏輯。只不過我們沒有將 virtual DOM 內部的實現。

現在我們來好好理理思路,當我們寫如下程式碼時:

new Vue({
    el: '#app',
    data: {
        a: 1,
        b: [1, 2, 3]
    }
})

Vue 所做的事:

1、構建資料響應系統,使用 Observer 將資料data轉換為訪問器屬性;將 el 編譯為 render 函式,render 函式返回值為虛擬DOM

2、在 _mount 中對 _update 求值,而 _update 又會對 render 求值,render 內部又會對依賴的變數求值,收集為被求值的變數的依賴,當變數改變時,_update 又會重新執行一遍,從而做到 re-render

用一張詳細一點的圖表示就是這樣的:

詳細流程

到此,我們從大體流程,挑著重點的走了一遍Vue,但是還有很多細節我們沒有提及,比如:

1、將模板轉為 render 函式的時候,實際是先生成的抽象語法樹(AST),再將抽象語法樹轉成的 render 函式