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() 或模板字面量进行显式转换。空值合并运算符(??)在这里也有帮助——它只在 null 或 undefined 时才回退,不像 || 会将 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'
}
}
在需要保留上下文的回调函数中使用箭头函数。在对象方法中使用普通函数。当将方法作为回调传递时,显式绑定或用箭头函数包装。
变量提升和暂时性死区
使用 let 和 const 声明的变量会被提升但不会被初始化,这会创建一个暂时性死区(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 带来的意外,并清晰地表明意图。
Discover how at OpenReplay.com.
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 规则,如 eqeqeq 和 no-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.