跳至正文
返回博客

Vue2 知识点

结合源码整理 Vue2 生命周期、响应式、双端 diff、组件缓存、自定义指令和虚拟 DOM。

更新于

vue 生命周期

参考 源码

生命周期 描述
beforeCreate 组件实例被创建之初, data 、 props 等都还未被初始化,无法访问
created state ( props / methods / data / computed / watch ) 初始化完成,但是还未创建 vm.$el
beforeMount VNode 挂载到 DOM 之前触发。
此时已经能获取 vm.$el。
此时 DOM 上的内容还是老的未被替换的内容,也可能 DOM 还未挂载到 DOM 上
mounted VNode 挂载到 DOM 上后触发。
beforeUpdate 监听组件 state ( props / methods / data / computed / watch ) 变化时触发,在组件更新之前,此时更新 state 不会触发 beforeUpdate
updated 组件 VNode 更新后触发,此时更新 state ( props / methods / data / computed / watch ) 将会重新触发 beforeUpdate 、 updated 等流程
beforeDestroy 组件实例销毁前触发,此时仍然可以访问 state ( props / methods / data / computed / watch )
destroyed 组件实例销毁后触发,此时已经无法访问到 state ( props / methods / data / computed / watch )
activated keep-alive 缓存组件从缓存挂载入 vDOM 后触发,先于 updated
deactivated keep-alive 缓存组件从 vDOM 中被移除时触发
errorCaptured 在后代组件中出现错误时调用
setup composition API 中新增。
@vue/composition-api 包中对 data 进行拦截,在 data 初始化之前调用此函数。
在 2.7 版本后,会在 props 初始化完成、 methods 初始化之前触发。
所以, setup 中不能访问到 data 定义的响应式变量
beforeUnmount beforeDestroy 别名, composition API 中正式更名为 beforeUnmount
unmounted destroyed 别名 composition API 中正式更名为 unmounted

初始化

  • 在调用 beforeCreate 之前,数据初始化并未完成,像 data 、 props 这些属性无法访问到
  • 到了 created 的时候,数据已经初始化完成,能够访问 data 、 props 这些属性,但这时候并未完成 DOM 的挂载,因此无法访问到 DOM 元素
  • state 初始化顺序: props > methods > data > computed > watch
---
title: Vue2 初始化流程
---
graph TD
  A[通过自增生成 uid]
  B[init scope]
  C[init lifecycle]
  D[init events]
  E[init render]
  F[trigger beforeCreate]
  G[init injections]
  H[init state]
  HA[init state: init props]
  HB[init state: init methods]
  HC[init state: init data]
  HD[init state: init computed]
  HE[init state: init watch]
  I[init provide]
  J[trigger created]

  A --> B
  B --> C
  C --> D

  subgraph beforeCreate 调用之前
    D --> E
  end

  E --> F
  F --> G

  subgraph created 调用之前
    G --> H
    H --> HA
    HA --> HB
    HB --> HC
    HC --> HD
    HD --> HE
    HE --> I
  end

  I --> J

挂载

通过 vm.$mount + 公共 mount 将触发挂载流程,具体流程如下:

---
title: Vue2 挂载组件流程
---
graph TD
  A[获取挂载点 DOM]
  B[统一渲染参数为 render<br>优先级上: render > template > outerHTML<br>tempate 会解析为 AST 再生成 render]
  C[mount 参数校验]
  D[trigger beforeMount]
  E[初始化 updateComponent 函数]
  F[使用 watcher 监听 vDOM ,在 vDOM 变更后更新 DOM]
  G[执行 immediate watch]
  H[挂载完成]
  I[trigger mounted]

  subgraph beforeMount 触发之前
    subgraph "vm.$mount 函数"
      A --> B
    end
    B --> C
  end

  C --> D
  D --> E

  subgraph mounted 触发之前
    E --> F
    F --> G
    G --> H
  end

  H --> I

