Vue原始碼閱讀 - 檔案結構與執行機制

SHERlocked93發表於2018-07-01

vue已是目前國內前端web端三分天下之一,同時也作為本人主要技術棧之一,在日常使用中知其然也好奇著所以然,另外最近的社群湧現了一大票vue原始碼閱讀類的文章,在下借這個機會從大家的文章和討論中汲取了一些營養,同時對一些閱讀原始碼時的想法進行總結,出產一些文章,作為自己思考的輸出,本人水平有限,歡迎留言討論~

目標Vue版本:2.5.17-beta.0

vue原始碼註釋:github.com/SHERlocked9…

宣告:文章中原始碼的語法都使用 Flow,並且原始碼根據需要都有刪節(為了不被迷糊 @_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~

0. 前備知識

  • Flow
  • ES6語法
  • 常用的設計模式
  • 柯里化等函數語言程式設計思想

這裡推介幾篇前備文章:JS 靜態型別檢查工具 FlowECMAScript 6 入門 - 阮一峰JS中的柯里化JS 觀察者模式JS 利用高階函式實現函式快取(備忘模式)

1. 檔案結構

檔案結構在vue的CONTRIBUTING.md中有介紹,這邊直接翻譯過來:

├── scripts ------------------------------- 包含與構建相關的指令碼和配置檔案
│   ├── alias.js -------------------------- 原始碼中使用到的模組匯入別名
│   ├── config.js ------------------------- 專案的構建配置
├── build --------------------------------- 構建相關的檔案,一般情況下我們不需要動
├── dist ---------------------------------- 構建後檔案的輸出目錄
├── examples ------------------------------ 存放一些使用Vue開發的應用案例
├── flow ---------------------------------- JS靜態型別檢查工具 [Flow](https://flowtype.org/) 的型別宣告
├── package.json
├── test ---------------------------------- 測試檔案
├── src ----------------------------------- 原始碼目錄
│   ├── compiler -------------------------- 編譯器程式碼,用來將 template 編譯為 render 函式
│   │   ├── parser ------------------------ 存放將模板字串轉換成元素抽象語法樹的程式碼
│   │   ├── codegen ----------------------- 存放從抽象語法樹(AST)生成render函式的程式碼
│   │   ├── optimizer.js ------------------ 分析靜態樹,優化vdom渲染
│   ├── core ------------------------------ 存放通用的,平臺無關的執行時程式碼
│   │   ├── observer ---------------------- 響應式實現,包含資料觀測的核心程式碼
│   │   ├── vdom -------------------------- 虛擬DOM的 creation 和 patching 的程式碼
│   │   ├── instance ---------------------- Vue建構函式與原型相關程式碼
│   │   ├── global-api -------------------- 給Vue建構函式掛載全域性方法(靜態方法)或屬性的程式碼
│   │   ├── components -------------------- 包含抽象出來的通用元件,目前只有keep-alive
│   ├── server ---------------------------- 服務端渲染(server-side rendering)的相關程式碼
│   ├── platforms ------------------------- 不同平臺特有的相關程式碼
│   │   ├── weex -------------------------- weex平臺支援
│   │   ├── web --------------------------- web平臺支援
│   │   │   ├── entry-runtime.js ---------------- 執行時構建的入口
│   │   │   ├── entry-runtime-with-compiler.js -- 獨立構建版本的入口
│   │   │   ├── entry-compiler.js --------------- vue-template-compiler 包的入口檔案
│   │   │   ├── entry-server-renderer.js -------- vue-server-renderer 包的入口檔案
│   ├── sfc ------------------------------- 包含單檔案元件(.vue檔案)的解析邏輯,用於vue-template-compiler包
│   ├── shared ---------------------------- 整個程式碼庫通用的程式碼
複製程式碼

幾個重要的目錄:

  • compiler: 編譯,用來將template轉化為render函式
  • core: Vue的核心程式碼,包括響應式實現、虛擬DOM、Vue例項方法的掛載、全域性方法、抽象出來的通用元件等
  • platform: 不同平臺的入口檔案,主要是 web 平臺和 weex 平臺的,不同平臺有其特殊的構建過程,當然我們的重點是 web 平臺
  • server: 服務端渲染(SSR)的相關程式碼,SSR 主要把元件直接渲染為 HTML 並由 Server 端直接提供給 Client 端
  • sfc: 主要是 .vue 檔案解析的邏輯
  • shared: 一些通用的工具方法,有一些是為了增加程式碼可讀性而設定的

其中在platform下src/platforms/web/entry-runtime.js檔案作為執行時構建的入口,ESM方式輸出 dist/vue.runtime.esm.js,CJS方式輸出 dist/vue.runtime.common.js,UMD方式輸出 dist/vue.runtime.js,不包含模板 template 到 render 函式的編譯器 src/platforms/web/entry-runtime-with-compiler.js檔案作為執行時構建的入口,ESM方式輸出 dist/vue.esm.js,CJS方式輸出 dist/vue.common.js,UMD方式輸出 dist/vue.js,包含compiler

2. 入口檔案

任何前端專案都可以從 package.json 檔案看起,先來看看它的 script.dev 就是我們執行 npm run dev 的時候它的命令列:

"scripts": {
    "dev": "rollup -w -c scripts/config.js --environment TARGET:web-full-dev"
}
複製程式碼

這裡的 rollup 是一個類似於 webpack 的JS模組打包器,事實上 Vue - v1.0.10 版本之前用的還是 webpack ,其後改成了 rollup ,如果想知道為什麼換成 rollup ,可以看看 尤雨溪本人的回答,總的來說就是為了打出來的包體積小一點,初始化速度快一點。

可以看到這裡 rollup 去執行 scripts/config.js 檔案,並且給了個引數 TARGET:web-full-dev,那來看看 scripts/config.js 裡面是啥

// scripts/config.js

const builds = {
  'web-full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.js'),  // 入口檔案
    dest: resolve('dist/vue.js'),                          // 輸出檔案
    format: 'umd',                                         // 參看下面的編譯方式說明
    env: 'development',                                    // 環境
    alias: { he: './entity-decoder' },                     // 別名
    banner                                        // 每個包前面的註釋-版本/作者/日期.etc
  },
}
複製程式碼

