console.log 骗了我一整个通宵:原来它才是时间旅行者
引言
“这个 bug 我明明修好了,为什么控制台还在报错?”
凌晨三点,我盯着屏幕上的代码,眼袋比眼睛还大。明明我已经在 15 行打印了 user.name,显示的是 '张三';到 30 行修改了 user.name = '李四',然后又在 45 行打印了一次,结果控制台第一次打印的地方展开一看,居然也变成了 '李四'!
那一刻我差点把电脑吃了——难道代码在时间旅行?还是说 JavaScript 引擎有自己的想法?
直到隔壁工位的老王路过,瞄了一眼我的屏幕,幽幽地说:“小伙子,你也被 console.log 骗了吧?”
一、案发现场:被篡改的历史记录
先来看一段“案发代码”:
const user = { name: '张三', age: 18 };
console.log(user); // 打印 user
user.name = '李四';
console.log(user); // 打印修改后的 user
你觉得控制台会输出什么?按照常理,应该是两次不同的对象:第一次 {name: '张三', age: 18},第二次 {name: '李四', age: 18}。
但如果你在 Chrome 控制台里运行,展开第一次打印的那个对象,你会发现——它也是 {name: '李四', age: 18}!仿佛历史被篡改了一样。
二、为什么 console.log 会说谎?
2.1 凶手是谁?
答案是:引用类型 + 控制台的“惰性”展示。
当你执行 console.log(user) 时,浏览器并没有立刻把 user 对象的快照保存下来,而是保存了对象的引用。在控制台的界面上,对象是可展开的,当你点击展开图标时,控制台才会去读取当前内存中该对象的属性值。
也就是说,console.log 打印的是一个“活的”对象——它像一台摄像机,记录的不是当时的照片,而是一个实时直播的摄像头。等你点开看的时候,看到的是直播画面,而非当时的回放。
2.2 基本类型为什么没问题?
let name = '张三';
console.log(name); // '张三'
name = '李四';
console.log(name); // '李四'
这里打印的都是基本类型,不会出现篡改历史的问题,因为基本类型是直接存储值,没有引用关系。控制台直接显示当时的字符串值。
2.3 浏览器们的小心思
- Chrome/Edge:上面描述的行为最常见。你第一次打印的对象,展开后可能会显示最新的值。
- Firefox:早期版本也有类似问题,但现在似乎在打印时会对对象进行“快照”?具体版本有差异。
- Safari:表现也不同,有时会保留快照。
所以跨浏览器调试时更要小心——你以为 Safari 没毛病,结果 Chrome 给你来个篡改。
三、真实案例:因为一个 console.log 通宵加班
我曾经维护过一个老项目,有一个函数负责更新用户信息,其中有一段:
function updateUser(user) {
console.log('更新前:', user); // 调试用
user.name = '新名字';
console.log('更新后:', user); // 调试用
saveToServer(user);
}
当时我发现控制台里两个 log 展开后 name 都是 '新名字',于是以为 saveToServer 之前 user 已经被改过了,所以怀疑其他代码也修改了 user 引用。我在整个项目里搜索,一无所获。
后来我用 JSON.stringify 打印:
console.log('更新前:', JSON.stringify(user));
终于看到真实的“当时的值”是旧名字。原来 user 对象根本没有被外部修改,是 console.log 骗了我!
四、如何让 console.log 说真话?
4.1 快照大法:深拷贝
在打印前把对象深拷贝一份:
console.log('user 当时的值:', JSON.parse(JSON.stringify(user)));
注意:这种方法无法处理循环引用、函数、undefined、Symbol 等,但对于普通对象足够了。
4.2 展开运算符?小心!
console.log({ ...user });
这样会创建一个新的对象,但它的属性值如果是引用类型,仍然是指向原对象的引用。比如 user.friends 是一个数组,展开后 friends 还是原来的数组,之后修改 user.friends.push('王五'),你打印的那个副本里的 friends 也会变。所以只适用于一层浅拷贝。
4.3 用 console.table 打印表格
对于数组或对象,console.table 会生成一个表格,它会取打印时刻的值,但同样可能受引用影响?实际上 console.table 也是读取当前属性值,所以如果之后修改了原始对象,表格里的数据不会自动更新(因为已经渲染成静态表格了)。这一点比展开对象要可靠。
4.4 使用断点 debugger
最好的办法:直接打断点,在 Sources 面板里查看作用域中的变量值,那是真正的“当时的值”。
debugger; // 代码执行到这里会暂停,你可以慢慢看变量
4.5 自定义一个 safeLog
function safeLog(...args) {
args.forEach(arg => {
if (typeof arg === 'object' && arg !== null) {
console.log(JSON.parse(JSON.stringify(arg)));
} else {
console.log(arg);
}
});
}
五、其他类似的“时间旅行”陷阱
5.1 数组的 console.log
同样的问题,数组也是对象。
const arr = [1, 2, 3];
console.log(arr); // 展开后可能变成 [1,2,3,4]
arr.push(4);
5.2 异步中的闭包
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 全是 3
}, 100);
}
这不是 console 的问题,而是闭包捕获了同一个变量。但也是常见的“以为当时的值是 0,1,2”的坑。
5.3 事件监听中的“旧”数据
let count = 0;
button.addEventListener('click', () => {
console.log(count); // 每次点击打印最新的 count,而不是绑定时的值
});
这也不是 console 的问题,但同样是“值”与“引用”的区别。
六、总结:别太相信 console.log,它只是个演员
console.log 是我们调试的利器,但它也有自己的脾气。理解它的行为,才能避免在 bug 排查时被误导。
- 记住:打印对象时,控制台保留的是引用,展开时看到的是当前值。
-
对策:深拷贝、
console.table、断点,或者打印基本类型。 - 心态:遇到奇怪现象,先怀疑工具,再怀疑代码。
最后,分享一个老程序员的玩笑:“当你把 console.log 删干净之后,bug 就消失了。”——有时候,真的是 console.log 在搞鬼。
每日一问:你在调试时还遇到过哪些让人抓狂的“假象”?是 console.log 的延时?还是 sourcemap 错位?欢迎在评论区吐槽,让我们一起长点记性!
(本文虚构故事如有雷同,纯属你也经历过)