深刻剖析Vue源碼 - 完整掛載流程和模板編譯

前面幾節咱們從new Vue建立實例開始,介紹了建立實例時執行初始化流程中的重要兩步,配置選項的資源合併,以及響應式系統的核心思想,數據代理。在合併章節,咱們對Vue豐富的選項合併策略有了基本的認知,在數據代理章節咱們又對代理攔截的意義和使用場景有了深刻的認識。按照Vue源碼的設計思路,初始化過程還會進行不少操做,例如組件之間建立關聯,初始化事件中心,初始化數據並創建響應式系統等,並最終將模板和數據渲染成爲dom節點。若是直接按流程的前後順序分析每一個步驟的實現細節,會有不少概念很難理解。所以在這一章節,咱們先重點分析一個概念,實例的掛載渲染流程。javascript

3.1 Runtime Only VS Runtime + Compiler

在正文開始以前,咱們先了解一下vue基於源碼構建的兩個版本,一個是runtime only(一個只包含運行時的版本),另外一個是runtime + compiler(一個同時包含編譯器和運行時的版本)。而兩個版本的區別僅在於後者包含了一個編譯器。html

什麼是編譯器,百度百科這樣解釋道:vue

簡單講,編譯器就是將「一種語言(一般爲高級語言)」翻譯爲「另外一種語言(一般爲低級語言)」的程序。一個現代編譯器的主要工做流程:源代碼 (source code) → 預處理器 (preprocessor) → 編譯器 (compiler) → 目標代碼 (object code) → 連接器 (Linker) → 可執行程序 (executables)。java

通俗點講,編譯器是一個提供了將源代碼轉化爲目標代碼的工具。從Vue的角度出發,內置的編譯器實現了將template模板轉換編譯爲可執行javascript腳本的功能。node

3.1.1 Runtime + Compiler

一個完整的Vue版本是包含編譯器的,咱們可使用template進行模板編寫。編譯器會自動將模板字符串編譯成渲染函數的代碼,源碼中就是render函數。 若是你須要在客戶端編譯模板 (好比傳入一個字符串給 template 選項,或掛載到一個元素上並以其 DOM 內部的 HTML 做爲模板),就須要一個包含編譯器的版本。webpack

// 須要編譯器的版本
new Vue({
  template: '<div>{{ hi }}</div>'
})
複製代碼

3.1.2 Runtime Only

只包含運行時的代碼擁有建立Vue實例、渲染並處理Virtual DOM等功能,基本上就是除去編譯器外的完整代碼。Runtime Only的適用場景有兩種: 1.咱們在選項中經過手寫render函數去定義渲染過程,這個時候並不須要包含編譯器的版本即可完整執行。web

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

2.藉助vue-loader這樣的編譯工具進行編譯,當咱們利用webpack進行Vue的工程化開發時,經常會利用vue-loader.vue進行編譯,儘管咱們也是利用template模板標籤去書寫代碼,可是此時的Vue已經不須要利用編譯器去負責模板的編譯工做了,這個過程交給了插件去實現。算法

很明顯,編譯過程對性能會形成必定的損耗,而且因爲加入了編譯的流程代碼,Vue代碼的整體積也更加龐大(運行時版本相比完整版體積要小大約 30%)。所以在實際開發中,咱們須要藉助像webpackvue-loader這類工具進行編譯,將Vue對模板的編譯階段合併到webpack的構建流程中,這樣不只減小了生產環境代碼的體積,也大大提升了運行時的性能,一箭雙鵰。編程

3.2 實例掛載的基本思路

有了上面的基礎,咱們回頭看初始化_init的代碼,在代碼中咱們觀察到initProxy後有一系列的函數調用,這些函數包括了建立組件關聯,初始化事件處理,定義渲染函數,構建數據響應式系統等,最後還有一段代碼,在el存在的狀況下,實例會調用$mount進行實例掛載。數組

