Jw
首页
技术
英语
首页
技术
英语
  • 01-权衡的艺术

    • 1.1 命令式与声明式
    • 1.2 性能与可维护性的权衡
    • 1.3 虚拟DOM的性能到底如何
    • 1.4 运行时和编译时
  • 02-框架设计的核心要素

    • 2.1 提升用户的开发体验
    • 2.2 控制框架代码的体积
    • 2.3 框架要做到良好的 Tree-Shaking
    • 2.4 框架应该输出怎样的构建产物
    • 2.5 特性开关
    • 2.6 错误处理
    • 2.7 良好的 TypeScript 类型支持
  • 03-Vue.js 3 的设计思路

    • 3.1 声明式地描述UI
    • 3.2 初识渲染器
    • 3.3 组件的本质
    • 3.4 模板的工作原理
    • 3.5 Vue.js 是各个模块组成的有机整体
  • 04-响应系统的作用与实现

    • 4.1 响应式数据与副作用函数
    • 4.2 响应式数据的基本实现
    • 4.3 设计一个完善的响应系统
    • 4.4 分支切换与 cleanup
    • 4.5 嵌套的effect与effect栈
    • 4.6 避免无限递归循环
    • 4.7 调度执行
    • 4.8 计算属性 computed 与 lazy
    • 4.9 watch 的实现原理
    • 4.10 立即执行的 watch 与回调执行时机
    • 4.11 过期的副作用
  • 05-非原始值的响应式方案

    • 5.1 理解 Proxy 和 Reflect
    • 5.2 JavaScript 对象及 Proxy 的工作原理
    • 5.3 如何代理 Object
    • 5.4 合理地触发响应
    • 5.5 浅响应与深响应
    • 5.6 只读和浅只读
    • 5.7 代理数组
    • 5.7.1 数组的索引与 length
    • 5.7.2 遍历数组
    • 5.7.3 数组的查找方法
    • 5.7.4 隐式修改数组长度的原型方法
    • 5.8 代理 Set 和 Map
    • 5.8.1 如何代理Set和Map
    • 5.8.2 建立响应联系
    • 5.8.3 避免污染原始数据
    • 5.8.4 处理forEach
    • 5.8.5 迭代器方法
    • 5.8.6 values与keys方法
  • 06-原始值的响应式方案

    • 6.1 引入 ref 的概念
    • 6.2 响应丢失问题
    • 6.3 自动脱ref
  • 07-渲染器的设计

    • 7.1 渲染器与响应系统的结合
    • 7.2 渲染器的基本概念
    • 7.3 自定义渲染器
  • 08-挂载与更新

    • 8.1 挂载子节点和元素的属性
    • 8.2 HTML Attributes 与 DOM Properties
    • 8.3 正确地设置元素属性
    • 8.4 class的处理
    • 8.5 卸载操作
    • 8.6 区分vnode类型
    • 8.7 事件的处理
    • 8.8 事件冒泡与更新时机问题
    • 8.9 更新子节点
    • 8.10 文本节点和注释节点
    • 8.11 Fragment
  • 09-简单 Diff 算法

    • 9.1 减少 DOM 操作的性能开销
    • 9.2 DOM 复用与 key 的作用
    • 9.3 找到需要移动的元素
    • 9.4 如何移动元素
    • 9.5 添加新元素
    • 9.6 移除不存在的元素
  • 10-双端 Diff 算法

    • 10.1 双端比较的原理
    • 10.3 非理想状况的处理方式
    • 10.4 添加新元素
    • 10.5 移除不存在的元素
  • 11-快速 Diff 算法

    • 11.1 相同的前置元素和后置元素
    • 11.2 判断是否需要进行 DOM 移动操作
    • 11.3 如何移动元素
  • 12-组件的实现原理

    • 12.1 渲染组件
    • 12.2 组件状态与自更新
    • 12.3 组件实例与组件的生命周期
    • 12.4 props与组件的被动更新
    • 12.5 setup 函数的作用与实现
    • 12.6 组件事件与 emit 的实现
    • 12.7 插槽的工作原理与实现
    • 12.8 注册生命周期
  • 13-异步组件与函数式组件

    • 13.1 异步组件要解决的问题
    • 13.2.1 封装defineAsyncComponent函数
    • 13.2.2 超时与 Error 组件
    • 13.2.3 延迟与 Loading 组件
    • 13.2.4 重试机制
    • 13.3 函数式组件
  • 14-内建组件和模块

    • 14.1.1 组件的激活与失活
    • 14.1.2 include 和 exclude
    • 14.1.3 缓存管理
    • 14.2.1 Teleport 组件要解决的问题
    • 14.2.2 实现 Teleport 组件
    • 14.3.1 原生 DOM 的过渡
    • 14.3.2 实现 Transition 组件
  • 15-编译器核心技术概览

    • 15.1 模板 DSL 的编译器
    • 15.2 parser 的实现原理与状态机
    • 15.3 构造AST
    • 15.4 AST 的转换与插件化架构
    • 15.4.1 节点的访问
    • 15.4.2 转换上下文与节点操作
    • 15.4.3 进入与退出
    • 15.5 将模板 AST 转为 JavaScript AST
    • 15.6 代码生成
  • 16-解析器

    • 16.1 文本模式及其对解析器的影响
    • 16.2 递归下降算法构造模板 AST
    • 16.3 状态机的开启与停止
    • 16.4 解析标签节点
    • 16.5 解析属性
    • 16.6.1 解析文本
    • 16.6.2 解码命名字符引用
    • 16.6.3 解码数字字符引用
    • 16.7 解析插值与注释
  • 17-编译优化

    • 17.1.1 传统Diff算法问题
    • 17.1.2 Block 与 PatchFlags
    • 17.1.3 收集动态节点
    • 17.1.4 渲染器的运行时支持
    • 17.2.1 带有 v-if 指令的节点
    • 17.2.2 带有 v-for 指令的节点
    • 17.2.3 Fragment 的稳定性
    • 17.3 静态提升
    • 17.4 预字符串化
    • 17.5 缓存内联事件处理函数
    • 17.6 v-once
  • 18-同构渲染

    • 18.1 CSR、SSR 以及同构渲染
    • 18.2 将虚拟 DOM 渲染为 HTML 字符串
    • 18.3 将组件渲染为 HTML 字符串
    • 18.4 客户端激活的原理
    • 18.5.1 组件的生命周期
    • 18.5.2 使用跨平台的 API
    • 18.5.3 只在某一端引入模块
    • 18.5.4 避免交叉请求引起的状态污染
    • 18.5.5 ClientOnly 组件

