深入剖析Vue原始碼 - 例項掛載,編譯流程

不做祖國的韭菜發表於2019-05-04

前面幾節我們從new Vue初始化開始,分別介紹了選項的配置合併,設定vue例項代理以及初始化生命週期等操作,按照vue原始碼的設計思路,接下來過程還會進行初始化事件中心,初始化渲染,初始化資料並建立響應式系統的操作,最終vue會將模板和資料渲染成為最終的DOM。然而在初始化資料和建立響應式系統的過程中,我們需要弄清楚資料是如何驅動模板乃至資料更新如何驅動檢視更新。為了弄清楚這一點,我們需要知道Vue的模板渲染流程,因此我把vue掛載,渲染模板資料到檢視的過程放到前面進行分析。往後的幾個小節也會圍繞這一塊內容展開。

3.1 Runtime Only VS Runtime + Compiler

在正文開始之前,先了解vue基於原始碼構建的兩個版本,一個是runtime only,另一個是runtime加compiler的版本,兩個版本的主要區別在於後者的原始碼包括了一個編譯器。

什麼是編譯器,百度百科上面的解釋是

簡單講,編譯器就是將“一種語言(通常為高階語言)”翻譯為“另一種語言(通常為低階語言)”的程式。一個現代編譯器的主要工作流程:原始碼 (source code) → 前處理器 (preprocessor) → 編譯器 (compiler) → 目的碼 (object code) → 連結器 (Linker) → 可執行程式 (executables)。

通俗點講,編譯器是一個提供了將原始碼轉化為目的碼的工具。更進一步理解,vue內建的編譯器實現了將.vue檔案轉換編譯為可執行javascript指令碼的功能。

3.1.1 Runtime + Compiler

一個完整的vue版本是包含編譯器的,我們可以使用template進行模板編寫。編譯器會自動將模板編譯成render 函式。

// 需要編譯器的版本
new Vue({
  template: '<div>{{ hi }}</div>'
})
複製程式碼
3.1.2 Runtime Only

而對於一個不包含編譯器的runtime-only版本,需要傳遞一個編譯好的render函式,如下所示:

// 不需要編譯器
new Vue({
  render (h) {
    return h('div', this.hi)
  }
})
複製程式碼

很明顯,編譯過程對效能有一定的損耗,並且由於加入了編譯過程的程式碼,vue程式碼體積也更加龐大,所以我們可以藉助webpack的vue-loader工具進行編譯,將編譯階段從vue的構建中剝離出來,這樣既優化了效能,也縮小了體積。

3.2 掛載的基本思路

vue掛載的流程是比較複雜的,我們通過流程圖理清基本的實現思路。

深入剖析Vue原始碼 - 例項掛載,編譯流程
如果用一句話概括掛載的過程,可以描述為掛載元件,將渲染函式生成虛擬DOM,更新檢視時,將虛擬DOM渲染成為真正的DOM。

詳細的過程是:首先確定掛載的DOM元素,且必須保證該元素不能為html,body這類跟節點。判斷選項中是否有render這個屬性(如果不在執行時編譯,則在選項初始化時需要傳遞render渲染函式)。當有render這個屬性時,預設我們使用的是runtime-only的版本,從而跳過模板編譯階段,呼叫真正的掛載函式$mount。另一方面,當我們傳遞是template模板時(即在不使用外接編譯器的情況下,我們將使用runtime+compile的版本),Vue原始碼將首先進入編譯階段。該階段的核心是兩步,一個是把模板解析成抽象的語法樹,也就是我們常聽到的AST,第二個是根據給定的AST生成目標平臺所需的程式碼,在瀏覽器端是前面提到的render函式。完成模板編譯後,同樣會進入$mount掛載階段。真正的掛載過程,執行的是mountComponent方法,該函式的核心是例項化一個渲染watcher,具體watcher的內容,另外放章節討論。我們只要知道渲染watcher的作用,一個是初始化的時候會執行回撥函式,另一個是當 vm 例項中監測的資料發生變化的時候執行回撥函式。而這個回撥函式就是updateComponent,這個方法會通過vm._render生成虛擬DOM,並最終通過vm._update將虛擬DOM轉化為真正的DOM

往下,我們從程式碼的角度出發,瞭解一下掛載的實現思路,下面只提取mount骨架程式碼說明。