Vue.prototype._init = function (options) {
  ···
  // 選項合併
  vm.$options = mergeOptions(
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  );
  // 數據代理
  initProxy(vm);
  vm._self = vm;
  initLifecycle(vm);
  // 初始化事件處理
  initEvents(vm);
  // 定義渲染函數
  initRender(vm);
  // 構建響應式系統
  initState(vm);
  // 等等
  ···
  if (vm.$options.el) {
    vm.$mount(vm.$options.el);
  }
}
複製代碼

以手寫template模板爲例,理清楚什麼是掛載。咱們會在選項中傳遞template爲屬性的模板字符串,如<div>{{message}}</div>,最終這個模板字符串經過中間過程將其轉成真實的DOM節點,並掛載到選項中el表明的根節點上完成視圖渲染。這個中間過程就是接下來要分析的掛載流程。

Vue掛載的流程是比較複雜的,接下來我將經過流程圖,代碼分析兩種方式爲你們展現掛載的真實過程。

3.2.1 流程圖

若是用一句話歸納掛載的過程,能夠描述爲 確認掛載節點,編譯模板爲render函數,渲染函數轉換Virtual DOM,建立真實節點。

3.2.2 代碼分析

接下來咱們從代碼的角度去剖析掛載的流程。掛載的代碼較多,下面只提取骨架相關的部分代碼。

// 內部真正實現掛載的方法
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 不須要編譯
  // render選項不存在,表明是template模板的形式,此時須要進行模板的編譯過程
  if (!options.render) {
    ···
    // 使用內部編譯器編譯模板
  }
  // 不管是template模板仍是手寫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, {})
}

複製代碼

咱們用語言描述掛載流程的基本思路。

  • 肯定掛載的DOM元素,這個DOM須要保證不能爲html,body這類跟節點。
  • 咱們知道渲染有兩種方式,一種是經過template模板字符串,另外一種是手寫render函數,前面提到template模板須要運行時進行編譯,然後一個能夠直接用render選項做爲渲染函數。所以掛載階段會有兩條分支,template模板會先通過模板的解析,最終編譯成render渲染函數參與實例掛載,而手寫render函數能夠繞過編譯階段,直接調用掛載的$mount方法。
  • 針對template而言,它會利用Vue內部的編譯器進行模板的編譯,字符串模板會轉換爲抽象的語法樹,即AST樹,並最終轉化爲一個相似function(){with(){}}的渲染函數,這是咱們後面討論的重點。
  • 不管是template模板仍是手寫render函數,最終都將進入mountComponent過程,這個階段會實例化一個渲染watcher,具體watcher的內容,另外放章節討論。咱們先知道一個結論,渲染watcher的回調函數有兩個執行時機,一個是在初始化時執行,另外一個是當vm實例檢測到數據發生變化時會再次執行回調函數。
  • 回調函數是執行updateComponent的過程,這個方法有兩個階段,一個是vm._render,另外一個是vm._updatevm._render會執行前面生成的render渲染函數,並生成一個Virtual Dom tree,而vm._update會將這個Virtual Dom tree轉化爲真實的DOM節點。

3.3 模板編譯

經過文章前半段的學習,咱們對Vue的掛載流程有了一個初略的認識。這裏有兩個大的流程須要咱們詳細去理解,一個是template模板的編譯,另外一個是updateComponent的實現細節。updateComponent的過程,咱們放到下一章節重點分析,而這一節剩餘的內容咱們將會圍繞模板編譯的設計思路展開。

(編譯器的實現細節是異常複雜的,要在短篇幅內將整個編譯的過程掌握是不切實際的,而且從大方向上也不須要徹底理清編譯的流程。所以針對模板,文章分析只是淺嘗即止,更多的細節讀者能夠自行分析)

3.3.1 template的三種寫法

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

  • 字符串模板
var vm = new Vue({
  el: '#app',
  template: '<div>模板字符串</div>'
})
複製代碼
  • 選擇符匹配元素的 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'
})
複製代碼
  • 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')
})

複製代碼

模板編譯的前提須要對template模板字符串的合法性進行檢測,三種寫法對應代碼的三個不一樣分支。

Vue.prototype.$mount = function () {
  ···
  if(!options.render) {
    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);
    }
  }
}

