阿卡不拉阿卡不拉
Vue3
阿卡的博客
Vue3
阿卡的博客
  • Vue3

    • 快速入门

      • 搭建工程 👌
      • 模板语法
      • 响应式基础
      • 响应式常用 API
      • 计算属性
      • 类与样式绑定
      • 条件和列表渲染
      • 事件处理
      • 表单处理
      • 生命周期
      • 侦听器
      • 组件介绍
      • Props
      • 自定义事件
      • 组件v-model
      • 插槽
      • 前端路由介绍
      • KeepAlive内置组件
      • 状态管理库
      • 组件库介绍
    • 深入本质

      • 虚拟DOM本质
      • 模板的本质
      • 组件树和虚拟DOM树
      • 数据拦截的本质
      • 响应式数据的本质
      • 响应式的本质
      • 响应式和组件渲染
      • 实现响应式系统 1
      • 实现响应式系统 2
      • 图解EFFECT
      • 实现响应式系统 3
      • 手写computed
      • 手写watch
      • 指令的本质
      • 插槽的本质
      • v-model的本质
      • setup 语法标签
      • 组件生命周期
      • keepalive 生命周期
      • keepalive的本质
      • key的本质
    • 细节补充

      • 【Vue】属性透传
      • 【Vue】依赖注入
      • 【Vue】组合式函数 👌
      • 【Vue】自定义指令
      • 【Vue】插件
      • 【Vue】Transition
      • 【Vue】TransitionGroup
      • 【Vue】Teleport
      • 【Vue】异步组件
      • 【Vue】Suspense
      • 【Router】路由模式
      • 【Router】路由零碎知识
      • 【Router】路由匹配语法
      • 【Router】路由组件传参
      • 【Router】内置组件和函数
      • 【Router】导航守卫
      • 【Router】过渡特效
      • 【Router】滚动行为
      • 【Router】动态路由
      • 【State】通信方式总结
      • 【State】Pinia 自定义插件
      • 【场景】封装树形组件
      • 【场景】自定义 ref 实现防抖
      • 【场景】懒加载
      • 【场景】虚拟列表
      • 【场景】虚拟列表优化
      • 【第三方库】VueUse
      • 【第三方库】vuedragable
      • 【第三方库】vue-drag-resize
      • 【第三方库】vue-chartjs
      • 【第三方库】vuelidate
      • 【第三方库】vue3-lazyload
      • 【场景】Websocket 聊天室
      • 【Vite】✨ 认识 Vite👌
      • 【Vite】配置文件 👌
      • 【Vite】✨ 依赖预构建 👌
      • 【Vite】构建生产版本 👌
      • 【Vite】环境变量与模式
      • 【Vite】CLI
      • 【Vite】Vite 插件
  • 笔面试

    • HTML

      • HTML 面试题汇总
      • 文档声明
      • 语义化
      • W3C 标准组织
      • SEO
      • iframe
      • 微格式
      • 替换元素
      • 页面可见性
    • CSS

      • CSS 面试题汇总
      • CSS 单位总结
      • 居中方式总结
      • 隐藏元素方式总结
      • 浮动
      • 定位总结
      • BFC
      • CSS 属性计算过程
      • CSS 层叠继承规则总结
      • @import 指令
      • CSS3 calc 函数
      • CSS3 媒体查询
      • 过渡和动画事件
      • 渐进增强和优雅降级
      • CSS3 变形
      • 渐进式渲染
      • CSS 渲染性能优化
      • 层叠上下文
      • CSS3 遮罩
    • JavaScript

      • JavaScript 面试题汇总
      • ✨ let、var、const 的区别
      • JS中的数据类型
      • 包装类型
      • 数据类型的转换
      • 运算符
      • ✨ 原型链
      • ✨ this 指向
      • ✨ 垃圾回收与内存泄漏
      • ✨ 执行栈和执行上下文
      • ✨ 作用域和作用域链
      • ✨ 闭包
      • DOM 事件的注册和移除
      • DOM 事件的传播机制
      • 阻止事件默认行为
      • 递归
      • ✨ 属性描述符
      • class 和构造函数区别
      • 浮点数精度问题
      • 严格模式
      • ✨ 函数防抖和节流
      • ✨ WeakSet 和 WeakMap
      • ✨ 深浅拷贝
      • 函数柯里化
      • Node 事件循环
      • 尺寸和位置
    • 浏览器

      • 浏览器面试题汇总
      • ✨ 浏览器的渲染流程
      • ✨ 资源提示关键词
      • 浏览器的组成部分
      • IndexedDB
      • ✨ File API
      • ✨ 浏览器缓存
      • ✨ 浏览器跨标签页通信
      • Web Worker
    • 网络

      • 网络面试题汇总
      • 五层网络模型 👌
      • 常见请求方法 👌
      • ✨cookie👌
      • 面试题
      • 加密
      • ✨JWT👌
      • ✨ 同源策略及跨域问题 👌
      • 文件上传
      • ✨ 输入 url 地址之后
      • 文件下载
      • ✨ session
      • ✨ TCP
      • ✨ CSRF 攻击
      • ✨XSS 攻击 👌
      • ✨ 网络性能优化
      • 断点续传
      • 域名和 DNS
      • SSL、TLS、HTTPS 的关系
      • ✨ HTTP 各版本差异 👌
      • HTTP 缓存协议
      • ✨ WebSocket
    • 工程化

      • CMJ 和 ESM
      • npx
      • ESLint
    • Vue2

      • Vue 面试题汇总相关
      • 组件通信方式总结
      • 虚拟 DOM
      • v-model
      • 数据响应式原理
      • diff
      • 生命周期详解
      • computed
      • 过滤器
      • 作用域插槽
      • 过度和动画
      • 优化
      • keep-alive
      • 长列表优化
      • 其他 API
      • 模式和环境变量
      • 更多配置
      • 更多命令
      • 嵌套路由
      • 路由切换动画
    • Vue3

      • ✨ Vue3 整体变化 👌
      • ✨ Vue3 响应式变化 👌
      • ✨ nextTick 实现原理 👌
      • 两道代码题 👌
      • Vue 运行机制
      • 渲染器核心功能
      • 事件绑定与更新