format 編譯方式說明:

  • es: ES Modules,使用ES6的模板語法輸出
  • cjs: CommonJs Module,遵循CommonJs Module規範的檔案輸出
  • amd: AMD Module,遵循AMD Module規範的檔案輸出
  • umd: 支援外鏈規範的檔案輸出,此檔案可以直接使用script標籤

這裡的 web-full-dev 就是對應剛剛我們在命令列裡傳入的命令,那麼 rollup 就會按下面的 entry 入口檔案開始去打包,還有其他很多命令和其他各種輸出方式和格式可以自行檢視一下原始碼。

因此本文主要的關注點在包含 compiler 編譯器的 src/platforms/web/entry-runtime-with-compiler.js 檔案,在生產和開發環境中我們使用 vue-loader 來進行 template 的編譯從而不需要帶 compiler 的包,但是為了更好的理解原理和流程還是推介從帶 compiler 的入口檔案看起。

先看看這個檔案,這裡匯入了個 Vue ,看看它從哪來的

// src/platforms/web/entry-runtime-with-compiler.js

import Vue from './runtime/index'
複製程式碼

繼續看

// src/platforms/web/runtime/index.js

import Vue from 'core/index'
複製程式碼

keep moving

// src/core/index.js

import Vue from './instance/index'
複製程式碼

keep moving*2

// src/core/instance/index.js

/* 這裡就是vue的建構函式了,不用ES6的Class語法是因為mixin模組劃分的方便 */
function Vue(options) {
  this._init(options)         // 初始化方法,位於 initMixin 中
}

// 下面的mixin往Vue.prototype上各種掛載
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue
複製程式碼

當我們 new Vue( ) 的時候,實際上呼叫的就是這個建構函式,可以從這裡開始看了。

3. 執行機制

這裡我用xmind粗略的畫了一張執行機制圖,基本上後面的分析都在這張圖上面的某些部分了

本文 Vue 例項都是用 vm 來表示

Vue原始碼閱讀 - 檔案結構與執行機制

