首页 / 文章 / 十种会悄悄损害你代码库的常见 JavaScript 习惯

十种会悄悄损害你代码库的常见 JavaScript 习惯

介绍了十种常见的 JavaScript 和 TypeScript 潜在问题,从宽松相等性到状态修改,并展示了替代每种问题的更安全编程方式。

1748 词

无论哪家公司或使用何种框架,你遇到的几乎所有 JavaScript 项目都会存在同一组反复出现的小问题。这些往往不是什么罕见的错误或难以预测的边界情况,而是那几种屡次出现的不良习惯。

这些问题不会立刻导致应用程序崩溃,但正因如此才格外危险。它们会潜伏不动,直到项目规模扩大、有新成员开始修改代码或使用量突然激增,才会以那种能耗费一整个下午时间的错误形式显现出来。以下是出现频率最高的十种模式,以及更好的解决方案。

1. 使用 == 而非 ===

JavaScript 中的宽松相等运算符会在比较之前强制转换类型,其结果往往极难预测:

0 == "0"        // true
0 == ""         // true
"" == "0"       // false
null == undefined // true

当然,一旦记住了这些强制转换规则,其背后的逻辑其实很简单。但没人应该为了编写一个简单的条件语句而不得不记住这些规则。 everywhere都应使用===,因为它能同时检查值和类型,从而给出你期望的精确比较结果,不会出现任何隐式的转换。

if (userInput === "0") { ... }  // clear, predictable

2. 直接修改状态

这种错误会引发难以追踪的故障,因为其症状往往出现在与实际原因相距很远的地方:

function addItem(cart, item) {
  cart.items.push(item); // mutates the original array
  return cart;
}

如果那个cart对象在React状态、Redux存储或任何通过比较引用来检测变化的系统中被监视,那么这种原位修改对它们来说完全是不可见的。引用本身从未改变,因此不会触发重新渲染,也不会通知任何订阅者,最终只能面对一个莫名其妙拒绝更新的UI。

function addItem(cart, item) {
  return { ...cart, items: [...cart.items, item] };
}

创建全新的对象或数组而非修改原始对象确实会占用更多内存。但作为交换,你能获得可预测的状态变化,这种权衡其实远比看上去更值得频繁采用。

3. 未处理Promise拒绝情况

如果 async 函数在未使用 try/catch 包装的情况下抛出异常,或者 .then() 链中缺少 .catch(),则往往会导致异常悄无声息地失败。在浏览器中可能完全看不出任何反应;而在 Node 环境中则可能会产生未被处理的拒绝警告,这类警告很容易在大量的日志中被忽略。

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}
getUser(42); // if this fails, where does the error go?
async function getUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`Request failed: ${res.status}`);
    return await res.json();
  } catch (err) {
    logger.error("Failed to fetch user", { id, err });
    throw err;
  }
}

任何可能出错的 async 函数都需要针对这种错误情况制定明确的处理策略。不加以处理其实算不上策略,只不过是日后会爆发的漏洞罢了。

4. 深度嵌套的回调函数

没人会故意编写复杂的回调地狱代码。它是一层层逐步积累起来的,每次增加一个异步步骤,直到代码嵌套到六层深,缩进格式就像楼梯一样:

getUser(id, (user) => {
  getOrders(user.id, (orders) => {
    getShipping(orders[0].id, (shipping) => {
      updateUI(shipping); // and it keeps going
    });
  });
});

async/await 的出现正是为了解决这类嵌套问题:

async function loadShippingInfo(id) {
  const user = await getUser(id);
  const orders = await getOrders(user.id);
  const shipping = await getShipping(orders[0].id);
  updateUI(shipping);
}

底层的异步行为并无变化,但现在的代码逻辑是从上到下依次执行,大致相当于你口头讲述时的顺序。

5. 全局变量的隐秘侵入

如果在严格模式之外省略了 constletvar 声明,JavaScript 会悄悄将该变量绑定到全局对象上,而不会抛出错误:

function calculateTotal() {
  total = 0; // no declaration — this is now global
  for (const item of items) total += item.price;
  return total;
}

现在,total变量位于函数作用域之外,因此可能会与代码库中其他同名的变量发生冲突。根据执行顺序的不同,它要么覆盖其他变量,要么被其他变量覆盖。“use strict”语句放在文件开头可以将这种问题转化为立即显现的错误,而非隐匿的延迟错误。使用import/export的现代模块语法会自动启用严格模式,因此在使用ES模块时这类错误就会变得极为罕见。

6. 使用===比较对象和数组

这种错误通常出现在那些过度遵守规则#1的人身上。===是按引用而非内容来比较对象和数组的:

{ a: 1 } === { a: 1 }       // false
[1, 2, 3] === [1, 2, 3]     // false

两个外观完全相同的对象在内存中仍然是独立的实体,因此严格相等比较会将它们视为不相等。若要比较实际内容,需要采用深度比较方法:可以使用 lodash 的 isEqualJSON.stringify 处理简单情况,或编写自定义比较函数。在此处使用 === 并非语法错误,只是它回答的问题与你真正想要解答的问题不同而已。

