七种会悄悄引发未来错误的常见 JavaScript 习语
解释了诸如真值判断、可选链操作和展开语法等常见的 JavaScript 模式,是如何隐藏那些会随着代码发展而悄然引发问题的假设的。
该拉取请求将十二行JavaScript代码精简为四行。更新后的版本运用了可选链操作、真值判断、对象展开以及结构化的查找表,从而使代码审查更加顺利。修改后的代码结构更为紧凑、阅读起来更自然,并且能正确处理应用程序当前处理的各类输入。
数月后,0开始被认可为有效的配置值。API响应不再包含之前始终存在的某个字段。原本只需处理数十条记录的后台任务突然需要处理数千条记录。一个嵌套对象在原本以为已被复制的场景中被再次使用。这些后续事件看似与之前的代码重构毫无关联,但实际上每一件都暴露出该精简代码在未明确说明的情况下所隐含的某些假设。
这正是某些 JavaScript 习惯用法存在风险的原因。它们在当时很少显得有误,而熟练的开发者之所以会使用它们,正是由于在当前条件下它们既方便又富有表现力且准确无误。问题在于,当这种便利性悄悄地将几种不同的状态合并为单一行为时,或者当那些看似能提供安全保障的语法实际上只是简写时,麻烦就出现了。
因此,在代码审查时的真正问题不在于某段代码是否巧妙或简洁,而在于它是否足够清晰地揭示了自身的假设,从而使未来的修改会引发明显的错误,而非悄无声息的错误结果。
1. 真值判断会将不同值归为同一决策结果
想象一个用于控制操作允许重试次数的配置项。
function getRetryCount(config) {
if (!config.retries) {
return 3;
}
return config.retries;
}
只要retries是正数或不存在,这种逻辑就是可行的。但一旦有人通过将值设为0来完全禁用重试功能,该函数就会悄悄忽略这一明确选择,转而返回3。JavaScript的真值规则其实被正确应用了——只是这些规则不再符合业务实际需求。
在JavaScript中,除了false之外还有多个值会被视为假值:0、""、null、undefined以及NaN都属于此类。因此像if (!value)这样的条件语句并非真正在检测某事物是否缺失,而是在检测JavaScript是否会将其强制转换为false,这比“它是否存在?”这个问题的范畴要宽得多。
更准确的版本取决于实际合同的内容。
function getRetryCount(config) {
if (config.retries === undefined) {
return 3;
}
return config.retries;
}
当null和undefined都用于表示“未提供值”时,空值合并运算符能更清晰地表达这一意图。
const retries = config.retries ?? 3;
对于表单输入、分页参数、价格、限制值、功能开关、计数器以及其他数值设置而言,这种区分非常重要。名为enabled的开关完全可以取值为false,搜索框可以为空字符串,偏移量也可以为0。将所有假值都视为“未提供”虽然简洁,但会丢失有用信息。
这些情况并不会使真值检查本身变得错误。当需求确实与真值性相关时,比如仅在字符串非空时才渲染它,这类检查就能很好地发挥作用。但一旦所处理的场景状态复杂到超出 JavaScript 自带的布尔值强制转换能力所能处理的范围,它们就会变成负担。
在审查时值得问的问题很直接:这个条件真的需要检测假值吗,还是说只是用真值性来替代某个更具体的业务规则?
2. 可选链操作能让有问题的数据变成“静默缺失”的数据
可选链操作堪称 JavaScript 最有价值的新增功能之一,因为它大大降低了处理深度嵌套的可选结构时的难度。
const avatarUrl = user.profile?.avatar?.url;
当profile或avatar确实可能不存在时,这正是可选链操作存在的意义所在。此时返回undefined是有意义的,因为缺失本就是一种正常且预期的结果。
问题出现在将可选链操作应用于本应始终存在的数据时。
function getShippingCountry(order) {
return order.customer?.address?.country;
}
假设系统的契约规定,任何可发货的订单都必须包含客户信息和配送地址。如果上游代码出现错误导致某个订单缺少了这些信息,可选链操作会悄悄地将本应属于契约违反的情况转化为普通的undefined。这个问题可能要过很久才会显现出来,表现为税额计算错误、缺失配送标签,或是代码库中其他无关部分的异常行为。
在这种情况下,更好的做法可能是立即失败,就在那个假设真正起作用的环节。
function getShippingCountry(order) {
if (!order.customer?.address) {
throw new Error(
`Order ${order.id} is missing a required shipping address`
);
}
return order.customer.address.country;
}
这种写法虽然更为冗长,但表达得更清晰。它能让日后阅读代码的人明白,该地址并非可有可无、可能出现也可能不出现的数据——如果缺少它,就意味着该对象根本不应被视为适用于此操作的有效对象。
当类型或 API 接口中出现不确定性时,可选链操作也容易随之扩散。一个不确定的字段会变成 foo?.bar,很快又会变成 foo?.bar?.baz,不久之后所有调用者都会默默忍受缺失的数据,而不会去探究本应必填的字段为何会不断消失。这种语法虽然减少了异常抛出,却同样可能增加整体的模糊性。
是否应使用可选链操作符,应当由领域模型来决定,而非出于便利性考虑。如果某个值确实是可选的,那么可选链操作符是一种简洁的表达方式;但如果该值是必需的,让undefined继续在程序中传递,则会使得后续出现的错误更难追溯到其真正起源。
3. 展开语法看似完全复制,但嵌套对象仍保持原有身份
将一个对象展开到新对象中,是一种简洁的方式来实现看似不可变的更新操作。
const updatedUser = {
...user,
name: "Umar",
};
在顶层,这种方式确实会根据预期创建一个包含相应值的新对象。这种成功体验往往会让开发者将{ ...object }理解为“克隆此对象”,而实际上该操作仅复制了该对象上直接存在的属性。
任何再嵌套一层的内容都不会得到这样的处理。
const original = {
name: "Umar",
preferences: {
theme: "light",
},
};
const copy = { ...original };
copy.preferences.theme = "dark";
console.log(original.preferences.theme);
// "dark"
在这里,copy 确实是一个独立的外层对象,但 copy.preferences 仍然指向与 original.preferences 完全相同的对象。展开操作复制的是引用本身,而非其关联的结构。
在状态管理中,这种情况很快就会带来风险,因为生成的代码在外观上仍表现为不可变的更新。
const nextState = { ...state };
nextState.user.preferences.theme = "dark";
外部对象是新的,但user和preferences很可能是来自先前状态的同一个对象。任何基于引用相等性来决定是重新渲染还是重新计算的逻辑,最终都会面对一种混合状态:部分内容是新创建的,而部分内容则在原处被修改。与此同时,任何仍持有旧状态引用的其他代码也会默默地看到这些修改。
如果嵌套对象中只有一个分支确实需要更改,应刻意复制该分支,而非依赖单一的顶层展开操作。
const nextState = {
...state,
user: {
...state.user,
preferences: {
...state.user.preferences,
theme: "dark",
},
},
};
虽然需要输入更多代码,但每发生一次变化,相关部分都会获得新的标识,而未作修改的部分则仍可安全地共享。当状态树变得过于复杂且嵌套较深时,像 Immer 这样的工具可以简化这种处理方式,但它们并不能消除根本要求:你仍然需要知道哪些引用在更新后依然有效,哪些无效。
展开语法本身并无缺陷。问题在于误以为浅拷贝就能实现深度隔离。
每当你通过展开语法来保护某个对象免受后续其他地方的修改时,都应停下来检查之后还有哪些嵌套引用仍在被共享。这一检查远比语法是否使用了 ... 更为重要。
4. 通过 JSON.parse(JSON.stringify(...)) 进行往返转换并不等同于克隆
避免浅拷贝问题的一种常用技巧就是这行代码:
const copy = JSON.parse(JSON.stringify(original));
它似乎能解决嵌套引用的问题,因为解析JSON字符串确实会生成全新的对象。对于已经呈纯JSON格式的数据,它甚至能给出你期望的结果。
问题在于将这种结果视为深度克隆的绝对保证。
JSON仅支持有限的数据模型。一旦对象包含该模型之外的值,序列化过程可能会悄悄修改它们、删除它们或引发错误。
以这个对象为例:
const value = {
createdAt: new Date(),
missing: undefined,
settings: new Map([["theme", "dark"]]),
};
经过一次 JSON 的序列化与反序列化处理后,Date 实例就变成了普通的字符串,失去了所有与 Date 相关的特性。undefined 属性也会完全消失。而 Map 也无法保持其原有的键值对结构,如果调用方期望得到一个结构完整的副本,这就成了问题。
循环引用则会更直接地导致问题:
const user = {
name: "Umar",
};
user.self = user;
JSON.stringify(user);
// TypeError
其他类型的值也会带来各自的难题。BigInt 需要特殊处理才能通过普通的 JSON.stringify,以符号为键的属性会被直接忽略,而自定义类的实例则会失去最初赋予它们类特性的原型。
这些因素并不会让 JSON 序列化变得不可靠——它正在履行序列化应有的功能,即将 JavaScript 值压缩成 JSON 能支持的更简洁格式。
当确实需要结构克隆时,只要所处理的类型在 structuredClone() 的支持范围内,它通常会是更好的内置选择。对于与特定业务领域紧密相关的数据,编写专门的复制函数可能会更加可靠,因为这样能明确指定哪些属性需要保留,而无需假设所有属性都应被通用方式克隆。
还需注意的是,深度克隆往往根本不是合适的解决方案。如果只需修改对象中的某个嵌套分支,克隆整个结构只会浪费精力,还会让人难以看清究竟是哪部分发生了变化。
一个好的习惯是为操作起与其功能相符的名称。当你要序列化某物时,就要明确地执行序列化操作;当你想要克隆某物时,应选择其行为确实符合你所存储数据的克隆方法。
5. Promise.all() 无法限制同时运行的数量
想象一个需要查询一批用户详细信息的应用程序。
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
对于少量用户来说,这种方法效果很好,通常也是正确的选择。代码会启动所有的异步请求,等待它们全部完成,然后按输入数组的顺序返回结果。
后来,有人将同一个函数用于包含50,000条记录的数据集。
const users = await Promise.all(
userIds.map((id) => fetchUser(id))
);
语法本身没有变化,但处理规模却变了。由于 .map() 会为每个元素调用 fetchUser,而每次调用很可能立即发起网络或数据库请求,因此 Promise.all() 并不会限制这些请求可以同时运行的数量。
这可能会导致下游服务不堪重负、连接池被耗尽、遇到速率限制、内存使用量激增,或者一旦错误不断累积就会引发大量重试。从表面上看,这段代码仍然像是简单的批量操作,因此很容易忽视其实际运行成本。
Promise.all()中还隐含了另一个假设:一旦有一个承诺被拒绝,整个操作就会立即失败。当将这组操作视为一个整体任务时,这种行为是正确的,但这并不意味着其他正在进行的操作会被取消——即便出现故障,它们仍会继续执行。
当这些任务实际上是相互独立的,并且你希望无论单个任务是否失败都能获取所有结果时,Promise.allSettled()通常更符合你的需求。
const results = await Promise.allSettled(
userIds.map((id) => fetchUser(id))
);
但如果你真正担心的是其他系统的负载,仅靠在 Promise.all() 和 Promise.allSettled() 之间切换是无法解决这个问题的——这两种方法都无法限制并发数量。你真正需要的是批量处理、信号量、队列,或是某种能够同时仅保持固定数量操作处于活跃状态的并发限制工具。
当输入数据量较小且可预测,且目标系统能够承受并行负载时,这种处理方式完全没问题。问题会在后来出现:原本为处理十条记录而设计的逻辑被用于处理一万条记录时,却没人停下来重新考虑这样做是否仍然安全。
并发性并非取决于你等待承诺的方式有多简洁,而在于你启动了多少任务以及底层系统实际上能够处理多少工作量。
6. 看起来具有函数式特性的数组方法仍可能引发状态变更
JavaScript中的数组方法有助于构建简洁且易于理解的数据处理流程,大多数情况下这确实是个优点。
const activeUsers = users
.filter((user) => user.active)
.map((user) => user.name);
由于filter()和map()都会返回新的数组,人们很容易认为任何数组方法链都不会影响原始数组。但一旦sort()、reverse()、splice()或其他一些会修改数据的函数被加入方法链,这种假设就不成立了。
想象一个作为属性接收users数组的React组件:
function UserList({ users }) {
const sortedUsers = users.sort(
(a, b) => a.name.localeCompare(b.name)
);
return sortedUsers.map((user) => (
<User key={user.id} user={user} />
));
}
看起来这段代码会生成一个新的 sortedUsers 数组,但实际上 sort() 是在原数组上直接重新排序,并仅返回该数组的引用。因此,那些仅负责渲染的组件会意外地改变其父组件中的数据。
这种数据变更可能会长期隐藏不露,直到应用程序的其他部分依赖原有的数据顺序。由于渲染出的结果看似正确,该组件的测试会持续通过,而其他组件或缓存值则会开始使用它们原本未预料到的、已重新排序的数据。
一种解决办法是在使用会修改数据的函数之前先复制数组。
const sortedUsers = [...users].sort(
(a, b) => a.name.localeCompare(b.name)
);
如果目标环境支持,较新的 JavaScript 运行时还提供了不会修改数据的替代函数,比如 toSorted()。
这里真正的教训并非记住哪些数组方法是“安全的”,而是要思考某个操作是否尊重了数据的所有者。
如果一个函数被传入了它并不拥有的值,却悄悄地修改它,就会产生与该函数本应负责的功能毫无关联的行为。当数据根本不会被修改,或者是在真正拥有该值的地方明确地进行修改时,代码会容易得多理解。
7. 查表机制可以隐藏无效输入而非直接拒绝它们
人们常常用普通对象作为调度表来替代switch语句,因为这样写起来更具声明性。
const handlers = {
create: createUser,
update: updateUser,
delete: deleteUser,
};
handlers[action]?.(payload);
这种方式很简洁,而且在很多情况下,通过查表进行调度确实是一种不错的模式。但容易被忽视的是,当action与任何已知键都不匹配时会发生什么。
由于该示例使用了可选链操作,无法识别的操作只会默默地什么都不做。
action = "udpate"; // typo
handlers[action]?.(payload); // no handler runs
一个简单的拼写错误就可能以“成功执行无操作”的形式在系统中传播。除非调用者检查是否有有意义的返回值,否则应用程序的其他部分可能会报告操作已成功,尽管实际上并未执行任何操作。
明确地显示错误会让代码契约更加透明可信。
const handler = handlers[action];
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
一旦键值来自用户输入或未经严格验证的来源,查找表就会带来更多风险。普通对象也会从其原型继承属性,除非你特意阻止这种情况发生,因此将任意字符串视为安全的属性查找方式,需要比处理一小组内部已知的命令时更加谨慎。
使用Map可以更清晰地体现字典式的使用意图:
const handlers = new Map([
["create", createUser],
["update", updateUser],
["delete", deleteUser],
]);
const handler = handlers.get(action);
if (!handler) {
throw new Error(`Unsupported action: ${action}`);
}
return handler(payload);
在 TypeScript 中,将区分联合类型与完整的 switch 语句结合使用能带来更大优势,因为每当新增一种操作时,编译器就会自动标记代码库中所有需要更新的部分。
但这并不意味着 switch 语句就绝对优于查找表。当操作本质上是由数据驱动的,且对未知键的行为已有明确规定时,调度表更为适用。
问题出现在间接调用使得人们无法区分“程序确实处理了该情况”与“根本没有任何反应”这两种情形时。
聪明设计的真正代价是隐藏的假设
这七种习惯其实都不应被完全禁止。
当“真值性”确实符合你的领域规则时,真值检查就能很好地发挥作用。对于真正可选的关系,可选链操作则显得非常有用。对象展开非常适合进行浅层更新。JSON则是处理具有 JSON 结构的数据的理想格式。Promise.all()则非常适合用于有界并行任务。当你完全拥有某个数组时,使用修改数组的方法也是可以的。而查找表则能让调度逻辑变得极为简单。
问题在于将那些看似方便的默认值误认为是语法真正能提供的保证。!value并不代表“缺失”,?.也不意味着“安全存在且有效”,{ ...obj }更不是“完全独立的副本”。将数据序列化为JSON再反序列化回来也不等同于“通用克隆”,而Promise.all()更不能代表“受控的并发处理”。
正因如此,最易于维护的JavaScript代码版本并不总是最短的。多写几行代码来区分undefined和0、拒绝格式错误的对象结构、限制并行请求的数量,或在遇到未知操作时抛出异常,这些措施都能保留重要信息,否则未来的维护者就不得不从提交历史中费力查找。
每当某段代码让你觉得异常巧妙时,不妨花点时间看看有哪些内容被省略了。有时答案是只有不必要的繁琐步骤被去除,而核心含义依然完整——这正是良好抽象应具备的功能。另一些时候,被省略的则是当前需求尚未涉及的区别。
正是第二种情况往往会成为日后错误的根源。
在代码审查时,一个有用的习惯是思考:当需求发生变化后,要让某个简洁的代码模式继续正常工作,需要哪些条件保持成立。如果该条件很重要但在代码中无处体现,就应在逻辑尚清晰易懂时将其明确写下来。
未来的维护者很少会遇到问题,因为JavaScript允许他们编写大量代码。但他们之所以会出问题,是因为这些代码不再能明确指出哪些差异才是真正重要的。