上面這個圖可以分為多個部分細加閱讀,具體的實現我們在後面的文章中詳細討論,這裡先貼一部分原始碼嚐嚐鮮

3.1 初始化 _init( )

Vue原始碼閱讀 - 檔案結構與執行機制

當我們在 main.js 裡 new Vue( ) 後,Vue 會呼叫建構函式的 _init( ) 方法,這個方法是位於 core/instance/index.js 的 initMixin( ) 方法中定義的

// src/core/instance/index.js

/* 這裡就是Vue的建構函式 */
function Vue(options) {
  this._init(options)              // 初始化方法,位於 initMixin 中
}

// 下面的mixin往Vue.prototype上各種掛載,這是在載入的時候已經掛載好的
initMixin(Vue)                     // 給Vue.prototype新增:_init函式,...
stateMixin(Vue)                    // 給Vue.prototype新增:$data屬性, $props屬性, $set函式, $delete函式, $watch函式,...
eventsMixin(Vue)                   // 給Vue.prototype新增:$on函式, $once函式, $off函式, $emit函式, $watch方法,...
lifecycleMixin(Vue)                // 給Vue.prototype新增: _update方法, $forceUpdate函式, $destroy函式,...
renderMixin(Vue)                   // 給Vue.prototype新增: $nextTick函式, _render函式,...

export default Vue
複製程式碼

我們可以看看 init( ) 這個方法到底進行了哪些初始化:

// src/core/instance/index.js

Vue.prototype._init = function(options?: Object) {
  const vm: Component = this

  initLifecycle(vm)                     // 初始化生命週期 src/core/instance/lifecycle.js
  initEvents(vm)                        // 初始化事件 src/core/instance/events.js
  initRender(vm)                        // 初始化render src/core/instance/render.js
  callHook(vm, 'beforeCreate')          // 呼叫beforeCreate鉤子
  initInjections(vm)                    // 初始化注入值 before data/props src/core/instance/inject.js
  initState(vm)                         // 掛載 data/props/methods/watcher/computed
  initProvide(vm)                       // 初始化Provide after data/props
  callHook(vm, 'created')               // 呼叫created鉤子

  if (vm.$options.el) {                    // $options可以認為是我們傳給 `new Vue(options)` 的options
    vm.$mount(vm.$options.el)              // $mount方法
  }
}
複製程式碼

這裡 _init() 方法中會對當前 vm 例項進行一系列初始化設定,比較重要的是初始化 State 的方法 initState(vm) 的時候進行 data/props 的響應式化,這就是傳說中的通過 Object.defineProperty() 方法對需要響應式化的物件設定 getter/setter,以此為基礎進行依賴蒐集(Dependency Collection),達到資料變化驅動檢視變化的目的。

最後檢測 vm.$options 上面有沒有 el 屬性,如果有的話使用 vm.$mount 方法掛載 vm,形成資料層和檢視層的聯絡。這也是如果沒有提供 el 選項就需要自己手動 vm.$mount('#app') 的原因。

我們看到 created 鉤子是在掛載 $mount 之前呼叫的,所以我們在 created 鉤子觸發之前是無法操作 DOM 的,這是因為還沒有渲染到 DOM 上。

3.2 掛載 $mount( )

Vue原始碼閱讀 - 檔案結構與執行機制

掛載方法 vm.$mount( ) 在多個地方有定義,是根據不同打包方式和平臺有關的,src/platform/web/entry-runtime-with-compiler.jssrc/platform/web/runtime/index.jssrc/platform/weex/runtime/index.js,我們的關注點在第一個檔案,但在 entry-runtime-with-compiler.js 檔案中會首先把 runtime/index.js 中的 $mount 方法儲存下來,並在最後用 call 執行:

// src/platform/web/entry-runtime-with-compiler.js

const mount = Vue.prototype.$mount    // 把原來的$mount儲存下來,位於 src/platform/web/runtime/index.js
Vue.prototype.$mount = function(
  el?: string | Element,    // 掛載的元素
  hydrating?: boolean       // 服務端渲染相關引數
): Component {
  el = el && query(el)
  
  const options = this.$options
  if (!options.render) {                // 如果沒有定義render方法
    let template = options.template
    
    // 把獲取到的template通過編譯的手段轉化為render函式
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {...}, this)
      options.render = render
    }
  }
  return mount.call(this, el, hydrating)      // 執行原來的$mount
}
複製程式碼

