ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端调试进阶:玩转 console 实用函数,告别低效日志

前端调试进阶:玩转 console 实用函数,告别低效日志

写了这么些年前端,我最大的感受之一就是:绝大多数人根本没把 console 当回事。每天用得最多的三个方法就是 log、warn、error,其他的一概没碰过。尤其是字符串拼接日志的时候,满屏都是“值为:”“结果:”这种没有辨识度的输出,排查问题全靠肉眼在一堆黑白文字里捞。实际上,浏览器把 console 设计成了一整套调试工具箱,光 Utilities Function 就有十几个,每一个都有明确的适用场景。今天这篇是这个系列的第 8 天,集中讲那些常被忽略、但真正能帮你提升调试效率的 console 实用函数。

如果你平常只是用 console.log 打印变量看一眼结果,那今天的内容绝对值得花十分钟过一遍。我把每个函数拆开讲,解释它为什么存在、适合解决什么问题、有哪些坑,最后还会给一套可以直接抄的封装思路,让你在项目里少写不少重复代码。

1. 先把基础打扎实:格式化占位符与样式输出

很多前端新人写 console.log 的时候,习惯这样拼接字符串:

console.log('用户' + userId + '在' + time + '下单,金额为' + price);

这么写的问题很明显:变量一多,引号和加号满天飞,眼睛看过去全是转义符,很难一眼挑出变量插在哪。而且如果中间某个变量是 undefined,整行日志拼接出来可能直接变成“undefined”,排查的时候还得再复制一遍去定位是哪个变量出了问题。

1.1 五个常用占位符:%s、%o、%c、%d、%i

其实 console 系列方法原生支持类似 C 语言 printf 的占位符写法,只是很多人不知道或者没用顺手。用法很简单,第一个参数传格式字符串,后面的参数依次填入占位符的位置:

占位符作用示例
%s字符串console.log('%s 完成了任务', name)
%d / %i整数console.log('重试 %d 次', retryCount)
%f浮点数console.log('耗时 %f ms', cost)
%o / %O可展开的 DOM / JavaScript 对象console.log('%o', domNode)
%c后续文字应用 CSS 样式console.log('%c这是红色', 'color:red')

我常用的组合是这样:

console.log('%s 用户下单成功,订单号:%d,实付金额:%f 元', user.name, order.id, order.amount);

这种写法最大的好处是,模板和变量完全分离,日志模板稳定,变量位置一目了然,后期改成 i18n 或者统一封装也更方便。用了一段习惯之后,再回去看那种字符串拼接的写法,你会觉得特别累。

1.2 用 %c 给日志“上色”:可复制的彩色日志函数

%c 是很多人忽略的宝藏占位符。它后面的文字会被第二个参数里的 CSS 样式修饰。浏览器 DevTools 控制台支持常见 CSS 属性,比如 color、background、font-size、padding、border-radius,甚至能用图片做背景(base64 编码)。

我以前排查接口联调问题时写过一个小工具函数,热度至今还在用:

function tagLog(tag, message, color = '#35495e') { console.log( `%c${tag}%c ${message}`, `background:${color}; color:#fff; padding:2px 6px; border-radius:3px;`, 'color:inherit;' ); } tagLog('API', '用户列表请求成功', '#409eff'); tagLog('CACHE', '命中缓存,跳过请求', '#67c23a'); tagLog('ERROR', 'token 失效', '#f56c6c');

效果上,控制台里会出现带底色的标签,日志优先级一眼就能区分。尤其是接口多、日志刷屏快的场景,有颜色区分和没颜色区分的排查效率完全两码事。

1.3 占位符的注意点:格式串与参数个数

占位符虽然好用,但有两个坑需要提前说清楚。

第一个坑是参数个数不匹配。如果格式串里写了 3 个占位符,却只传了 2 个变量,漏掉的那个位置浏览器会显示占位符本身,比如直接输出“%s”,看起来像 bug 而不是日志。反过来,如果参数比占位符多,多出来的参数也会正常打印出来。所以封装的时候最好保证参数个数和模板一一对应,别偷懒。

第二个坑是 %o 和普通字符串混用时容易出现 JSON 转字符串的误会。console.log 接收对象参数时,浏览器默认以可展开对象的形式展示,但在格式串里如果你写成 '%s',对象会被转成字符串 "[object Object]",排查时完全看不到内部结构。所以只要想展开对象,就得用 %o 而不是 %s。

另外补充一点,%c 样式在 Node.js 终端里是无效的,终端控制台不会解析 CSS。如果写的是服务端调试代码,彩色输出基本会变成乱码,直接降级成普通文本就好。

2. 别再用 console.log 打印数组了:结构化的表格与分组技巧

