Vue原理解析(四):你知道被你們聊爛了的虛擬Dom是怎麼生成的嗎?

上一篇:Vue原理解析(三):快速搞懂new Vue()時到底作了什麼?(下)html

在通過初始化階段以後,即將開始組件的掛載,不過在掛載以前頗有必要提一下虛擬Dom的概念。這個想必你們有所耳聞,咱們知道vue@2.0開始引入了虛擬Dom,主要解決的問題是,大部分狀況下能夠下降使用JavaScript去操做跨線程的龐大Dom所須要的昂貴性能,讓Dom操做的性能更高;以及虛擬Dom能夠用於SSR以及跨端使用。虛擬Dom,顧名思義並非真實的Dom,而是使用JavaScript的對象來對真實Dom的一個描述。一個真實的Dom也無非是有標籤名,屬性,子節點等這些來描述它,如頁面中的真實Dom是這樣的:vue

<div id='app' class='wrap'>
  <h2>
    hello
  </h2>
</div>
複製代碼

咱們能夠在render函數內這樣描述它:node

new Vue({
  render(h) {
    return h('div', {
      attrs: {
        id: 'app',
        class: 'wrap'
      }
    }, [
      h('h2', 'hello')
    ])
  }
})
複製代碼

這個時候它並非用對象來描述的,使用的是render函數內的數據結構去描述的真實Dom,而如今咱們須要將這段描述轉爲用對象的形式,render函數使用的是參數h方法並用VNode這個類來實例化它們,因此咱們再瞭解h的實現原理前,首先來看下VNode類是什麼,找到它定義的地方:面試

export default class VNode {
  constructor (
    tag
    data
    children
    text
    elm
    context
    componentOptions
    asyncFactory
  ) {
    this.tag = tag  // 標籤名
    this.data = data  // 屬性 如id/class
    this.children = children  // 子節點
    this.text = text  // 文本內容
    this.elm = elm  // 該VNode對應的真實節點
    this.ns = undefined  // 節點的namespace
    this.context = context  // 該VNode對應實例
    this.fnContext = undefined  // 函數組件的上下文
    this.fnOptions = undefined  // 函數組件的配置
    this.fnScopeId = undefined  // 函數組件的ScopeId
    this.key = data && data.key  // 節點綁定的key 如v-for
    this.componentOptions = componentOptions  //  組件VNode的options
    this.componentInstance = undefined  // 組件的實例
    this.parent = undefined  // vnode組件的佔位符節點
    this.raw = false  // 是否爲平臺標籤或文本
    this.isStatic = false  // 靜態節點
    this.isRootInsert = true  // 是否做爲根節點插入
    this.isComment = false  // 是不是註釋節點
    this.isCloned = false  // 是不是克隆節點
    this.isOnce = false  // 是不是v-noce節點
    this.asyncFactory = asyncFactory  // 異步工廠方法
    this.asyncMeta = undefined  //  異步meta
    this.isAsyncPlaceholder = false  // 是否爲異步佔位符
  }

  get child () {  // 別名
    return this.componentInstance
  }
}
複製代碼

這是VNode類定義的地方,挺嚇人的,它支持一共最多八個參數,其實常常用到的並很少。如tag是元素節點的名稱,children爲它的子節點,text是文本節點內的文本。實例化後的對象就有二十三個屬性做爲在vue的內部一個節點的描述,它描述的是將它建立爲一個怎樣的真實Dom。大部分屬性默認是falseundefined,而經過這些屬性有效的值就能夠組裝出不一樣的描述,如真實的Dom中會有元素節點、文本節點、註釋節點等。而經過這樣一個VNode類,也能夠描述出相應的節點,部分節點vue內部還作了相應的封裝:數組

註釋節點瀏覽器

export const createEmptyVNode = (text = '') => {
  const node = new VNode()
  node.text = text
  node.isComment = true
  return node
}
複製代碼
  • 建立一個空的VNode,有效屬性只有textisComment來表示一個註釋節點。
真實的註釋節點:
<!-- 註釋節點 -->

VNode描述:
createEmptyVNode ('註釋節點')
{
  text: '註釋節點',
  isComment: true
}
複製代碼

文本節點緩存

export function createTextVNode (val) {
  return new VNode(undefined, undefined, undefined, String(val))
}
複製代碼
  • 只是設置了text屬性,描述的是標籤內的文本