key的本质

在关系型数据库中,有一个 primary key 的概念,这个其实和这里的 key 有一定的相似性。

在关系型数据库中,primary key 用于标记这条数据的唯一性,因此在上表中只有 id 这个字段能够作为主键,另外 3 个字段都不行。

那么为什么需要对一条数据做唯一性标识呢?那就是方便精准的查找。这就好比现实生活中的身份证号,所有人都是独一无二的,你名字可能相同、年龄、性别这些都可能相同,而身份证号则是每个人的一个唯一标识,能够精准找到这个人。

Vue 中的 key,道理就是一样的,key 其实也是用来做唯一标识,谁的唯一标识呢,就是虚拟节点 VNode 的唯一标识。

不采用复用策略

假设更新前的虚拟 DOM 为:

const oldVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '1'},
    {type: 'p', children: '2'},
    {type: 'p', children: '3'},
  ]
}
<div>
  <p>1</p>
  <p>2</p>
  <p>3</p>
</div>

更新后的虚拟 DOM 为:

const newVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '4'},
    {type: 'p', children: '5'},
    {type: 'p', children: '6'},
  ]
}

如果完全不采用复用策略,那么当更新子节点的时候,需要执行 6 次 DOM 操作。

  • 卸载所有旧的子节点,需要 3 次 DOM 的删除操作
  • 挂载所有新的子节点,需要 3 次 DOM 的添加操作

通过观察我们发现,VNode 的变化,仅仅是 p 元素的子节点(文本节点)发生变化,p 元素本身其实没有任何的变化。因此最为理想的做法是更新这个 3 个 p 元素的文本节点内容,这样只会涉及到 3 次 DOM 操作,性能提升一倍。

采用复用策略

  1. 先考虑更新前后长度不变、类型不变的情况

这里可以写出如下的伪代码:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string'){
    // 说明该节点的子节点就是文本节点
    // ...
  } else if(Array.isArray(n2.children)){
    // 说明该节点的子节点也是数组
    const oldChildren = n1.children; // 旧的子节点数组
    const newChildren = n2. children; // 新的子节点数组
    
    // 目前假设长度没有变化
    for(let i = 0; i < oldChildren.length; i++){
      // 对文本子节点进行更新
      patch(oldChildren[i], newChildren[i])
    }
  } else {
    // 其他情况
    // ...
  }
}
  1. 考虑长度发生变化的情况

    • 对于新节点更多的情况,那就需要挂载新的节点

    • 对于新节点变少的情况,那就需要卸载多余的旧节点

因此我们的伪代码会发生一些变化:

function patchChildren(n1, n2, container){
  if(typeof n2.children === 'string'){
    // 说明该节点的子节点就是文本节点
    // ...
  } else if(Array.isArray(n2.children)){
    // 说明该节点的子节点也是数组
    const oldChildren = n1.children; // 旧的子节点数组
    const newChildren = n2. children; // 新的子节点数组
    
    // 存储一下新旧节点的长度
    const oldLen = oldChildren.length; // 旧子节点数组长度
    const newLen = newChildren.length; // 新子节点数组长度
    
    // 接下来先找这一组长度的公共值,也就是最小值
    const commonLength = Math.min(oldLen, newLen);
    
    // 先遍历最小值,把该处理的节点先跟新
    for(let i = 0; i < commonLength; i++){
      // 对文本子节点进行更新
      patch(oldChildren[i], newChildren[i])
    }
    
    // 然后接下来处理长度不同的情况
    if(newLen > oldLen){
      // 新节点多,那么就做新节点的挂载
      for(let i = commonLength; i < newLen; i++){
        patch(null, newChildren[i], container);
      }
    } else if(oldLen > newLen){
      // 旧节点多,做旧节点的卸载
      for(let i = commonLength; i < oldLen; i++){
        unmount(oldChildren[i]);
      }
    }
  } else {
    // 其他情况
    // ...
  }
}
  1. 考虑类型发生变化
const oldVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '1'},
    {type: 'div', children: '2'},
    {type: 'span', children: '3'},
  ]
}
const newVNode = {
  type: 'div',
  children: [
    {type: 'span', children: '3'},
    {type: 'p', children: '1'},
    {type: 'div', children: '2'},
  ]
}

按照目前上面的设计,当遇到这种情况的时候,通通不能复用,又回到最初的情况,需要 6 次 DOM 的操作。

但是我们稍作观察,会发现上面的例子中仅仅是元素标签移动了位置,因此最理想的情况是移动 DOM 即可,这样也能达到对 DOM 节点的复用。

这里涉及到一个问题:如何确定是同一个类型能够复用的节点?

如果仅仅只是判断 VNode 的 type 值是否相同,这种方式并不可靠!

const oldVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '3'},
    {type: 'div', children: '2'},
    {type: 'p', children: '1'},
  ]
}
const newVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '1'},
    {type: 'p', children: '3'},
    {type: 'div', children: '2'},
  ]
}

在这种情况下,没有办法很好的有一个对应关系,因为有多种相同类型的节点。

加入key标识

key 相当于给每一个 VNode 一个身份证号,通过这个身份证号就可以找到唯一的那个 VNode,而非多个。

const oldVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '3', key: 1},
    {type: 'div', children: '2', key: 2},
    {type: 'p', children: '1', key: 3},
  ]
}
const newVNode = {
  type: 'div',
  children: [
    {type: 'p', children: '1', key: 3},
    {type: 'p', children: '3', key: 1},
    {type: 'div', children: '2', key: 2},
  ]
}

因此,在实际的判断中,如果 VNode 的 type 属性和 key 属性都相同,那么就说明是同一组映射,并且在新旧节点中都出现了,那么就可以进行 DOM 节点的复用。

哪怕没有 key,我在旧节点中找到一个类型相同的,就复用该 DOM 节点,这样的设计不行么?

实际上,在没有 key 的情况下,Vue 内部采用的就是这样的复用策略,这种策略在 Vue 中被称之为“就地更新”策略。这种策略默认是高效的,但是这种复用策略仅仅是保证 DOM 节点的类型对上了,如果节点本身还依赖子组件状态或者临时 DOM 状态,由于这种复用策略没有精准的对上号,因此会涉及到子组件状态或者临时 DOM 状态的还原。

举个例子,假设旧节点是三个男生,新节点也是三个男生

如果不考虑其他的因素,只考虑是否是男生,然后简单的把名字变一下,那么这种就地复用的策略是非常高效。

但是很多时候依赖子组件状态或者临时的 DOM 状态:

在这种情况下,就地复用的策略反而是低效的,因为涉及到子组件状态或者临时的 DOM 状态的恢复。

因此在这个时候,最好的方式就是加上 key,让新旧节点能够精准的对应上。

还有一点需要注意,那就是 避免使用下标来作为 key 值。使用下标作为 key 值时,如果列表中的元素顺序发生变化,Vue 会复用错误的元素,导致不必要的 DOM 更新和渲染错误。

例如,当你在列表中插入或删除元素时,使用下标会使得每个元素的 key 发生变化,导致 Vue 不能正确识别元素,从而导致状态和数据的不一致。

// 初始状态
[{ id: 1, text: 'Item 1' }, { id: 2, text: 'Item 2' }, { id: 3, text: 'Item 3' }]

// 删除第二个元素后的状态
[{ id: 1, text: 'Item 1' }, { id: 3, text: 'Item 3' }]

在这种情况下,如果使用下标作为 key 值,当删除第二个元素后,第三个元素的下标会从 2 变为 1,这会使 Vue 误以为原本的第三个元素和第二个元素是同一个,从而导致错误的更新。

key 本质上就是给 VNode 节点做唯一性标识,算是 VNode 的一个身份证号。

特别是在渲染列表时。key 的作用主要有以下几点:

  1. 高效的更新: key 帮助 Vue 识别哪些元素是变化的、哪些是新的、哪些是需要被移除的。
    • 在没有 key 的情况下,Vue 会尽量复用已有元素,而不管它们的实际内容是否发生了变化,这可能导致不必要的更新或者错误的更新。
    • 通过使用 key,Vue 可以准确地知道哪些元素发生了变化,从而高效地更新 DOM。
  2. 确保元素的唯一性: key 属性需要是唯一的,这样每个元素在列表中都可以被唯一标识。这避免了在元素移动、插入或删除时出现混淆,确保 Vue 可以正确地追踪每个元素。
  3. 提升渲染性能: 使用 key 可以显著提升列表渲染的性能。因为 Vue 能通过 key 快速定位到需要更新的元素,而不是重新渲染整个列表。尤其在处理大型列表时,使用 key 可以避免大量不必要的 DOM 操作,提升应用的响应速度。
最近更新:: 2025/7/9 07:29
Contributors: AK
Prev
keepalive的本质