不能 mount 到 body / html 的原因

  • vue 源码 中禁止此行为
  • vue2 中的 mount 实现会对挂载点进行替换,如果直接替换 body 节点, body children 中还有待执行的 JS script ,容易引发不可预测问题
  • vDOM diff 算法基于空白 DOM + 空白 vDOM 对应,在无法替换 body 的情况下, DOM 与 vDOM 不匹配,可能导致其他问题
  • 影响其他第三方库工作,可能将其自身的全局功能替换掉,如组件库中的全局弹窗等
  • scoped 样式是基于 HTML 元素的 hash 属性,如果挂载到 body 上, scoped 可能会失效
  • 丢失 body 后,可能会导致 SEO 问题或者 a11y 问题

更新

在挂载流程中,创建了 watcher 监听 vDOM 变化,用以更新 DOM 。

当 watcher 监听到 vDOM 变更时,就会触发 beforeUpdate 了,完整的 更新流程 如下:

---
title: Vue2 Update 组件流程
---
graph TD
  A[watcher 监听到 VNode 变更]
  B[trigger beforeUpdate]
  C[patch vnode]
  D[刷新任务调度队列]
  E[调度队列中触发组件的 activated 与 update hook]

  subgraph beforeUpdate 触发之前
    A
  end

  A --> B

  subgraph update 触发之前
    B --> C
    C --> D
  end

  D --> E

总结

  • new Vue的时候会调用_init方法

    • 定义 $set、 $get 、$delete、$watch 等方法
    • 定义 $on、$off、$emit、$once 等事件
    • 定义 _update、$forceUpdate、$destroy生命周期
  • 调用$mount进行页面的挂载

  • 挂载的时候主要是通过mountComponent方法

  • 定义updateComponent更新函数

  • 执行render生成虚拟DOM

  • _update将虚拟DOM生成真实DOM结构,并且渲染到页面中

v-if 与 v-for 优先级

参考 源码 可以看到, v-for 先于 v-if 。

所以在 v-if 与 v-for 应用于同一元素的时候,会先生成 N 个元素,再对每个元素应用 v-if 判断。

data 函数与对象

  • 根实例对象 data 可以是对象也可以是函数(根实例是单例),不会产生数据污染情况
  • 组件实例对象 data 必须为函数,目的是为了防止多个组件实例对象之间共用一个 data ,产生数据污染。采用函数的形式, initData 时会将其作为工厂函数都会返回全新 data 对象

为什么 data 不能是对象

参考 vue 源码

在初始化 data 时,如果 data 是对象则会直接使用,如果是函数则会调用函数。

本质问题不是 data 的形式,如果在 data 中返回了共享的对象,那么也会导致所有该组件的实例共享此 data

vue 在 合并 option 的时候 ,也会进行校验

为什么 data 需要是函数

定义组件从本质上来说,就是一个构造函数。

渲染时,会将定义组件实例化为对象,而 data 会存在于共享实例中,所有组件实例共享。

为了避免污染,所以需要隔离作用域,也就是每次组件 create 的时候重新拷贝一份,而不是使用共享的 data 。

双向绑定

原理

通过 Object.defineProperty 递归遍历对象的每个属性,为每个属性创建 Dep (依赖收集器),并在 getter 中收集依赖,在 setter 中触发更新

整个依赖收集采用 观察者模式 + 发布/订阅者模式 实现

---
title: 双向绑定架构设计图
---
flowchart TD
    A[原始对象] --> B[Observer]
    B -->|递归遍历| C[defineReactive]
    C --> D[创建Dep实例]
    D -->|getter| E[收集Watcher依赖]
    D -->|setter| F["触发Dep.notify()"]
    F --> G[Watcher更新视图]
  • Observe 类: 递归遍历对象所有属性,调用 Object.defineProperty 定义新的 getter / setter ,每个响应对象仅有一个 Observe 实例,挂载到 vm.__ob__ 上。
  • Dep 类(依赖收集器): 每个属性对应一个 Dep ,通过闭包保存,如果是数组会放到 vm.__ob__.dep 上。
    • depend(): 收集当前活跃的 Watcher 。
    • notify(): 通知所有订阅的 Watcher 执行。
  • Watcher 类
    • 渲染 Watcher: 监听 state 变化,触发组件 update
    • computed: 缓存 Watcher 结果
    • 自定义 Watch: 用户自定义的 state 监听行为