VNode描述:
createTextVNode('文本節點')
{
  text: '文本節點'
}
複製代碼

克隆節點bash

export function cloneVNode (vnode) {
  const cloned = new VNode(
    vnode.tag,
    vnode.data,
    vnode.children,
    vnode.text,
    vnode.elm,
    vnode.context,
    vnode.componentOptions,
    vnode.asyncFactory
  )
  cloned.ns = vnode.ns
  cloned.isStatic = vnode.isStatic
  cloned.key = vnode.key
  cloned.isComment = vnode.isComment
  cloned.fnContext = vnode.fnContext
  cloned.fnOptions = vnode.fnOptions
  cloned.fnScopeId = vnode.fnScopeId
  cloned.asyncMeta = vnode.asyncMeta
  cloned.isCloned = true
  return cloned
}
複製代碼
  • 將一個現有的VNode節點拷貝一份,只是被拷貝節點的isCloned屬性爲false,而拷貝獲得的節點的isCloned屬性爲true,除此以外它們徹底相同。

元素節點數據結構

真實的元素節點:
<div>
  hello
  <span>Vue!</span>
</div>

VNode描述:
{
  tag: 'div',
  children: [
    {
      text: 'hello'
    }, 
    {
      tag: 'span',
      children: [
        {
          text: Vue!
        }
      ]
    }
  ],
}
複製代碼

組件節點app

渲染App組件:
new Vue({
  render(h) {
    return h(App)
  }
})

VNode描述:
{
  tag: 'vue-component-2',
  componentInstance: {...},
  componentOptions: {...},
  context: {...},
  data: {...}
}
複製代碼
  • 組件的VNode會和元素節點相比會有兩個特有的屬性componentInstancecomponentOptionsVNode的類型有不少,它們都是從這個VNode類中實例化出來的,只是屬性不一樣。

開始掛載階段

this._init() 方法的最後:

... 初始化

if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}
複製代碼

若是用戶有傳入el屬性,就執行vm.$mount方法並傳入el開始掛載。這裏的$mount方法在完整版和運行時版本又會有點不一樣,他們區別以下:

運行時版本:
Vue.prototype.$mount = function(el) { // 最初的定義
  return mountComponent(this, query(el));
}

完整版:
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function(el) {  // 拓展編譯後的

  if(!this.$options.render) {            ---|
    if(this.$options.template) {         ---|
      ...通過編譯器轉換後獲得render函數  ---|  編譯階段
    }                                    ---|
  }                                      ---|
  
  return mount.call(this, query(el))
}

-----------------------------------------------

export function query(el) {  // 獲取掛載的節點
  if(typeof el === 'string') {  // 好比#app
    const selected = document.querySelector(el)
    if(!selected) {
      return document.createElement('div')
    }
    return selected
  } else {
    return el
  }
}
複製代碼

完整版有一個騷操做,首先將$mount方法緩存到mount變量上,而後使用函數劫持的手段從新定義$mount函數,並在其內部增長編譯相關的代碼,最後仍是使用原來定義的$mount方法掛載。因此核心是要了解最初定義$mount方法時內的mountComponent方法:

export function mountComponent(vm, el) {
  vm.$el = el
  ...
  callHook(vm, 'beforeMount')
  ...
  const updateComponent = function () {
    vm._update(vm._render())
  }
  ...
}
複製代碼

首先將傳入的el賦值給vm.$el,這個時候el是一個真實dom,接着會執行用戶本身定義的beforeMount鉤子。接下來會定義一個重要的函數變量updateComponent,它的內部首先會執行vm._render()方法,將返回的結果傳入vm._update()內再執行。咱們這章主要就來分析這個vm._render()方法作了什麼事情,來看下它的定義:

Vue.prototype._render = function() {
  const vm = this
  const { render } = vm.$options

  const vnode = render.call(vm, vm.$createElement)
  
  return vnode
}
複製代碼

首先會獲得自定義的render函數,傳入vm.$createElement這個方法(也就是上面例子內的h方法),將執行的返回結果賦值給vnode,這裏也就完成了render函數內數據結構轉爲vnode的操做。而這個vm.$createElement是在以前初始化initRender方法內掛載到vm實例下的:

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)  // 編譯
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)  // 手寫
複製代碼

不管是編譯而來仍是手寫的render函數,它們都是返回了createElement這個函數,繼續查找它的定義:

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

