前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • Vue

    • 从源码解读Vue生命周期
    • 组件的本质
    • 有状态组件的设计
    • 设计 VNode
    • 辅助创建 VNode 的 h 函数
    • 自定义渲染器和异步渲染
    • 渲染器之挂载
    • 渲染器的核心 Diff 算法
    • 渲染器之patch
    • 图解 Vue 响应式原理
    • 图解 Vue 异步更新
    • 剖析 Vue 内部运行机制
    • vue响应式原理模拟
    • vue状态管理之vuex
    • 理解Vue的设计思想及实现Vue
    • diff算法深入
    • vue router vuex原理分析
    • Vue3初探响应式原理.
    • vue2源码分析
    • vue组件化实践
  • React

  • Webpack

  • Node

  • Javascript

  • 综合

文章学习导航
面试总览

30 秒速记

  • 多子节点比较要同时处理身份与顺序
  • 没键通常按位置更新,有键按业务身份匹配
  • 先确保内容正确,再安排移动、增加和删除
  • 旧索引序列帮助找可保持位置的节点
  • 重复键会让匹配失去可靠依据

列表差异比较是在核对谁还在、谁换了位置、谁新增或离开。 稳定键让渲染器找到同一个业务项,复用节点后仍要更新它的属性和孩子。没有键时按位置复用虽然能显示结果,却可能把局部状态留给另一条数据。确定匹配关系后,再根据前后顺序安排移动,避免全部删除重建;具体最少移动策略取决于使用的算法。

版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。

# 减小DOM操作的性能开销

上一章我们讨论了渲染器是如何更新各种类型的 VNode 的,实际上,上一章所讲解的内容归属于完整的 Diff 算法之内,但并不包含核心的 Diff 算法。那什么才是核心的 Diff 算法呢?看下图:

我们曾在上一章中讲解子节点更新的时候见到过这张图,当时我们提到只有当新旧子节点的类型都是多个子节点时,核心 Diff 算法才派得上用场,并且当时我们采用了一种仅能实现目标但并不完美的算法:遍历旧的子节点,将其全部移除;再遍历新的子节点,将其全部添加,如下高亮代码所示:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          // 新的 children 中有多个子节点
          // 遍历旧的子节点,将其全部移除
          for (let i = 0; i < prevChildren.length; i++) {
            container.removeChild(prevChildren[i].el)
          }
          // 遍历新的子节点,将其全部添加
          for (let i = 0; i < nextChildren.length; i++) {
            mount(nextChildren[i], container)
          }
          break
      }
      break
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

为了便于表述,我们把这个算法称为:简单 Diff 算法。简单 Diff 算法虽然能够达到目的,但并非最佳处理方式。我们经常会遇到可排序的列表,假设我们有一个由 li 标签组成的列表:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>
@前端进阶之旅: 代码已经复制到剪贴板

列表中的 li 标签是 ul 标签的子节点,我们可以使用下面的数组来表示 ul 标签的 children:

[
  h('li', null, 1),
  h('li', null, 2),
  h('li', null, 3)
]
@前端进阶之旅: 代码已经复制到剪贴板

接着由于数据变化导致了列表的顺序发生了变化,新的列表顺序如下:

[
  h('li', null, 3),
  h('li', null, 1),
  h('li', null, 2)
]
@前端进阶之旅: 代码已经复制到剪贴板

新的列表和旧的列表构成了新旧 children,当我们使用简单 Diff 算法更新这两个列表时,其操作行为可以用下图表示:

在这张图中我们使用圆形表示真实 DOM 元素,用菱形表示 VNode,旧的 VNode 保存着对真实 DOM 的引用(即 vnode.el 属性),新的 VNode 是不存在对真实 DOM 的引用的。上图描述了简单 Diff 算法的操作行为,首先遍历旧的 VNode,通过旧 VNode 对真实 DOM 的引用取得真实 DOM,即可将已渲染的 DOM 移除。接着遍历新的 VNode 并将其全部添加到页面中。

