1. 程式人生 > 其它 >手寫 Vue2 系列 之 初始渲染

手寫 Vue2 系列 之 初始渲染

前言

上一篇文章 手寫 Vue2 系列 之 編譯器 中完成了從模版字串到 render 函式的工作。當我們得到 render 函式之後,接下來就該進入到真正的掛載階段了:

掛載 -> 例項化渲染 Watcher -> 執行 updateComponent 方法 -> 執行 render 函式生成 VNode -> 執行 patch 進行首次渲染 -> 遞迴遍歷 VNode 建立各個節點並處理節點上的普通屬性和指令 -> 如果節點是自定義元件則建立元件例項 -> 進行元件的初始化、掛載 -> 最終所有 VNode 變成真實的 DOM 節點並替換掉頁面上的模版內容 -> 完成初始渲染

目標

所以,本篇文章目標就是實現上面描述的整個過成,完成初始渲染。整個過程中涉及如下知識點:

  • render helper

  • VNode

  • patch 初始渲染

  • 指令(v-model、v-bind、v-on)的處理

  • 例項化子元件

  • 插槽的處理

實現

接下來就正式進入程式碼實現過程,一步步實現上述所有內容,完成頁面的初始渲染。

mount

/src/compiler/index.js

/**
 * 編譯器
 */
export default function mount(vm) {
  if (!vm.$options.render) { // 沒有提供 render 選項,則編譯生成 render 函式
    // ...
  }
  mountComponent(vm)
}

mountComponent

/src/compiler/mountComponent.js

/**
 * @param {*} vm Vue 例項
 */
export default function mountComponent(vm) {
  // 更新元件的的函式
  const updateComponent = () => {
    vm._update(vm._render())
  }

  // 例項化一個渲染 Watcher,當響應式資料更新時,這個更新函式會被執行
  new Watcher(updateComponent)
}

vm._render

/src/compiler/mountComponent.js

/**
 * 負責執行 vm.$options.render 函式
 */
Vue.prototype._render = function () {
  // 給 render 函式繫結 this 上下文為 Vue 例項
  return this.$options.render.apply(this)
}

render helper

/src/compiler/renderHelper.js

/**
 * 在 Vue 例項上安裝執行時的渲染幫助函式,比如 _c、_v,這些函式會生成 Vnode
 * @param {VueContructor} target Vue 例項
 */
export default function renderHelper(target) {
  target._c = createElement
  target._v = createTextNode
}

createElement

/src/compiler/renderHelper.js

/**
 * 根據標籤資訊建立 Vnode
 * @param {string} tag 標籤名 
 * @param {Map} attr 標籤的屬性 Map 物件
 * @param {Array<Render>} children 所有的子節點的渲染函式
 */
function createElement(tag, attr, children) {
  return VNode(tag, attr, children, this)
}

createTextNode

/src/compiler/renderHelper.js

/**
 * 生成文字節點的 VNode
 * @param {*} textAst 文字節點的 AST 物件
 */
function createTextNode(textAst) {
  return VNode(null, null, null, this, textAst)
}

VNode

/src/compiler/vnode.js

/**
 * VNode
 * @param {*} tag 標籤名
 * @param {*} attr 屬性 Map 物件
 * @param {*} children 子節點組成的 VNode
 * @param {*} text 文字節點的 ast 物件
 * @param {*} context Vue 例項
 * @returns VNode
 */
export default function VNode(tag, attr, children, context, text = null) {
  return {
    // 標籤
    tag,
    // 屬性 Map 物件
    attr,
    // 父節點
    parent: null,
    // 子節點組成的 Vnode 陣列
    children,
    // 文字節點的 Ast 物件
    text,
    // Vnode 的真實節點
    elm: null,
    // Vue 例項
    context
  }
}

vm._update

/src/compiler/mountComponent.js

