写JavaScript的人,几乎每天都要和console.log打交道。但据我观察,大多数人调试日志的姿势,长期停留在“一个变量打一行、字符串全靠加号拼”。这不叫会用console.log,只能算会用浏览器自带的打印按钮。
我自己早期也在这个上面栽过跟头——线上排查问题,日志打了六七十行,关键对象全被拼成了[object Object],查了一晚上才发现是数据字段对不上,根源就是日志根本没把信息打出来。console这个全局对象,其实远不止console.log一种用法,旁边那群兄弟API配合起来,能把调试效率拉高一个档次,而且大部分技巧学完就能用。这篇文章梳理一下我在浏览器和Node.js两个环境里实际验证过的console技巧,既适合刚入门的新手,也适合写了几年还停留在console.log(变量)的老同学。每一条都附了应用场景和避坑提醒,可以直接照用到你的项目里。
1. 多参数与格式化占位符:先治最常见的拼字符串毛病
1.1 多参数传入:为什么加号会让日志失灵
很多新手拿到对象第一反应是console.log('用户信息:' + user),结果控制台输出一行让所有人崩溃的文字:"用户信息:[object Object]"。原因很简单:加号触发了隐式toString(),普通对象的toString()返回的就是[object Object],你关心的字段全都看不见。数组也一样,'列表:' + arr会把数组转成逗号分隔的字符串,遇到嵌套数组和对象直接塌陷。
正确的做法是把参数分开传:console.log('用户信息:', user)。这时候浏览器和Node都会把你传的每个参数当成一次独立渲染,对象保持对象的形态,控制台里可点击展开,Node里则会用util.inspect的格式输出带缩进的结构。别小看这一点,排查接口返回值、组件props时,能展开一个对象和看到一行[object Object],差别就是能不能定位问题的区别。
我自己的习惯是在团队里推广一条约定:日志里涉及对象、数组、函数、DOM节点,一律用逗号分隔传参,禁止用加号拼接。这个约定几乎不增加学习成本,但对日志可读性的提升立竿见影。真需要把对象转成字符串的场景,用JSON.stringify(obj, null, 2)显式序列化,而不是让它被隐式转换。
1.2 格式化占位符:模板化日志的底层支撑
除了多参数,浏览器控制台和Node还支持一套格式化占位符。很多前端同学知道%c可以加样式,却忽略了其他几个。我实际用过的占位符整理如下:
| 占位符 | 含义 | 示例 |
|---|---|---|
| %s | 字符串 | console.log('%s来了', '张三') 输出“张三来了” |
| %d / %i | 整数数字 | console.log('数量:%d', 3.7) 输出“数量:3”,自动截断 |
| %f | 浮点数 | console.log('价格:%f', 19.9) 输出浮点数值 |
| %o | 对象 / DOM节点 | 浏览器会渲染成可展开的结构 |
| %O | 对象(宽泛) | 偏对象属性视角的展开 |
| %c | CSS样式 | 给后续文本套用样式,详见第5章 |
| %% | 字面量%号 | 想输出%本身时使用 |
看着简单,用起来有讲究。比如我想在日志里统一记录请求节点,可以写:
console.log('%s 请求开始,id=%d,来源=%s', 'POST /api/order', 1024, 'checkout')这样所有请求日志都走同一个模板,后续在控制台按格式过滤、在日志系统里做grep会非常方便。团队如果定了日志规范,占位符能让关键字段的格式保持一致,不至于有人打"id=1024",有人打"id 1024",有人打"订单号是1024"。
需要留意两个坑:一是占位符参数顺序必须和模板顺序一一对应,写错位置会输出莫名其妙的结果;二是%c在Node.js里不生效,服务端日志就不要指望它加颜色了。另外,占位符适合“格式化文本”,不适合“展示复杂对象”,碰到对象还是老老实实用多参数传参。
2. 数据可视化输出:table、dir、group 把日志变成面板
2.1 console.table:数组和对象一眼扫出问题
先看一个场景。接口返回用户列表,你用console.log(list),控制台输出一堆折成很多行的对象,眼睛很难比对某个字段。换成console.table(list),同样的数据瞬间变成表格:每行一条记录,每列一个字段,字段的差异一眼就能看出来。尤其是列表数据量大的时候,表格才是人眼扫描最快的形式。
const users = [ { id: 1, name: '张三', status: 'active' }, { id: 2, name: '李四', status: 'blocked' }, { id: 3, name: '王五', status: 'active' } ] console.table(users)输出是一张三列表格,行号就是数组索引。如果某个字段不关心,还可以传第二参数指定列:console.table(users, ['name', 'status']),只显示这两列,表格更短更清晰。对于普通对象,console.table输出的是key/value两列,适合快速查看配置对象。
实际经验:在处理后端返回的列表数据时,我几乎必用console.table。字段多的时候指定columns拍平,配合浏览器自带的表头排序,能很快找到某条记录的异常状态。但注意,表格里遇到嵌套对象会显示成对象字符串,遇到函数、undefined字段基本就是空的,所以它适合“宽而浅”的数据,不适合“深”的数据。
2.2 console.dir:把深层对象彻底展开
console.log打印一个深嵌套对象,默认展开的层级有限,很多时候要手动点好几层才能看到目标字段。console.dir就是为这个场景准备的。浏览器里console.dir(obj, { depth: null })可以把对象所有层级都铺开;Node里也支持第二个参数,比如console.dir(obj, { depth: 2 })限制展开到两层。
我通常在排查“某个对象内部到底长什么样”时用dir,尤其是那些来自第三方库的配置对象或React/Vue的组件props,结构往往藏得很深。console.log点五层才能看到的值,console.dir一步到位。唯一要注意的是,depth设为null或很大的值,输出会非常长,特别是在浏览器里会占用大量控制台空间,定位完问题记得清理。
2.3 console.group:给日志分组,不再平铺一堆
一次请求会打出几十条日志,全平铺在控制台根本分不清先后和归属。console.group和console.groupEnd可以解决这个问题。调用console.group('订单流程')之后,后续所有日志会缩进一层,形成一个可折叠的分组,直到console.groupEnd()结束。groupCollapsed默认折叠,适合把大量细节日志收纳起来,需要时再展开。
具体场景我这么用:请求发出前开组,打印请求参数;响应回来后打印状态码和处理结果,最后groupEnd。这样一个请求对应一个组,多个请求在控制台里就是多个独立块。循环处理数据时也可以用groupCollapsed给每条数据开一个折叠组,平时不看细节,出错时单独点开那条就够。
分组还有一个隐藏好处:可以嵌套。外层group('请求'),内层group('响应头'),层级关系在控制台里直接成了树形结构。这比用字符串前缀模拟层级要直观得多。
3. 条件判定与调用链追踪:assert、count、trace 的救场时刻
3.1 console.assert:断言失败才输出,还不打断运行
调试数据校验时,常见写法是:
if (res.code !== 200) { console.error('接口返回异常', res) }浏览器里可以简写成一行:
console.assert(res.code === 200, '接口返回异常', res)console.assert的第一个表达式为false时才输出错误日志,为true时什么都不做。它不会抛出异常,不会中断代码执行,所以非常适合在调试阶段做“软校验”。我看到很多人在写if + console.error,信息量一样,代码却多出三行。
这里必须敲黑板:浏览器和Node.js对这一条的实现不一致。浏览器里断言失败只是打印一条错误日志;但Node.js里断言失败的行为可能直接抛出一个AssertionError,直到进程退出,除非你用try/catch接住。所以在Node脚本里使用console.assert前,先跑一行代码确认你当前Node版本的语义;不想踩这个雷,就直接用if + console.error代替。这个坑我见过不止一次——本地写着写着进程突然退出,还不知道是哪行日志干的。
3.2 console.count:数一数这段代码到底执行了几次
有些问题不容易复现,比如某个事件回调被触发了两次,或者某个定时器一直在跑。普通console.log会刷屏,而且不显示次数。console.count('事件触发')会在每次执行时递增并打印“事件触发: 1”“事件触发: 2”这样的计数。想重置就调用console.countReset()。
我用过的一个经典场景:同事反馈下拉框的change事件偶尔不响应,我在事件回调的开头加了一行console.count('change回调'),然后在控制台反复操作,很快就发现某次点击回调被触发了两次,第二次又做了一遍初始化,反而把状态改坏了。没有count,这种问题只能靠肉眼数日志。
注意:count的标签是全局累加的,同一个标签在不同函数里也会互相计数。如果只是单次调试,用个不重名的标签即可。
3.3 console.trace:回答“你从哪里来”的问题
当一段公共函数被十多个地方调用,而数据在某一次突然不对时,最烦人的问题是:这次是谁调用了它?console.log只能告诉你函数的内部状态,却给不了调用来源。console.trace()会打印当前调用栈,也就是从入口一路到这一行的函数调用链。
我在公共的格式化函数里加过console.trace('formatData called'),然后在本地复现异常场景。控制台里立刻显示调用链,找到那个少传了一个参数的调用方,整个过程不到五分钟。这比在十多个调用点逐个加日志快太多了。
trace的输出比较长,适合临时排查,用完就删。浏览器和Node.js都支持这个API,Node下的输出会是堆栈帧列表,同样能看出调用路径。
4. 性能计时与Node.js差异:time家族确认耗时,stdout暗藏坑
4.1 console.time / timeEnd:测代码耗时的标准姿势
想知道一段代码跑了多久,很多人会写下开始时间和结束时间,然后相减。console.time('label')加console.timeEnd('label')把这个过程封装好了:同一label配对出现,timeEnd时自动打印“label: xxxms”,格式美观还不用自己维护变量。
console.time('parseJSON') const data = JSON.parse(rawText) console.timeEnd('parseJSON')中间想记录某个阶段,可以再调console.timeLog('parseJSON'),打印当前累计耗时,不会终止计时器。多个计时器可以用不同label并行,互不干扰。需要注意同一label不要重复开启,否则会收到警告。
我比较两个数据处理方案的耗时,就用两个不同label分别计时,肉眼就能看出差距。相比手写Date.now()差值,还要小心时区和精度问题,console.time这套API显然是更省事的选择。当然追求更高精度可以用performance.now(),日常耗时对比console.time完全够用。
4.2 Node.js环境差异:console.log写向stdout,console.error写向stderr
浏览器和Node.js的console对象大体上是同构的,但细节差异值得注意。
| 能力 | 浏览器 DevTools | Node.js |
|---|---|---|
| console.assert 失败 | 打印错误日志,不中断 | 可能抛出AssertionError并中断进程 |
| %c 样式 | 支持 | 不支持 |
| console.table | 支持 | 支持 |
| console.time | 支持 | 支持 |
| 输出目标 | DevTools控制台 | stdout / stderr |
在Node里,console.log输出到stdout,console.error输出到stderr,这也是日志系统标准的分流方式:stdout收业务日志,stderr收错误日志。但有一个隐藏问题:当stdout指向管道或文件而不是终端时,console.log的写入是异步的。程序异常退出、进程崩溃时,最后那几行日志有可能来不及写完,这会让线上排查时丢失关键信息。
如果对日志完整性要求高,可以在关键节点用类似fs.writeSync(process.stdout.fd, msg)的方式做一次同步兜底,或者干脆在业务里接入专门的日志库。我个人的建议是:短生命周期的脚本、异常退出的边缘场景,别只依赖console.log,至少给错误路径加一个同步落盘的处理。
4.3 循环与高频日志的性能陷阱
console.log本身不便宜,每次调用都要走格式化、渲染、IO多道工序。循环里贴console.log,数据量大时浏览器直接卡顿,Node里跑批处理也会被明显拖慢。我拿一万次循环做过对比,带console.log的版本比不带慢了几十倍,印象极深。
高频场景尤其要注意:mousemove、scroll、requestAnimationFrame、WebSocket消息回调,这些事件本来就是高频触发,你要是每次回调都console.log一条,控制台会刷到完全没法看,还会拖垮页面性能。我的做法是:这类高频日志开一个开关,平时关闭,需要排查时打开并加节流,比如只在状态变化时打印,或者每N次才打一条。
调试期可以随便打,但代码提交前要检查这些高频日志,该删的删、该降级的降级。
5. 生产级日志治理:级别开关、样式化、脱敏与快照陷阱
5.1 %c样式化:把控制台变成可视化面板
前面表格里提到%c,现在展开聊。%c可以给后续文本套上CSS样式,让日志从普通文本变成带背景色、文字色、圆角、内边距的“标签块”。
console.log('%c[API]%c 请求失败', 'background:#f44336;color:#fff;padding:2px 6px;border-radius:3px', 'color:#f44336')多个%c可以给一段日志的不同片段分别设样式。这个技巧做服务启动banner很合适,比如打印项目名和版本号;给请求日志加一个醒目的彩色标签也很实用,扫控制台时一眼就能区分请求、响应、警告和错误。注意Node环境不认%c,样式化是浏览器专属玩法。另外样式别用得太花,它的价值是让重要信息跳出来,而不是把整个控制台变成调色盘。
5.2 一个可复用的分级日志封装
除了原生的console方法,我更推荐在项目里做一个极简的Logger封装:按debug/info/warn/error分级,通过环境变量或配置控制输出级别。这样线上可以把debug和info关掉,保留warn和error,日志量立刻降下来,同时代码里已经写好的日志不用删。
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40 } class Logger { constructor(options = {}) { this.level = options.level || 'debug' this.timestamps = options.timestamps !== false } shouldLog(level) { return LEVELS[level] >= LEVELS[this.level] } format(level, args) { const parts = [] if (this.timestamps) parts.push(new Date().toISOString()) parts.push(`[${level.toUpperCase()}]`) return parts.concat(args) } debug(...args) { if (!this.shouldLog('debug')) return console.log(...this.format('debug', args)) } info(...args) { if (!this.shouldLog('info')) return console.info(...this.format('info', args)) } warn(...args) { if (!this.shouldLog('warn')) return console.warn(...this.format('warn', args)) } error(...args) { if (!this.shouldLog('error')) return console.error(...this.format('error', args)) } } const logger = new Logger({ level: process.env.LOG_LEVEL || 'debug' }) logger.info('server started on port %d', 8080)这套封装只有几十行,但已经覆盖了80%的日志治理需求。想再进一步,还可以在format里通过new Error().stack截取调用位置,把文件名和行号拼进日志,方便定位是哪个模块打出来的。调试期可以开启,线上如果担心性能开销再关掉。
5.3 生产环境日志:开关、例外与脱敏思路
生产环境怎么处理日志,网上说法很多,我试过几种,最后沉淀下来的原则是:别指望构建工具自动删除所有console.log。有些方案会连console.error一起删掉,线上犯错时什么都没有。更稳妥的做法是入口层控制级别:生产环境把Logger的level设为'warn',业务日志全关,错误日志保留。
如果确实想靠构建期清理,新一代Webpack的terser-webpack-plugin里更可控的做法是配置terserOptions.compress.pure_funcs,把console.log、console.info、console.debug声明为纯函数,让压缩阶段把它们剔除,而console.warn和console.error保留。无论用哪种方案,都要在构建产物里实际跑一次验证,别只看配置生效就以为万事大吉。
脱敏也必须提。很多项目直接在业务代码里console.log(token、手机号、身份证号),这些信息会原样进浏览器控制台、进日志文件、进日志收集系统,安全隐患非常大。日志封装里可以加一层脱敏处理:匹配常见的敏感字段名和手机号,输出前替换成***。调试阶段可以临时关闭脱敏方便定位,但线上输出必须默认开启。
5.4 打印引用对象时的“实时快照”陷阱,以及我的收尾心得
最后说一说一个非常容易误导人的细节:浏览器控制台里,console.log一个对象之后,如果你在后续代码里修改了这个对象的属性,再回到控制台展开之前那条日志,看到的很可能不是打印时刻的值,而是当前时刻的值。原因是DevTools对对象采用惰性求值,展开时才去读取对象的当前状态。
const user = { name: 'Alice' } console.log('before', user) user.name = 'Bob' // 浏览器里展开上面那条 before 日志,name 很可能显示为 Bob这个现象会让新手怀疑人生,以为代码执行顺序都错了。解决起来也简单:打印时做一个浅拷贝,console.log('before', { ...user }),或者直接输出JSON字符串。Node环境打印对象时输出的是格式化快照,一般不受这个影响,保险起见也可以序列化后再看。
调试这事,最终拼的还是习惯。我现在的做法是:调试期大胆打,凡是能帮助定位的地方都拆开打;代码合入前过一遍日志,能合并的合并,能降级的降级;线上环境只留warn和error,并保证日志里不出现手机号、token这类敏感信息。说到底,console.log学再多技巧,也不如建立一套自己的日志规范。如果你也想让自己的调试过程更顺手,建议从把今天讲到的API各用一遍开始,用熟了再谈怎么治理。