数组

数组监听采用添加中间原型的方式,进行拦截,以便监听到数据变更,触发响应回调,示例代码如下:

// 获取数组的原型
const arrayProto = Array.prototype
// 基于数组的原型创建一个新的对象
const middleProto = Object.create(arrayProto)

// 一个要被重写的方法列表
const overrideMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']

for (const method of overrideMethods) {
  // 缓存原始方法
  const original = arrayProto[method]
  // 定义重写的方法
  Object.defineProperty(middleProto, method, {
    value: function mutator(...args) {
      // 调用原始方法
      const result = original.apply(this, args)
      // 获取数组对象的 ob 对象,它是一个 Observer 实例
      const ob = this.__ob__
      // 通知变更
      ob.dep.notify()
      return result
    },
    enumerable: false,
    writable: true,
    configurable: true,
  })
}

// 假设 arr 是 Vue 管理的数组
// 将 arr 的原型指向重写了方法的对象
Reflect.setPrototypeOf(arr, middleProto)

局限性

  • 静态监听: 只能监听初始定义好的属性,对于新增属性难以监听,需要通过 $set 赋予响应性,或者每次赋值后手动触发 $forceUpdate 重新渲染。
  • 数组实现不完整
    • 无法监听数组成员的变更,比如通过 index 访问成员并赋值、通过给 length 属性赋值导致的数组成员变更等。
    • 数组函数覆盖不完全,只能覆盖有限方法,扩展性差。
  • 性能问题: 性能开销大,尤其是嵌套对象时,在初始化 dep 场景下会有大量内存开销。

diff 算法

整体策略为:深度优先,同层比较,双端比较

同层比较

---
title: Vue2 双向 diff 算法流程
---
graph TD
  oldA[A]
  oldB[B]
  oldC[C]
  oldB1[B1]
  oldB2[B2]
  oldC1[C1]
  oldC2[C2]

  newA[A]
  newB[B]
  newC[C]
  newB1[B]
  newB2[B]
  newC1[C1]
  newC2[C2]

  subgraph old[旧的 VDOM]
    oldA --> oldB
    oldA --> oldC
    oldB --> oldB1
    oldB --> oldB2
    oldC --> oldC1
    oldC --> oldC2
  end

  subgraph new[新的 VDOM]
    newA --> newB
    newA --> newC
    subgraph "全部替换,不会复用"
      newB --> newB1
      newB --> newB2
    end
    newC --> newC1
    newC --> newC2
  end

  old --> new

vnode 树 patch

当数据发生改变时, setter 会调用 Dep.notify 通知所有订阅者 Watcher ,订阅者就会调用 patch 更新 DOM 节点

patch 主要有以下四个判断:

  1. 没有新节点: 直接触发旧 VNode 的 destroy
  2. 没有旧节点: 通过 createElm 创建新的 VNode
  3. 新旧节点是同一节点: 通过 patchVnode 更新节点
  4. 新旧节点不是同一节点: 删除旧节点,创建新节点

函数实现见 源码 ,下面为部分核心代码:

function patch(oldVnode, vnode, hydrating, removeOnly) {
  // 1. 没有新结点
  if (isUndef(vnode)) {
    if (isDef(oldVnode)) {
      invokeDestroyHook(oldVnode)
    }
    return
  }

  let isInitialPatch = false
  const insertedVnodeQueue: any[] = []
  if (isUndef(oldVnode)) {
    // 2. 没有旧节点,直接创建新节点
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 3. 新旧节点是同一组件
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
      // 4. 新旧节点不是统一组件
      if (isRealElement) {
        // 如果是 DOM 节点,判断是不是 SSR 渲染的结果
        if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
          oldVnode.removeAttribute(SSR_ATTR)
          hydrating = true
        }
        // 如果是 SSR 水合后的节点,通过水合去更新,直接结束 patch
        if (isTrue(hydrating)) {
          if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
            invokeInsertHook(vnode, insertedVnodeQueue, true)
            return oldVnode
          }
        }
        // 如果水合失败 / 不是 SSR ,将旧节点修改为空白节点去更新
        oldVnode = emptyNodeAt(oldVnode)
      }

      // 替换旧节点
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)

      // 创建新结点
      createElm(
        vnode,
        insertedVnodeQueue,
        oldElm._leaveCb ? null : parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 递归更新父节点
      if (isDef(vnode.parent)) {
      }

      // 删除旧节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 0, 0)
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode)
      }
    }
  }

  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  return vnode.elm
}

