为了账号安全,请及时绑定邮箱和手机立即绑定

细谈 vue 核心- vdom 篇

标签:
Vue.js 源码

很早之前,我曾写过一篇文章,分析并实现过一版简易的 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

开篇前,先讲一下 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 啦!

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;': '>',
  '"': '"',
  '&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. 相关流程图

谈到这,先挪(盗)用下我以前文章中相关的两张图

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]
}

接下来就是 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]
}

用张图来总结上面的流程

当以上条件都不满足的情况,则进行其他操作。

在看其他操作前,我们先看一下函数 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 这块也一定有了自己的理解了。
那么,我们再回到文章开头我们抛出的问题,大家知道为什么会出现这个问题了么?

点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
38
获赞与收藏
163

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消