在这个过程中我们能够注意到:更新前后的真实 DOM 元素都是 li 标签。那么可不可以复用 li 标签呢?这样就能减少“移除”和“新建” DOM 元素带来的性能开销,实际上是可以的,我们在讲解 pathcElement 函数时了解到,当新旧 VNode 所描述的是相同标签时,那么这两个 VNode 之间的差异就仅存在于 VNodeData 和 children 上,所以我们完全可以通过遍历新旧 VNode,并一一比对它们,这样对于任何一个 DOM 元素来说,由于它们都是相同的标签,所以更新的过程是不会“移除”和“新建”任何 DOM 元素的,而是复用已有 DOM 元素,需要更新的只有 VNodeData 和 children。优化后的更新操作可以用下图表示:

用代码实现起来也非常简单,如下高亮代码所示:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          for (let i = 0; i < prevChildren.length; i++) {
            patch(prevChildren[i], nextChildren[i], container)
          }
          break
      }
      break
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

通过遍历旧的 children,将新旧 children 中相同位置的节点拿出来作为一对“新旧 VNode”,并调用 patch 函数更新之。由于新旧列表的标签相同,所以这种更新方案较之前相比,省去了“移除”和“新建” DOM 元素的性能开销。而且从实现上看,代码也较之前少了一些,真可谓一举两得。但不要高兴的太早,细心的同学可能已经发现问题所在了,如上代码中我们遍历的是旧的 children,如果新旧 children 的长度相同的话,则这段代码可以正常工作,但是一旦新旧 children 的长度不同,这段代码就不能正常工作了,如下图所示:

当新的 children 比旧的 children 的长度要长时,多出来的子节点是没办法应用 patch 函数的,此时我们应该把多出来的子节点作为新的节点添加上去。类似的,如果新的 children 比旧的 children 的长度要短时,我们应该把旧的 children 中多出来的子节点移除,如下图所示:

