常见的会导致代码隐秘出错的 JavaScript 和 TypeScript 潜在问题
那些看似正确实则会出错的 JavaScript 和 TypeScript 陷阱:NaN 比较、类型强制转换、async/await 的时序问题,以及会给你错误判断的相等性检查。
来源: 20 JavaScript/TypeScript Mistakes That Look Right… But Break Your App
比较与相等性陷阱
1. NaN永远不等于任何值,甚至不包括它自己。
[1, NaN, 3].indexOf(NaN); // -1, even though NaN is right there
根据规范,NaN === NaN的运算结果为false,因此任何基于严格相等性设计的机制——包括indexOf——即便该值就在数组中也无法找到它。应改用Number.isNaN(value),或使用专为正确处理此边缘情况而设计的.includes()方法。
2. typeof null的返回值为"object"。
if (typeof value === "object") { value.someMethod(); } // value could be null
这是该语言中固有的旧有缺陷,任何将typeof视为可靠“是否为对象”检测方法的人都会因此犯错。null能通过该检查,但一旦尝试对其调用方法就会出问题。
3. 通过JSON.stringify()比较对象时顺序敏感。
JSON.stringify({ a: 1, b: 2 }) === JSON.stringify({ b: 2, a: 1 }); // false
表示相同数据的两个对象在序列化为字符串时,其键的顺序可能不同——这在经过数据库处理或代码库的其他部分后很常见。应避免直接比较字符串,而应使用合适的深度相等性检测工具。
4. 未指定比较器的.sort()会将所有内容视为文本。
[10, 2, 1].sort(); // [1, 10, 2]
数值首先会被转换为字符串,然后再逐个字符进行比较,这会破坏数值的原有顺序。对于数字,应始终提供显式的比较函数,例如 (a, b) => a - b。
类型强制转换
5. 一旦操作数中有一个是字符串,+ 运算符就会立即转为字符串连接操作。
function total(price, quantity) {
return price + quantity; // "5" + 3 = "53" if price came from a form input
}
来自查询参数、表单字段或解析后的 JSON 的数值,即便看起来像数字,往往仍为字符串形式。+ 运算符不会自动将字符串转换为数字,反而会将数字转换为字符串。
6> 解构赋值中的默认值仅适用于属性为 undefined 的情况,绝不适用于 null。
function greet({ name = "friend" } = {}) { return `Hi, ${name}`; }
greet({ name: null }); // "Hi, null" — the default never kicks in
显式传递 null 被视为提供了值而非省略了值,因此完全绕过了默认的 fallback 处理。如果调用者确实可能传入 null,则需要单独对其进行检查。
异步定时
7. 对承诺数组执行 .filter(Boolean) 操作没有任何作用。
const results = items.map((item) => checkAsync(item)); // array of Promise objects
const valid = results.filter(Boolean); // every Promise is truthy, filters nothing
无论 Promise 实例最终是成功解析还是失败拒绝,它始终被视为真值。你需要先使用 await Promise.all(results) 将其全部解析,然后再对得到的值进行过滤。
8. 可选链式调用会停止整个表达式的求值,而不仅仅是可选部分。
user?.getPermissions().includes("admin"); // if user is null, this is undefined, not an error, but also never calls .includes at all
人们很容易误以为只有 ?. 这一部分会被跳过,但实际上其后所有链式调用的执行也会停止。这通常是期望的行为,但当某个尾随调用——比如日志记录或分析功能——悄无声息地从未执行时,就会变成一个隐蔽的错误。
9. 一旦有一个 promise 被拒绝,Promise.all 就会丢弃所有结果。
const [a, b, c] = await Promise.all([taskA(), taskB(), taskC()]);
// if taskC rejects, you get nothing back from taskA and taskB either, even if they succeeded
这是有意设计的“全有或全无”机制。如果在某些情况下部分成功也是可以接受的,那么 Promise.allSettled 会返回每个 promise 的最终结果——无论成功还是失败——而不会因为某个任务失败就丢弃那些成功的结果。
对象与数组的修改
10. const 只锁定变量绑定,而不锁定它所指向的对象。
const config = { retries: 3 };
config.retries = 10; // completely legal, const never protected this
使用 const 声明变量仅能防止将其重新赋值为其他值,它所引用的对象依然可以像使用 let 时那样被修改。如果需要真正的不可变性,应使用 Object.freeze()——但请注意该方法仅能冻结一层深度的对象。
11. .sort() 会修改原始数组,这与 .map() 或 .filter() 不同。
const original = [3, 1, 2];
const sorted = original.sort();
console.log(original); // [1, 2, 3] — also changed, probably not what was intended
由于大多数常用数组方法都会返回新数组,人们很容易认为所有数组方法都是如此。.sort() 和 .reverse() 是例外,如果修改了数组而代码中的其他部分仍持有该数组的引用,就会出现“此值自行改变”的典型错误。
12. for...in 会遍历继承来的属性,而不仅仅是对象自身的键。
Array.prototype.customMethod = function () {};
const arr = [1, 2, 3];
for (const key in arr) { console.log(key); } // includes "customMethod" too
在实践中这种情况很少出现问题,因为很少有人会扩展内置原型,但从原型链继承来的任何可枚举属性都会出现在 for...in 循环中。使用 Object.keys() 或 for...of 可以完全避免这种意外情况。
函数与作用域
13. 箭头函数从不绑定自己的 this。
const counter = {
count: 0,
increment: () => { this.count++; }, // `this` here is NOT the counter object
};
箭头函数会从其定义时的词法作用域中获取 this,而非从调用它的对象中获取。这使它们非常适合需要保留外部 this 的回调函数,但对于那些应当引用自身实例的对象或类方法来说则不是理想选择。
14. 如果省略break语句,switch语句会默默地继续执行后续分支。
switch (status) {
case "pending":
console.log("Order is pending");
case "shipped": // no break above, this runs too
console.log("Order has shipped");
}
只要跳过一个break,所有后续的分支都会被执行,无论其条件是否真的满足。虽然有时会故意让代码继续执行后续分支,但这种情况极为罕见,因此缺少break几乎总是表明存在错误而非设计选择。
日期与数字
15. 0.1 + 0.2的结果并非0.3。
0.1 + 0.2 === 0.3; // false
0.1 + 0.2; // 0.30000000000000004
这是由标准浮点数表示方式导致的,几乎所有语言都会出现这种情况,并非JavaScript所特有。应避免对计算得到的小数值使用严格相等比较,而应检查两个数值之间的差值是否在可接受的容差范围内。
16. 只包含日期的字符串会被解析为UTC时间,而用斜杠分隔的日期则按本地时间解析。
new Date("2024-01-01"); // midnight UTC
new Date("01/01/2024"); // midnight local time
两个看似描述同一天的字符串,根据运行代码的时区不同,可能会对应不同的日历日期。应选择一种日期格式并始终如一地使用它,同时借助成熟的日期处理库而非手动解析字符串。
TypeScript特有的陷阱
17. 类型断言不会在运行时进行任何验证。
const user = JSON.parse(rawData) as User; // TypeScript trusts you, nothing is actually checked
写入as User意味着让编译器完全信任你,它确实会如此行事,而不会检查解析后的值是否真的具有你所声称的格式。有问题或意外的数据会以已验证的名义继续在程序中流动,最终导致后续环节出现故障。
18. 非空断言运算符仅能抑制警告,无法改变现实情况。
function getName(user?: User) {
return user!.name; // TypeScript stops warning you; the runtime null check is still gone
}
添加 ! 可让 TypeScript 确信某个值既不是 null 也不是 undefined。如果这一假设错误,编译器只会停止标记相关风险,而错误会在运行时出现,恰好是在类型系统原本能够捕获它的地方。
19. 过多属性检查仅适用于直接传递的对象字面量,不适用于具有相同结构的变量。
type Config = { retries: number };
function setup(config: Config) {}
setup({ retries: 3, timeout: 5000 }); // error: 'timeout' does not exist on type Config
const options = { retries: 3, timeout: 5000 };
setup(options); // no error — same excess property, but it slips through
直接将对象字面量传递给函数调用时,TypeScript会检查该对象的每个属性,任何多余的属性都会被拒绝。如果先将该对象赋值给一个变量,再传递该变量,这种检查就会完全消失。这是类型检查器行为上的一个特殊现象——也就是说,由于值传递到函数的方式不同,拼写错误或多余的字段都可能悄无声息地绕过验证。
20. 数字枚举可以接受任意数字,而不仅仅是其声明的成员。
enum Status { Pending, Shipped, Delivered }
function process(status: Status) {}
process(999); // no compile error, even though 999 isn't a declared Status value
在底层实现上,TypeScript中的数字枚举实际上就是普通数字,因此类型系统会接受任何数值作为有效值,无论其是否对应于已声明的成员。而字符串枚举则能真正起到限制作用,所以当需要让编译器拒绝无效输入时,建议使用字符串枚举或由字符串字面量构成的联合类型。
这二十个问题都不是晦涩难懂或罕见的。每一个都在经过审查并实际部署的代码中出现过,原因就在于在特定的输入、时机或边界情况出现之前,它们看起来都与普通的JavaScript或TypeScript代码毫无区别。了解这份清单并不能替代仔细测试代码的必要性,但它能让你提前做好准备:你可以有针对性地检查那些已知需要关注的问题,而不会在生产环境中才意外发现它们。