打印数组和对象数组的时候,我非常推荐先问自己一个问题:我要的是“一条日志”,还是“一组结构化数据”?如果是后者,console.log 并不是最优解,因为它把所有数据压成一行,字段一多根本看不完。这时候 console.table 和 console.group 才是正确的打开方式。

2.1 console.table:从“数据乱流”到表格视图

console.table 的用法很简单:

const users = [ { id: 1, name: '张三', role: 'admin' }, { id: 2, name: '李四', role: 'editor' }, { id: 3, name: '王五', role: 'viewer' }, ]; console.table(users);

浏览器控制台会直接渲染成一张表格,每一行是一个对象,每一列是一个属性。字段多到横着放不下的时候,比 console.log 一条 JSON 长字符串直观得多。除了数组,console.table 也支持对象作为第一个参数,对象的 key 会变成表格的行名。

更实用的是第二个参数 columns,可以指定只展示哪些列:

console.table(users, ['name', 'role']);

这样表格里只保留 name 和 role 两列,id 被过滤掉,现场讲给同事听的时候不会被无关字段干扰。我在排查表格数据渲染问题时,经常先把接口返回的原始数据竖成表,然后对比页面上的表格,哪行少了、哪列映射错了,一眼就能找到。

2.2 console.group:给日志套上可折叠的文件夹

console.group 解决的是日志分类和层级问题。用法是开启一个分组,组内的日志会自动缩进一层,调用 console.groupEnd 结束分组。如果不想组内日志默认展开,用 console.groupCollapsed 代替 console.group,控制台默认折叠显示,点开才看内容。

我写过一个遍历渲染列表的排查案例:

