|
| 1 | +--- |
| 2 | +id: array-methods |
| 3 | +title: 数组方法插件 |
| 4 | +--- |
| 5 | + |
| 6 | +<center> |
| 7 | +<div data-ea-publisher="immerjs" data-ea-type="image" className="horizontal bordered"></div> |
| 8 | +</center> |
| 9 | + |
| 10 | +## 概述 |
| 11 | + |
| 12 | +数组方法插件(`enableArrayMethods()`)通过避免在迭代期间创建不必要的 Proxy,优化了 Immer producer 中的数组操作。这可以显著提升大量使用数组的操作的性能。 |
| 13 | + |
| 14 | +**为什么这一点很重要?** 如果不使用该插件,迭代期间每次访问数组元素(例如在 `filter`、`find`、`slice` 中)都会创建一个 Proxy 对象。对于包含 1000 个元素的数组,仅一次迭代就意味着 1000 次以上的 Proxy trap 调用。启用插件后,回调会接收基础值(未经 Proxy 包装的值),只有在追踪修改需要时才会创建 Proxy。 |
| 15 | + |
| 16 | +## 安装 |
| 17 | + |
| 18 | +在应用的入口处启用一次插件: |
| 19 | + |
| 20 | +```javascript |
| 21 | +import {enableArrayMethods} from "immer" |
| 22 | + |
| 23 | +enableArrayMethods() |
| 24 | +``` |
| 25 | + |
| 26 | +这会使你的打包体积增加约 **2KB**。 |
| 27 | + |
| 28 | +## 修改数组的方法 |
| 29 | + |
| 30 | +这些方法会就地修改数组,直接操作 draft 的内部副本,而不会为每个元素创建 Proxy: |
| 31 | + |
| 32 | +| 方法 | 返回值 | 说明 | |
| 33 | +| ----------- | ------------ | ---------------------------- | |
| 34 | +| `push()` | 新长度 | 在末尾添加元素 | |
| 35 | +| `pop()` | 被移除的元素 | 移除并返回最后一个元素 | |
| 36 | +| `shift()` | 被移除的元素 | 移除并返回第一个元素 | |
| 37 | +| `unshift()` | 新长度 | 在开头添加元素 | |
| 38 | +| `splice()` | 被移除的元素 | 在任意位置添加或移除元素 | |
| 39 | +| `sort()` | draft 数组 | 就地对元素排序 | |
| 40 | +| `reverse()` | draft 数组 | 就地反转数组 | |
| 41 | + |
| 42 | +```javascript |
| 43 | +import {produce, enableArrayMethods} from "immer" |
| 44 | + |
| 45 | +enableArrayMethods() |
| 46 | + |
| 47 | +const base = {items: [3, 1, 4, 1, 5]} |
| 48 | + |
| 49 | +const result = produce(base, draft => { |
| 50 | + draft.items.push(9) // 在末尾添加 9 |
| 51 | + draft.items.sort() // 排序:[1, 1, 3, 4, 5, 9] |
| 52 | + draft.items.reverse() // 反转:[9, 5, 4, 3, 1, 1] |
| 53 | +}) |
| 54 | +``` |
| 55 | + |
| 56 | +## 不修改数组的方法 |
| 57 | + |
| 58 | +不修改数组的方法可以根据返回值分为以下几类: |
| 59 | + |
| 60 | +### 子集操作(返回 draft) |
| 61 | + |
| 62 | +这些方法选择原数组中已有的元素,并为返回的元素**创建 draft Proxy**。回调接收的是**基础值**(这正是优化所在),但**返回的数组**中包含新创建的 draft Proxy,它们仍指向原来的位置。**修改返回的元素会影响 draft 状态。** |
| 63 | + |
| 64 | +| 方法 | 返回值 | 是否为 draft? | |
| 65 | +| ------------ | ------------------------------ | -------------- | |
| 66 | +| `filter()` | 匹配元素组成的数组 | ✅ 是 | |
| 67 | +| `slice()` | 指定范围内的元素组成的数组 | ✅ 是 | |
| 68 | +| `find()` | 第一个匹配元素或 `undefined` | ✅ 是 | |
| 69 | +| `findLast()` | 最后一个匹配元素或 `undefined` | ✅ 是 | |
| 70 | + |
| 71 | +```javascript |
| 72 | +const base = { |
| 73 | + items: [ |
| 74 | + {id: 1, value: 10}, |
| 75 | + {id: 2, value: 20}, |
| 76 | + {id: 3, value: 30} |
| 77 | + ] |
| 78 | +} |
| 79 | + |
| 80 | +const result = produce(base, draft => { |
| 81 | + // filter 返回 draft,修改会追踪到原数组 |
| 82 | + const filtered = draft.items.filter(item => item.value > 15) |
| 83 | + filtered[0].value = 999 // 这会影响 draft.items[1] |
| 84 | + |
| 85 | + // find 返回一个 draft,修改会被追踪 |
| 86 | + const found = draft.items.find(item => item.id === 3) |
| 87 | + if (found) { |
| 88 | + found.value = 888 // 这会影响 draft.items[2] |
| 89 | + } |
| 90 | + |
| 91 | + // slice 返回 draft |
| 92 | + const sliced = draft.items.slice(0, 2) |
| 93 | + sliced[0].value = 777 // 这会影响 draft.items[0] |
| 94 | +}) |
| 95 | + |
| 96 | +console.log(result.items[0].value) // 777 |
| 97 | +console.log(result.items[1].value) // 999 |
| 98 | +console.log(result.items[2].value) // 888 |
| 99 | +``` |
| 100 | + |
| 101 | +### 转换操作(返回基础值) |
| 102 | + |
| 103 | +这些方法会创建可能包含外部元素或经过重新组织的数据的**新数组**。它们返回的是**基础值**,而不是 draft。**修改返回的元素不会追踪回 draft 状态。** |
| 104 | + |
| 105 | +| 方法 | 返回值 | 是否为 draft? | |
| 106 | +| ---------- | ------------------ | -------------- | |
| 107 | +| `concat()` | 合并后的新数组 | ❌ 否 | |
| 108 | +| `flat()` | 扁平化后的新数组 | ❌ 否 | |
| 109 | + |
| 110 | +```javascript |
| 111 | +const base = {items: [{id: 1, value: 10}]} |
| 112 | + |
| 113 | +const result = produce(base, draft => { |
| 114 | + // concat 返回基础值,修改不会被追踪 |
| 115 | + const concatenated = draft.items.concat([{id: 2, value: 20}]) |
| 116 | + concatenated[0].value = 999 // 这不会影响 draft.items[0] |
| 117 | + |
| 118 | + // 如需真正使用 concat 的结果,请将其赋值: |
| 119 | + draft.items = draft.items.concat([{id: 2, value: 20}]) |
| 120 | +}) |
| 121 | + |
| 122 | +// 原值未改变,因为 concat 的结果没有被赋值给它 |
| 123 | +console.log(result.items[0].value) // 10(未改变) |
| 124 | +``` |
| 125 | + |
| 126 | +**为什么要这样区分?** |
| 127 | + |
| 128 | +- **子集操作**(`filter`、`slice`、`find`)选择原数组中已经存在的元素。返回 draft 可以让修改传播回数据源。 |
| 129 | +- **转换操作**(`concat`、`flat`)创建可能包含外部元素或经过重新组织的数据的新数据结构,因此无法进行实用的 draft 追踪。 |
| 130 | + |
| 131 | +### 返回原始值的方法 |
| 132 | + |
| 133 | +这些方法返回原始值(数字、布尔值、字符串)。原始值不能成为 draft,因此不存在追踪问题: |
| 134 | + |
| 135 | +| 方法 | 返回值 | |
| 136 | +| ------------------ | -------------------- | |
| 137 | +| `indexOf()` | 数字(索引或 -1) | |
| 138 | +| `lastIndexOf()` | 数字(索引或 -1) | |
| 139 | +| `includes()` | 布尔值 | |
| 140 | +| `some()` | 布尔值 | |
| 141 | +| `every()` | 布尔值 | |
| 142 | +| `findIndex()` | 数字(索引或 -1) | |
| 143 | +| `findLastIndex()` | 数字(索引或 -1) | |
| 144 | +| `join()` | 字符串 | |
| 145 | +| `toString()` | 字符串 | |
| 146 | +| `toLocaleString()` | 字符串 | |
| 147 | + |
| 148 | +```javascript |
| 149 | +const base = { |
| 150 | + items: [ |
| 151 | + {id: 1, active: true}, |
| 152 | + {id: 2, active: false} |
| 153 | + ] |
| 154 | +} |
| 155 | + |
| 156 | +const result = produce(base, draft => { |
| 157 | + const index = draft.items.findIndex(item => item.id === 2) |
| 158 | + const hasActive = draft.items.some(item => item.active) |
| 159 | + const allActive = draft.items.every(item => item.active) |
| 160 | + |
| 161 | + console.log(index) // 1 |
| 162 | + console.log(hasActive) // true |
| 163 | + console.log(allActive) // false |
| 164 | +}) |
| 165 | +``` |
| 166 | + |
| 167 | +## 未被重写的方法 |
| 168 | + |
| 169 | +以下方法**不会**被插件拦截,而是继续按照标准 Proxy 行为工作。回调接收 draft,修改会正常追踪: |
| 170 | + |
| 171 | +| 方法 | 说明 | |
| 172 | +| --------------- | ---------------------- | |
| 173 | +| `map()` | 转换每个元素 | |
| 174 | +| `flatMap()` | 映射后再扁平化 | |
| 175 | +| `forEach()` | 对每个元素执行回调 | |
| 176 | +| `reduce()` | 归并为单个值 | |
| 177 | +| `reduceRight()` | 从右向左归并为单个值 | |
| 178 | + |
| 179 | +```javascript |
| 180 | +const base = { |
| 181 | + items: [ |
| 182 | + {id: 1, value: 10, nested: {count: 0}}, |
| 183 | + {id: 2, value: 20, nested: {count: 0}} |
| 184 | + ] |
| 185 | +} |
| 186 | + |
| 187 | +const result = produce(base, draft => { |
| 188 | + // forEach 接收 draft,修改会正常工作 |
| 189 | + draft.items.forEach(item => { |
| 190 | + item.value *= 2 |
| 191 | + }) |
| 192 | + |
| 193 | + // map 未被重写,回调接收 draft |
| 194 | + // 返回数组中的元素也是从 draft.items 中提取的 draft |
| 195 | + const mapped = draft.items.map(item => item.nested) |
| 196 | + // 对结果数组中元素的修改会传播回去 |
| 197 | + mapped[0].count = 999 // ✅ 这会影响 draft.items[0].nested.count |
| 198 | +}) |
| 199 | + |
| 200 | +console.log(result.items[0].nested.count) // 999 |
| 201 | +``` |
| 202 | + |
| 203 | +## 回调行为 |
| 204 | + |
| 205 | +对于被重写的方法,回调接收的是**基础值**(不是 draft)。这是优化的核心,因为它避免了在迭代期间为每个元素创建 Proxy。 |
| 206 | + |
| 207 | +```javascript |
| 208 | +const base = { |
| 209 | + items: [ |
| 210 | + {id: 1, value: 10}, |
| 211 | + {id: 2, value: 20} |
| 212 | + ] |
| 213 | +} |
| 214 | + |
| 215 | +produce(base, draft => { |
| 216 | + draft.items.filter(item => { |
| 217 | + // 这里的 item 是基础值,而不是 draft |
| 218 | + // 读取属性没有问题 |
| 219 | + return item.value > 15 |
| 220 | + |
| 221 | + // 但这里的直接修改不会被追踪: |
| 222 | + // item.value = 999 // ❌ 不会影响 draft |
| 223 | + }) |
| 224 | + |
| 225 | + // 应当改用返回的 draft: |
| 226 | + const filtered = draft.items.filter(item => item.value > 15) |
| 227 | + filtered[0].value = 999 // ✅ 可以生效,因为 filtered[0] 是 draft |
| 228 | +}) |
| 229 | +``` |
| 230 | + |
| 231 | +## 方法返回行为汇总 |
| 232 | + |
| 233 | +| 类别 | 方法 | 返回值 | 是否追踪修改? | |
| 234 | +| ------------ | -------------------------------------------------------------------------------------------------- | ------------ | ----------------------- | |
| 235 | +| **子集** | `filter`、`slice`、`find`、`findLast` | draft Proxy | ✅ 是 | |
| 236 | +| **转换** | `concat`、`flat` | 基础值 | ❌ 否 | |
| 237 | +| **原始值** | `indexOf`、`includes`、`some`、`every`、`findIndex`、`findLastIndex`、`lastIndexOf`、`join`、`toString`、`toLocaleString` | 原始值 | 不适用 | |
| 238 | +| **修改数组** | `push`、`pop`、`shift`、`unshift`、`splice`、`sort`、`reverse` | 取决于方法 | ✅ 是(修改 draft) | |
| 239 | +| **未重写** | `map`、`flatMap`、`forEach`、`reduce`、`reduceRight` | 标准行为 | ✅ 是(回调接收 draft) | |
| 240 | + |
| 241 | +## 何时使用 |
| 242 | + |
| 243 | +在以下情况下,可以启用数组方法插件: |
| 244 | + |
| 245 | +- 应用的 producer 中包含大量数组迭代 |
| 246 | +- 经常对大型数组使用 `filter`、`find`、`some`、`every` 等方法 |
| 247 | +- 性能分析显示数组操作是性能瓶颈 |
| 248 | + |
| 249 | +该插件在以下场景中最有帮助: |
| 250 | + |
| 251 | +- 大型数组(100 个以上元素) |
| 252 | +- 频繁调用包含数组操作的 producer |
| 253 | +- 大多数元素不会被修改的读取密集型操作(筛选、搜索) |
| 254 | + |
| 255 | +## 性能收益 |
| 256 | + |
| 257 | +**不使用插件时:** |
| 258 | + |
| 259 | +- 迭代期间每次访问数组元素都会创建一个 Proxy |
| 260 | +- 对 1000 个元素执行一次 `filter()`,会创建 1000 个以上的 Proxy |
| 261 | + |
| 262 | +**使用插件时:** |
| 263 | + |
| 264 | +- 回调直接接收基础值 |
| 265 | +- 只为你实际修改的特定元素或符合筛选条件的元素创建 Proxy |
| 266 | + |
| 267 | +```javascript |
| 268 | +// 不使用插件:约 3000 次以上的 Proxy trap 调用 |
| 269 | +// 使用插件:约 10~20 次 Proxy trap 调用 |
| 270 | +const result = produce(largeState, draft => { |
| 271 | + const filtered = draft.items.filter(x => x.value > threshold) |
| 272 | + // 只有被修改的元素才会创建 Proxy |
| 273 | + filtered.forEach(item => { |
| 274 | + item.processed = true |
| 275 | + }) |
| 276 | +}) |
| 277 | +``` |
0 commit comments