Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程

慕晨同學發表於2019-03-03

寫在前面

因為最近做的專案採取的技術棧是vue.js,加上自己對vue.js的底層執行機制很感興趣,所以最近每天花點時間,大概一兩個月左右把vue.js原始碼捋了一遍,在這裡針對模版和資料渲染成最終的DOM的過程這一部分做一下總結。

在看原始碼的過程當中,可能當中有自己理解出偏差或者大家有理解不一樣的地方,歡迎大家評論或私信我,共同學習進步。

Vue.js執行機制全域性概覽圖

Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程

這是我在網上找的一張Vue.js執行機制全域性概覽圖。可能有一些人在初次看到這張圖的時候有點模糊。希望模糊的同學在看完下文的分析之後再回頭看這幅圖能有豁然開朗的感覺。

Vue.js原始碼目錄的設計

Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程
我們在看vue.js原始碼的時候,瞭解原始碼的目錄設計是非常必要的。這是擷取vue.js的原始碼目錄,它們的大體功能如下:

資料夾 功能
compiler 編譯相關(將模版解析成ast語法樹,ast語法樹優化,程式碼生成)
core 核心功能相關(vue例項化,全域性api封裝,虛擬DOM,偵測變化等)
platforms 不同平臺支援相關(包含web和weex)
server 服務端渲染相關(服務端渲染相關的邏輯)
sfc 解析.vue檔案相關(將.vue檔案內容解析成一個javascript物件)
shared 不同平臺共享程式碼(定義一些全域性共享的工具方法)

從原始碼目錄設計來看,作者們把原始碼拆分成一個個獨立的模組,相關的邏輯放在專門的目錄下去維護,這樣一來程式碼的可讀性和可維護性就變得非常清晰。

從Vue的入口開始

Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程
真正初始化的地方在src/core/index.js中:

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'

initGlobalAPI(Vue)

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

Object.defineProperty(Vue.prototype, '$ssrContext', {
  get () {
    /* istanbul ignore next */
    return this.$vnode && this.$vnode.ssrContext
  }
})

// expose FunctionalRenderContext for ssr runtime helper installation
Object.defineProperty(Vue, 'FunctionalRenderContext', {
  value: FunctionalRenderContext
})

Vue.version = '__VERSION__'

export default Vue

複製程式碼

這裡有兩處非常關鍵的程式碼,import Vue from './instance/index.js'和initGlobalAPI(Vue),Vue的定義和將Vue作為引數傳入initGlobalAPI,初始化全域性API。

先來看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實際上是一個用Function實現的Class,這也是為什麼我們要通過new Vue()來例項化它。

initGlobalAPI()定義在src/core/global-api/index.js中,它的作用是在Vue物件本身擴充套件一些全域性的方法,這些全域性API都可以在Vue官網中找到。

從new Vue()開始

new Vue({
    el: '#app',
    data() {
        return {
            message: '11'
        }
    },
    mounted() {
        console.log(this.message)  
    },
    methods: {}
});
複製程式碼

可能很多人在寫vue寫程式碼的時候,或多或少都有這樣的疑問?

1.new Vue背後發生了哪些事情?
2.為什麼在mounted過程中能通過this.message列印出data中定義的message?
3.模版和資料是如何渲染成最終的DOM的?

帶著這些疑問,我們來分析一下,在new Vue內部究竟發生來哪些事情。

Vue實際上是一個類,定義在src/core/instance/index.js中:

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

我們可以看見,通過new關鍵字來初始化Vue的時候,會呼叫_init方法。該方法定義在src/core/instance/init.js中:

   Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // a uid
    vm._uid = uid++

    let startTag, endTag
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      startTag = `vue-perf-start:${vm._uid}`
      endTag = `vue-perf-end:${vm._uid}`
      mark(startTag)
    }

    // 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)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      vm._name = formatComponentName(vm, false)
      mark(endTag)
      measure(`vue ${vm._name} init`, startTag, endTag)
    }

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
複製程式碼
可以看見,_init方法主要做了兩件事情:

1.合併配置,初始化生命週期,初始化事件,初始化render,初始化data,computed,methods,wacther等等。

2.在初始化的最後,如果檢測到有el屬性,則呼叫vm.$mount方法掛載vm,mount元件。

在生命週期beforeCreate和created的時候會呼叫initState來初始化state,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)
  }
}
複製程式碼

在此過程中,會依次初始化props、methods、data、computed與watch,這也就是Vue.js對options中的資料進行“響應式化”(即雙向繫結)的過程。在initData方法中:

 function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, 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
  const methods = vm.$options.methods
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (process.env.NODE_ENV !== 'production') {
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  // observe data
  observe(data, true /* asRootData */)
}
複製程式碼

最後會呼叫observer(),observe會通過defineReactive對data中的物件進行雙向繫結,最終通過Object.defineProperty對物件設定setter以及getter的方法。getter的方法主要用來進行依賴收集。setter方法會在物件被修改的時候觸發(不存在新增屬性的情況,新增屬性請用Vue.set),這時候setter會通知閉包中的Dep,Dep中有一些訂閱了這個物件改變的Watcher觀察者物件,Dep會通知Watcher物件更新檢視。

分析proxy(vm, _data, key)這行程式碼,將data上的屬性掛載到vm上,再來看proxy方法的定義:

 export function proxy (target: Object, sourceKey: string, key: string) {
  sharedPropertyDefinition.get = function proxyGetter () {
    return this[sourceKey][key]
  }
  sharedPropertyDefinition.set = function proxySetter (val) {
    this[sourceKey][key] = val
  }
  Object.defineProperty(target, key, sharedPropertyDefinition)
}
複製程式碼

