es-toolkit 兼容层 updateWith 详解:用 customizer 精确控制嵌套对象路径的创建
【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit
导读
本文以 es-toolkit 兼容层(compat)中面向 lodash 兼容的updateWith函数为核心,讲解如何在一个深层嵌套对象中,通过 updater 函数更新指定路径的值,并利用 customizer 回调控制路径缺失时中间对象(数组或普通对象)的创建形态。读完本文,你将掌握updateWith的完整签名、字符串/数组两种路径写法、customizer 的三个入参与返回约定,以及其底层实现如何协同get、toPath、assignValue、isIndex等内部模块完成路径解析与安全写入。
函数概览
updateWith是 es-toolkit 兼容层中update的增强版本(官方参考文档)。它与update的唯一区别在于:当路径中的某一段不存在时,update总是按「数字下标创建数组、其余创建普通对象」的默认规则补齐结构,而updateWith允许你通过 customizer 自定义这一段中间容器究竟应该是什么。
const updated = updateWith(obj, path, updater, customizer);与 update / setWith 的关系
在 src/compat/object/update.ts 中可以看到,update实际上是updateWith的特例:
export function update(obj: object, path: PropertyPath, updater: (value: any) => any): any { return updateWith(obj, path, updater, () => undefined); }也就是说,update等价于「customizer 恒返回undefined」的updateWith,此时完全走默认的中间对象创建逻辑。而setWith则是用固定值替换 updater 的同类函数(见 setWith 文档),内部同样基于updateWith的路径遍历机制实现。
基本用法:更新已存在路径上的值
当路径已存在时,updateWith的行为与update完全一致:取出旧值传给 updater,用返回值替换旧值。
import { updateWith } from 'es-toolkit/compat'; // 基本行为(与 update 相同) const object = { a: [{ b: { c: 3 } }] }; updateWith(object, 'a[0].b.c', n => n * n); // => { a: [{ b: { c: 9 } }] } // 使用数组形式的路径 updateWith(object, ['a', 0, 'b', 'c'], n => n + 10); // => { a: [{ b: { c: 13 } }] }路径的两种写法
- 字符串路径:如
'a[0].b.c',支持点号与括号下标混合语法;空括号'a[]'会被解析为字面键''(测试 updateWith.spec.ts 验证了这一点),转义写法如'a[-1.23]["[\"b\"]"].c'也能正确解析。 - 数组路径:如
['a', 0, 'b', 'c'],元素可以是字符串、数字或 symbol。数组路径不会被强制转成字符串(['a', 'b', 'c']不会匹配'a,b,c'键),同时也不会忽略其中包含点号或空字符串的段(见 src/compat/object/updateWith.spec.ts 中「not coerce array paths to strings」「handle empty paths」两组用例)。
核心能力:用 customizer 控制中间对象的创建
当路径不存在时,updateWith需要沿途创建中间容器。默认规则是:若下一段路径是数组下标则创建数组,否则创建普通对象。而 customizer 可以改写这一规则:
import { updateWith } from 'es-toolkit/compat'; const object = {}; // 使用 Object 构造函数作为 customizer(强制创建普通对象而非数组) updateWith(object, '[0][1]', () => 'a', Object); // => { '0': { '1': 'a' } } // (默认行为会是 { '0': ['a'] })customizer 接收(value, key, object)三个参数,其中value是当前路径段的旧值(通常为undefined),key是要创建的键,object是当前所在的父对象。它返回undefined时回退到默认行为,返回其他任意值则作为新建的中间容器:
import { updateWith } from 'es-toolkit/compat'; const customizer = (value: any, key: string, object: any) => { // 对数字键创建普通对象而不是数组 if (!isNaN(Number(key))) { return {}; } }; const result = {}; updateWith(result, '[0][1]', () => 'value', customizer); // => { '0': { '1': 'value' } }路径已存在时 customizer 不会被调用
customizer 只负责「创建缺失的中间对象」,因此当整条路径都已经存在时,它不会被触发:
import { updateWith } from 'es-toolkit/compat'; const object = { a: { b: 1 } }; updateWith( object, 'a.b', n => n * 2, () => { console.log('Not called'); // 不会被调用 return {}; } ); // => { a: { b: 2 } }测试 src/compat/object/updateWith.spec.ts 也专门验证了「customizer 修改原对象时,最后一级仍应被正确更新」这一边界行为。
源码级原理:updateWith 的执行链路
updateWith的实现位于 src/compat/object/updateWith.ts,核心流程分为四步:
1. 空值保护
if (obj == null && !isObject(obj)) { return obj; }当传入null或undefined时直接原样返回,不会抛错(对应测试 updateWith.spec.ts 中的 nullish 用例)。
2. 路径解析
let resolvedPath: PropertyKey[]; if (isKey(path, obj)) { resolvedPath = [path]; } else if (Array.isArray(path)) { resolvedPath = path; } else { resolvedPath = toPath(path); }- 若路径本身是对象的已有键(
isKey判定),直接作为单段路径; - 数组路径原样保留,逐段使用;
- 字符串路径交给
toPath(src/compat/util/toPath.ts)解析成段数组,支持点号与括号语法。
3. 计算新值
const updateValue = updater(get(obj, resolvedPath));先通过get读取路径当前的旧值,交给 updater 计算出新值,再开始逐段写入。
4. 逐段写入与中间对象决策
for (let i = 0; i < resolvedPath.length && current != null; i++) { const key = toKey(resolvedPath[i]); if (isUnsafeToWriteProperty(key)) { return obj; } let newValue: unknown; if (i === resolvedPath.length - 1) { newValue = updateValue; } else { const objValue = current[key]; const customizerResult = customizer?.(objValue, key as string, obj); newValue = customizerResult !== undefined ? customizerResult : isObject(objValue) ? objValue : isIndex(resolvedPath[i + 1]) ? [] : {}; } assignValue(current, key, newValue); current = current[key]; }这段循环揭示了三个关键设计:
- 中间对象的决策优先级:customizer 返回值 > 已存在的对象(保留原引用)> 下一段是数组下标则建数组 > 否则建普通对象。其中「下一段是否为数组下标」由 src/compat/_internal/isIndex.ts 判定——它只把无符号十进制整数(
0或[1-9]\d*)视为下标,因此'1a'这类以数字开头但非下标的键仍会创建普通对象(测试 updateWith.spec.ts 专门覆盖了这一点)。 - 原型链污染防护:每段写入前都会调用
isUnsafeToWriteProperty(src/_internal/isUnsafeToWriteProperty.ts)检查__proto__、constructor、prototype等危险键,一旦命中立即中止整个更新并返回原对象。测试 updateWith.spec.ts 验证了'a[0][__proto__].b'、'constructor.prototype.polluted'等路径都不会污染全局Object.prototype。 - 惰性写入:最终赋值通过 src/compat/_internal/assignValue.ts 完成,它会在新旧值相同时跳过赋值(避免触发不必要的 setter),这对
NaN、对象引用等场景同样适用(测试 updateWith.spec.ts)。
参数与返回值
| 参数 | 类型 | 说明 |
|---|---|---|
obj | T(object) | 要修改的目标对象。传入null/undefined时原样返回,不抛错 |
path | PropertyKey \| readonly PropertyKey[] | 要更新的属性路径,可用字符串(点号/括号语法)或数组表示 |
updater | (oldValue: any) => any | 接收旧值、返回新值的函数 |
customizer | (value: any, key: string, object: T) => any,可选 | 决定缺失路径段要创建什么中间容器;返回undefined时走默认逻辑(下标建数组、其余建对象) |
返回值:T——修改后的原对象(原地修改,与update一致,测试中断言actual与object是同一引用)。
实践建议与注意事项
官方文档在页面开头明确提示:updateWith因复杂的路径解析与 customizer 处理而运行较慢,在性能敏感的代码中应优先使用现代的直接属性赋值或可选链(optional chaining)写法;updateWith更适合需要动态路径、且必须控制中间容器形态的 lodash 迁移场景。
使用时的几个要点:
- 路径尽量用数组形式:可避免字符串解析的歧义,也能正确处理包含点号、空串的键;
- customizer 记得在不需要定制时返回
undefined:返回其他值会强制替换已存在的对象,可能破坏原有引用结构; - 切勿传入
__proto__/constructor/prototype相关路径:虽然实现内部会拦截并中止,但这类写法本身就是反模式; updateWith会原地修改对象:若不想改动原对象,请先自行浅拷贝/深拷贝。
相关函数与延伸阅读
- update 参考文档:
update是updateWith的特例(customizer 恒为undefined); - setWith 参考文档:以固定值代替 updater,同样支持 customizer 控制中间对象形态;
- updateWith 实现源码:核心遍历与写入逻辑;
- updateWith 测试用例:覆盖路径解析、原型链污染防护、符号键、边界路径等 19 组场景;
- set 相关实现 与 get 实现:
updateWith读取旧值依赖get,写入依赖assignValue,三者共享同一套路径模型。
【免费下载链接】es-toolkitA modern JavaScript utility library that's 2-3 times faster and up to 97% smaller, a major upgrade to lodash.项目地址: https://gitcode.com/GitHub_Trending/es/es-toolkit
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考