很早之前,我曾寫過一篇文章,分析並實現過一版簡易的 vdom
。想看的可以點選 傳送門
聊聊為什麼又想著寫這麼一篇文章,實在是專案裡,不管自己還是同事,都或多或少會遇到這塊的坑。所以這裡當給小夥伴們再做一次總結吧,希望大夥看完,能對 vue
中的 vdom
有一個更好的認知。好了,接下來直接開始吧
一、丟擲問題
在開始之前,我先丟擲一個問題,大家可以先思考,然後再接著閱讀後面的篇幅。先上下程式碼
<template>
<el-select
class="test-select"
multiple
filterable
remote
placeholder="請輸入關鍵詞"
:remote-method="remoteMethod"
:loading="loading"
@focus="handleFoucs"
v-model="items">
<!-- 這裡 option 的 key 直接繫結 vfor 的 index -->
<el-option
v-for="(item, index) in options"
:key="index"
:label="item.label"
:value="item.value">
<el-checkbox
:label="item.value"
:value="isChecked(item.value)">
{{ item.label }}
</el-checkbox>
</el-option>
</el-select>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class TestSelect extends Vue {
options: Array<{ label: string, value: string }> = []
items: Array<string> = []
list: Array<{ label: string, value: string }> = []
loading: boolean = false
states = ['Alabama', 'Alaska', 'Arizona',
'Arkansas', 'California', 'Colorado',
'Connecticut', 'Delaware', 'Florida',
'Georgia', 'Hawaii', 'Idaho', 'Illinois',
'Indiana', 'Iowa', 'Kansas', 'Kentucky',
'Louisiana', 'Maine', 'Maryland',
'Massachusetts', 'Michigan', 'Minnesota',
'Mississippi', 'Missouri', 'Montana',
'Nebraska', 'Nevada', 'New Hampshire',
'New Jersey', 'New Mexico', 'New York',
'North Carolina', 'North Dakota', 'Ohio',
'Oklahoma', 'Oregon', 'Pennsylvania',
'Rhode Island', 'South Carolina',
'South Dakota', 'Tennessee', 'Texas',
'Utah', 'Vermont', 'Virginia',
'Washington', 'West Virginia', 'Wisconsin',
'Wyoming']
mounted () {
this.list = this.states.map(item => {
return { value: item, label: item }
})
}
remoteMethod (query) {
if (query !== '') {
this.loading = true
setTimeout(() => {
this.loading = false
this.options = this.list.filter(item => {
return item.label.toLowerCase()
.indexOf(query.toLowerCase()) > -1
})
}, 200)
} else {
this.options = this.list
}
}
handleFoucs (e) {
this.remoteMethod(e.target.value)
}
isChecked (value: string): boolean {
let checked = false
this.items.forEach((item: string) => {
if (item === value) {
checked = true
}
})
return checked
}
}
</script>
複製程式碼
輸入篩選後效果圖如下
然後我在換一個關鍵詞進行搜尋,結果就會出現以下展示的問題
我並沒有進行選擇,但是 select 選擇框中展示的值卻發生了變更。老司機可能一開始看程式碼,就知道問題所在了。其實把 option 裡面的 key
繫結換一下就OK,換成如下的
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value">
<el-checkbox
:label="item.value"
:value="isChecked(item.value)">
{{ item.label }}
</el-checkbox>
</el-option>
複製程式碼
那麼問題來了,這樣可以避免問題,但是為什麼可以避免呢?其實,這塊就牽扯到 vdom 裡 patch 相關的內容了。接下來我就帶著大家重新把 vdom 再撿起來一次
開始之前,看幾個下文中經常出現的 API
isDef()
export function isDef (v: any): boolean %checks {
return v !== undefined && v !== null
}
複製程式碼
isUndef()
export function isUndef (v: any): boolean %checks {
return v === undefined || v === null
}
複製程式碼
isTrue()
export function isTrue (v: any): boolean %checks {
return v === true
}
複製程式碼
二、class VNode
開篇前,先講一下 VNode ,vue
中的 vdom
其實就是一個 vnode
物件。
對 vdom
稍作了解的同學都應該知道,vdom
建立節點的核心首先就是建立一個對真實 dom 抽象的 js 物件樹,然後通過一系列操作(後面我再談具體什麼操作)。該章節我們就只談 vnode
的實現
1、constructor
首先,我們可以先看看, VNode 這個類對我們這些使用者暴露了哪些屬性出來,挑一些我們常見的看
constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component
) {
this.tag = tag // 節點的標籤名
this.data = data // 節點的資料資訊,如 props,attrs,key,class,directives 等
this.children = children // 節點的子節點
this.text = text // 節點對應的文字
this.elm = elm // 節點對應的真實節點
this.context = context // 節點上下文,為 Vue Component 的定義
this.key = data && data.key // 節點用作 diff 的唯一標識
}
複製程式碼
2、for example
現在,我們舉個例子,假如我需要解析下面文字
<template>
<div class="vnode" :class={ 'show-node': isShow } v-show="isShow">
This is a vnode.
</div>
</template>
複製程式碼
使用 js 進行抽象就是這樣的
function render () {
return new VNode(
'div',
{
// 靜態 class
staticClass: 'vnode',
// 動態 class
class: {
'show-node': isShow
},
/**
* directives: [
* {
* rawName: 'v-show',
* name: 'show',
* value: isShow
* }
* ],
*/
// 等同於 directives 裡面的 v-show
show: isShow,
[ new VNode(undefined, undefined, undefined, 'This is a vnode.') ]
}
)
}
複製程式碼
轉換成 vnode 後的表現形式如下
{
tag: 'div',
data: {
show: isShow,
// 靜態 class
staticClass: 'vnode',
// 動態 class
class: {
'show-node': isShow
},
},
text: undefined,
children: [
{
tag: undefined,
data: undefined,
text: 'This is a vnode.',
children: undefined
}
]
}
複製程式碼
然後我再看一個稍微複雜一點的例子
<span v-for="n in 5" :key="n">{{ n }}</span>
複製程式碼
假如讓大家使用 js 對其進行物件抽象,大家會如何進行呢?主要是裡面的 v-for 指令,大家可以先自己帶著思考試試。
OK,不賣關子,我們現在直接看看下面的 render 函式對其的抽象處理,其實就是迴圈 render 啦!
function render (val, keyOrIndex, index) {
return new VNode(
'span',
{
directives: [
{
rawName: 'v-for',
name: 'for',
value: val
}
],
key: val,
[ new VNode(undefined, undefined, undefined, val) ]
}
)
}
function renderList (
val: any,
render: (
val: any,
keyOrIndex: string | number,
index?: number
) => VNode
): ?Array<VNode> {
// 僅考慮 number 的情況
let ret: ?Array<VNode>, i, l, keys, key
ret = new Array(val)
for (i = 0; i < val; i++) {
ret[i] = render(i + 1, i)
}
return ret
}
renderList(5)
複製程式碼
轉換成 vnode 後的表現形式如下
[
{
tag: 'span',
data: {
key: 1
},
text: undefined,
children: [
{
tag: undefined,
data: undefined,
text: 1,
children: undefined
}
]
}
// 依次迴圈
]
複製程式碼
3、something else
我們看完了 VNode Ctor
的一些屬性,也看了一下對於真實 dom vnode 的轉換形式,這裡我們就稍微補個漏,看看基於 VNode
做的一些封裝給我們暴露的一些方法
// 建立一個空節點
export const createEmptyVNode = (text: string = '') => {
const node = new VNode()
node.text = text
node.isComment = true
return node
}
// 建立一個文字節點
export function createTextVNode (val: string | number) {
return new VNode(undefined, undefined, undefined, String(val))
}
// 克隆一個節點,僅列舉部分屬性
export function cloneVNode (vnode: VNode): VNode {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text
)
cloned.key = vnode.key
cloned.isCloned = true
return cloned
}
複製程式碼
捋清楚 VNode
相關方法,下面的章節,將介紹 vue
是如何將 vnode
渲染成真實 dom
三、render
1、createElement
在看 vue 中 createElement 的實現前,我們先看看同檔案下私有方法 _createElement
的實現。其中是對 tag 具體的一些邏輯判定
- tagName 繫結在 data 引數裡面
if (isDef(data) && isDef(data.is)) {
tag = data.is
}
複製程式碼
- tagName 不存在時,返回一個空節點
if (!tag) {
return createEmptyVNode()
}
複製程式碼
- tagName 是 string 型別的時候,直接返回對應 tag 的 vnode 物件
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
複製程式碼
- tagName 是非 string 型別的時候,則執行
createComponent()
建立一個 Component 物件
vnode = createComponent(tag, data, context, children)
複製程式碼
- 判定 vnode 型別,進行對應的返回
if (Array.isArray(vnode)) {
return vnode
} else if (isDef(vnode)) {
// namespace 相關處理
if (isDef(ns)) applyNS(vnode, ns)
// 進行 Observer 相關繫結
if (isDef(data)) registerDeepBindings(data)
return vnode
} else {
return createEmptyVNode()
}
複製程式碼
createElement()
則是執行 _createElement()
返回 vnode
return _createElement(context, tag, data, children, normalizationType)
複製程式碼
2、render functions
i. renderHelpers
這裡我們先整體看下,掛載在 Vue.prototype
上的都有哪些 render 相關的方法
export function installRenderHelpers (target: any) {
target._o = markOnce // v-once render 處理
target._n = toNumber // 值轉換 Number 處理
target._s = toString // 值轉換 String 處理
target._l = renderList // v-for render 處理
target._t = renderSlot // slot 槽點 render 處理
target._q = looseEqual // 判斷兩個物件是否大體相等
target._i = looseIndexOf // 對等屬性索引,不存在則返回 -1
target._m = renderStatic // 靜態節點 render 處理
target._f = resolveFilter // filters 指令 render 處理
target._k = checkKeyCodes // checking keyCodes from config
target._b = bindObjectProps // v-bind render 處理,將 v-bind="object" 的屬性 merge 到VNode屬性中
target._v = createTextVNode // 建立文字節點
target._e = createEmptyVNode // 建立空節點
target._u = resolveScopedSlots // scopeSlots render 處理
target._g = bindObjectListeners // v-on render 處理
}
複製程式碼
然後在 renderMixin()
方法中,對 Vue.prototype
進行 init 操作
export function renderMixin (Vue: Class<Component>) {
// render helps init 操作
installRenderHelpers(Vue.prototype)
// 定義 vue nextTick 方法
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this)
}
Vue.prototype._render = function (): VNode {
// 此處定義 vm 例項,以及 return vnode。具體程式碼此處忽略
}
}
複製程式碼
ii. AST 抽象語法樹
到目前為止,我們看到的 render
相關的操作都是返回一個 vnode
物件,而真實節點的渲染之前,vue 會對 template 模板中的字串進行解析,將其轉換成 AST 抽象語法樹,方便後續的操作。關於這塊,我們直接來看看 vue 中在 flow 型別裡面是如何定義 ASTElement
介面型別的,既然是開篇丟擲的問題是由 v-for
導致的,那麼這塊,我們就僅僅看看 ASTElement
對其的定義,看完之後記得舉一反三去原始碼裡面理解其他的定義哦?
declare type ASTElement = {
tag: string; // 標籤名
attrsMap: { [key: string]: any }; // 標籤屬性 map
parent: ASTElement | void; // 父標籤
children: Array<ASTNode>; // 子節點
for?: string; // 被 v-for 的物件
forProcessed?: boolean; // v-for 是否需要被處理
key?: string; // v-for 的 key 值
alias?: string; // v-for 的引數
iterator1?: string; // v-for 第一個引數
iterator2?: string; // v-for 第二個引數
};
複製程式碼
iii. generate 字串轉換
renderList
在看 render function
字串轉換之前,先看下 renderList
的引數,方便後面的閱讀
export function renderList (
val: any,
render: (
val: any,
keyOrIndex: string | number,
index?: number
) => VNode
): ?Array<VNode> {
// 此處為 render 相關處理,具體細節這裡就不列出來了,上文中有列出 number 情況的處理
}
複製程式碼
genFor
上面看完定義,緊接著我們再來看看,generate
是如何將 AST 轉換成 render function 字串的,這樣同理我們就看對 v-for
相關的處理
function genFor (
el: any,
state: CodegenState,
altGen?: Function,
altHelper?: string
): string {
const exp = el.for // v-for 的物件
const alias = el.alias // v-for 的引數
const iterator1 = el.iterator1 ? `,${el.iterator1}` : '' // v-for 第一個引數
const iterator2 = el.iterator2 ? `,${el.iterator2}` : '' // v-for 第二個引數
el.forProcessed = true // 指令需要被處理
// return 出對應 render function 字串
return `${altHelper || '_l'}((${exp}),` +
`function(${alias}${iterator1}${iterator2}){` +
`return ${(altGen || genElement)(el, state)}` +
'})'
}
複製程式碼
genElement
這塊整合了各個指令對應的轉換邏輯
export function genElement (el: ASTElement, state: CodegenState): string {
if (el.staticRoot && !el.staticProcessed) { // 靜態節點
return genStatic(el, state)
} else if (el.once && !el.onceProcessed) { // v-once 處理
return genOnce(el, state)
} else if (el.for && !el.forProcessed) { // v-for 處理
return genFor(el, state)
} else if (el.if && !el.ifProcessed) { // v-if 處理
return genIf(el, state)
} else if (el.tag === 'template' && !el.slotTarget) { // template 根節點處理
return genChildren(el, state) || 'void 0'
} else if (el.tag === 'slot') { // slot 節點處理
return genSlot(el, state)
} else {
// component or element 相關處理
}
}
複製程式碼
generate
generate
則是將以上所有的方法整合到一個物件中,其中 render
屬性對應的則是 genElement
相關的操作,staticRenderFns
對應的則是字串陣列。
export function generate (
ast: ASTElement | void,
options: CompilerOptions
): CodegenResult {
const state = new CodegenState(options)
const code = ast ? genElement(ast, state) : '_c("div")'
return {
render: `with(this){return ${code}}`, // render
staticRenderFns: state.staticRenderFns // render function 字串陣列
}
}
複製程式碼
3、render 栗子
看了上面這麼多,對 vue 不太瞭解的一些小夥伴可能會覺得有些暈,這裡直接舉一個 v-for
渲染的例子給大家來理解。
i. demo
<div class="root">
<span v-for="n in 5" :key="n">{{ n }}</span>
</div>
複製程式碼
這塊首先會被解析成 html 字串
let html = `<div class="root">
<span v-for="n in 5" :key="n">{{ n }}</span>
</div>`
複製程式碼
ii. 相關正則
拿到 template 裡面的 html 字串之後,會對其進行解析操作。具體相關的正規表示式在 src/compiler/parser/html-parser.js
裡面有提及,以下是相關的一些正規表示式以及 decoding map
的定義。
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
const comment = /^<!\--/
const conditionalComment = /^<!\[/
const decodingMap = {
'<': '<',
'>': '>',
'"': '"',
'&': '&',
' ': '\n',
'	': '\t'
}
const encodedAttr = /&(?:lt|gt|quot|amp);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#10|#9);/g
複製程式碼
iii. parseHTML
vue
解析 template 都是使用 while
迴圈進行字串匹配的,每每解析完一段字串都會將已經匹配完的部分去除掉,然後 index
索引會直接對剩下的部分繼續進行匹配。具體有關 parseHTML
的定義如下,由於文章到這篇幅已經比較長了,我省略掉了正則迴圈匹配指標的一些邏輯,想要具體瞭解的小夥伴可以自行研究或者等我下次再出一篇文章詳談這塊的邏輯。
export function parseHTML (html, options) {
const stack = [] // 用來儲存解析好的標籤頭
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no
let index = 0 // 匹配指標索引
let last, lastTag
while (html) {
// 此處是對標籤進行正則匹配的邏輯
}
// 清理剩餘的 tags
parseEndTag()
// 迴圈匹配相關處理
function advance (n) {
index += n
html = html.substring(n)
}
// 起始標籤相關處理
function parseStartTag () {
let match = {
tagName: start[1],
attrs: [],
start: index
}
// 一系列匹配操作,然後對 match 進行賦值
return match
}
function handleStartTag (match) {}
// 結束標籤相關處理
function parseEndTag (tagName, start, end) {}
}
複製程式碼
經過 parseHTML()
進行一系列正則匹配處理之後,會將字串 html 解析成以下 AST 的內容
{
'attrsMap': {
'class': 'root'
},
'staticClass': 'root', // 標籤的靜態 class
'tag': 'div', // 標籤的 tag
'children': [{ // 子標籤陣列
'attrsMap': {
'v-for': "n in 5",
'key': n
},
'key': n,
'alias': "n", // v-for 引數
'for': 5, // 被 v-for 的物件
'forProcessed': true,
'tag': 'span',
'children': [{
'expression': '_s(item)', // toString 操作(上文有提及)
'text': '{{ n }}'
}]
}]
}
複製程式碼
到這裡,再結合上面的 generate
進行轉換便是 render
這塊的邏輯了。
四、diff and patch
哎呀,終於到 diff 和 patch 環節了,想想還是很雞凍呢。
1、一些 DOM 的 API 操作
看進行具體 diff 之前,我們先看看在 platforms/web/runtime/node-ops.js
中定義的一些建立真實 dom 的方法,正好溫習一下 dom
相關操作的 API
createElement()
建立由 tagName 指定的 HTML 元素
export function createElement (tagName: string, vnode: VNode): Element {
const elm = document.createElement(tagName)
if (tagName !== 'select') {
return elm
}
if (vnode.data && vnode.data.attrs && vnode.data.attrs.multiple !== undefined) {
elm.setAttribute('multiple', 'multiple')
}
return elm
}
複製程式碼
createTextNode()
建立文字節點
export function createTextNode (text: string): Text {
return document.createTextNode(text)
}
複製程式碼
createComment()
建立一個註釋節點
export function createComment (text: string): Comment {
return document.createComment(text)
}
複製程式碼
insertBefore()
在參考節點之前插入一個擁有指定父節點的子節點
export function insertBefore (parentNode: Node, newNode: Node, referenceNode: Node) {
parentNode.insertBefore(newNode, referenceNode)
}
複製程式碼
removeChild()
從 DOM 中刪除一個子節點
export function removeChild (node: Node, child: Node) {
node.removeChild(child)
}
複製程式碼
appendChild()
將一個節點新增到指定父節點的子節點列表末尾
export function appendChild (node: Node, child: Node) {
node.appendChild(child)
}
複製程式碼
parentNode()
返回父節點
export function parentNode (node: Node): ?Node {
return node.parentNode
}
複製程式碼
nextSibling()
返回兄弟節點
export function nextSibling (node: Node): ?Node {
return node.nextSibling
}
複製程式碼
tagName()
返回節點標籤名
export function tagName (node: Element): string {
return node.tagName
}
複製程式碼
setTextContent()
設定節點文字內容
export function setTextContent (node: Node, text: string) {
node.textContent = text
}
複製程式碼
2、一些 patch 中的 API 操作
提示:上面我們列出來的 API 都掛在了下面的 nodeOps
物件中了
createElm()
建立節點
function createElm (vnode, parentElm, refElm) {
if (isDef(vnode.tag)) { // 建立標籤節點
vnode.elm = nodeOps.createElement(tag, vnode)
} else if (isDef(vnode.isComment)) { // 建立註釋節點
vnode.elm = nodeOps.createComment(vnode.text)
} else { // 建立文字節點
vnode.elm = nodeOps.createTextNode(vnode.text)
}
insert(parentElm, vnode.elm, refElm)
}
複製程式碼
insert()
指定父節點下插入子節點
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) { // 插入到指定 ref 的前面
if (ref.parentNode === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else { // 直接插入到父節點後面
nodeOps.appendChild(parent, elm)
}
}
}
複製程式碼
addVnodes()
批量呼叫createElm()
來建立節點
function addVnodes (parentElm, refElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
createElm(vnodes[startIdx], parentElm, refElm)
}
}
複製程式碼
removeNode()
移除節點
function removeNode (el) {
const parent = nodeOps.parentNode(el)
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
複製程式碼
removeNodes()
批量移除節點
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
}
複製程式碼
sameVnode()
是否為相同節點
function sameVnode (a, b) {
return (
a.key === b.key &&
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
)
}
複製程式碼
sameInputType()
是否有相同的 input type
function sameInputType (a, b) {
if (a.tag !== 'input') return true
let i
const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
return typeA === typeB
}
複製程式碼
3、節點 diff
i. 相關流程圖
談到這,先挪(盜)用下我以前文章中相關的兩張圖
ii. diff、patch 操作合二為一
看過我以前文章的小夥伴都應該知道,我之前文章中關於 diff 和 patch 是分成兩個步驟來實現的。而 vue
中則是將 diff 和 patch 操作合二為一了。現在我們來看看,vue
中對於這塊具體是如何處理的
function patch (oldVnode, vnode) {
// 如果老節點不存在,則直接建立新節點
if (isUndef(oldVnode)) {
if (isDef(vnode)) createElm(vnode)
// 如果老節點存在,新節點卻不存在,則直接移除老節點
} else if (isUndef(vnode)) {
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
removeVnodes(parentElm, , 0, [oldVnode].length -1)
} else {
const isRealElement = isDef(oldVnode.nodeType)
// 如果新舊節點相同,則進行具體的 patch 操作
if (isRealElement && sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode)
} else {
// 否則建立新節點,移除老節點
createElm(vnode, parentElm, nodeOps.nextSibling(oldElm))
removeVnodes(parentElm, [oldVnode], 0, 0)
}
}
}
複製程式碼
然後我們再看 patchVnode
中間相關的邏輯,先看下,前面提及的 key
在這的用處
function patchVnode (oldVnode, vnode) {
// 新舊節點完全一樣,則直接 return
if (oldVnode === vnode) {
return
}
// 如果新舊節點都被標註靜態節點,且節點的 key 相同。
// 則直接將老節點的 componentInstance 直接拿過來便OK了
if (
isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
}
複製程式碼
接下來,我們看看 vnode 上面的文字內容是如何進行對比的
- 若 vnode 為非文字節點
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
// 如果 oldCh,ch 都存在且不相同,則執行 updateChildren 函式更新子節點
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch)
// 如果只有 ch 存在
} else if (isDef(ch)) {
// 老節點為文字節點,先將老節點的文字清空,然後將 ch 批量插入到節點 elm 下
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1)
// 如果只有 oldCh 存在,則直接清空老節點
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
// 如果 oldCh,ch 都不存在,且老節點為文字節點,則只將老節點文字清空
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
}
複製程式碼
- 若 vnode 為文字節點,且新舊節點文字不同,則直接將設定為 vnode 的文字內容
if (isDef(vnode.text) && oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
複製程式碼
iii. updateChildren
首先我們先看下方法中對新舊節點起始和結束索引的定義
function updateChildren (parentElm, oldCh, newCh) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
}
複製程式碼
直接畫張圖來理解下
緊接著就是一個 while
迴圈讓新舊節點起始和結束索引不斷往中間靠攏
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)
複製程式碼
若 oldStartVnode
或者 oldEndVnode
不存在,則往中間靠攏
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
}
複製程式碼
接下來就是 oldStartVnode
,newStartVnode
,oldEndVnode
,newEndVnode
兩兩對比的四種情況了
// oldStartVnode 和 newStartVnode 為 sameVnode,進行 patchVnode
// oldStartIdx 和 newStartIdx 向後移動一位
else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// oldEndVnode 和 newEndVnode 為 sameVnode,進行 patchVnode
// oldEndIdx 和 newEndIdx 向前移動一位
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// oldStartVnode 和 newEndVnode 為 sameVnode,進行 patchVnode
// 將 oldStartVnode.elm 插入到 oldEndVnode.elm 節點後面
// oldStartIdx 向後移動一位,newEndIdx 向前移動一位
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode)
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 同理,oldEndVnode 和 newStartVnode 為 sameVnode,進行 patchVnode
// 將 oldEndVnode.elm 插入到 oldStartVnode.elm 前面
// oldEndIdx 向前移動一位,newStartIdx 向後移動一位
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
複製程式碼
用張圖來總結上面的流程
當以上條件都不滿足的情況,則進行其他操作。
在看其他操作前,我們先看一下函式 createKeyToOldIdx
,它的作用主要是 return
出 oldCh
中 key
和 index
唯一對應的 map
表,根據 key
,則能夠很方便的找出相應 key
在陣列中對應的索引
function createKeyToOldIdx (children, beginIdx, endIdx) {
let i, key
const map = {}
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key
if (isDef(key)) map[key] = i
}
return map
}
複製程式碼
除此之外,這塊還有另外一個輔助函式 findIdxInOld
,用來找出 newStartVnode
在 oldCh
陣列中對應的索引
function findIdxInOld (node, oldCh, start, end) {
for (let i = start; i < end; i++) {
const c = oldCh[i]
if (isDef(c) && sameVnode(node, c)) return i
}
}
複製程式碼
接下來我們看下不滿足上面條件的具體處理
else {
// 如果 oldKeyToIdx 不存在,則將 oldCh 轉換成 key 和 index 對應的 map 表
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
// 如果 idxInOld 不存在,即老節點中不存在與 newStartVnode 對應 key 的節點,直接建立一個新節點
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
// 在 oldCh 找到了對應 key 的節點,且該節點與 newStartVnode 為 sameVnode,則進行 patchVnode
// 將 oldCh 該位置的節點清空掉,並在 parentElm 中將 vnodeToMove 插入到 oldStartVnode.elm 前面
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// 找到了對應的節點,但是卻屬於不同的 element 元素,則建立一個新節點
createElm(newStartVnode, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
// newStartIdx 向後移動一位
newStartVnode = newCh[++newStartIdx]
}
複製程式碼
經過這一系列的操作,則完成了節點之間的 diff
和 patch
操作,即完成了 oldVnode
向 newVnode
轉換的操作。
文章到這裡也要告一段落了,看到這裡,相信大家已經對 vue
中的 vdom
這塊也一定有了自己的理解了。
那麼,我們再回到文章開頭我們丟擲的問題,大家知道為什麼會出現這個問題了麼?
emmm,如果想要繼續溝通此問題,歡迎大家加群進行討論,前端大雜燴:731175396。小夥伴們記得加群哦,哪怕一起來水群也是好的啊 ~ (注:群裡單身漂亮妹紙真的很多哦,當然帥哥也很多,比如。。。me)