在 Vue 2.0 版本中,所有 Vue 的元件的渲染最終都需要 render 方法,無論我們是用單檔案 .vue 方式開發元件,還是寫了 el 或者 template 屬性,最終都會轉換成 render 方法。這裡的 compileToFunctions 就是把 template 編譯為 render 的方法,後面會介紹。

// src/platform/weex/runtime/index.js

Vue.prototype.$mount = function (
  el?: string | Element,    // 掛載的元素
  hydrating?: boolean       // 服務端渲染相關引數
): Component {
  el = el && inBrowser ? query(el) : undefined        // query就是document.querySelector方法
  return mountComponent(this, el, hydrating)          // 位於core/instance/lifecycle.js
}
複製程式碼

這裡的 el 一開始如果不是DOM元素的話會被 query 方法換成DOM元素再被傳給 mountComponent 方法,我們繼續看 mountComponent 的定義:

// src/core/instance/lifecycle.js

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')            // 呼叫beforeMount鉤子

  // 渲染watcher,當資料更改,updateComponent作為Watcher物件的getter函式,用來依賴收集,並渲染檢視
  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // 渲染watcher, Watcher 在這裡起到兩個作用,一個是初始化的時候會執行回撥函式
  // ,另一個是當 vm 例項中的監測的資料發生變化的時候執行回撥函式
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')            // 呼叫beforeUpdate鉤子
      }
    }
  }, true /* isRenderWatcher */)

  // 這裡注意 vm.$vnode 表示 Vue 例項的父虛擬 Node,所以它為 Null 則表示當前是根 Vue 的例項
  if (vm.$vnode == null) {
    vm._isMounted = true               // 表示這個例項已經掛載
    callHook(vm, 'mounted')            // 呼叫mounted鉤子
  }
  return vm
}
複製程式碼

mountComponent 方法裡例項化了一個渲染 Watcher,並且傳入了一個 updateComponent ,這個方法:() => { vm._update(vm._render(), hydrating) } 首先使用 _render 方法生成 VNode,再呼叫 _update 方法更新DOM。可以看看檢視更新部分的介紹

這裡呼叫了幾個鉤子,他們的時機可以關注一下。

3.3 編譯 compile( )

如果在需要轉換 render 的場景下,比如我們寫的 template ,將會被 compiler 轉換為 render 函式,這其中會有幾個步驟組成:

Vue原始碼閱讀 - 檔案結構與執行機制

入口位於剛剛 src/platform/web/entry-runtime-with-compiler.js 的 compileToFunctions 方法:

// src/platforms/web/compiler/index.js

const { compile, compileToFunctions } = createCompiler(baseOptions)
export { compile, compileToFunctions }
複製程式碼

繼續看這裡的 createCompiler 方法:

// src/compiler/index.js

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
    optimize(ast, options)
  }
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})
複製程式碼

這裡可以看到有三個重要的過程 parseoptimizegenerate,之後生成了 render 方法程式碼。

  • parse:會用正則等方式解析 template 模板中的指令、class、style等資料,形成抽象語法樹 AST
  • optimize:優化AST,生成模板AST樹,檢測不需要進行DOM改變的靜態子樹,減少 patch 的壓力
  • generate:把 AST 生成 render 方法的程式碼

3.4 響應式化 observe( )

Vue作為一個MVVM框架,我們知道它的 Model 層和 View 層之間的橋樑 ViewModel 是做到資料驅動的關鍵,Vue的響應式是通過 Object.defineProperty 來實現,給被響應式化的物件設定 getter/setter ,當 render 函式被渲染的時候會觸發讀取響應式化物件的 getter 進行依賴收集,而在修改響應式化物件的時候會觸發設定 settersetter 方法會 notify 它之前收集到的每一個 watcher 來告訴他們自己的值更新了,從而觸發 watcherupdatepatch 更新檢視。