// 內部真正實現掛載的方法
Vue.prototype.$mount = function (el, hydrating) {
  el = el && inBrowser ? query(el) : undefined;
  // 呼叫mountComponent方法掛載
  return mountComponent(this, el, hydrating)
};
// 快取了原型上的 $mount 方法
var mount = Vue.prototype.$mount;
// 重新定義$mount,為包含編譯器和不包含編譯器的版本提供不同封裝,最終呼叫的是快取原型上的$mount方法
Vue.prototype.$mount = function (el, hydrating) {
  // 獲取掛載元素
  el = el && query(el);
  // 掛載元素不能為跟節點
  if (el === document.body || el === document.documentElement) {
    warn(
      "Do not mount Vue to <html> or <body> - mount to normal elements instead."
    );
    return this
  }
  var options = this.$options;
  // 需要編譯 or 不需要編譯
  if (!options.render) {
    ···
    // 使用內部編譯器編譯模板
  }
  // 最終呼叫快取的$mount方法
  return mount.call(this, el, hydrating)
}
// mountComponent方法思路
function mountComponent(vm, el, hydrating) {
  // 定義updateComponent方法,在watch回撥時呼叫。
  updateComponent = function () {
    // render函式渲染成虛擬DOM, 虛擬DOM渲染成真實的DOM
    vm._update(vm._render(), hydrating);
  };
  // 例項化渲染watcher
  new Watcher(vm, updateComponent, noop, {})
}

複製程式碼

3.3 編譯過程 - 模板編譯成 render 函式

通過文章前半段的學習,我們對Vue的掛載流程有了一個初略的認識。接下來將先從模板編譯的過程展開。閱讀原始碼時發現,模板的編譯過程是相當複雜的,要在短篇幅內將整個編譯的過程講開是不切實際的,因此這節剩餘內容只會對實現思路做簡單的介紹。

3.3.1 template的三種寫法

template模板的編寫有三種方式,分別是:

// 1. 熟悉的字串模板
var vm = new Vue({
  el: '#app',
  template: '<div>模板字串</div>'
})
// 2. 選擇符匹配元素的 innerHTML模板
<div id="app">
  <div>test1</div>
  <script type="x-template" id="test">
    <p>test</p>
  </script>
</div>
var vm = new Vue({
  el: '#app',
  template: '#test'
})
// 3. dom元素匹配元素的innerHTML模板
<div id="app">
  <div>test1</div>
  <span id="test"><div class="test2">test2</div></span>
</div>
var vm = new Vue({
  el: '#app',
  template: document.querySelector('#test')
})

複製程式碼

三種寫法對應程式碼的三個不同分支。

var template = options.template;
  if (template) {
    // 針對字串模板和選擇符匹配模板
    if (typeof template === 'string') {
      // 選擇符匹配模板,以'#'為字首的選擇器
      if (template.charAt(0) === '#') {
        // 獲取匹配元素的innerHTML
        template = idToTemplate(template);
        /* istanbul ignore if */
        if (!template) {
          warn(
            ("Template element not found or is empty: " + (options.template)),
            this
          );
        }
      }
    // 針對dom元素匹配
    } else if (template.nodeType) {
      // 獲取匹配元素的innerHTML
      template = template.innerHTML;
    } else {
      // 其他型別則判定為非法傳入
      {
        warn('invalid template option:' + template, this);
      }
      return this
    }
  } else if (el) {
    // 如果沒有傳入template模板,則預設以el元素所屬的根節點作為基礎模板
    template = getOuterHTML(el);
  }
複製程式碼

其中X-Template模板的方式一般用於模板特別大的 demo 或極小型的應用,官方不建議在其他情形下使用,因為這會將模板和元件的其它定義分離開。

3.3.2 流程圖解

vue原始碼中編譯流程程式碼比較繞,涉及的函式處理邏輯比較多,實現流程中巧妙的運用了偏函式的技巧將配置項處理和編譯核心邏輯抽取出來,為了理解這個設計思路,我畫了一個邏輯圖幫助理解。

深入剖析Vue原始碼 - 例項掛載,編譯流程

3.3.3 邏輯解析

即便有流程圖,編譯邏輯理解起來依然比較晦澀,接下來,結合程式碼分析每個環節的執行過程。

var ref = compileToFunctions(template, {
  outputSourceRange: "development" !== 'production',
  shouldDecodeNewlines: shouldDecodeNewlines,
  shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
  delimiters: options.delimiters,
  comments: options.comments
}, this);

// 將compileToFunction方法暴露給Vue作為靜態方法存在
Vue.compile = compileToFunctions;

複製程式碼