proxy通過defineProperty實現了代理,把target[sourceKey][key]的讀寫變成了對target[key]的讀寫。這就能解釋剛才提出第二個的問題:為什麼在mounted過程中能通過this.message列印出data中定義的message?

再回過頭來看_init方法過程中mount元件的實現。先來看Runtime+compiler版本的$mount的實現,在src/platforms/web/entry-runtime-with-compiler.js檔案中:

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  /* istanbul ignore if */
  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
  // resolve template/el and convert to render function
  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) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  return mount.call(this, el, hydrating)
}
複製程式碼

這段程式碼首先快取了原型上的$mount方法。首先,對el做了限制,不能把Vue掛載在html或body這樣的跟節點上,然後如果沒有定義render方法,則會把el或者template字串轉換成render方法,因為在Vue2.x版本中,所有的Vue元件的渲染最終都需要render方法,在程式碼的最後,有這麼一行程式碼Vue.compile = compileToFunctions,compileToFunctions函式的作用,是把模版template編譯成render函式。

template是如何編譯成render function的?

Vue提供了兩個版本,一個是Runtime+Compiler版本的,一個是Runtime only版本的。Runtime+Compiler是包含編譯程式碼的,可以把編譯過程放在執行時來做。而Runtime only是不包含編譯程式碼的,所以需要藉助webpack的vue-loader來把模版編譯成render函式。

在實際開發當中,我們通常在元件中採用的是編寫template模版。那template是如何編譯的呢?來看一下編譯的入口,定義在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
  }
})

複製程式碼

編譯主要有三個過程:

1.解析模版字串生成AST

  • AST(在電腦科學中,抽象語法樹(abstract syntax tree或者縮寫為AST),或者語法樹(syntax tree),是原始碼的抽象語法結構的樹狀表現形式,這裡特指程式語言的原始碼。)
 const ast = parse(template.trim(), options)
複製程式碼

parse 會用正則等方式解析 template模板中的指令、class、style等資料,形成AST樹。AST是一種用Javascript物件的形式來描述整個模版,整個parse的過程就是利用正規表示式來順序地解析模版,當解析到開始標籤,閉合標籤,文字的時候會分別對應執行響應的回撥函式,從而達到構造AST樹的目的。

舉個例子:

<div :class="c" class="demo" v-if="isShow">
    <span v-for="item in sz">{{item}}</span>
</div>
複製程式碼

經過一系列的正則解析,會得到的AST如下:

 {
    /* 標籤屬性的map,記錄了標籤上屬性 */
    'attrsMap': {
        ':class': 'c',
        'class': 'demo',
        'v-if': 'isShow'
    },
    /* 解析得到的:class */
    'classBinding': 'c',
    /* 標籤屬性v-if */
    'if': 'isShow',
    /* v-if的條件 */
    'ifConditions': [
        {
            'exp': 'isShow'
        }
    ],
    /* 標籤屬性class */
    'staticClass': 'demo',
    /* 標籤的tag */
    'tag': 'div',
    /* 子標籤陣列 */
    'children': [
        {
            'attrsMap': {
                'v-for': "item in sz"
            },
            /* for迴圈的引數 */
            'alias': "item",
            /* for迴圈的物件 */
            'for': 'sz',
            /* for迴圈是否已經被處理的標記位 */
            'forProcessed': true,
            'tag': 'span',
            'children': [
                {
                    /* 表示式,_s是一個轉字串的函式 */
                    'expression': '_s(item)',
                    'text': '{{item}}'
                }
            ]
        }
    ]
}
複製程式碼

當構造完AST之後,下面就是優化這顆AST樹。

2.optimize:優化AST語法樹

 optimize(ast, options)
複製程式碼

為什麼此處會有優化過程?我們知道Vue是資料驅動,是響應式的,但是template模版中並不是所有的資料都是響應式的,也有許多資料是初始化渲染之後就不會有變化的,那麼這部分資料對應的DOM也不會發生變化。後面有一個 update 更新介面的過程,在這當中會有一個 patch 的過程, diff 演算法會直接跳過靜態節點,從而減少了比較的過程,優化了 patch 的效能。

來看下optimize這部分程式碼的定義,在src/compiler/optimize.js中:

 export function optimize (root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  isStaticKey = genStaticKeysCached(options.staticKeys || '')
  isPlatformReservedTag = options.isReservedTag || no
  // first pass: mark all non-static nodes.
  markStatic(root)
  // second pass: mark static roots.
  markStaticRoots(root, false)
}
複製程式碼

我們可以看到,optimize實際上就做了2件事情,一個是呼叫markStatic()來標記靜態節點,另一個是呼叫markStaticRoots()來標記靜態根節點。

3.codegen:將優化後的AST樹轉換成可執行的程式碼。

 const code = generate(ast, options)
複製程式碼

template模版經歷過parse->optimize->codegen三個過程之後,就可以d得到render function函式了。

最後

Vue.js原始碼角度:剖析模版和資料渲染成最終的DOM的過程

再看上面這張圖,是不是有一個大概的脈絡了呢。本文是我寫的第一篇Vue.js原始碼學習的文章,可能會有許多的缺陷,希望在以後的學習探索慢慢改進了吧。

相關文章