Vue原始碼閱讀 - 檔案結構與執行機制

響應式化的入口位於 src/core/instance/init.js 的 initState 中:

// src/core/instance/state.js

export function initState(vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}
複製程式碼

它非常規律的定義了幾個方法來初始化 propsmethodsdatacomputedwathcer,這裡只看 initData 方法,來窺一豹

// src/core/instance/state.js

function initData(vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
                    ? getData(data, vm)
                    : data || {}
  
  observe(data, true /* asRootData */) // 給data做響應式處理
}
複製程式碼

首先判斷了下 data 是不是函式,是則取返回值不是則取自身,之後有一個 observe 方法對 data 進行處理,看看這個方法

// src/core/observer/index.js

export function observe (value: any, asRootData: ?boolean): Observer | void {
  let ob: Observer | void
  ob = new Observer(value)
  return ob
}
複製程式碼

這個方法主要用 data 去例項化一個 Observer 物件例項,Observer 是一個 Class,Observer 的建構函式使用 defineReactive 方法給物件的鍵響應式化,它給物件的屬性遞迴新增 getter/setter,用於依賴收集和 notify 更新,這個方法大概是這樣的

// src/core/observer/index.js

function defineReactive (obj, key, val) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter () {
            /* 進行依賴收集 */
            return val;
        },
        set: function reactiveSetter (newVal) {
            if (newVal === val) return;
            notify();                // 觸發更新
        }
    });
}
複製程式碼

3.5 檢視更新 patch( )

Vue原始碼閱讀 - 檔案結構與執行機制

當使用 defineReactive 方法將物件響應式化後,當 render 函式被渲染的時候,會讀取響應化物件的 getter 從而觸發 getter 進行 watcher 依賴的收集,而在修改響應化物件的值的時候,會觸發 setter 通知 notify 之前收集的依賴,通知自己已被修改,請按需重新渲染檢視。被通知的 watcher 呼叫 update 方法去更新檢視,位於上面介紹過的傳遞給 new Watcher( )updateComponent 方法中,這個方法會呼叫 update 方法去 patch 更新檢視。

// src/core/instance/lifecycle.js

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

// 渲染watcher, Watcher 在這裡起到兩個作用,一個是初始化的時候會執行回撥函式
// ,另一個是當 vm 例項中的監測的資料發生變化的時候執行回撥函式
new Watcher(vm, updateComponent, noop, {...}, true /* isRenderWatcher */)
複製程式碼

這個 _render 方法生成虛擬 Node, _update 方法中的會將新的 VNode 與舊的 VNode 一起傳入 patch

// src/core/instance/lifecycle.js

Vue.prototype._update = function(vnode: VNode, hydrating?: boolean) { // 呼叫此方法去更新檢視
  const vm: Component = this
  const prevVnode = vm._vnode
  vm._vnode = vnode

  if (!prevVnode) {
    // 初始化
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    //更新
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}
複製程式碼

_update 呼叫 __patch__ 方法,它主要是對新老 VNode 進行比較 patchVnode,經過 diff 演算法得出它們的差異直接,最後這些差異的對應 DOM 進行更新。

到這裡基本上一個主要的流程就介紹完了,我們大概瞭解了一個 Vue 從一個建構函式的例項化開始是如何運轉的,後面會展開來討論一下各個部分的內容,在下才疏學淺,未免紕漏,歡迎大家討論~


本文是系列文章,隨後會更新後面的部分,共同進步~

  1. Vue原始碼閱讀 - 檔案結構與執行機制
  2. Vue原始碼閱讀 - 依賴收集原理
  3. Vue原始碼閱讀 - 批量非同步更新與nextTick原理

網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~

參考:

  1. Vue2.1.7原始碼學習
  2. Vue.js 技術揭祕
  3. 剖析 Vue.js 內部執行機制
  4. Vue.js 文件
  5. 【大型乾貨】手拉手帶你過一遍vue部分原始碼
  6. MDN - Object.defineProperty()

PS:歡迎大家關注我的公眾號【前端下午茶】,一起加油吧~

Vue原始碼閱讀 - 檔案結構與執行機制

相關文章