通过分析我们得出一个规律,我们不应该总是遍历旧的 children,而是应该遍历新旧 children 中长度较短的那一个,这样我们能够做到尽可能多的应用 patch 函数进行更新,然后再对比新旧 children 的长度,如果新的 children 更长,则说明有新的节点需要添加,否则说明有旧的节点需要移除。最终我们得到如下实现:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          // 新的 children 中有多个子节点
          // 获取公共长度,取新旧 children 长度较小的那一个
          const prevLen = prevChildren.length
          const nextLen = nextChildren.length
          const commonLength = prevLen > nextLen ? nextLen : prevLen
          for (let i = 0; i < commonLength; i++) {
            patch(prevChildren[i], nextChildren[i], container)
          }
          // 如果 nextLen > prevLen,将多出来的元素添加
          if (nextLen > prevLen) {
            for (let i = commonLength; i < nextLen; i++) {
              mount(nextChildren[i], container)
            }
          } else if (prevLen > nextLen) {
            // 如果 prevLen > nextLen,将多出来的元素移除
            for (let
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
  • Vue

    • 从源码解读Vue生命周期
    • 组件的本质
    • 有状态组件的设计
    • 设计 VNode
    • 辅助创建 VNode 的 h 函数
    • 自定义渲染器和异步渲染
    • 渲染器之挂载
    • 渲染器的核心 Diff 算法
      • 减小DOM操作的性能开销
      • 尽可能的复用 DOM 元素
        • key 的作用
        • 找到需要移动的节点
        • 移动节点
        • 添加新元素
        • 移除不存在的元素
      • 另一个思路 - 双端比较
      • inferno 所采用的核心 Diff 算法及原理
      • 不足之处
      • References
    • 渲染器之patch
    • 图解 Vue 响应式原理
    • 图解 Vue 异步更新
    • 剖析 Vue 内部运行机制
    • vue响应式原理模拟
    • vue状态管理之vuex
    • 理解Vue的设计思想及实现Vue
    • diff算法深入
    • vue router vuex原理分析
    • Vue3初探响应式原理.
    • vue2源码分析
    • vue组件化实践
  • React

  • Webpack

  • Node

  • Javascript

  • 综合

完整面试题地址:
作者:程序员poetry
扫码关注作者公众号:「前端进阶之旅」 每天分享技术干货
前端进阶之旅公众号二维码

Vue渲染器的核心 Diff 算法|原理篇

文章学习导航
面试总览

30 秒速记

  • 多子节点比较要同时处理身份与顺序
  • 没键通常按位置更新,有键按业务身份匹配
  • 先确保内容正确,再安排移动、增加和删除
  • 旧索引序列帮助找可保持位置的节点
  • 重复键会让匹配失去可靠依据

列表差异比较是在核对谁还在、谁换了位置、谁新增或离开。 稳定键让渲染器找到同一个业务项,复用节点后仍要更新它的属性和孩子。没有键时按位置复用虽然能显示结果,却可能把局部状态留给另一条数据。确定匹配关系后,再根据前后顺序安排移动,避免全部删除重建;具体最少移动策略取决于使用的算法。

版本校准: 本文出现 Object.defineProperty、Dep、Watcher 和双端 Diff 时,主要描述 Vue 2 实现;Vue 3 使用 Proxy、effect 与新的渲染器路径。Vue 2 已于 2023-12-31 结束维护,新项目应以 Vue 3 为基线,旧项目参考 Vue 2 EOL 官方说明 制定迁移与安全策略。

# 减小DOM操作的性能开销

上一章我们讨论了渲染器是如何更新各种类型的 VNode 的,实际上,上一章所讲解的内容归属于完整的 Diff 算法之内,但并不包含核心的 Diff 算法。那什么才是核心的 Diff 算法呢?看下图:

我们曾在上一章中讲解子节点更新的时候见到过这张图,当时我们提到只有当新旧子节点的类型都是多个子节点时,核心 Diff 算法才派得上用场,并且当时我们采用了一种仅能实现目标但并不完美的算法:遍历旧的子节点,将其全部移除;再遍历新的子节点,将其全部添加,如下高亮代码所示:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          // 新的 children 中有多个子节点
          // 遍历旧的子节点,将其全部移除
          for (let i = 0; i < prevChildren.length; i++) {
            container.removeChild(prevChildren[i].el)
          }
          // 遍历新的子节点,将其全部添加
          for (let i = 0; i < nextChildren.length; i++) {
            mount(nextChildren[i], container)
          }
          break
      }
      break
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

为了便于表述,我们把这个算法称为:简单 Diff 算法。简单 Diff 算法虽然能够达到目的,但并非最佳处理方式。我们经常会遇到可排序的列表,假设我们有一个由 li 标签组成的列表:

<ul>
  <li>1</li>
  <li>2</li>
  <li>3</li>
</ul>
@前端进阶之旅: 代码已经复制到剪贴板

列表中的 li 标签是 ul 标签的子节点,我们可以使用下面的数组来表示 ul 标签的 children:

[
  h('li', null, 1),
  h('li', null, 2),
  h('li', null, 3)
]
@前端进阶之旅: 代码已经复制到剪贴板

接着由于数据变化导致了列表的顺序发生了变化,新的列表顺序如下:

[
  h('li', null, 3),
  h('li', null, 1),
  h('li', null, 2)
]
@前端进阶之旅: 代码已经复制到剪贴板

新的列表和旧的列表构成了新旧 children,当我们使用简单 Diff 算法更新这两个列表时,其操作行为可以用下图表示:

在这张图中我们使用圆形表示真实 DOM 元素,用菱形表示 VNode,旧的 VNode 保存着对真实 DOM 的引用(即 vnode.el 属性),新的 VNode 是不存在对真实 DOM 的引用的。上图描述了简单 Diff 算法的操作行为,首先遍历旧的 VNode,通过旧 VNode 对真实 DOM 的引用取得真实 DOM,即可将已渲染的 DOM 移除。接着遍历新的 VNode 并将其全部添加到页面中。

在这个过程中我们能够注意到:更新前后的真实 DOM 元素都是 li 标签。那么可不可以复用 li 标签呢?这样就能减少“移除”和“新建” DOM 元素带来的性能开销,实际上是可以的,我们在讲解 pathcElement 函数时了解到,当新旧 VNode 所描述的是相同标签时,那么这两个 VNode 之间的差异就仅存在于 VNodeData 和 children 上,所以我们完全可以通过遍历新旧 VNode,并一一比对它们,这样对于任何一个 DOM 元素来说,由于它们都是相同的标签,所以更新的过程是不会“移除”和“新建”任何 DOM 元素的,而是复用已有 DOM 元素,需要更新的只有 VNodeData 和 children。优化后的更新操作可以用下图表示:

用代码实现起来也非常简单,如下高亮代码所示:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          for (let i = 0; i < prevChildren.length; i++) {
            patch(prevChildren[i], nextChildren[i], container)
          }
          break
      }
      break
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