console.groupCollapsed('商品列表渲染排查'); console.log('接口返回条数:', list.length); list.forEach((item, index) => { console.log(`第${index}条:%s,价格 %f`, item.name, item.price); // 这里还可以嵌套子分组打印更详细的字段 console.groupCollapsed('详情'); console.log('库存:', item.stock); console.log('SKU 数:', item.skuCount); console.groupEnd(); }); console.groupEnd();

这么做的好处是控制台不会瞬间刷出几十几百条平铺日志,而是折叠成一个标题,需要看哪块再展开哪块。排查线上问题时,我通常会在关键逻辑入口打一个 group,整个请求的生命周期日志全部收进去,定位问题像翻文件夹一样顺畅。

2.3 console.dir 与 console.dirxml:打印对象与 DOM 的正确姿势

console.log 在打印 DOM 节点时,控制台会以 HTML 标签的形式展示,鼠标悬停还会在页面上高亮元素。这在某些场景下好用,但如果你想知道这个 DOM 节点有哪些属性、绑定过哪些事件、事件回调长什么样,HTML 标签模式就帮不上忙了。

这时候用 console.dir,它会以“对象树”的方式展开 DOM 节点的完整 JavaScript 属性,包括 dataset、style、attributes、甚至内部自定义属性,排查事件绑定是否生效特别管用。

对应的还有一个 console.dirxml,专门用于打印 DOM 元素或 XML/HTML 结构的字符串表示。它会以可展开的标签树形式展示,适合看 DOM 嵌套层级和结构是否正常。比如排查一个弹窗插到 body 哪个位置了,用 dirxml 比手动去 Elements 面板里找快很多。

有一回我排查一个点击事件不生效的问题,先 console.log 打印了绑定元素,浏览器高亮的是正常节点,但怎么点都没反应。后来用 console.dir 展开一看,发现元素上有两层引用,事件绑在了被替换后的旧节点上,新节点根本没绑。这就是 dir/log 差异在实际排查里的价值。

3. 四两拨千斤的计量工具:count、assert 与 time 系列

第 3 部分讲三个特别适合在调试阶段用的计量型函数:console.time、console.count、console.assert。它们不是用来“看”数据的,而是用来回答三个高频问题:这段代码花了多久?这段代码到底执行了多少次?这个条件到底是不是真的?

3.1 console.time / timeLog / timeEnd 的三种用法

性能排查时,我最早用的是 Date.now() 手动算差值:

const start = Date.now(); // 中间一大段逻辑 console.log('耗时:', Date.now() - start, 'ms');

后来发现又笨又容易漏,因为每次都得引入一个变量、手动做减法。console.time 直接把这套流程压缩成三步:

console.time('请求耗时'); // 模拟异步请求 await fetchData(); console.timeLog('请求耗时', '数据已返回'); // 后续处理 processData(); console.timeEnd('请求耗时');

console.time(label) 开一个计时器,console.timeLog(label) 在计时过程中打印当前累计耗时,console.timeEnd(label) 停止计时并输出总时长。三个方法共享同一个标签名,console.timeLog 和 console.timeEnd 之间可以穿插任意代码。

需要注意:标签必须一致。如果你开计时器用的是 'A',结束用的是 'a',浏览器会直接提示“No such label”,不会帮你自动匹配。第二个注意点是时间单位默认是毫秒,但浏览器显示时精度会随耗时变化,大耗时自动转成秒或分钟,不用自己换算。

我在排查首屏性能问题时,会在资源加载前、DOM 解析后、图片加载完成后各放一个 timeLog,一眼就能看出时间到底耗在哪个阶段。这种连续分段计时是 console.time 最实用的场景。

3.2 console.count:确认回调有没有重复触发

console.count 做的是计数。它在同一个标签下每调用一次,控制台就输出一次累积次数。看到输出内容大概长这样:

count: 1 count: 2

我经常拿它排查两类问题:一类是事件回调被重复绑定,另一类是定时器或轮询被多次启动。比如怀疑某个按钮点击事件绑了两遍:

btn.addEventListener('click', function () { console.count('click handler'); // 业务逻辑 });

如果点击一次按钮,控制台瞬间跳出两个 count,说明这个回调在代码里被重复注册了。比在回调里手动维护一个自增变量更省事,因为不需要额外声明计数变量,也不会污染作用域。配合 console.countReset(label) 可以重置计数,适合那些从某个操作开始重新计数的场景。

3.3 console.assert:需要“必现断言”时的轻量选择

console.assert(condition, message) 接受一个条件表达式,条件为 falsy 时才会输出错误信息。它和 if + console.error 的区别在于,assert 不需要你写 if 分支,并且在某些浏览器里它会以 Error 形式展示,但不会中断后续代码执行。

我经常在前端配置检查里用它:

console.assert(config.apiBaseUrl, 'API 地址不能为空'); console.assert(typeof config.timeout === 'number', 'timeout 必须是数字');

配置缺失的时候控制台会红字提示,但不影响后续代码继续跑,适合用来做轻量级启动校验。注意 console.assert 在 Node.js 里的行为有一点不一样:当条件为 false 时,Node 端会抛出一个 AssertionError,如果没被 try/catch 捕获,整个进程可能直接退出。所以服务端代码里用 console.assert 前要先确认自己的 Node 版本和行为预期,浏览器端则没有这个问题。

4. 踩坑与进化:引用陷阱、堆栈追踪与内存监控

前面讲的基本是“怎么用更舒服”,这一节要讲几个很多人踩了都不知道自己踩了的坑,以及几个能帮你往前多走一步的函数。

4.1 引用对象陷阱:console.log 展开的是“当时的我”还是“现在的我”

这是 console.log 最经典的坑,没有之一。

const obj = { count: 0 }; setInterval(() => { obj.count++; console.log(obj); }, 1000);

运行这段代码,控制台每条日志旁边都有一个可展开的小三角。如果你点开第一条日志去看,会发现 count 已经是几十甚至上百了,而不是打印那一刻的 0。原因很简单:console.log 打印对象时,存储的是对象的引用,而不是快照。浏览器为了性能,不在打印瞬间深拷贝对象,等你手动展开时才去读取当前值。

所以如果你需要确认对象在某个时间点的确切状态,不要直接 console.log(obj),而是把关键字段序列化出来,或者打印拆出来的原始值:

console.log(obj.count); console.log(JSON.stringify(obj));

这个坑在排查动态数据变化的问题时特别误导人,我第一次遇到时盯着控制台看了十分钟,还以为是 setInterval 被触发了多次,其实只是引用展开机制在捣鬼。

4.2 console.trace:拿到完整调用栈

console.trace() 的作用是把当前函数被调用的完整调用栈打印出来。有些业务方法被很多地方调用,你不知道当前这一条到底是谁触发的,这时候 trace 比 log 强得多。

比如封装了一个登录弹窗方法:

function showLoginModal() { console.trace('登录弹窗被触发'); // 显示弹窗 }

控制台会输出一条调用链,显示 showLoginModal 是被哪个函数调用的,那个函数又是被哪个调用链拉起来的。排查路由守卫、事件冒泡、组合式 API 互相调用时特别有用,省得在代码里一层层往上翻。

4.3 console.memory 与性能面板的配合使用

console.memory 在 Chrome 里可以查看当前 JS 堆内存用量,返回一个包含 usedJSHeapSize、totalJSHeapSize、jsHeapSizeLimit 等字段的对象。这个属性不是标准 API,但调试阶段用一用很顺手。

排查内存泄漏时,我一般会在关键节点连续打几个 console.memory,比如一个定时器执行前后各打一次,看 usedJSHeapSize 是否只增不减:

console.log('执行前', console.memory.usedJSHeapSize); // 业务逻辑 console.log('执行后', console.memory.usedJSHeapSize);

注意它不是精确的剖面工具,只能看个趋势,真定位泄漏点还是得配合 Performance 面板做堆快照对比。但作为快速初筛,够用了。顺便提一句,console.profile 和 console.profileEnd 在部分浏览器里已标记为废弃,想记录 JS 执行性能建议直接用 Performance 面板录制,功能更完整,也更可靠。

5. 把 Utilities Function 封装成自己的调试工具箱

函数学再多,最后都要落到项目里才有效。这一节分享一个我实际用过的封装思路,把前面这些技巧整合成一个简单但好用的调试工具模块,日常开发直接引入就能用。

5.1 从小函数到一套可复用的日志工具箱

我在项目里写过这样一个 debug 工具,核心思路是分级控制 + 占位符格式化 + 样式区分:

const Debugger = (function () { const level = window.localStorage.getItem('debug_level') || 'log'; const levels = ['log', 'info', 'warn', 'error']; function canOutput(method) { return levels.indexOf(method) >= levels.indexOf(level); } function prefix(tag) { return `%c[${tag}]`; } function style(color) { return `background:${color}; color:#fff; padding:1px 4px; border-radius:2px;`; } return { log(tag, ...args) { if (canOutput('log')) console.log(prefix(tag), style('#35495e'), ...args); }, info(tag, ...args) { if (canOutput('info')) console.info(prefix(tag), style('#409eff'), ...args); }, warn(tag, ...args) { if (canOutput('warn')) console.warn(prefix(tag), style('#e6a23c'), ...args); }, error(tag, ...args) { if (canOutput('error')) console.error(prefix(tag), style('#f56c6c'), ...args); }, }; })(); Debugger.log('API', '请求完成'); Debugger.warn('API', '响应缓慢');

通过 localStorage 控制日志级别,开发环境默认全量输出,线上切到 error 级别,就能让 console 只在出问题的时候喊一嗓子,不刷屏不干扰。这个思路扩展到 Vue/React 项目里也一样,各个模块封装自己的 tag,日志按模块过滤,排查问题时打开对应模块即可。

5.2 生产环境要不要清掉 console?怎么清?

关于生产环境要不要清掉 console,我的个人观点是:不能一刀切。如果你的线上页面用到了 console 做关键信息展示(比如版本号、上报状态),那你不能全删。但如果在生产环境里打大量调试日志,既拖性能又暴露内部逻辑,这时候就建议处理一下。

常规做法是用构建工具插件统一移除。比如 Webpack 生态里常见的 babel-plugin-transform-remove-console,配置好之后构建产物里不会保留任何 console 语句。如果你用的是 Vite,也可以写一个自定义插件,在 transform 阶段把 console 调用替换成空函数,或者直接把字符串替换掉。

不过比“移除”更稳妥的其实是“降级”,也就是把 console 重写成一个空实现。这样万一线上要临时复现问题,还能通过注入脚本恢复。这个思路在前面那个 Debugger 封装里体现得很自然,改一个 level 就行,不需要动构建流程。

5.3 浏览器差异与兼容性一览

最后整理一张表格,把我今天提到的这些方法在不同环境下的表现差异列一下,免得大家踩坑:

方法ChromeFirefoxSafariNode.js备注
console.log支持支持支持支持通用基础方法
console.table支持支持支持支持(终端降级为文本)Safari 旧版列展示有缺失
console.dir支持支持支持支持(有限)浏览器端查看对象属性更全
console.group支持支持支持支持部分终端不缩进
console.time支持支持支持支持Node 支持同名计时
console.count支持支持支持支持终端显示方式和浏览器不同
console.assert支持支持支持支持(抛异常)Node 行为不同需确认
console.trace支持支持支持支持输出格式不同
console.memoryChrome 支持不支持不支持不支持非标准,仅 Chromium 内核

如果你在 Chromium 内核的浏览器里开发,这一套函数基本都能直接用;如果遇到旧版本 Safari,建议先在本地验证一下需要用的函数是否支持,尤其是 table 和 group 的展示效果。Node.js 环境则要额外注意 assert 抛异常和样式失效的问题。

我个人在实际操作中的体会是:console 这些 Utilities Function 单个拆开看都很简单,但组合起来能带来很大提升。很多人觉得调试就是打开 DevTools 打个断点就行,其实配合 console 的结构化输出、计数和计时,很多问题在断点之前就能定位到具体模块。最后再分享一个小技巧:调试完了记得把临时日志统一删掉,或者在封装的 Debugger 里调高日志级别,别让调试代码悄悄跟着上线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进