代码能跑,不代表它就是对的:5 个潜伏在常规下的 JavaScript Bug
JavaScript 的动态性和复杂性意味着,即便代码在表面上能正常工作,其深层也可能隐藏着一些不易察的陷阱。这些陷阱往往出乎我们的意料。本文梳理了几个在 JavaScript 开发中难以被发现的隐藏 Bug,希望能帮助我们编写出更健壮、更可预测的代码。
让我们一起来看看那些潜伏在代码中的“魔鬼细节”。
1. 隐式的陷阱:被遗忘的 async/await
和 try...catch
async/await
极大地提升了异步代码的可读性,但它也引入了一个隐藏的风险:未被捕获的 Promise.reject
会变成一个静默的、未处理的异常。
错误场景:
async function fetchData() {
// 如果 API 返回 404 或 500 错误,这个 Promise 将会是 rejected 状态。
const data = await fetch("https://api.example.com/data");
// 下面这行代码将永远不会执行
console.log("数据处理完成", data);
}
// 调用了函数,但没有处理潜在的错误。
fetchData();
// 程序会继续执行,但前面发生的错误就像被“吞掉”了一样。
console.log("程序继续执行");
问题根源:await
仅仅是一个语法糖,它会暂停 async
函数的执行,等待 Promise
被解决(resolved)。如果这个 Promise
变成了 rejected
状态,await
会将其作为异常抛出。如果没有 try...catch
块来捕获这个异常,它就会沿着调用栈向上传播,最终成为一个 unhandledrejection
(未处理的拒绝)。
正确姿势:
始终使用 try...catch
来包裹 await
表达式,或在调用链的更高层级进行捕获。
async function fetchData() {
try {
// 等待 fetch 请求完成
const response = await fetch("https://api.example.com/data");
// 检查 HTTP 响应状态是否 ok
if (!response.ok) {
// 如果不 ok,手动抛出一个错误,这样可以被 catch 捕获
thrownewError(`HTTP 错误! 状态: ${response.status}`);
}
// 解析 JSON 数据
const data = await response.json();
console.log("数据处理完成", data);
} catch (error) {
// 如果 try 块中的任何 await 失败或抛出错误,都将在这里捕获
console.error("获取数据失败:", error);
}
}
fetchData();
2. “一荣俱荣,一损俱损”的 Promise.all
当需要并行处理多个 Promise
时,Promise.all
是首选。但即便是专家,有时也会忘记它的“快速失败”(fail-fast)特性。
错误场景:
假设我们需要获取用户详情和他的帖子列表,并且希望即便帖子列表获取失败,我们依然能看到用户详情。
async function getUserProfile(userId) {
try {
// Promise.all 等待两个 Promise 都完成
const [user, posts] = awaitPromise.all([
api.fetchUser(userId), // 这个成功了
api.fetchPosts(userId), // 但这个因为网络问题失败了
]);
// 由于一个失败,整个 Promise.all 都会失败,这里不会执行
renderProfile(user, posts);
} catch (error) {
// 整个代码块都失败了,我们连用户信息都拿不到。
console.error("获取用户资料失败", error);
}
}
问题根源:Promise.all
的设计是,只要其中任何一个 Promise
变成 rejected
,整个 Promise.all
就会立即以该失败原因为由 reject
,而不会等待其他的 Promise
完成。
正确姿势:
使用 Promise.allSettled
。它会等待所有的 Promise
都有一个结果(无论是 fulfilled
还是 rejected
),然后返回一个包含每个 Promise
状态和结果(或原因)的对象数组。
async function getUserProfile(userId) {
// 使用 allSettled,它会等待所有 promise 完成,无论成功还是失败
const results = awaitPromise.allSettled([api.fetchUser(userId), api.fetchPosts(userId)]);
// 从结果数组中分别获取用户和帖子的结果
const userResult = results[0];
const postsResult = results[1];
// 检查获取用户的结果状态
if (userResult.status === "fulfilled") {
// 即便帖子获取失败,我们依然可以渲染用户信息
renderUser(userResult.value);
} else {
// 如果获取用户失败,记录错误
console.error("获取用户失败:", userResult.reason);
}
// 检查获取帖子的结果状态
if (postsResult.status === "fulfilled") {
// 如果成功,渲染帖子
renderPosts(postsResult.value);
} else {
// 如果失败,记录错误
console.error("获取帖子失败:", postsResult.reason);
}
}
3. 数组迭代中的意外突变
在 forEach
或 for...of
循环中直接修改(增加或删除)数组本身,是导致不可预测行为的常见原因。
错误场景:
从数组中移除所有偶数。
let numbers = [1, 2, 3, 4, 5, 6];
numbers.forEach((num, index) => {
if (num % 2 === 0) {
// 灾难开始了!在迭代中修改数组
// 当 index=1 (值为2) 被删除时,原数组变为 [1, 3, 4, 5, 6]。
// 下一次迭代,index 变为 2,访问的是新数组的第3个元素 (值为4)。
// 元素 3 被跳过了!
numbers.splice(index, 1);
}
});
// 输出结果是 [1, 3, 5, 6] <--- 6 被跳过了!
console.log(numbers);
问题根源:当你使用 splice
删除一个元素时,数组的长度和后续元素的索引都发生了改变。然而,forEach
的迭代过程并不会根据这种变化来调整其内部计数器,这导致它跳过了被移除元素紧随其后的那个元素。
正确姿势:
不要在迭代中修改原数组。最佳实践是创建一个新数组。
let numbers = [1, 2, 3, 4, 5, 6];
// 使用 filter,这是函数式编程的最佳实践,它返回一个新数组,不修改原数组
const oddNumbers = numbers.filter((num) => num % 2 !== 0);
// 输出: [1, 3, 5]
console.log(oddNumbers);
如果确实需要原地修改,请使用反向循环。
let numbers = [1, 2, 3, 4, 5, 6];
// 从后向前遍历,即使删除了元素,也不会影响尚未遍历的元素的索引
for (let i = numbers.length - 1; i >= 0; i--) {
if (numbers[i] % 2 === 0) {
numbers.splice(i, 1);
}
}
// 输出: [1, 3, 5]
console.log(numbers);
4. 闭包的内存陷阱与内存泄漏
闭包是 JavaScript 的一个强大特性,但也是内存泄漏的主要来源之一,尤其是在处理 DOM 事件监听时。
错误场景:
function setupHeavyObjectListener() {
// 假设这是一个包含大量数据的“重”对象
const heavyObject = {
/* 一个包含大量数据的对象 */
};
const element = document.getElementById("my-element");
element.addEventListener("click", () => {
// 这个匿名函数形成了一个闭包,持有了对 heavyObject 的引用
console.log("Clicked!", heavyObject.someProperty);
});
// 假设 element 稍后从 DOM 中被移除了
// element = null;
}
setupHeavyObjectListener();
问题根源:即使 element
从 DOM 中被移除,只要事件监听器没有被显式地移除(使用 removeEventListener
),这个监听器(闭包)就依然存在,并且它会永远引用着 heavyObject
。这导致 heavyObject
和 element
都无法被垃圾回收器回收。
正确姿势:
在组件卸载或元素销毁时,总是清理事件监听器。
function setupHeavyObjectListener() {
const heavyObject = {
/* ... */
};
const element = document.getElementById("my-element");
// 将监听器函数单独定义,以便之后可以引用它来移除监听
const handleClick = () => {
console.log("Clicked!", heavyObject.someProperty);
};
element.addEventListener("click", handleClick);
// 返回一个清理函数,这是“钩子”或组件生命周期中常用的模式
return() => {
element.removeEventListener("click", handleClick);
// 确认监听器已被清理
console.log("监听器已清理!");
// 此后,handleClick 闭包和它引用的 heavyObject 就可以被垃圾回收了
};
}
const cleanup = setupHeavyObjectListener();
// 在未来的某个时刻,例如组件卸载时调用
// cleanup();
5. 对象深浅拷贝之谜
这是一个永恒的话题。即使是经验丰富的开发者,也可能在不经意间对嵌套对象进行了浅拷贝,从而导致意料之外的副作用。
错误场景:
const userProfile = {
name: "Alex",
settings: {
theme: "dark",
notifications: true,
},
};
// 使用展开语法来“拷贝”
const updatedProfile = { ...userProfile };
// 修改新对象的嵌套属性
updatedProfile.settings.theme = "light";
// 原来的对象也被修改了!
// 输出: 'light'
console.log(userProfile.settings.theme);
问题根源:展开语法 (...
) 和 Object.assign()
都只执行浅拷贝。它们会创建一个新的顶层对象,但如果属性值是对象或数组,它们只会复制引用,而不是值本身。
正确姿势:
对于深层嵌套的对象,需要使用深拷贝。
简单场景(对象不包含 function
, undefined
, Symbol
等):可以使用 structuredClone
(推荐的现代方法) 或 JSON.parse(JSON.stringify())
。
// 使用 structuredClone 进行标准化的深拷贝
const deepCopiedProfile = structuredClone(userProfile);
// 或者使用 JSON 魔术方法,但要注意其局限性
// const deepCopiedProfile = JSON.parse(JSON.stringify(userProfile));
// 修改深拷贝后的对象
deepCopiedProfile.settings.theme = "light";
// 原对象不会受到影响
// 输出: 'dark'
console.log(userProfile.settings.theme);
复杂场景:使用成熟的库,如 Lodash 的 _.cloneDeep()
。
JavaScript 是一门看似简单却充满细节的语言,当我们开始对这些“小问题”变得敏感时,代码质量和开发效率必将迈上一个新的台阶。

优网科技秉承"专业团队、品质服务" 的经营理念,诚信务实的服务了近万家客户,成为众多世界500强、集团和上市公司的长期合作伙伴!
优网科技成立于2001年,擅长网站建设、网站与各类业务系统深度整合,致力于提供完善的企业互联网解决方案。优网科技提供PC端网站建设(品牌展示型、官方门户型、营销商务型、电子商务型、信息门户型、微信小程序定制开发、移动端应用(手机站、APP开发)、微信定制开发(微信官网、微信商城、企业微信)等一系列互联网应用服务。