通过遍历旧的 children,将新旧 children 中相同位置的节点拿出来作为一对“新旧 VNode”,并调用 patch 函数更新之。由于新旧列表的标签相同,所以这种更新方案较之前相比,省去了“移除”和“新建” DOM 元素的性能开销。而且从实现上看,代码也较之前少了一些,真可谓一举两得。但不要高兴的太早,细心的同学可能已经发现问题所在了,如上代码中我们遍历的是旧的 children,如果新旧 children 的长度相同的话,则这段代码可以正常工作,但是一旦新旧 children 的长度不同,这段代码就不能正常工作了,如下图所示:

当新的 children 比旧的 children 的长度要长时,多出来的子节点是没办法应用 patch 函数的,此时我们应该把多出来的子节点作为新的节点添加上去。类似的,如果新的 children 比旧的 children 的长度要短时,我们应该把旧的 children 中多出来的子节点移除,如下图所示:

通过分析我们得出一个规律,我们不应该总是遍历旧的 children,而是应该遍历新旧 children 中长度较短的那一个,这样我们能够做到尽可能多的应用 patch 函数进行更新,然后再对比新旧 children 的长度,如果新的 children 更长,则说明有新的节点需要添加,否则说明有旧的节点需要移除。最终我们得到如下实现:

function patchChildren(
  prevChildFlags,
  nextChildFlags,
  prevChildren,
  nextChildren,
  container
) {
  switch (prevChildFlags) {
    // 省略...

    // 旧的 children 中有多个子节点
    default:
      switch (nextChildFlags) {
        case ChildrenFlags.SINGLE_VNODE:
          // 省略...
        case ChildrenFlags.NO_CHILDREN:
          // 省略...
        default:
          // 新的 children 中有多个子节点
          // 获取公共长度,取新旧 children 长度较小的那一个
          const prevLen = prevChildren.length
          const nextLen = nextChildren.length
          const commonLength = prevLen > nextLen ? nextLen : prevLen
          for (let i = 0; i < commonLength; i++) {
            patch(prevChildren[i], nextChildren[i], container)
          }
          // 如果 nextLen > prevLen,将多出来的元素添加
          if (nextLen > prevLen) {
            for (let i = commonLength; i < nextLen; i++) {
              mount(nextChildren[i], container)
            }
          } else if (prevLen > nextLen) {
            // 如果 prevLen > nextLen,将多出来的元素移除
            for (let i = commonLength; i < prevLen; i++) {
              container.removeChild(prevChildren[i].el)
            }
          }
          break
      }
      break
  }
}
@前端进阶之旅: 代码已经复制到剪贴板

TIP

完整代码&在线体验地址: https://codesandbox.io/s/qqxxlxzwm6

实际上,这个算法就是在没有 key 时所采用的算法,该算法是存在优化空间的,下面我们将分析如何进一步优化。

# 尽可能的复用 DOM 元素

# key 的作用

在上一小节中,我们通过减少 DOM 操作的次数使得更新的性能得到了提升,但它仍然存在可优化的空间,要明白如何优化,那首先我们需要知道问题出在哪里。还是拿上一节的例子来说,假设旧的 children 如下:

[
  h('li', null, 1),
  h('li', null, 2),
  h('li', null, 3)
]
@前端进阶之旅: 代码已经复制到剪贴板

新的 children 如下:

[
  h('li', null, 3),
  h('li', null, 1),
  h('li', null, 2)
]
@前端进阶之旅: 代码已经复制到剪贴板

我们来看一下,如果使用前面讲解的 Diff 算法来更新这对新旧 children 的话,会进行哪些操作:首先,旧 children 的第一个节点和新 children 的第一个节点进行比对(patch),即:

h('li', null, 1)
// vs
h('li', null, 3)
@前端进阶之旅: 代码已经复制到剪贴板

← 渲染器之挂载渲染器之patch →

fe