相同节点的判断条件可以看 源码 ,主要包括以下几个条件:

  1. key 相同
  2. 都是异步组件或者都不是
  3. 组件 tag 相同
  4. data 都定义或者都没定义
  5. 如果是 input 的话需要比较 input 的 type 是否相同

同一节点的属性、子节点 patch

patchVnode 主要工作流程 如下:

  1. 新节点是否为文本节点,如果是,直接更新 DOM 的文本内容即可。
  2. 新旧节点如果有子节点,先 patch 子节点,如果子节点不一致,通过 updateChildren 更新子节点
  3. 只有新节点有子节点,直接创建新的子节点追加到 vnode 中即可。
  4. 只有旧节点有子节点,直接删除旧节点的子节点即可。

以下为省略了兼容性代码后的代码摘抄:

function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  // 如果新旧节点一致,什么都不做
  if (oldVnode === vnode) {
    return
  }

  // 让 vnode.el 引用到现在的真实 dom ,当 el 修改时, vnode.el 会同步变化
  const elm = vnode.elm = oldVnode.elm

  // 异步组件占位符
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 如果新旧都是静态节点,并且具有相同的 key
  // 当 vnode 是克隆节点或是 v-once 指令控制的节点时
  // 只需要把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上
  // 也不用再有其他操作
  if (isTrue(vnode.isStatic)
    && isTrue(oldVnode.isStatic)
    && vnode.key === oldVnode.key
    && (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) {
      cbs.update[i](oldVnode, vnode)
    }
    if (isDef(i = data.hook) && isDef(i = i.update)) {
      i(oldVnode, vnode)
    }
  }
  // 如果 vnode 不是文本节点或者注释节点
  if (isUndef(vnode.text)) {
    // 并且都有子节点
    if (isDef(oldCh) && isDef(ch)) {
      // 并且子节点不完全一致,则调用 updateChildren
      if (oldCh !== ch) {
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      }

      // 如果只有新的 vnode 有子节点
    } else if (isDef(ch)) {
      if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
      // elm 已经引用了老的 dom 节点,在老的 dom 节点上添加子节点
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)

      // 如果新 vnode 没有子节点,而 vnode 有子节点,直接删除老的 oldCh
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)

      // 如果老节点是文本节点
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, '')
    }

    // 如果新 vnode 和老 vnode 是文本节点或注释节点
    // 但是 vnode.text != oldVnode.text 时,只需要更新 vnode.elm 的文本内容就可以
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) {
      i(oldVnode, vnode)
    }
  }
}

子节点更新(双端比较)

主要原理

查看 源码 可以发现,主要维护的是这四对关系:

  • oldHead 与 newHead
  • oldTail 与 newTail
  • oldHead 与 newTail
  • oldTail 与 newHead

具体来说,就是处理以下场景:

  1. oldHead === newHead ,直接通过 patchVnode 更新, newHead 、 oldHead 指针后移
  2. oldTail === newTail ,直接通过 patchVnode 更新, newTail 、 oldTail 指针前移
  3. oldHead === newTail ,直接通过 patchVnode 更新,再移动节点到 newHead 位置,然后后移 newHead 、 前移 oldTail
  4. oldTail === newHead ,直接通过 patchVnode 更新,再移动节点到 newTail 位置,然后后移 oldHead 、 前移 newTail
  5. 都不匹配的情况下,也会尽力复用旧节点,具体处理流程如下:
    1. 遍历旧节点,按照 key 查找是否存在于 newHead key 相同的节点,如果存在则通过 patchVnode 更新,再移动到 newHead 位置,并且 newHead 后移
    2. 旧节点中未能查找到相同 key 的节点,直接创建新节点,并且 newHead 后移