這是編譯的入口,也是Vue對外暴露的編譯方法。compileToFunctions需要傳遞三個引數:template模板,編譯配置選項以及Vue例項。我們先大致瞭解一下配置中的幾個預設選項

  • 1.delimiters 該選項可以改變純文字插入分隔符,當不傳遞值時,vue預設的分隔符為 {{}},使用者可通過該選項修改
  • 2.comments 當設為 true 時,將會保留且渲染模板中的 HTML註釋。預設行為是捨棄它們。

接著一步步尋找compileToFunctions根源

var createCompiler = createCompilerCreator(function baseCompile (template,options) {
  //把模板解析成抽象的語法樹
  var ast = parse(template.trim(), options);
  // 配置中有程式碼優化選項則會對Ast語法樹進行優化
  if (options.optimize !== false) {
    optimize(ast, options);
  }
  var code = generate(ast, options);
  return {
    ast: ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
});
複製程式碼

createCompilerCreator角色定位為建立編譯器的建立者。他傳遞了一個基礎的編譯器baseCompile作為引數,baseCompile是真正執行編譯功能的地方,他傳遞template模板和基礎的配置選項作為引數。實現的功能有兩個

  • 1.把模板解析成抽象的語法樹,簡稱AST,程式碼中對應parse部分
  • 2.可選:優化AST語法樹,執行optimize方法
  • 3.根據不同平臺將AST語法樹生成需要的程式碼,對應的generate函式

具體看看createCompilerCreator的實現方式。

function createCompilerCreator (baseCompile) {
    return function createCompiler (baseOptions) {
      // 內部定義compile方法
      function compile (template, options) {
        ···
        // 將剔除空格後的模板以及合併選項後的配置作為引數傳遞給baseCompile方法,其中finalOptions為baseOptions和使用者options的合併
        var compiled = baseCompile(template.trim(), finalOptions);
        {
          detectErrors(compiled.ast, warn);
        }
        compiled.errors = errors;
        compiled.tips = tips;
        return compiled
      }
      return {
        compile: compile,
        compileToFunctions: createCompileToFunctionFn(compile)
      }
    }
  } 
複製程式碼

createCompilerCreator函式只有一個作用,利用偏函式將baseCompile基礎編譯方法快取,並返回一個編譯器函式,該函式內部定義了真正執行編譯的compile方法,並最終將compilecompileToFunctons作為兩個物件屬性返回,這也是compileToFunctions的來源。而內部compile的作用,是為了將基礎的配置baseOptions和使用者自定義的配置options進行合併,(baseOptions是跟外部平臺相關的配置),最終返回合併配置後的baseCompile編譯方法。

compileToFunctions來源於createCompileToFunctionFn函式的返回值,該函式會將編譯的方法compile作為引數傳入。

 function createCompileToFunctionFn (compile) {
    var cache = Object.create(null);

    return function compileToFunctions (template,options,vm) {
      options = extend({}, options);
      ···
      // 快取的作用:避免重複編譯同個模板造成效能的浪費
      if (cache[key]) {
        return cache[key]
      }
      // 執行編譯方法
      var compiled = compile(template, options);
      ···
      // turn code into functions
      var res = {};
      var fnGenErrors = [];
      // 編譯出的函式體字串作為引數傳遞給createFunction,返回最終的render函式
      res.render = createFunction(compiled.render, fnGenErrors);
      // 渲染優化相關
      res.staticRenderFns = compiled.staticRenderFns.map(function (code) {
        return createFunction(code, fnGenErrors)
      });
      ···
      return (cache[key] = res)
    }
  }
複製程式碼

最終,我們找到了compileToFunctions真正的執行過程var compiled = compile(template, options);,並將編譯後的函式體字串通過creatFunction轉化為render函式返回。

function createFunction (code, errors) {
  try {
    return new Function(code)
  } catch (err) {
    errors.push({ err: err, code: code });
    return noop
  }
}
複製程式碼

其中函式體字串類似於"with(this){return _m(0)}",最終的render渲染函式為function(){with(this){return _m(0)}}

至此,Vue中關於編譯過程的思路也梳理清楚了,編譯邏輯之所以繞,主要是因為Vue在不同平臺有不同的編譯過程,而每個編譯過程的baseOptions選項會有所不同,同時在同一個平臺下又不希望每次編譯時傳入相同的baseOptions引數,因此在createCompilerCreator初始化編譯器時便傳入引數,並利用偏函式將配置進行快取。同時剝離出編譯相關的合併配置,這些都是Vue在編譯這塊非常巧妙的設計。

總結:

文章的兩個重點,一個是理清楚了掛載的基本流程,另一個是瞭解了原始碼在編譯設計中巧妙的實現思路。巨集觀上掌握這些設計思想對於後續某個具體流程的分析具有一定的指導意義。

相關文章