export default createElement(
  context, 
  tag, 
  data, 
  children, 
  normalizationType, 
  alwaysNormalize) {
  if(Array.isArray(data) || isPrimitive(data)) {  // data是數組或基礎類型
    normalizationType = children  --|
    children = data               --| 參數移位
    data = undefined              --|
  }
  
  if (isTrue(alwaysNormalize)) { // 若是是手寫render
    normalizationType = ALWAYS_NORMALIZE
  }
  
  return _createElement(contenxt, tag, data, children, normalizationType)
}
複製代碼

這裏是對傳入的參數處理,若是第三個參數傳入的是數組(子元素)或者是基礎類型的值,就將參數位置改變。而後對傳入的最後一個參數是true仍是false作處理,這會決定以後對children屬性的處理方式。這裏又是對_createElement作的封裝,因此咱們還要繼續看它的定義:

export function _createElement(
  context, tag, data, children, normalizationType
  ) {
  
  if (normalizationType === ALWAYS_NORMALIZE) { // 手寫render函數
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) { //編譯render函數
    children = simpleNormalizeChildren(children)
  }
  
  if(typeof tag === 'string') {  // 標籤
    let vnode, Ctor
    if(config.isReservedTag(tag)) {  // 若是是html標籤
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
    ...
  } else { // 就是組件了
    vnode = createComponent(tag, data, context, children)
  }
  ...
  return vnode
}
複製代碼

首先咱們會看到針對最後一個參數的布爾值對children作不一樣的處理,若是是編譯的render函數,就將children格式化爲一維數組:

function simpleNormalizeChildren(children) {  // 編譯render的處理函數
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}
複製代碼

咱們如今主要看下手寫的render函數是怎麼處理的,從接下來的_createElement方法咱們知道,轉化VNode是分爲兩種狀況的:

1. 普通的元素節點轉化爲VNode

以一段children是二維數組代碼爲示例,咱們來講明普通元素是如何轉VNode的:

render(h) {
  return h(
    "div",
    [
      [
        [h("h1", "title h1")],
        [h('h2', "title h2")]
      ],
      [
        h('h3', 'title h3')
      ]
    ]
  );
}
複製代碼

由於_createElement方法是對h方法的封裝,因此h方法的第一個參數對應的就是_createElement方法內的tag,第二個參數對應的是data。又由於h方法是遞歸的,因此首先從h('h1', 'title h1')開始解析,通過參數上移以後children就是title h1這段文本了,因此會在normalizeChildren方法將它轉爲[createTextVNode(children)]一個文本的VNode節點:

function normalizeChildren(children) {  // 手寫`render`的處理函數
  return isPrimitive(children)  //原始類型 typeof爲string/number/symbol/boolean之一
    ? [createTextVNode(children)]  // 轉爲數組的文本節點
    : Array.isArray(children)  // 若是是數組
      ? normalizeArrayChildren(children)
      : undefined
}
複製代碼

接着會知足_createElement方法內的這個條件:

if(typeof tag === 'string'){ tag爲h1標籤
  if(config.isReservedTag(tag)) {  // 是html標籤
    vnode = new VNode(
      tag,  // h1
      data, // undefined
      children,  轉爲了 [{text: 'title h1'}]
      undefined,
      undefined,
      context
    )
  }
}
...
return vnode

返回的vnode結構爲:
{
  tag: h1,
  children: [
    { text: title h1 }
  ]
}
複製代碼

而後依次處理h('h2', "title h2")h('h3', 'title h3')會獲得三個VNode實例的節點。接着會執行最外層的h(div, [[VNode,VNode],[VNode]])方法,注意它的結構是二維數組,這個時候它就知足normalizeChildren方法內的Array.isArray(children)這個條件了,會執行normalizeArrayChildren這個方法:

function normalizeArrayChildren(children) {
  const res = []  // 存放結果
  
  for(let i = 0; i < children.length; i++) {  // 遍歷每一項
    let c = children[i]
    if(isUndef(c) || typeof c === 'boolean') { // 若是是undefined 或 布爾值
      continue  // 跳過
    }
    
    if(Array.isArray(c)) {  // 若是某一項是數組
      if(c.length > 0) {
        c = normalizeArrayChildren(c) // 遞歸結果賦值給c,結果就是[VNode]
        ... 合併相鄰的文本節點
        res.push.apply(res, c)  //小操做
      }
    } else {
      ...
      res.push(c)
    }
  }
  return res
}
複製代碼

若是children內的某一項是數組就遞歸調用本身,將自身傳入並將返回的結果覆蓋自身,遞歸內的結果就是res.push(c)獲得的,這裏c也是[VNode]數組結構。覆蓋本身以後執行res.push.apply(res, c),添加到res內。這裏vue秀了一個小操做,在一個數組內push一個數組,原本應該是二維數組的,使用這個寫法後res.push.apply(res, c)後,結果最後是就是一維數組了。res最後返回的結果[VNode, VNode, VNode],這也是children最終的樣子。接着執行h('div', [VNode, VNode, VNode])方法,又知足了以前一樣的條件:

if (config.isReservedTag(tag)) {  // 標籤爲div
  vnode = new VNode(
    tag, data, children, undefined, undefined, context
  )
} 
return vnode
複製代碼

因此最終獲得的vnode結構就是這樣的:

{
  tag: 'div',
  children: [VNode, VNode, VNode]
}
複製代碼

以上就是普通元素節點轉VNode的具體過程。

2. 組件轉化爲VNode

接下來咱們來了解組件VNode的建立過程,常見示例以下:

main.js
new Vue({
  render(h) {
    return h(App)
  }
})

app.vue
import Child from '@/pages/child'
export default {
  name: 'app',
  components: {
    Child
  }
}
複製代碼

不知道你們有將引入的組件直接打印出來過沒有,咱們在main.js內打印下App組件:

{
  beforeCreate: [ƒ]
  beforeDestroy: [ƒ]
  components: {Child: {…}}
  name: "app"
  render: ƒ ()
  staticRenderFns: []
  __file: "src/App.vue"
  _compiled: true
}
複製代碼

咱們只是定義了namecomponents屬性,打印出來爲何會多了這麼多屬性?這是vue-loader解析後添加的,例如render: ƒ ()就是將App組件的template模板轉換而來的,咱們記住這個一個組件對象便可。

讓咱們簡單看一眼以前_createElement函數:

export function _createElement(
  context, tag, data, children, normalizationType
  ) {
  ...
  if(typeof tag === 'string') {  // 標籤
    ...
  } else { // 就是組件了
    vnode = createComponent(
      tag,  // 組件對象
      data,  // undefined
      context,  // 當前vm實例
      children  // undefined
    )
  }
  ...
  return vnode
}
複製代碼

很明顯這裏的tag並不一個string,轉而會調用createComponent()方法:

export function createComponent (  // 上
  Ctor, data = {}, context, children, tag
) {
  const baseCtor = context.$options._base
  
  if (isObject(Ctor)) {  // 組件對象
    Ctor = baseCtor.extend(Ctor)  // 轉爲Vue的子類
  }
  ...
}
複製代碼

這裏要補充一點,在new Vue()以前定義全局API時:

export function initGlobalAPI(Vue) {
  ...
  Vue.options._base = Vue
  Vue.extend = function(extendOptions){...}
}
複製代碼

通過初始化合並options以後當前實例就有了context.$options._base這個屬性,而後執行它的extend這個方法,傳入咱們的組件對象,看下extend方法的定義:

Vue.cid = 0
let cid = 1
Vue.extend = function (extendOptions = {}) {
  const Super = this  // Vue基類構造函數
  const name = extendOptions.name || Super.options.name
  
  const Sub = function (options) {  // 定義構造函數
    this._init(options)  // _init繼承而來
  }
  
  Sub.prototype = Object.create(Super.prototype)  // 繼承基類Vue初始化定義的原型方法
  Sub.prototype.constructor = Sub  // 構造函數指向子類
  Sub.cid = cid++
  Sub.options = mergeOptions( // 子類合併options
    Super.options,  // components, directives, filters, _base
    extendOptions  // 傳入的組件對象
  )
  Sub['super'] = Super // Vue基類

  // 將基類的靜態方法賦值給子類
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use

  ASSET_TYPES.forEach(function (type) { // ['component', 'directive', 'filter']
    Sub[type] = Super[type]
  })
  
  if (name) {  讓組件能夠遞歸調用本身,因此必定要定義name屬性
    Sub.options.components[name] = Sub  // 將子類掛載到本身的components屬性下
  }

  Sub.superOptions = Super.options
  Sub.extendOptions = extendOptions

  return Sub
}
複製代碼

仔細觀察extend這個方法不難發現,咱們傳入的組件對象至關於就是以前new Vue(options)裏面的options,也就是用戶自定義的配置,而後和vue以前就定義的原型方法以及全局API合併,而後返回一個新的構造函數,它擁有Vue完整的功能。讓咱們繼續createComponent的其餘邏輯:

export function createComponent (  // 中
  Ctor, data = {}, context, children, tag
) {
  ...
  const listeners = data.on  // 父組件v-on傳遞的事件對象格式
  data.on = data.nativeOn  // 組件的原生事件
  
  installComponentHooks(data)  // 爲組件添加鉤子方法
  ...
}
複製代碼

以前說明初始化事件initEvents時,這裏的data.on就是父組件傳遞給子組件的事件對象,賦值給變量listenersdata.nativeOn是綁定在組件上有native修飾符的事件。接着會執行一個組件比較重要的方法installComponentHooks,它的做用是往組件的data屬性下掛載hook這個對象,裏面有initprepatchinsertdestroy四個方法,這四個方法會在以後的將VNode轉爲真實Dompatch階段會用到,當咱們使用到時再來看它們的定義是什麼。咱們繼續createComponent的其餘邏輯:

export function createComponent (  // 下
  Ctor, data = {}, context, children, tag
) {
  ...
  const name = Ctor.options.name || tag  // 拼接組件tag用
  
  const vnode = new VNode(  // 建立組件VNode
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,  // 對應tag屬性
    data, // 有父組件傳遞自定義事件和掛載的hook對象
    undefined,  // 對應children屬性
    undefined,   // 對應text屬性
    undefined,   // 對應elm屬性
    context,  // 當前實例
    {  // 對應componentOptions屬性
      Ctor,  // 子類構造函數
      propsData, // props具體值的對象集合
      listeners,   // 父組件傳遞自定義事件對象集合
      tag,  // 使用組件時的名稱
      children // 插槽內的內容,也是VNode格式
    },  
    asyncFactory
  )
  
  return vnode
}
複製代碼

組件生成的VNode以下:

{
  tag: 'vue-component-1-app',
  context: {...},
  componentOptions: {
    Ctor: function(){...},
    propsData: undefined,
    children: undefined,
    tag: undefined,
    children: undefined
  },
  data: {
    on: undefined,  // 爲原生事件
    data: {
      init: function(){...},
      insert: function(){...},
      prepatch: function(){...},
      destroy: function(){...}
    }
  }
}
複製代碼

若是看到tag屬性是vue-component開頭就是組件了,以上就組件VNode的初始化。簡單理解就是若是h函數的參數是組件對象,就將它轉爲一個Vue的子類,雖然組件VNodechildrentexteleundefined,但它的獨有屬性componentOptions保存了組件須要的相關信息。它們的VNode生成了,接下來的章節咱們將使用它們,將它們變爲真實的Dom~。

最後咱們仍是以一道vue可能會被問到的面試題做爲本章的結束吧~

面試官微笑而又不失禮貌的問道:

  • 請問vue@2爲何要引入虛擬Dom,談談對虛擬Dom的理解?

懟回去:

  1. 隨着現代應用對頁面的功能要求越複雜,管理的狀態越多,若是仍是使用以前的JavaScript線程去頻繁操做GUI線程的碩大Dom,對性能會有很大的損耗,並且也會形成狀態難以管理,邏輯混亂等狀況。引入虛擬Dom後,在框架的內部就將虛擬Dom樹形結構與真實Dom作了映射,讓咱們不用在命令式的去操做Dom,能夠將重心轉爲去維護這棵樹形結構內的狀態便可,狀態的變化就會驅動Dom發生改變,具體的Dom操做vue幫咱們完成,並且這些大部分能夠在JavaScript線程完成,性能更高。
  2. 虛擬Dom只是一種數據結構,可讓它不只僅使用在瀏覽器環境,還能夠用與SSR以及Weex等場景。

下一篇: Vue原理解析(五):完全搞懂虛擬Dom到真實Dom的生成過程

順手點個贊或關注唄,找起來也方便~

參考:

Vue.js源碼全方位深刻解析

Vue.js深刻淺出

Vue.js組件精講

剖析 Vue.js 內部運行機制

相關文章
相關標籤/搜索