function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  let oldStartIdx = 0 // 旧头索引,也就是 oldHead
  let newStartIdx = 0 // 新头索引,也就是 newHead
  let oldEndIdx = oldCh.length - 1 // 旧尾索引,也就是 oldTail
  let newEndIdx = newCh.length - 1 // 新尾索引,也就是 newTail
  let oldStartVnode = oldCh[0] // oldVnode的第一个child
  let oldEndVnode = oldCh[oldEndIdx] // oldVnode的最后一个child
  let newStartVnode = newCh[0] // newVnode的第一个child
  let newEndVnode = newCh[newEndIdx] // newVnode的最后一个child
  let oldKeyToIdx, idxInOld, vnodeToMove, refElm

  // removeOnly is a special flag used only by <transition-group>
  // to ensure removed elements stay in correct relative positions
  // during leaving transitions
  const canMove = !removeOnly

  // 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 如果oldVnode的第一个child不存在
    if (isUndef(oldStartVnode)) {
      // oldStart索引右移
      oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left

      // 如果oldVnode的最后一个child不存在
    } else if (isUndef(oldEndVnode)) {
      // oldEnd索引左移
      oldEndVnode = oldCh[--oldEndIdx]

      // oldStartVnode和newStartVnode是同一个节点
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      // patch oldStartVnode和newStartVnode, 索引左移,继续循环
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]

      // oldEndVnode和newEndVnode是同一个节点
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // patch oldEndVnode和newEndVnode,索引右移,继续循环
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]

      // oldStartVnode和newEndVnode是同一个节点
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // patch oldStartVnode和newEndVnode
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      // 如果removeOnly是false,则将oldStartVnode.eml移动到oldEndVnode.elm之后
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // oldStart索引右移,newEnd索引左移
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]

      // 如果oldEndVnode和newStartVnode是同一个节点
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // patch oldEndVnode和newStartVnode
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      // 如果removeOnly是false,则将oldEndVnode.elm移动到oldStartVnode.elm之前
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // oldEnd索引左移,newStart索引右移
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]

      // 如果都不匹配
    } else {
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }

      // 尝试在oldChildren中寻找和newStartVnode的具有相同的key的Vnode
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

      // 如果未找到,说明newStartVnode是一个新的节点
      if (isUndef(idxInOld)) { // New element
        // 创建一个新Vnode
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)

        // 如果找到了和newStartVnodej具有相同的key的Vnode,叫vnodeToMove
      } else {
        vnodeToMove = oldCh[idxInOld]
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
          warn(
            'It seems there are duplicate keys that is causing an update error. '
            + 'Make sure each v-for item has a unique key.'
          )
        }

        // 比较两个具有相同的key的新节点是否是同一个节点
        // 不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
        if (sameVnode(vnodeToMove, newStartVnode)) {
          // patch vnodeToMove和newStartVnode
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
          // 清除
          oldCh[idxInOld] = undefined
          // 如果removeOnly是false,则将找到的和newStartVnodej具有相同的key的Vnode,叫vnodeToMove.elm
          // 移动到oldStartVnode.elm之前
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)

          // 如果key相同,但是节点不相同,则创建一个新的节点
        } else {
          // same key but different element. treat as new element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        }
      }

      // 右移
      newStartVnode = newCh[++newStartIdx]
    }
  }
}

总结

  • 当数据发生改变时,渲染 watcher 就会调用 patch 给真实的 DOM 打补丁
  • 通过 isSameVnode 进行判断,相同则调用 patchVnode 方法
  • patchVnode 做了以下操作:
    • 找到对应的真实 dom ,称为 el
    • 如果都有都有文本节点且不相等,将 el 文本节点设置为 Vnode 的文本节点
    • 如果 oldVnode 有子节点而 VNode 没有,则删除 el 子节点
    • 如果 oldVnode 没有子节点而 VNode 有,则将 VNode 的子节点真实化后添加到 el
    • 如果两者都有子节点,则执行 updateChildren 函数比较子节点
  • updateChildren 主要做了以下操作:
    • 设置新旧 VNode 的头尾指针
    • 新旧头尾指针进行比较,循环向中间靠拢,根据情况调用 patchVnode 进行 patch 重复流程、调用 createElem 创建一个新节点,从哈希表寻找 key 一致的 VNode 节点再分情况操作

