細談 vue 核心- vdom 篇

qiangdada發表於2019-04-08

很早之前,我曾寫過一篇文章,分析並實現過一版簡易的 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>
複製程式碼

輸入篩選後效果圖如下

細談 vue 核心- vdom 篇

然後我在換一個關鍵詞進行搜尋,結果就會出現以下展示的問題

細談 vue 核心- vdom 篇

我並沒有進行選擇,但是 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

開篇前,先講一下 VNodevue 中的 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 啦!

細談 vue 核心- vdom 篇

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 = {
  '&lt;': '<',
  '&gt;': '>',
  '&quot;': '"',
  '&amp;': '&',
  '&#10;': '\n',
  '&#9;': '\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. 相關流程圖

談到這,先挪(盜)用下我以前文章中相關的兩張圖

細談 vue 核心- vdom 篇

細談 vue 核心- vdom 篇

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
}
複製程式碼

直接畫張圖來理解下

細談 vue 核心- vdom 篇

緊接著就是一個 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]
}
複製程式碼

接下來就是 oldStartVnodenewStartVnodeoldEndVnodenewEndVnode 兩兩對比的四種情況了

// 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]
}
複製程式碼

用張圖來總結上面的流程

細談 vue 核心- vdom 篇

當以上條件都不滿足的情況,則進行其他操作。

在看其他操作前,我們先看一下函式 createKeyToOldIdx,它的作用主要是 returnoldChkeyindex 唯一對應的 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 ,用來找出 newStartVnodeoldCh 陣列中對應的索引

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]
}
複製程式碼

經過這一系列的操作,則完成了節點之間的 diffpatch 操作,即完成了 oldVnodenewVnode 轉換的操作。

文章到這裡也要告一段落了,看到這裡,相信大家已經對 vue 中的 vdom 這塊也一定有了自己的理解了。 那麼,我們再回到文章開頭我們丟擲的問題,大家知道為什麼會出現這個問題了麼?

emmm,如果想要繼續溝通此問題,歡迎大家加群進行討論,前端大雜燴:731175396。小夥伴們記得加群哦,哪怕一起來水群也是好的啊 ~ (注:群裡單身漂亮妹紙真的很多哦,當然帥哥也很多,比如。。。me)

細談 vue 核心- vdom 篇

相關文章