11.2 判断是否需要进行 DOM 移动操作

预处理后如果新旧子节点两边都还有剩余,就进入需要移动节点的非理想情况:先用 source 数组记录「新节点 → 旧节点索引」的映射,再用递增的 k 值(maxNewIndexSoFar)判断节点的相对顺序是否被打乱。

一、三种 Diff 算法的共同规则

无论是简单 Diff、双端 Diff 还是快速 Diff,都遵循同样的两条规则:

  1. 判断是否有节点需要移动,以及应该如何移动;
  2. 找出那些需要被添加或移除的节点。

快速 Diff 判断「是否需要移动」的思路与简单 Diff 类似:遍历过程中遇到的位置索引如果一直递增,说明相对顺序没变;一旦出现回退,就说明需要移动节点。

二、source 数组的作用

  • source 存储「新的一组子节点中的节点」在「旧的一组子节点」中的位置索引。
  • 之后用 source 计算最长递增子序列,确定哪些节点不需要移动(见 11.3)。
  • source[i] === -1 表示下标 i 对应的新节点在旧节点中找不到可复用节点,是新增节点。

三、第一版:两层嵌套循环填充 source(O(n²))

  • oldStart 与 newStart 都等于预处理结束后的 j;
  • count 为剩余未处理的新节点数量:newEnd - j + 1;
  • source 下标从 0 开始,而剩余节点的索引未必从 0 开始,所以用 k - newStart 作为下标。
} else {
  // 非理想情况
  const count = newEnd - j + 1
  const source = new Array(count)
  source.fill(-1)

  const oldStart = j
  const newStart = j

  for (let i = oldStart; i <= oldEnd; i++) {
    const oldVNode = oldChildren[i]
    for (let k = newStart; k <= newEnd; k++) {
      const newVNode = newChildren[k]
      // 找到拥有相同 key 值的可复用节点
      if (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        source[k - newStart] = i // 值 i 是该节点在旧节点中的位置索引
      }
    }
  }
}