ref

原理

本质是通过 Vue.observable 实现的。

Vue.observable ,让一个对象变成响应式数据。 Vue 内部会用它来处理 data 函数返回的对象

const obj = { foo: 123 }
const objRef = Vue.observable(obj)

console.log(obj === objRef) // true
// `Vue.observable` 会通过 `Object.defineProperty` 设置每个属性的 `getter` / `setter` ,并不会直接返回一个新对象。

源码分析

参考 完整源码 ,精简版如下:

export function observe(
  value: any,
  shallow?: boolean,
  ssrMockReactivity?: boolean
): Observer | void {
  // 如果已经是响应式变量就直接返回
  if (value && hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    return value.__ob__
  }

  if (
    // 全局开关,判断是否要进行响应操作
    shouldObserve
    // 使用 SSR 模拟响应,并且未渲染完成
    && (ssrMockReactivity || !isServerRendering())
    // 非原始类型
    && (isArray(value) || isPlainObject(value))
    // 变量可以扩展
    && Object.isExtensible(value)
    // 变量自身未被标记不能响应
    && !value.__v_skip /* ReactiveFlags.SKIP */ &&
    // 没有 composition API 的响应标记
    !isRef(value)
    // 不是 VNode
    && !(value instanceof VNode)
  ) {
    // 返回新的响应
    return new Observer(value, shallow, ssrMockReactivity)
  }
}
export class Observer {
  /** 收集依赖 */
  dep: Dep
  /** 有多少 Vue 实例,将该对象作为 root $data */
  vmCount: number

  constructor(public value: any, public shallow = false, public mock = false) {
    this.dep = mock ? mockDep : new Dep()
    this.vmCount = 0
    def(value, '__ob__', this)
    if (isArray(value)) {
      if (!mock) {
        if (hasProto) {
          // 多加一层原型链,修改数组变异方法,基本都是走这里
          /* eslint-disable no-proto */
          ;(value as any).__proto__ = arrayMethods
          /* eslint-enable no-proto */
        } else {
          // 不支持 `__proto__` 的场景下,手动定义变异方法
          for (let i = 0, l = arrayKeys.length; i < l; i++) {
            const key = arrayKeys[i]
            def(value, key, arrayMethods[key])
          }
        }
      }

      // 如果需要深度 ref ,就递归继续处理
      if (!shallow) {
        this.observeArray(value)
      }
    } else {
      // 处理对象只需要枚举 key ,挨个定义即可
      const keys = Object.keys(value)
      for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock)
      }
    }
  }

  /**
   * Observe a list of Array items.
   */
  observeArray(value: any[]) {
    for (let i = 0, l = value.length; i < l; i++) {
      observe(value[i], false, this.mock)
    }
  }
}

keep-alive

生命周期

vue 内部的缓存组件,有一个哈希表缓存已经创建出来的组件 VNode ,被缓存的组件生命周期如下:

  • 首次进入
    1. beforeRouteEnter
    2. beforeCreate
    3. created
    4. beforeMount
    5. mounted
    6. activated
    7. beforeRouteLeave
    8. deactivated
  • 再次进入
    1. beforeRouteEnter
    2. activated
    3. beforeRouteLeave
    4. deactivated

SSR 期间不会触发 activated 、 deactivated

实现原理

见 源码

缓存结构为

interface CacheEntry {
  /** 组件 name */
  name?: string
  /** 组件 tag */
  tag?: string
  /** 组件实例 */
  componentInstance?: Component
}

// key 不是组件 name ,而是根据规则生成的唯一 ID
// 解决同时缓存多个同一组件不同实例的互相冲突的问题
type CacheEntryMap = Record<string, CacheEntry | null>

缓存失效、过期的时候,会调用 pruneCacheEntry 清理缓存:

  1. 销毁组件
  2. 从 cache 中移除缓存数据
  3. 从已缓存的 keys 中移除组件对应的唯一 ID

keep-alive 工作流程如下:

