console.log 骗了我一整个通宵:原来它才是时间旅行者

console.log 骗了我一整个通宵:原来它才是时间旅行者 引言“这个 bug 我明明修好了为什么控制台还在报错”凌晨三点我盯着屏幕上的代码眼袋比眼睛还大。明明我已经在 15 行打印了user.name显示的是张三到 30 行修改了user.name 李四然后又在 45 行打印了一次结果控制台第一次打印的地方展开一看居然也变成了李四那一刻我差点把电脑吃了——难道代码在时间旅行还是说 JavaScript 引擎有自己的想法直到隔壁工位的老王路过瞄了一眼我的屏幕幽幽地说“小伙子你也被 console.log 骗了吧”一、案发现场被篡改的历史记录先来看一段“案发代码”constuser{name:张三,age:18};console.log(user);// 打印 useruser.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 基本类型为什么没问题letname张三;console.log(name);// 张三name李四;console.log(name);// 李四这里打印的都是基本类型不会出现篡改历史的问题因为基本类型是直接存储值没有引用关系。控制台直接显示当时的字符串值。2.3 浏览器们的小心思Chrome/Edge上面描述的行为最常见。你第一次打印的对象展开后可能会显示最新的值。Firefox早期版本也有类似问题但现在似乎在打印时会对对象进行“快照”具体版本有差异。Safari表现也不同有时会保留快照。所以跨浏览器调试时更要小心——你以为 Safari 没毛病结果 Chrome 给你来个篡改。三、真实案例因为一个 console.log 通宵加班我曾经维护过一个老项目有一个函数负责更新用户信息其中有一段functionupdateUser(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 自定义一个 safeLogfunctionsafeLog(...args){args.forEach(arg{if(typeofargobjectarg!null){console.log(JSON.parse(JSON.stringify(arg)));}else{console.log(arg);}});}五、其他类似的“时间旅行”陷阱5.1 数组的 console.log同样的问题数组也是对象。constarr[1,2,3];console.log(arr);// 展开后可能变成 [1,2,3,4]arr.push(4);5.2 异步中的闭包for(vari0;i3;i){setTimeout((){console.log(i);// 全是 3},100);}这不是 console 的问题而是闭包捕获了同一个变量。但也是常见的“以为当时的值是 0,1,2”的坑。5.3 事件监听中的“旧”数据letcount0;button.addEventListener(click,(){console.log(count);// 每次点击打印最新的 count而不是绑定时的值});这也不是 console 的问题但同样是“值”与“引用”的区别。六、总结别太相信 console.log它只是个演员console.log是我们调试的利器但它也有自己的脾气。理解它的行为才能避免在 bug 排查时被误导。记住打印对象时控制台保留的是引用展开时看到的是当前值。对策深拷贝、console.table、断点或者打印基本类型。心态遇到奇怪现象先怀疑工具再怀疑代码。最后分享一个老程序员的玩笑“当你把 console.log 删干净之后bug 就消失了。”——有时候真的是 console.log 在搞鬼。每日一问你在调试时还遇到过哪些让人抓狂的“假象”是 console.log 的延时还是 sourcemap 错位欢迎在评论区吐槽让我们一起长点记性本文虚构故事如有雷同纯属你也经历过