Vue.prototype._update = function (vnode) {
  // 老的 VNode
  const prevVNode = this._vnode
  // 新的 VNode
  this._vnode = vnode
  if (!prevVNode) {
    // 老的 VNode 不存在,則說明時首次渲染根元件
    this.$el = this.__patch__(this.$el, vnode)
  } else {
    // 後續更新元件或者首次渲染子元件,都會走這裡
    this.$el = this.__patch__(prevVNode, vnode)
  }
}

安裝 __patch__、render helper

/src/index.js

/**
 * 初始化配置物件
 * @param {*} options 
 */
Vue.prototype._init = function (options) {
  // ...
  initData(this)
  // 安裝執行時的渲染工具函式
  renderHelper(this)
  // 在例項上安裝 patch 函式
  this.__patch__ = patch
  // 如果存在 el 配置項,則呼叫 $mount 方法編譯模版
  if (this.$options.el) {
    this.$mount()
  }
}

patch

/src/compiler/patch.js

/**
 * 初始渲染和後續更新的入口
 * @param {VNode} oldVnode 老的 VNode
 * @param {VNode} vnode 新的 VNode
 * @returns VNode 的真實 DOM 節點
 */
export default function patch(oldVnode, vnode) {
  if (oldVnode && !vnode) {
    // 老節點存在,新節點不存在,則銷燬元件
    return
  }

  if (!oldVnode) { // oldVnode 不存在,說明是子元件首次渲染
    createElm(vnode)
  } else {
    if (oldVnode.nodeType) { // 真實節點,則表示首次渲染根元件
      // 父節點,即 body
      const parent = oldVnode.parentNode
      // 參考節點,即老的 vnode 的下一個節點 —— script,新節點要插在 script 的前面
      const referNode = oldVnode.nextSibling
      // 建立元素
      createElm(vnode, parent, referNode)
      // 移除老的 vnode
      parent.removeChild(oldVnode)
    } else {
      console.log('update')
    }
  }
  return vnode.elm
}

createElm

/src/compiler/patch.js

/**
 * 建立元素
 * @param {*} vnode VNode
 * @param {*} parent VNode 的父節點,真實節點
 * @returns 
 */
function createElm(vnode, parent, referNode) {
  // 記錄節點的父節點
  vnode.parent = parent
  // 建立自定義元件,如果是非元件,則會繼續後面的流程
  if (createComponent(vnode)) return

  const { attr, children, text } = vnode
  if (text) { // 文字節點
    // 建立文字節點,並插入到父節點內
    vnode.elm = createTextNode(vnode)
  } else { // 元素節點
    // 建立元素,在 vnode 上記錄對應的 dom 節點
    vnode.elm = document.createElement(vnode.tag)
    // 給元素設定屬性
    setAttribute(attr, vnode)
    // 遞迴建立子節點
    for (let i = 0, len = children.length; i < len; i++) {
      createElm(children[i], vnode.elm)
    }
  }
  // 如果存在 parent,則將建立的節點插入到父節點內
  if (parent) {
    const elm = vnode.elm
    if (referNode) {
      parent.insertBefore(elm, referNode)
    } else {
      parent.appendChild(elm)
    }
  }
}

createTextNode

/src/compiler/patch.js

/**
 * 建立文字節點
 * @param {*} textVNode 文字節點的 VNode
 */
function createTextNode(textVNode) {
  let { text } = textVNode, textNode = null
  if (text.expression) {
    // 存在表示式,這個表示式的值是一個響應式資料
    const value = textVNode.context[text.expression]
    textNode = document.createTextNode(typeof value === 'object' ? JSON.stringify(value) : String(value))
  } else {
    // 純文字
    textNode = document.createTextNode(text.text)
  }
  return textNode
}

setAttribute

/src/compiler/patch.js

/**
 * 給節點設定屬性
 * @param {*} attr 屬性 Map 物件
 * @param {*} vnode
 */