// 判斷el元素是否存在
function query (el) {
    if (typeof el === 'string') {
      var selected = document.querySelector(el);
      if (!selected) {
        warn(
          'Cannot find element: ' + el
        );
        return document.createElement('div')
      }
      return selected
    } else {
      return el
    }
  }
var idToTemplate = cached(function (id) {
  var el = query(id);
  return el && el.innerHTML
});
複製代碼

注意:其中X-Template模板的方式通常用於模板特別大的 demo 或極小型的應用,官方不建議在其餘情形下使用,由於這會將模板和組件的其它定義分離開。

3.3.2 編譯流程圖解

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

3.3.3 邏輯解析

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

Vue.prototype.$mount = function () {
  ···
  if(!options.render) {
    var template = options.template;
    if (template) {
      var ref = compileToFunctions(template, {
          outputSourceRange: "development" !== 'production',
          shouldDecodeNewlines: shouldDecodeNewlines,
          shouldDecodeNewlinesForHref: shouldDecodeNewlinesForHref,
          delimiters: options.delimiters,
          comments: options.comments
        }, this);
        var render = ref.render;
    }
    ...
  }
}
複製代碼

compileToFunctions有三個參數,一個是template模板,另外一個是編譯的配置信息,而且這個方法是對外暴露的編譯方法,用戶能夠自定義配置信息進行模板的編譯。最後一個參數是Vue實例。

// 將compileToFunction方法暴露給Vue做爲靜態方法存在
Vue.compile = compileToFunctions;
複製代碼

Vue的官方文檔中,Vue.compile只容許傳遞一個template模板參數,這是否意味着用戶沒法決定某些編譯的行爲?顯然不是的,咱們看回代碼,有兩個選項配置能夠提供給用戶,用戶只須要在實例化Vue時傳遞選項改變配置,他們分別是:

1.delimiters: 該選項能夠改變純文本插入分隔符,當不傳遞值時,Vue默認的分隔符爲 {{}}。若是咱們想使用其餘模板,能夠經過delimiters修改。

2.comments : 當設爲 true 時,將會保留且渲染模板中的 HTML註釋。默認行爲是捨棄它們。

注意,因爲這兩個選項是在完整版的編譯流程讀取的配置,因此在運行時版本配置這兩個選項是無效的

接着咱們一步步尋找compileToFunctions的根源。

首先咱們須要有一個認知,不一樣平臺對Vue的編譯過程是不同的,也就是說基礎的編譯方法會隨着平臺的不一樣有區別,編譯階段的配置選項也由於平臺的不一樣呈現差別。可是設計者又不但願在相同平臺下編譯不一樣模板時,每次都要傳入相同的配置選項。這纔有了源碼中較爲複雜的編譯實現。

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

var ref$1 = createCompiler(baseOptions);
var compile = ref$1.compile;
var compileToFunctions = ref$1.compileToFunctions;
複製代碼

這部分代碼是在Vue引入階段定義的,createCompilerCreator在傳遞了一個baseCompile函數做爲參數後,返回了一個編譯器的生成器,也就是createCompiler,有了這個生成器,當將編譯配置選項baseOptions傳入後,這個編譯器生成器便生成了一個指定環境指定配置下的編譯器,而其中編譯執行函數就是返回對象的compileToFunctions

這裏的baseCompile是真正執行編譯功能的地方,也就是前面說到的特定平臺的編譯方法。它在源碼初始化時就已經做爲參數的形式保存在內存變量中。咱們先看看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) {
        ···
      }
      return {
        compile: compile,
        compileToFunctions: createCompileToFunctionFn(compile)
      }
    }
  } 
複製代碼

createCompilerCreator函數只有一個做用,利用偏函數的思想將baseCompile這一基礎的編譯方法緩存,並返回一個編程器生成器,當執行var ref$1 = createCompiler(baseOptions);時,createCompiler會將內部定義的compilecompileToFunctions返回。

咱們繼續關注compileToFunctions的由來,它是createCompileToFunctionFn函數以compile爲參數返回的方法,接着看createCompileToFunctionFn的實現邏輯。

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)
    }
  }
複製代碼