function render() {
  // 获取默认插槽的内容
  const slot = this.$slots.default
  // 从插槽中取出需要展示的组件
  const vnode = getFirstComponentChild(slot)
  // 获取组件配置
  const componentOptions = vnode && vnode.componentOptions
  // 找不到配置直接返回,找到配置开始检查是否有缓存
  if (componentOptions) {
    // 获取组件名,匹配组件名是否满足 include 、 exclude 规则
    const name = _getComponentName(componentOptions)
    const { include, exclude } = this
    if (
      // 不在 included 中
      (include && (!name || !matches(include, name)))
      // 在 excluded 中
      || (exclude && name && matches(exclude, name))
    ) {
      // 不满足使用缓存条件,直接返回
      return vnode
    }

    const { cache, keys } = this
    // 缓存的 key 不是组件名,否则会重复,需要生成唯一 ID
    const key
      = vnode.key == null
        ? // 根据 cid + tag 作为缓存 key ,基本上可以确定唯一,冲突可能性较小
        componentOptions.Ctor.cid
        + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        // 有 key 就直接使用 key ,在约定中 key 本身就是作用域内唯一
        : vnode.key
    // 查找缓存是否存在
    if (cache[key]) {
      // 存在缓存,复用组件实例
      vnode.componentInstance = cache[key].componentInstance
      // LRU ,将其更新到队尾
      remove(keys, key)
      keys.push(key)
    } else {
      // 标记为需要缓存的组件, keep-alive 触发 updated 的时候会缓存该组件
      // 在 updated 中处理的原因是: 子组件更新会触发父组件 updated 。
      this.vnodeToCache = vnode
      this.keyToCache = key
    }

    // @ts-expect-error can vnode.data can be undefined
    vnode.data.keepAlive = true
  }
  return vnode || (slot && slot[0])
}

移除已缓存的组件

主流方案

通过动态修改 exclude ,利用 keep-alive 对 include 、 exclude 等属性的监听,动态修改绑定的组件名,在 nextTick 后重新更新 exclude 即可。

优点:

  • 不需要侵入 keep-alive 内部,后续升级较为简单。

缺点:

  • 数据传递逻辑较为复杂,需要从触发组件一直传递到 keep-alive 组件的父组件。
  • 如果本身存在动态 exclude 场景,逻辑上可能存在冲突( 手动删除后又被追加进来 )。
  • exclude 改动后需要全量更新缓存表,如果缓存的内容较多,可能会有一定的性能风险。

侵入组件内部方案

遍历 VDOM ,查找 keep-alive 组件 VNode ,通过源码中对应的 key 生成策略生成组件 key ,再将 cache[key] 删除。

优点

  • 老版本兼容( 2.1.0 支持 include / exclude )。
  • 不需要改动 exclude 。
  • 整体性能开销低,只需要往上遍历 VNode ,不需要重新更新所有缓存数据。

缺点

  • 侵入组件内部,可能导致意外错误。
  • 升级 Vue 的时候有遗留风险,可能导致升级后功能异常。

自定义指令

基本形式

以 v-name:arg.modify1.modify2="1 + 1" 为例:

  • name: 指令名,作为 name 参数传递给指令
  • arg: 指令参数,作为 arg 参数传递给指令
  • modify1/modify2: 指令修饰符,整合为 { modify1: true, modify2: true } 作为 modifiers 参数传递给指令
  • 1 + 1: 指令表达式,作为 expression 参数传递给指令
  • 2: 指令表达式的执行结果,作为 value 参数传递给指令

定义

Vue.directive('demo', {
  bind(el, binding, vnode) {
    const s = JSON.stringify
    el.innerHTML
      = `name: ${s(binding.name)}<br>`
        + `value: ${s(binding.value)}<br>`
        + `expression: ${s(binding.expression)}<br>`
        + `argument: ${s(binding.arg)}<br>`
        + `modifiers: ${s(binding.modifiers)}<br>`
        + `vnode keys: ${Object.keys(vnode).join(', ')}`
  }
})

// 组件内使用的指令
Vue.extends({
  directives: {
    focus(el) {
      // 直接使用 function 而不是对象也是可以的
      // 默认会为 `bind` 、 `update` 应用此 function
      el.focus()
    }
  }
})

