Back

JavaScript 陷阱:你会反复遇到的五个问题

JavaScript 陷阱:你会反复遇到的五个问题

你已经发布了通过代码检查、在开发环境中运行正常的代码,但它仍然在生产环境中崩溃了。事后看来,这个 bug 显而易见——缺少 await、数组被意外修改、this 指向了意外的位置。这些 JavaScript 陷阱之所以持续存在,是因为该语言的灵活性创造了现代工具并不总能捕获的微妙陷阱。

以下是五个在实际代码库中持续出现的常见 JS 错误,以及避免它们的实用方法。

核心要点

  • 使用严格相等运算符(===)来避免意外的类型强制转换行为
  • 箭头函数会保留其封闭作用域中的 this,而普通函数会动态绑定 this
  • 优先使用 const 并在作用域顶部声明变量,以避免暂时性死区错误
  • 使用 Promise.all 进行并行异步操作,当需要部分结果时使用 Promise.allSettled
  • 使用非变异数组方法如 toSorted()structuredClone() 进行深拷贝

类型强制转换仍然令人惊讶

JavaScript 的宽松相等运算符(==)会执行类型强制转换,产生看似不合逻辑的结果,直到你理解了底层算法。

0 == '0'           // true
0 == ''            // true
'' == '0'          // false
null == undefined  // true
[] == false        // true

解决方法很简单:在任何地方都使用严格相等(===)。但类型强制转换也会出现在其他上下文中。当任一操作数是字符串时,+ 运算符会执行字符串拼接:

const quantity = '5'
const total = quantity + 3 // '53', 而不是 8

现代 JavaScript 最佳实践建议在意图明确时使用 Number()String() 或模板字面量进行显式转换。空值合并运算符(??)在这里也有帮助——它只在 nullundefined 时才回退,不像 || 会将 0'' 视为假值。

this 绑定问题

this 的值取决于函数如何被调用,而不是它在哪里定义。这仍然是最持久的 JavaScript 陷阱之一。

const user = {
  name: 'Alice',
  greet() {
    console.log(this.name)
  }
}

const greet = user.greet
greet() // undefined—'this' 现在是全局对象

箭头函数从其封闭作用域捕获 this,这解决了一些问题,但当你实际需要动态绑定时会产生其他问题:

const user = {
  name: 'Alice',
  greet: () => {
    console.log(this.name) // 'this' 引用外部作用域,而不是 'user'
  }
}

在需要保留上下文的回调函数中使用箭头函数。在对象方法中使用普通函数。当将方法作为回调传递时,显式绑定或用箭头函数包装。

变量提升和暂时性死区

使用 letconst 声明的变量会被提升但不会被初始化,这会创建一个暂时性死区(TDZ),在此期间访问它们会抛出 ReferenceError:

console.log(x) // ReferenceError
let x = 5

这与 var 不同,var 会提升并初始化为 undefined。TDZ 从块的开始一直存在到声明被执行。

函数声明会完全提升,但函数表达式不会:

foo() // 正常工作
bar() // TypeError: bar is not a function

function foo() {}
const bar = function() {}

在作用域顶部声明变量,默认优先使用 const。这消除了 TDZ 带来的意外,并清晰地表明意图。

JavaScript 中的异步陷阱

忘记 await 很常见,但更微妙的异步错误会造成更大的损害。当可以并行执行时使用顺序 await 会浪费时间:

// 慢:顺序运行
const user = await fetchUser()
const posts = await fetchPosts()
const comments = await fetchComments()

// 快:并行运行
const [user, posts, comments] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchComments()
])

另一个常见问题:Promise.all 会快速失败。如果一个 promise 被拒绝,你会失去所有结果。当需要部分结果时使用 Promise.allSettled:

const results = await Promise.allSettled([fetchA(), fetchB(), fetchC()])
const successful = results.filter(r => r.status === 'fulfilled')

始终处理拒绝。未处理的 promise 拒绝可能会导致 Node 进程崩溃,并在浏览器中造成静默失败。

JavaScript 中的变异与不可变性

修改数组和对象会产生难以追踪的 bug,特别是在具有响应式状态的框架中:

const original = [3, 1, 2]
const sorted = original.sort() // 修改了 original!
console.log(original) // [1, 2, 3]

现代 JavaScript 提供了非变异的替代方法。对数组使用 toSorted()toReversed()with()。对于对象,展开语法创建浅拷贝:

const sorted = original.toSorted()
const updated = { ...user, name: 'Bob' }

记住展开创建的是浅拷贝。嵌套对象仍然共享引用:

const copy = { ...original }
copy.nested.value = 'changed' // 也会改变 original.nested.value

对于深度克隆,使用 structuredClone() 或显式处理嵌套结构。

结论

这五个问题——类型强制转换、this 绑定、变量提升、异步误用和意外变异——占据了 JavaScript bug 的不成比例的份额。通过实践,在代码审查中识别它们会变得自动化。

启用严格的 ESLint 规则,如 eqeqeqno-floating-promises。对于类型安全很重要的项目,考虑使用 TypeScript。最重要的是,编写能明确表达意图的代码,而不是依赖 JavaScript 的隐式行为。

常见问题

JavaScript 包含这两者是出于历史原因和灵活性。宽松相等运算符(==)在比较前执行类型强制转换,这可能有用但经常产生意外结果。严格相等运算符(===)在不进行强制转换的情况下比较值和类型。现代最佳实践强烈推荐使用 ===,因为它使比较可预测并减少 bug。

在回调函数、数组方法以及想要保留周围 this 上下文的情况下使用箭头函数。在对象方法、构造函数以及需要动态 this 绑定的情况下使用普通函数。关键区别在于箭头函数从其封闭作用域词法绑定 this,而普通函数根据调用方式确定 this。

暂时性死区(TDZ)是从进入作用域到 let 或 const 变量声明点之间的时期。在此期间访问变量会抛出 ReferenceError。通过在作用域顶部声明变量并默认优先使用 const 来避免 TDZ 问题。这使你的代码更可预测且更易读。

当所有 promise 都必须成功才能使操作有意义时使用 Promise.all——如果任何 promise 被拒绝,它会快速失败。当无论单个失败如何都需要所有 promise 的结果时使用 Promise.allSettled,例如从多个可选源获取数据时。Promise.allSettled 返回一个对象数组,将每个结果描述为 fulfilled 或 rejected。

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Check our GitHub repo and join the thousands of developers in our community.

OpenReplay