Vue 原理解析(四): 虛擬Dom是怎麼生成的

上一篇: vue 原理解析(三):初始化時created之前做了什麼

在經過初始化階段之後,即將開始組件的掛載,在掛載之前有必要了解下虛擬Dom 的概念。我們知道[email protected] 開始引入了虛擬dom, 主要解決的問題是, 大部分情況下可以降低使用Javascript 去操作跨線程的龐大dom所需要的昂貴性能,讓dom 操作的性能更高效; 以及虛擬Dom可以用於SSR以及跨端使用。
虛擬Dom, 顧明思議並不是真實的Dom, 而是使用javascript 的對象來對真實dom的一個描述。 一個真實的dom 也無非是有標籤名, 屬性, 子節點等這些來描述它,如下對比講解:
*真實Dom

<div id='app' class='wrap'>
  <h2>
    hello
  </h2>
</div>

我們可以在Render 函數裏這樣描述它:

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

這個時候它並不是用對象來描述的, 使用的是render函數內的數據結構去描述的真實Dom, 而我們需要將這段描述轉爲用對象的形式, render函數使用的是參數h方法並用VNdoe這個類來實例化它們, 所以我們再瞭解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
}

文本節點

VNode描述:
createTextVNode('文本節點')
{
  text: '文本節點'
}

克隆節點

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組件:
new Vue({
  render(h) {
    return h(App)
  }
})

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

開始掛載階段

this._init() 方法的最後:

... 初始化

if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

如果用戶有傳入el, 就執行 vm.mountelmount 方法並傳入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 方法時內的 mountComponnet 方法:

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 這個方法(也就是上面例子內的==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 是分兩種情況的:

普通的元素節點轉化爲 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 也是 [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的具體過程。

組件轉化爲 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
}

我們只是定義了namecomponnets 屬性, 打印出來爲什麼會多了這麼多屬性? 這是vue-loader解析後添加的, 例如: ==render: f() == 就是將 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這個對象, 裏面有init, prepatch, insert, destroy 四個方法, 這四個方法會在之後的將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 的子類, 雖然組件VNodechildren, text, ele == 爲 undefined, 但它的獨有屬性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的生成過程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章