function setAttribute(attr, vnode) {
  // 遍歷屬性,如果是普通屬性,直接設定,如果是指令,則特殊處理
  for (let name in attr) {
    if (name === 'vModel') {
      // v-model 指令
      const { tag, value } = attr.vModel
      setVModel(tag, value, vnode)
    } else if (name === 'vBind') {
      // v-bind 指令
      setVBind(vnode)
    } else if (name === 'vOn') {
      // v-on 指令
      setVOn(vnode)
    } else {
      // 普通屬性
      vnode.elm.setAttribute(name, attr[name])
    }
  }
}

setVModel

/src/compiler/patch.js

/**
 * v-model 的原理
 * @param {*} tag 節點的標籤名
 * @param {*} value 屬性值
 * @param {*} node 節點
 */
function setVModel(tag, value, vnode) {
  const { context: vm, elm } = vnode
  if (tag === 'select') {
    // 下拉框,<select></select>
    Promise.resolve().then(() => {
      // 利用 promise 延遲設定,直接設定不行,
      // 因為這會兒 option 元素還沒建立
      elm.value = vm[value]
    })
    elm.addEventListener('change', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'text') {
    // 文字框,<input type="text" />
    elm.value = vm[value]
    elm.addEventListener('input', function () {
      vm[value] = elm.value
    })
  } else if (tag === 'input' && vnode.elm.type === 'checkbox') {
    // 選擇框,<input type="checkbox" />
    elm.checked = vm[value]
    elm.addEventListener('change', function () {
      vm[value] = elm.checked
    })
  }
}

setVBind

/src/compiler/patch.js

/**
 * v-bind 原理
 * @param {*} vnode
 */
function setVBind(vnode) {
  const { attr: { vBind }, elm, context: vm } = vnode
  for (let attrName in vBind) {
    elm.setAttribute(attrName, vm[vBind[attrName]])
    elm.removeAttribute(`v-bind:${attrName}`)
  }
}

setVOn

/src/compiler/patch.js

/**
 * v-on 原理
 * @param {*} vnode 
 */
function setVOn(vnode) {
  const { attr: { vOn }, elm, context: vm } = vnode
  for (let eventName in vOn) {
    elm.addEventListener(eventName, function (...args) {
      vm.$options.methods[vOn[eventName]].apply(vm, args)
    })
  }
}

createComponent

/src/compiler/patch.js

/**
 * 建立自定義元件
 * @param {*} vnode
 */
function createComponent(vnode) {
  if (vnode.tag && !isReserveTag(vnode.tag)) { // 非保留節點,則說明是元件
    // 獲取元件配置資訊
    const { tag, context: { $options: { components } } } = vnode
    const compOptions = components[tag]
    const compIns = new Vue(compOptions)
    // 將父元件的 VNode 放到子元件的例項上
    compIns._parentVnode = vnode
    // 掛載子元件
    compIns.$mount()
    // 記錄子元件 vnode 的父節點資訊
    compIns._vnode.parent = vnode.parent
    // 將子元件新增到父節點內
    vnode.parent.appendChild(compIns._vnode.elm)
    return true
  }
}

isReserveTag

/src/utils.js

/**
 * 是否為平臺保留節點
 */
export function isReserveTag(tagName) {
  const reserveTag = ['div', 'h3', 'span', 'input', 'select', 'option', 'p', 'button', 'template']
  return reserveTag.includes(tagName)
}

插槽原理

以下示例是插槽的常用方式。插槽的原理其實很簡單,只是實現起來稍微有些麻煩罷了。

  • 解析

    如果元件標籤有子節點,在解析的時候將這些子節點,解析成一個特定的資料結構,該結構中包含了插槽的全部資訊,然後將該資料結構放到父節點的屬性上,其實就是找個地方存放這些資訊,然後在 renderSlot 中使用時取出來。當然這個解析過程是發生在父元件的解析過程中的。

  • 生成渲染函式

    在生成子元件的渲染函式階段,如果碰到 slot 標籤,則返回一個 _t 的渲染函式,函式接收兩個引數:屬性的 JSON 字串形式,slot 標籤的所有子節點的渲染函式組成的 children 陣列。

  • render helper

    在執行子元件的渲染函式時,如果執行到 vm._t,就會呼叫 renderSlot 方法,該方法會返回插槽的 VNode,然後進入子元件的 patch 階段,將這些 VNode 變成真實的 DOM 並渲染到頁面上。

以上就是插槽的原理,然後接下來實現的時候,在某些地方可能會稍微有點繞,多多少少是因為整體架構存在一些問題,所以裡面會有一些修補性質的程式碼,這些程式碼你可以理解為為了實現插槽功能,而寫的一點業務程式碼。你只需要把住插槽的本質即可。

示例

<!-- comp -->
<template>
  <div>
    <div>
      <slot name="slot1">
        <span>插槽預設內容</span>
      </slot>
    </div>
      <slot name="slot2" v-bind:test="xx">
        <span>插槽預設內容</span>
      </slot>
    <div>
    </div>
  </div>
</template>

<comp></comp>
<comp>
  <template v-slot:slot2="xx">
    <div>作用域插槽,通過插槽從父元件給子元件傳遞內容</div>
  </template>
<comp>

parse

/src/compiler/parse.js

function processElement() {
    // ...

    // 處理插槽內容
    processSlotContent(curEle)

    // 節點處理完以後讓其和父節點產生關係
    if (stackLen) {
      stack[stackLen - 1].children.push(curEle)
      curEle.parent = stack[stackLen - 1]
      // 如果節點存在 slotName,則說明該節點是元件傳遞給插槽的內容
      // 將插槽資訊放到元件節點的 rawAttr.scopedSlots 物件上
      // 而這些資訊在生成元件插槽的 VNode 時(renderSlot)會用到
      if (curEle.slotName) {
        const { parent, slotName, scopeSlot, children } = curEle
        // 這裡關於 children 的操作,只是單純為了避開 JSON.stringify 的迴圈引用問題
        // 因為生成渲染函式時需要對 attr 執行 JSON.stringify 方法
        const slotInfo = {
          slotName, scopeSlot, children: children.map(item => {
            delete item.parent
            return item
          })
        }
        if (parent.rawAttr.scopedSlots) {
          parent.rawAttr.scopedSlots[curEle.slotName] = slotInfo
        } else {
          parent.rawAttr.scopedSlots = { [curEle.slotName]: slotInfo }
        }
      }
    }
  }

processSlotContent

/src/compiler/parse.js

/**
 * 處理插槽
 * <scope-slot>
 *   <template v-slot:default="scopeSlot">
 *     <div>{{ scopeSlot }}</div>
 *   </template>
 * </scope-slot>
 * @param { AST } el 節點的 AST 物件
 */
function processSlotContent(el) {
  // 注意,具有 v-slot:xx 屬性的 template 只能是元件的根元素,這裡不做判斷
  if (el.tag === 'template') { // 獲取插槽資訊
    // 屬性 map 物件
    const attrMap = el.rawAttr
    // 遍歷屬性 map 物件,找出其中的 v-slot 指令資訊
    for (let key in attrMap) {
      if (key.match(/v-slot:(.*)/)) { // 說明 template 標籤上 v-slot 指令
        // 獲取指令後的插槽名稱和值,比如: v-slot:default=xx
        // default
        const slotName = el.slotName = RegExp.$1
        // xx
        el.scopeSlot = attrMap[`v-slot:${slotName}`]
        // 直接 return,因為該標籤上只可能有一個 v-slot 指令
        return
      }
    }
  }
}

generate

/src/compiler/generate.js

/**
 * 解析 ast 生成 渲染函式
 * @param {*} ast 語法樹 
 * @returns {string} 渲染函式的字串形式
 */
function genElement(ast) {
  // ...

  // 處理子節點,得到一個所有子節點渲染函式組成的陣列
  const children = genChildren(ast)

  if (tag === 'slot') {
    // 生成插槽的處理函式
    return `_t(${JSON.stringify(attrs)}, [${children}])`
  }

  // 生成 VNode 的可執行方法
  return `_c('${tag}', ${JSON.stringify(attrs)}, [${children}])`
}

renderHelper

/src/compiler/renderHelper.js

/**
 * 在 Vue 例項上安裝執行時的渲染幫助函式,比如 _c、_v,這些函式會生成 Vnode
 * @param {VueContructor} target Vue 例項
 */
export default function renderHelper(target) {
  // ...
  target._t = renderSlot
}

renderSlot

/src/compiler/renderHelper.js

/**
 * 插槽的原理其實很簡單,難點在於實現
 * 其原理就是生成 VNode,難點在於生成 VNode 之前的各種解析,也就是資料準備階段
 * 生成插槽的的 VNode
 * @param {*} attrs 插槽的屬性
 * @param {*} children 插槽所有子節點的 ast 組成的陣列
 */
function renderSlot(attrs, children) {
  // 父元件 VNode 的 attr 資訊
  const parentAttr = this._parentVnode.attr
  let vnode = null
  if (parentAttr.scopedSlots) { // 說明給當前元件的插槽傳遞了內容
    // 獲取插槽資訊
    const slotName = attrs.name
    const slotInfo = parentAttr.scopedSlots[slotName]
    // 這裡的邏輯稍微有點繞,建議開啟除錯,檢視一下資料結構,理清對應的思路
    // 這裡比較繞的邏輯完全是為了實現插槽這個功能,和插槽本身的原理沒關係
    this[slotInfo.scopeSlot] = this[Object.keys(attrs.vBind)[0]]
    vnode = genVNode(slotInfo.children, this)
  } else { // 插槽預設內容
    // 將 children 變成 vnode 陣列
    vnode = genVNode(children, this)
  }

  // 如果 children 長度為 1,則說明插槽只有一個子節點
  if (children.length === 1) return vnode[0]
  return createElement.call(this, 'div', {}, vnode)
}

genVNode

/src/compiler/renderHelper.js

/**
 * 將一批 ast 節點(陣列)轉換成 vnode 陣列
 * @param {Array<Ast>} childs 節點陣列
 * @param {*} vm 元件例項
 * @returns vnode 陣列
 */
function genVNode(childs, vm) {
  const vnode = []
  for (let i = 0, len = childs.length; i < len; i++) {
    const { tag, attr, children, text } = childs[i]
    if (text) { // 文字節點
      if (typeof text === 'string') { // text 為字串
        // 構造文字節點的 AST 物件
        const textAst = {
          type: 3,
          text,
        }
        if (text.match(/{{(.*)}}/)) {
          // 說明是表示式
          textAst.expression = RegExp.$1.trim()
        }
        vnode.push(createTextNode.call(vm, textAst))
      } else { // text 為文字節點的 ast 物件
        vnode.push(createTextNode.call(vm, text))
      }
    } else { // 元素節點
      vnode.push(createElement.call(vm, tag, attr, genVNode(children, vm)))
    }
  }
  return vnode
}

結果

好了,到這裡,模版的初始渲染就已經完成了,如果你能看到如下效果圖,則說明一切正常。因為整個過程涉及的內容還是比較多的,如果覺得某些地方不太清楚,建議再看看,仔細梳理下整個流程。

動圖連結:https://gitee.com/liyongning/typora-image-bed/raw/master/202203141833484.image

可以看到,原始標籤、自定義元件、插槽都已經完整的渲染到了頁面上,完成了初始渲染之後,接下來就該去實現後續的更新過程了,也就是下一篇 手寫 Vue2 系列 之 patch —— diff

連結

感謝各位的:關注點贊收藏評論,我們下期見。


當學習成為了習慣,知識也就變成了常識。 感謝各位的 關注點贊收藏評論

新視訊和文章會第一時間在微信公眾號傳送,歡迎關注:李永寧lyn

文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。