手寫 Vue2 系列 之 初始渲染
- 2022 年 3 月 17 日
- 筆記
- 精通 Vue 技術棧的源碼原理
前言
上一篇文章 手寫 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
}
結果
好了,到這裡,模版的初始渲染就已經完成了,如果你能看到如下效果圖,則說明一切正常。因為整個過程涉及的內容還是比較多的,如果覺得某些地方不太清楚,建議再看看,仔細梳理下整個流程。
動圖鏈接://gitee.com/liyongning/typora-image-bed/raw/master/202203141833484.image
可以看到,原始標籤、自定義組件、插槽都已經完整的渲染到了頁面上,完成了初始渲染之後,接下來就該去實現後續的更新過程了,也就是下一篇 手寫 Vue2 系列 之 patch —— diff。
鏈接
- 配套視頻,微信公眾號回復:”精通 Vue 技術棧源碼原理視頻版” 獲取
- 精通 Vue 技術棧源碼原理 專欄
- github 倉庫 liyongning/Vue 歡迎 Star
- github 倉庫 liyongning/Lyn-Vue-DOM 歡迎 Star
- github 倉庫 liyongning/Lyn-Vue-Template 歡迎 Star
感謝各位的:關注、點贊、收藏和評論,我們下期見。
當學習成為了習慣,知識也就變成了常識。 感謝各位的 關注、 點贊、收藏和評論。
新視頻和文章會第一時間在微信公眾號發送,歡迎關註:李永寧lyn
文章已收錄到 github 倉庫 liyongning/blog,歡迎 Watch 和 Star。