支持的钩子函数及触发时机

钩子函数名称 触发时机
bind 只调用一次,指令第一次绑定到元素时调用。在这里可以进行一次性的初始化设置。
inserted 被绑定元素插入父节点时调用 (仅保证父节点存在,但不一定已被插入文档中)。
update 所在组件的 VNode 更新时调用, 但是可能发生在其子 VNode 更新之前 。指令的值可能发生了改变,也可能没有。但是你可以通过比较更新前后的值来忽略不必要的模板更新
componentUpdated 指令所在组件的 VNode 及其子 VNode 全部更新后 调用。
unbind 只调用一次,指令与元素解绑时调用。

所有钩子支持的参数如下:

  • el: 指令绑定的元素,用于 DOM 操作
  • binding: 指令使用时的对象操作
    • name: 指令名
    • value: 指令绑定的值
    • oldValue: 指令绑定的前一个值,仅在 update 、 componentUpdated 中可用,不区分值是否修改都会传递
    • expression: 指令表达式。
    • arg: 指令参数
    • modifiers: 指令包含哪些修饰符
  • vnode: Vue 生成的虚拟节点
  • oldVnode: 上一个虚拟节点,仅在 update 、 componentUpdated 中可用

虚拟 DOM

解决的问题

  • 虚拟 DOM 诞生源自于 Diff 需求,在进行节点 Diff 的时候,原生 DOM 操作效率较低,每次创建、删除都需要大量资源开销,远不如只记录了关键信息的虚拟 DOM 高效。
  • 虚拟 DOM 本身就是一套抽象概念,在非 DOM 场景也可以正常渲染,比如跨端场景,只需要根据 schema 映射出对应的视图结构即可。
  • 编程范式从命令式转为声明式,开发者只需要描述状态对应的 UI 即可,无需手动操作 DOM 。并且由于开发者能力参差不齐,框架底层封装的 DOM 操作,往往比大多数手写的局部刷新代码还要高效。
  • 每个组件独立维护 VDOM ,通过 props 、 events 等方式通信,使得组件得以实现高内聚、低耦合的结构,提高可维护性。

无虚拟 DOM 路线

无虚拟 DOM 重新流行的原因:

  • 绕过 Diff 算法的性能瓶颈: 虚拟 DOM 的 Diff 算法复杂度较高(如传统 O(n³) 时间复杂度),在大规模数据更新时可能成为性能瓶颈。而无虚拟 DOM 框架通过细粒度响应式系统(如 Signals ),直接定位数据变化影响的 DOM 节点,无需全量比对
  • 编译时优化替代运行时开销: 以 Solid.js 和 Svelte 为代表的框架,在构建阶段将组件模板编译为高效的原生 DOM 操作代码,消除虚拟 DOM 的运行时计算和内存占用。例如, Solid.js 编译后仅生成与数据绑定的精确更新逻辑。
  • 原生 DOM 操作的高效性: 直接调用 element.textContent 等浏览器原生 API ,减少中间抽象层(虚拟 DOM )带来的性能损耗。实测显示,无虚拟 DOM 框架在低端设备上的渲染速度可提升 20 - 30% 。
  • 内存占用与启动性能优化: 虚拟 DOM 需维护完整树结构的副本,而 Solid.js 等框架通过细粒度响应式仅跟踪必要数据,显著降低内存占用和首次渲染耗时。
指标 虚拟 DOM 无虚拟 DOM
首次加载体积 较大(含有运行时库) 较小(仅包含编译后的代码)
内存占用 高(额外存储虚拟 DOM ) 低
渲染速度 依赖 Diff 算法效率 直接更新对应节点,速度提升 20% - 30%
CPU 使用率 较大(计算 Diff ) 较低(跳过 Diff 环节)

组件 API 化

// 导入组件
import Modal from './Modal.vue'
// extends 创建一个新的组件,隔离影响
const ComponentClass = Vue.extend(Modal);
// 实例化组件,并挂载到空 div
const instance = new ComponentClass({ el: document.createElement("div") });
// 将新 div 挂载到 body 上
document.body.appendChild(instance.$el);