7. 未清理事件监听器和定时器

每次调用 addEventListenersetInterval 或类似订阅函数时,都意味着会有某个机制在未来负责清理它们。如果忽视这一承诺,就会导致内存泄漏——这种问题在开发阶段很容易被忽略,但一旦应用投入生产就会带来严重后果:

useEffect(() => {
  window.addEventListener("resize", handleResize);
  // no cleanup — this listener never goes away
}, []);
useEffect(() => {
  window.addEventListener("resize", handleResize);
  return () => window.removeEventListener("resize", handleResize);
}, []);

8. 在 TypeScript 中过度使用 any

any 其实算不上真正的类型,更像是一种“逃生通道”;过度依赖它会让原本强类型的代码库悄悄变回弱类型,而实际上并没有人刻意追求这种结果:

function processPayment(data: any) {
  return data.amount * data.rate; // no safety net at all
}

每次你在这里操作 data 时,都只是在猜测。由于你已经明确要求编译器停止检查,它就无法识别拼写错误、缺失的属性或类型不匹配的问题。即便是一种定义较为宽松的类型,也总比完全没有类型要好:

type PaymentData = { amount: number; rate: number };

function processPayment(data: PaymentData) {
  return data.amount * data.rate;
}

如果你确实还不知道某个对象的结构,unknown 才是 any 相对应且更诚实的选项。它要求你在使用该类型之前先明确其具体类型,而非让你基于未经验证的假设行事。

9. 忽视 nullundefined 的区别

JavaScript 提供了两种不同的方式来表示“此处为空”,而那些使用方式不一致的代码库中往往会充斥着类似这样的检查语句:

if (value === null || value === undefined) { ... }

这种模式通常表明大家从未就统一规范达成一致。更整洁的做法是为每个值赋予明确的含义:undefined 表示“该值从未被设置”,而 null 表示“该值是故意设为空的”。这样一来,空值合并运算符就能让你一次性检测这两种情况,而无需重复编写比较代码:

const displayName = user.nickname ?? "Anonymous";

?? 仅在左侧值为 nullundefined 时才会起作用。这与 || 不同,后者还会将 0""false 视为默认值,而这些值往往是有效的,不应被当作缺失值处理。

10. 为计算机而非他人编写代码

此列表中的最后一项并非语法错误,但其造成的累积危害却比其他九项加起来还要严重。过于复杂的单行代码虽然编写起来可能令人有成就感,但对其他读者而言却极为难以理解:

const r = a.filter(x=>x.a).map(x=>x.b).reduce((a,b)=>a+b,0);

这段代码确实能运行。但之后阅读它的人,包括几个月后的你,都必须先反推出 ax 以及整个代码链中的其他符号实际上代表什么,才能进行安全的修改。

const activeUserBalances = users
  .filter((user) => user.isActive)
  .map((user) => user.balance);

const totalActiveBalance = activeUserBalances.reduce((sum, balance) => sum + balance, 0);

这个版本虽然多了几行代码,但无需任何解码即可立刻理解。JavaScript往往会在当下奖励巧妙的思路,却要在之后为此付出代价,而“之后”几乎总是指最初编写代码的人之外的其他人。

这十种习惯背后的共性

抛开具体细节不谈,这十种习惯其实都与那些晦涩的JavaScript冷知识无关。它们全都关乎可预测性:比较操作的结果与代码表述一致,状态不会在背后悄悄改变,错误会被捕获而非默默消失,以及代码结构与其实际功能相匹配。只要改掉这十种习惯,剩下的就只是常规的调试工作——那种存在于每个代码库中的调试方式,而非那些会悄悄耗费你整个下午时间的自我造成的问题。

相关阅读

  • 让高级工程师的代码更易被信任的九种编码习惯 —— 探讨了从保护性代码到严格的数据建模等九种具体的编码实践,这些实践能够提升代码的弹性、可读性,并使其在高压环境下更易于调试。
  • 让前端代码库多年仍可维护的十种架构习惯 —— 阐述了诸如优化删除性、明确数据流以及隔离业务逻辑等结构化习惯,这些习惯有助于代码库在多年变化中依然保持可维护性。
  • 软件考古学:解读遗留代码的实用方法 — 介绍一种循序渐进的方法,用于安全地分析缺乏文档记录的遗留代码库,内容包括从挖掘提交历史到在不影响生产环境的情况下进行代码重构。
  • 会打破你思维模式的十条常见 JavaScript 规则 — 探讨了十种微妙的 JavaScript 行为,从 `const` 的可变性到闭包以及异步错误处理,这些行为会悄悄地在经验丰富的开发者编写的代码中引发故障。
  • 七种会悄悄引发未来错误的常见 JavaScript 习语 — 阐述了诸如真值判断、可选链操作和展开语法等常见的 JavaScript 模式如何隐藏某些假设,而这些假设会在代码发展过程中悄然导致问题。