本文目录

[[toc]]

vue 生命周期

参考 源码

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

初始化

  • 在调用 beforeCreate 之前,数据初始化并未完成,像 dataprops 这些属性无法访问到
  • 到了 created 的时候,数据已经初始化完成,能够访问 dataprops 这些属性,但这时候并未完成 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

不能 mountbody / html 的原因

  • vue 源码 中禁止此行为
  • vue2 中的 mount 实现会对挂载点进行替换,如果直接替换 body 节点, body children 中还有待执行的 JS script ,容易引发不可预测问题
  • vDOM diff 算法基于空白 DOM + 空白 vDOM 对应,在无法替换 body 的情况下, DOMvDOM 不匹配,可能导致其他问题
  • 影响其他第三方库工作,可能将其自身的全局功能替换掉,如组件库中的全局弹窗等
  • 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$off等事件
    • 定义 _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

vnodepatch

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

patch 主要有以下四个判断:

  1. 没有新节点: 直接触发旧 VNodedestroy
  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 的话需要比较 inputtype 是否相同

同一节点的属性、子节点 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)
    }
  }
}

子节点更新(双端比较)

主要原理

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

  • oldHeadnewHead
  • oldTailnewTail
  • oldHeadnewTail
  • oldTailnewHead

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

  1. oldHead === newHead ,直接通过 patchVnode 更新, newHeadoldHead 指针后移
  2. oldTail === newTail ,直接通过 patchVnode 更新, newTailoldTail 指针前移
  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-live

生命周期

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. deactive

SSR 期间不会触发 activateddeactivated

实现原理

源码

缓存结构为

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

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

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

  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-aliveincludeexclude 等属性的监听,动态修改绑定的组件名,在 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: 指令绑定的前一个值,仅在 updatecomponentUpdated 中可用,不区分值是否修改都会传递
    • expression: 指令表达式。
    • arg: 指令参数
    • modifiers: 指令包含哪些修饰符
  • vnode: Vue 生成的虚拟节点
  • oldVnode: 上一个虚拟节点,仅在 updatecomponentUpdated 中可用

虚拟 DOM

解决的问题

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

无虚拟 DOM 路线

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

  • 绕过 Diff 算法的性能瓶颈: 虚拟 DOM 的 Diff 算法复杂度较高(如传统 O(n³) 时间复杂度),在大规模数据更新时可能成为性能瓶颈。而无虚拟 DOM 框架通过细粒度响应式系统(如 Signals ),直接定位数据变化影响的 DOM 节点,无需全量比对
  • 编译时优化替代运行时开销:Solid.jsSvelte 为代表的框架,在构建阶段将组件模板编译为高效的原生 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);