从零到一打造 Vue3 响应式系统 Day 26 - 数组长度变更处理

在我们构建响应式系统的过程中,虽然对于原生 JavaScript 对象的处理已经相当完善,但数组 (Array) 与普通对象的属性不同,数组的 length 属性与其数值索引之间有紧密的联动关系。

手动变更数组长度

最直接改变数组长度的方式就是手动赋值。虽然这在日常开发中不被鼓励,但一个健壮的响应式系统必须能正确处理这种情况。

HTML 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state.length)
    })

    setTimeout(() => {
      state.length = 2
    }, 1000)
  </script>
</body>

在上述示例中,我们直接修改了数组长度,它会触发更新(通常我们会避免直接更改数组长度的做法)。

像这样直接缩短数组长度,超出新长度的元素会被删除,如下图:

HTML 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state[3])
    })

    setTimeout(() => {
      state.length = 2
    }, 1000)
  </script>
</body>

执行这段代码,控制台会先输出 d,这符合预期。然而,一秒后当 state.length 被修改为 2 时,console.log 并没有再次执行。

然而,问题出在哪里?

我们的 effect 依赖的是 state[3]。当 state.length 被修改为 2 时,索引为 3 的元素实际上已经被删除了。

因此,这个 effect 所依赖的键 ('3') 后续不会再发生任何 set 行为,导致它再也没有机会被重新触发。依赖关系因此丢失。

这个"删除"操作,仅触发了 state 对象 length 属性的 set,并未触发 索引 '3' 的 set。

所以我们需要做的是,当 length 被缩短时,我们要找出所有依赖"被删除索引"的 effect,并通知它们重新执行:

TypeScript 复制代码
// dep.ts
export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  // 如果 depsMap 不存在,表示没有收集过依赖,直接返回
  if (!depsMap) return

  const targetIsArray = Array.isArray(target)

  // 新增:如果目标是数组且修改的是 length 属性
  if (targetIsArray && key === 'length') {
    // 遍历该数组的所有依赖
    depsMap.forEach((dep, depKey) => {
      // 如果依赖的键是 'length' 或大于等于新 length 值的索引
      // newValue 在 set 处理器中还拿不到,这里假设能拿到
      // 实际上应该在 set 中拿到 newValue 再传给 trigger
      if (depKey >= newValue || depKey === 'length') {
        // 通知访问了这些索引的 effect 以及访问了 length 的 effect 重新执行
        propagate(dep.subs)
      }
    })
  } else {
    // 如果不是数组,或者更新的不是 length,则直接获取依赖
    const dep = depsMap.get(key)
    // 如果依赖不存在,表示这个 key 没有在 effect 中被使用过,直接返回
    if (!dep) return

    // 找到依赖,触发更新
    propagate(dep.subs)
  }
}

(注:上面的代码片段为示意,newValue 需要从 set 处理器传入 trigger)

当 state.length = 2 时,effect 会被重新触发。现在看我们的示例代码,会发现触发更新后,输出结果是 undefined,因为 state[3] 已不存在。

数组方法导致长度变更

除了直接赋值外,一些我们常用的数组方法,如 pop、push、shift 等,也会隐式地影响到数组长度:

HTML 复制代码
<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state.length)
    })

    setTimeout(() => {
      state.push('e')
    }, 1000)
  </script>
</body>

这里可以看到,我们通过 push 增加了一个新元素,但是依赖 length 的 effect 并没有被触发更新:

那么,当 push 这类方法被调用时,我们如何侦测到 length 的隐式变更呢?

关键在于拦截 set 操作。push('e') 的底层操作,除了在索引 4 上设置新值,也会修改 length 属性。

我们可以在 set 代理中,比较操作前后的数组长度,如果不一致,就主动触发 length 属性的依赖更新:

TypeScript

javascript 复制代码
// baseHandlers.ts

export const mutableHandlers = {
  // ...
  set(target, key, newValue, receiver) {
    const oldValue = target[key]
    const targetIsArray = Array.isArray(target)
    // 如果 target 是数组,记录其旧长度
    const oldLength = targetIsArray ? target.length : 0
    
    const res = Reflect.set(target, key, newValue, receiver)
    
    // ... (ref 相关的处理)

    if (hasChanged(newValue, oldValue)) {
      // 触发当前 key 的更新
      trigger(target, key, newValue) // 把新值传进去
    }
    
    // 如果目标是数组,并且长度发生了变化
    if (targetIsArray && target.length !== oldLength) {
      // 并且被修改的 key 本身不是 'length' (避免重复触发)
      if (key !== 'length') {
        trigger(target, 'length', target.length) // 主动为 'length' 触发一次更新
      }
    }
    return res
  }
}

这段代码的逻辑是: 在 set 操作之后,我们检查目标是否为数组,并比较其新旧长度。

  • 如果长度发生了变化
  • 并且当前修改的 key 不是 length 本身(为了避免在 state.length = 2 这种情况下重复触发)
  • 我们就为 length 属性也主动触发一次更新。

今天我们聚焦于数组 length 属性的特殊性。通过分别在 trigger 函数和 set 代理中增加特殊的处理逻辑:

  • 在 trigger 中 :处理了手动缩短 length 时,对已删除索引的依赖触发。
  • 在 set 中 :处理了数组方法隐式改变 length 时,对 length 属性的依赖触发。

想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

相关推荐
Setsuna_F_Seiei1 分钟前
前端转型 Agent 开发 06 之 Agent Memory 记忆系统(让 Agent 更智能,更懂你)
前端·agent·ai编程
志尊宝22 分钟前
Vue3 零基础每日笔记(073):keep-alive 页面缓存实战——列表页返回不丢状态
vue.js·笔记·缓存·#vue #前端 #前端开发·#javascript·#vue.js
zhangzeyuaaa41 分钟前
Ruby 多线程、GVL(GIL)与 Mutex 完全指南
开发语言·前端·ruby
默_笙1 小时前
🌊 向量库和 ES 都查不出"关系",我只好给奶茶建了一张人脉网
前端·javascript
优选资讯1 小时前
标签打印软件怎么对接 Excel 数据
前端·excel
思无邪662 小时前
用 AI 做 JS 逆向:从抓包到复现的完整方法论
开发语言·javascript·人工智能
一个风轻云淡2 小时前
GCC 和 GDB命令简单解读
java·linux·前端
u0111026753 小时前
图片处理接口如何统一错误信息 前端提示与后端错误码设计
前端·状态模式
福兮说3 小时前
浏览器里把 iPhone 的 HEIC 转成 JPG:Chrome 解不开、转完大了七成、拍摄时间全丢,六个坑实测
前端·javascript·图像处理·chrome·ios·iphone·heic
libokaifa4 小时前
把语音模型塞进手机:sherpa-onnx 在 Android 上的 ASR / TTS
前端