createCompileToFunctionFn利用了閉包的概念,將編譯過的模板進行緩存,cache會將以前編譯過的結果保留下來,利用緩存能夠避免重複編譯引發的浪費性能。createCompileToFunctionFn最終會將compileToFunctions方法返回。

接下來,咱們分析一下compileToFunctions的實現邏輯。在判斷不使用緩存的編譯結果後,compileToFunctions會執行compile方法,這個方法是前面分析createCompiler時,返回的內部compile方法,因此咱們須要先看看compile的實現。

function createCompiler (baseOptions) {
  function compile (template, options) {
        var finalOptions = Object.create(baseOptions);
        var errors = [];
        var tips = [];
        var warn = function (msg, range, tip) {
          (tip ? tips : errors).push(msg);
        };
        // 選項合併
        if (options) {
          ···
          // 這裏會將用戶傳遞的配置和系統自帶編譯配置進行合併
        }

        finalOptions.warn = warn;
        // 將剔除空格後的模板以及合併選項後的配置做爲參數傳遞給baseCompile方法
        var compiled = baseCompile(template.trim(), finalOptions);
        {
          detectErrors(compiled.ast, warn);
        }
        compiled.errors = errors;
        compiled.tips = tips;
        return compiled
      }
      return {
        compile: compile,
        compileToFunctions: createCompileToFunctionFn(compile)
      }
}
複製代碼

咱們看到compile真正執行的方法,是一開始在建立編譯器生成器時,傳入的基礎編譯方法baseCompilebaseCompile真正執行的時候,會將用戶傳遞的編譯配置和系統自帶的編譯配置選項合併,這也是開頭提到編譯器設計思想的精髓。

執行完compile會返回一個對象,ast顧名思義是模板解析成的抽象語法樹,render是最終生成的with語句,staticRenderFns是以數組形式存在的靜態render

{
  ast: ast,
  render: code.render,
  staticRenderFns: code.staticRenderFns
}
複製代碼

createCompileToFunctionFn最終會返回另外兩個包裝過的屬性render, staticRenderFns,他們的核心是with語句封裝成執行函數。

// 編譯出的函數體字符串做爲參數傳遞給createFunction,返回最終的render函數
  res.render = createFunction(compiled.render, fnGenErrors);
  res.staticRenderFns = compiled.staticRenderFns.map(function (code) {
    return createFunction(code, fnGenErrors)
  });

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

至此,Vue中關於編譯器的設計思路也基本梳理清楚了,一開始看代碼的時候,總以爲編譯邏輯的設計特別的繞,分析完代碼後發現,這正是做者思路巧妙的地方。Vue在不一樣平臺上有不一樣的編譯過程,而每一個編譯過程的baseOptions選項會有所不一樣,同時也提供了一些選項供用戶去配置,整個設計思想深入的應用了偏函數的設計思想,而偏函數又是閉包的應用。做者利用偏函數將不一樣平臺的編譯方式進行緩存,同時剝離出編譯相關的選項合併,這些方式都是值得咱們平常學習的。

編譯的核心是parse,generate過程,這兩個過程筆者並無分析,緣由是抽象語法樹的解析分支較多,須要結合實際的代碼場景才更好理解。這兩部分的代碼會在後面介紹到具體邏輯功能章節時再次說起。

3.4 小結

這一節的內容有兩大塊,首先詳細的介紹了實例在掛載階段的完整流程,當咱們傳入選項進行實例化時,最終的目的是將選項渲染成頁面真實的可視節點。這個選項有兩種形式,一個是以template模板字符串傳入,另外一個是手寫render函數形式傳入,不論哪一種,最終會以render函數的形式參與掛載,render是一個用函數封裝好的with語句。渲染真實節點前須要將render函數解析成虛擬DOM,虛擬DOMjs和真實DOM之間的橋樑。最終的_update過程讓將虛擬DOM渲染成真實節點。第二個大塊主要介紹了做者在編譯器設計時巧妙的實現思路。過程大量運用了偏函數的概念,將編譯過程進行緩存而且將選項合併從編譯過程當中剝離。這些設計理念、思想都是值得咱們開發者學習和借鑑的。


相關文章
相關標籤/搜索