source 的下标是新节点位置(k - newStart),值是旧节点位置 i,两者不要搞混。

四、优化:用索引表把复杂度降到 O(n)

两层嵌套循环的复杂度是 O(n1 × n2),即 O(n²)。改进办法:先为新节点建一张 key → 位置索引 的索引表(keyIndex,Vue 源码中名为 keyToNewIndexMap),再遍历旧节点查表,两个 for 变成非嵌套关系,复杂度降为 O(n)。

const keyIndex = {}
for (let i = newStart; i <= newEnd; i++) {
  keyIndex[newChildren[i].key] = i
}

for (let i = oldStart; i <= oldEnd; i++) {
  const oldVNode = oldChildren[i]
  // 通过索引表快速找到新节点中相同 key 的位置
  const k = keyIndex[oldVNode.key]
  if (typeof k !== 'undefined') {
    const newVNode = newChildren[k]
    patch(oldVNode, newVNode, container)
    source[k - newStart] = i
  } else {
    // 索引表里查不到 → 该旧节点在新节点中已被删除
    unmount(oldVNode)
  }
}

五、判断是否需要移动:moved 与 pos

  • moved:初始 false,代表是否需要移动节点;
  • pos(Vue 源码中的 maxNewIndexSoFar):初始 0,代表遍历旧节点过程中遇到的最大索引值 k;
  • 判断规则:k < pos → 新节点索引出现回退,说明相对顺序被破坏 → moved = true;否则更新 pos = k;
  • patched:记录已经更新过的旧节点数量,达到需要更新的新节点数量 count 后,多出来的旧节点一律卸载。
let moved = false // 是否需要移动节点
let pos = 0 // 遍历旧节点过程中遇到的最大索引值 k
let patched = 0 // 已更新的旧节点数量

for (let i = oldStart; i <= oldEnd; i++) {
  const oldVNode = oldChildren[i]
  if (patched < count) {
    const k = keyIndex[oldVNode.key]
    if (typeof k !== 'undefined') {
      const newVNode = newChildren[k]
      patch(oldVNode, newVNode, container)
      patched++
      source[k - newStart] = i
      if (k < pos) {
        // 索引回退 → 需要移动节点
        moved = true
      } else {
        pos = k
      }
    } else {
      unmount(oldVNode)
    }
  } else {
    // 新节点已全部处理完,剩余的旧节点是多余的
    unmount(oldVNode)
  }
}

source 被填充(值不为 -1)只说明该新节点能复用某个旧节点;真正判断「要不要移动」的是 pos / moved。 pos 只记录最大值:只要后续的 k 一直不小于 pos,顺序就没变过;一旦小于 pos,就必然存在需要移动的节点。

六、示例

  • 旧:1 2 3 4 6 5;新:1 3 4 2 7 5
  • 预处理:前置只复用 key1;后置只复用 key5
  • 剩余新节点:3 4 2 7 → count = 4,newStart = 1
  • keyIndex = { 3: 1, 4: 2, 2: 3, 7: 4 }
i(旧索引)oldVNode.keyksource 变化moved / pos
123source[2] = 1k=3 > pos=0 → pos = 3
231source[0] = 2k=1 < pos=3 → moved = true
342source[1] = 3k=2 < pos=3 → moved = true
46查不到不变找不到 → unmount

最终 source = [2, 3, 1, -1]、moved = true;其中 -1(下标 3)对应 key7,是新增节点。

七、易错点

source[k - newStart] = i:下标是新节点位置(与 newStart 的差值),值是旧节点位置 i。 卸载有两处:索引表查不到(旧节点被删除)、patched 超出 count(多余的旧节点),两处都要调用 unmount。 patched 与 count 的比较:已更新数达到待更新数后,剩余旧节点的 key 已不可能命中索引表,直接卸载即可(patched < count 与 patched <= count 在此等价)。

相关

  • 11.1 相同的前置元素和后置元素:预处理摘掉相同的前置 / 后置节点,剩下的区间才会进入这里的非理想情况。
  • 11.3 如何移动元素:moved 为真时,用 source 的最长递增子序列计算出具体怎么移动。
最近更新: 2026/10/12 00:05
Contributors: liujw
Prev
11.1 相同的前置元素和后置元素
Next
11.3 如何移动元素