做了这么多年前端,踩过最多的坑基本都出在“调试全靠 console.log”这个习惯上。这个系列写到第 8 天,前面几天我们把断点、源码映射、网络面板这些偏“重型”的工具都过了一遍,今天换个轻快的角度,专门把 Console 面板里那批非常实用、但很多人一年到头都碰不了几次的 Utilities Function 单独拿出来盘一遍。很多人一提起 Console 就条件反射想到 console.log,其实 Console API 是一整套调试工具箱,光官方规范里挂着的工具函数就有十几个,更别说各浏览器私藏的小彩蛋。这篇文章会把它们全部过一遍,再附上我平时在真实项目里反复用的组合技巧。适合写过一两年前端、习惯性用 console.log 打点、想进一步提升调试效率的人;如果你是纯新手,看完也能直接复制代码把控制台玩出花。
1. 先把 Console 当工具箱,而不是记事本
1.1 Console Utilities 到底指什么
要理解今天要聊的东西,先得搞清楚一个概念:Console 面板里能用的 API 其实分两类。一类是 ECMAScript 和浏览器环境提供的标准 JavaScript 能力,比如在控制台声明变量、定义函数、执行表达式,这些是 JavaScript 解释器本来就支持的 REPL 能力;另一类才是浏览器专门暴露给开发者的调试专用接口,也就是 Console API 里那一批 utility 方法。很多教程把它们混在一起讲,导致不少人以为“在控制台能跑 JS”就等于“会用 Console 调试”。
Console API 本身不算复杂,核心也就十个左右的函数:log、error、warn、info、debug、table、dir、dirxml、group、groupEnd、time、timeEnd、count、assert、trace、clear。这里面大部分不是每天都会用到,但每一个都能在特定场景里把调试效率拉高一个档次。比如 console.table 在打印列表数据时,信息密度比 console.log 高一截;console.group 能让控制台里的日志从“一地鸡毛”变成“归档整齐的档案袋”。这些工具虽小,但组合起来就是一套轻量、不用装任何依赖、不用改构建配置的调试武器库。
1.2 为什么多数人只用 console.log
原因很简单,console.log 是大家的第一行调试代码,也是所有教程的默认示例。人的习惯一旦建立就很难改,尤其是这个习惯还能解决 80% 的问题。打印一个变量、看一下接口返回值、标记某个函数有没有走到,console.log 全都能干。但它有一个致命问题:信息太平面。一个对象打出来是一坨可展开的树,数组一多就满屏滚动,多条日志混在一起根本分不清谁是谁。
另一个原因是很多人不知道 Console 还有其他函数,或者知道有,但没在真实场景里用过,印象不深。工具这东西,知道了不用等于不知道,用了一次觉得好用才会形成肌肉记忆。所以我建议你把今天看到的每个函数都亲手在控制台跑一遍,不用刻意记 API,用两三次自然就记住了。
1.3 用“场景化”思维替换“打印变量”习惯
这是我个人调试方法里最重要的一点:不要问“这个变量是什么”,要问“我现在想确认什么结论”。前者让你下意识 console.log 一把梭,后者会让你想清楚用什么工具最合适。比如你想确认“接口返回的列表里有没有符合条件的数据”,console.log 打出来要手动翻半天,但 console.table 一行就能扫完;你想确认“这段代码跑了多久”,console.time 比你在 log 里手动算时间戳优雅得多;你想确认“这个回调到底被谁触发的”,console.trace 比在 log 里加一句‘我到了’有用得多。
顺着这个思路,下面几节我会按“场景”来讲,每个函数配一个真实可复制的用法,顺便把那些文档里没写明白的坑也一起说清楚。
2. 格式化输出:把日志调成可视化仪表盘
2.1 替换符:%s、%d 之外的冷门占位符
console.log 最被低估的能力是格式化字符串。很多人只会在括号里堆一堆变量,然后用逗号分隔,打出来以后控制台给每个参数前面加个箭头标记。用替换符可以把变量直接嵌进字符串模板里,日志看起来更干净,也更接近你想表达的自然语言。
基础替换符包括:%s 代表字符串,%d 代表整数,%i 也是整数,%f 代表浮点数,%o 代表优化过的 DOM 节点展示形式,%O 代表普通 JavaScript 对象结构,%c 代表后续参数是 CSS 样式。举个实际例子:
const userName = 'A同学'; const retryCount = 3; const delay = 250.5; console.log('用户 %s 重试了 %d 次,每次间隔 %f 毫秒', userName, retryCount, delay);输出结果是“用户 A同学 重试了 3 次,每次间隔 250.5 毫秒”。对比一下用逗号分隔的写法,这种形式在日志里可读性强非常多,尤其当你需要把日志留给别人看,或者过几天再回头查的时候。你也可以用 %o 和 %O 直接在内嵌字符串里输出对象,这在拼接日志时省掉一次“先转 JSON 再拼字符串”的麻烦。
2.2 用 %c 做日志着色与信息分级
%c 是替换符里最能提升调试体感的一个,它允许你给日志段落挂上一段 CSS 样式。实际写入控制台的样式和页面 CSS 不完全一样,只能使用部分属性,但常用的背景色、文字颜色、字号、加粗、边框都支持。
我常用的做法是封装一个简单的 logger,把日志按级别刷成不同颜色:
const Log = { success: (msg) => console.log('%c%s', 'color: #0a0; background: #eaffea; padding: 2px 6px; border-radius: 4px; font-weight: bold;', msg), warning: (msg) => console.log('%c%s', 'color: #a60; background: #fff3e0; padding: 2px 6px; border-radius: 4px;', msg), error: (msg) => console.log('%c%s', 'color: #c00; background: #ffecec; padding: 2px 6px; border-radius: 4px;', msg), }; Log.success('数据加载完成'); Log.warning('接口响应偏慢'); Log.error('渲染组件失败');实测下来,控制台被颜色分区之后,定位问题的速度会快很多。因为人眼对颜色的敏感度远高于对文字内容的扫描速度。需要提醒的是,%c 样式在部分旧版浏览器上会被忽略,但在 Chrome、Edge、Firefox 这些主流浏览器上表现都很稳定。生产环境日志如果要经过日志收集系统,样式会被丢掉,但那不影响日志文本本身。
2.3 对象展开陷阱:调试多帧状态时的“幽灵值”
这是一个很经典、但几乎没人注意的坑。你在控制台执行下面这段代码:
const obj = { count: 1 }; console.log(obj); obj.count = 100;大多数人以为控制台打印出来的是{ count: 1 },然后展开的时候看到的却是{ count: 100 }。因为 console.log 打印引用类型时,控制台保留的是对象的引用,不是当时那一刻的快照。你展开那个小箭头的时候,读到的是对象的“现在”,不是“当时”。
这个坑在调试异步逻辑时特别烦人:你打了一行 log,等异步回调改完这个对象再去看,发现所有日志展开都是最终状态,中间的每一帧全被覆盖了。解决方法是打印时主动做一次浅拷贝,比如console.log({ ...obj }),或者直接console.log(JSON.parse(JSON.stringify(obj)))。数据量不大时用前者就够了;嵌套特别深就用后者。这个细节在团队协作时非常值得告诉大家,能省下大量“我这个值明明是对的啊”的无效排查时间。
3. 结构化输出:table、dir 与 dirxml
3.1 console.table 三个实用细节
console.table 是我个人使用频率最高的 Console 工具函数,没有之一。它能把数组或对象渲染成表格,一眼扫完所有字段,比逐个展开对象树高出一个维度。基本用法如下:
const users = [ { name: 'A同学', age: 20, role: 'admin' }, { name: 'B同学', age: 22, role: 'user' }, { name: 'C同学', age: 18, role: 'guest' }, ]; console.table(users);这段代码会在控制台渲染出一张三行三列的表格,表头就是对象的键名。还有一个隐藏参数 columns,可以指定只展示哪些列,这在对象字段非常多、你只关心其中两三个字段的时候特别有用:
console.table(users, ['name', 'role']);console.table 还能处理二维数组,表格的行列会对应数组的索引;处理普通对象时,第一列是对象的键名,后面是值。但有两个坑需要注意:一是当表格数据量很大(比如上千条)时,控制台会明显卡顿,这时候建议先 filter 出关心的子集再打;二是列内容如果太长,表格单元格会显示省略号,鼠标悬停才能看全,这对 URL、长 ID 这类字段不太友好。如果只是为了快速判断数据分布,那完全够用。
3.2 console.dir 与 dirxml:看清对象与 DOM 的真实结构
console.dir 和 console.log 在打印普通对象时差异不明显,但打印 DOM 节点时完全是两个世界。console.log 一个 DOM 元素会显示成 HTML 标签的形态,你可以在 Elements 面板里直接点击跳转;console.dir 则会把这个元素的属性、事件监听器、内部样式等一切 JS 可访问的属性全部展开成对象树,特别适合排查“某个元素上到底挂了多少事件监听”、“这个元素的行内样式到底被改成了什么”这类问题。
console.dir 还接受第二个参数,options 对象可以传{ depth: 2, showHidden: true },用来控制展开深度和是否展示不可枚举属性。不过这个选项的兼容性在不同浏览器不完全一致,实测 Chrome 支持比较完整。
console.dirxml 则更偏向 DOM 树的可视化,它会把节点和后代节点渲染成 HTML 结构视图。调试组件渲染结果或者某个复杂 DOM 片段时,dirxml 比 dir 更直观,因为它展示的是结构,不是属性集合。平时用到的机会不多,但一旦遇到“为什么这个元素下面多了一个兄弟节点”这种问题,它就很有用了。
4. 时间与频率度量:不写一行性能代码也能测耗时
4.1 time/timeEnd/timeLog 轻量计时
前端性能排查最常做的一件事就是量化函数耗时。console.time 和 console.timeEnd 就是为此准备的,它们接收同一个字符串标签,标记一段代码的开始和结束,在结束时打印耗时:
console.time('列表渲染'); renderList(largeArray); console.timeEnd('列表渲染');console.time 可以同时开启多个互不干扰的计时器,只要标签不同。比如分别给“数据获取”和“列表渲染”计时,一次调试就能对比出瓶颈在哪个环节。我还会在计时中间用 console.timeLog 插入打点,它不会结束计时,只是把当前累计耗时打出来:
console.time('总流程'); loadData(); console.timeLog('总流程', '数据加载完成'); mountView(); console.timeEnd('总流程');这个组合特别适合排查“整个流程不慢,但不知道哪里慢”的问题。需要留意的是,time 和 timeEnd 的标签必须完全一致,不一致时 timeEnd 会打印一个警告而不是耗时结果;另外在计时开始之前如果已经存在同名计时器,老计时器会被自动结束,所以避免循环里重复开启同名计时器。
4.2 count/countReset 统计调用次数
console.count 的用途是“这段代码被执行了多少次”,它接收一个标签参数,每次调用都会输出标签和累计计数。我用得最多的场景是排查事件监听是否被重复绑定、递归是否陷入死循环、某个回调是不是被调了多次。
function handleClick() { console.count('按钮点击'); // ... }每点一次按钮,控制台就会打印“按钮点击: 1”“按钮点击: 2”,一路递增。如果你怀疑某段代码在页面初始化时被多次执行,直接在入口放一个 count,刷新几次页面,看计数器累加的次数就知道了。console.countReset 会把指定标签的计数归零,用于新一轮统计。这个函数平时看着不起眼,在排查内存泄漏、重复请求这类“调用次数异常”的问题时是真正的利器。
4.3 console.memory 与内存观察小技巧
Chrome 控制台里有一个非标准的属性 console.memory,可以读取 JavaScript 堆内存的当前使用量。它不是标准 API,别指望在其它浏览器或 Node 环境通用,但在 Chrome 里做快速内存趋势观察非常方便:
console.log(console.memory);返回对象里有 usedJSHeapSize、totalJSHeapSize、jsHeapSizeLimit 这些字段。你可以在页面空载和操作完一轮之后分别打印对比,如果 usedJSHeapSize 明显上涨且不回落,说明大概率存在内存泄漏。这个方法不如 Performance 面板精确,但胜在零成本、随时可用,适合做第一轮粗筛。真正精确定位泄漏对象时,再切换到 Memory 面板抓堆快照也不迟。
5. 条件、堆栈与分组:让日志拥有控制流
5.1 console.assert:安静的检查点
console.assert 是一个“只在条件不成立时输出错误”的断言函数。它不会中断代码执行,也不会抛出异常,只会在条件为 false 时打印一条错误日志。它最实用的场景是在流程中间放检查点,避免“无效日志”刷屏。
function calcTotal(items) { console.assert(Array.isArray(items), 'calcTotal: items 必须是数组'); console.assert(items.length > 0, 'calcTotal: items 不能为空'); // ... }这样当数据正常时控制台不会出现任何额外输出,一旦数据异常,错误信息会准确提示问题在哪。对比一下用 if + console.error 的写法,assert 一行搞定,而且意图更明确。需要注意一个细节:console.assert 的第二个及后续参数支持替换符,和 console.log 一样。但旧版浏览器对 assert 的返回值和输出格式支持不一致,推荐在 Chromium 内核浏览器上使用,它现在打印的是带红色错误图标的日志,辨识度很高。
5.2 console.trace:定位“谁在调用我”
console.trace 会打印当前调用栈,也就是“这一行代码是被谁一步步调用过来的”。在回调函数、事件处理器、第三方库的钩子里放一条 trace,可以快速理清调用链。
function onItemClick() { console.trace('条目被点击'); handleItemClick(); }控制台会自动展开一条栈信息,显示调用 onItemClick 的上一层函数、再上一层函数,一直到全局作用域。这对查“为什么这个回调被触发了”特别有效,尤其是当同一个函数被多个入口调用时,trace 能直接告诉你这次来自哪个入口。我在调试一个模拟项目 X 的全局事件总线时,就是靠 trace 排查出某个事件在初始化阶段被一个意料之外的模块触发,迅速看到了完整执行链。
5.3 group/groupCollapsed:日志折叠与分组
console.group 和 console.groupEnd 负责把一组日志折叠成一个可展开的块。group 默认展开,groupCollapsed 默认收起。调试复杂流程时,把“请求发起”“数据处理”“UI 更新”三个阶段各自包进一个组里,控制台瞬间就清晰了:
console.groupCollapsed('用户列表加载流程'); console.log('请求发起', url); console.log('数据处理', processedData); console.log('UI 更新', renderResult); console.groupEnd();默认收起的好处是日志多的时候不会刷屏,你只需要看到“用户列表加载流程”这一行标题,想深入看细节时再手动展开。group 支持嵌套使用,但嵌套层级别超过三四层,太深反而看不清。还要注意 group 和 groupEnd 必须成对出现,漏了 groupEnd 会导致后续日志全部被包进这个分组,控制台乱成一锅粥。如果发现分组异常,直接在控制台执行一下 groupEnd 可以把当前分组闭合掉。
5.4 console.clear 的使用边界
console.clear 会清空控制台当前内容,在调试时需要“重新开始”时有效。但它有个很容易被忽略的副作用:主动清空会影响到同一控制台里别人的调试信息。联调场景或者你在看第三方页面时,不要随便清屏,否则会误伤队友的信息。另一个相关的坑是,如果打开控制台的“Preserve log”选项,页面刷新后日志会保留;配合 clear 使用可以按轮次区分日志块。我自己习惯在某个复杂的自动化流程调试里,每启动一轮就清一次屏,这样下一轮日志从顶部开始,定位问题不用往下翻。
6. 进阶玩法:不改业务代码,也能给线上问题加日志
6.1 重写 console 注入时间戳与调用来源
线上问题有时候是最难调试的,因为你不能随便改线上代码加日志。但如果你能在页面加载早期执行一段脚本,把 console 方法整体重写一遍,就能实现“给所有日志自动加时间戳和调用来源”的效果。简单说就是把控制台变成一个自带审计功能的日志系统。
(() => { const originalLog = console.log.bind(console); console.log = (...args) => { const timestamp = new Date().toISOString(); const stack = new Error().stack.split('\n')[2]?.trim() || '未知位置'; originalLog(`[${timestamp}]`, ...args, `\n 调用来源: ${stack}`); }; })();这段代码在 DevTools 的 Console 里直接执行,或者通过书签脚本注入都可以。注意必须绑定了原始函数再替换,否则会循环递归。重写之后,所有 console.log 都会带上精确到毫秒的时间戳和调用位置,排查“某个日志是哪个模块、哪个函数打出来的”会省很多事。类似的思路还可以给 console.error、console.warn 加上网络请求状态、用户操作上下文等信息,做成一套临时调试面板。
6.2 组合使用的“接口耗时报表”
把 group、time 和 table 组合起来,可以实现一个很不错的网络请求调试工具。下面这段 monkey patch 会给页面上所有 fetch 请求自动生成耗时报表:
const originalFetch = window.fetch.bind(window); window.fetch = (...args) => { const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '未知URL'; const start = performance.now(); return originalFetch(...args).then(async (res) => { const cost = performance.now() - start; console.groupCollapsed(`[fetch] ${url} (${cost.toFixed(1)}ms)`); console.log('状态码:', res.status); console.log('耗时:', `${cost.toFixed(1)}ms`); console.log('耗时标记:', `${Math.round(cost)}ms`); console.groupEnd(); return res; }); };随后在控制台观察,所有接口请求都会按 URL 分组展示,点开就是状态码和耗时,扫一眼就能看出哪个接口拖慢了页面。再配合一个数组收集耗时数据,最后用 console.table 汇总成报表,就是一套零依赖的网络性能监控小工具。这个思路同样适用于 XHR、setTimeout 定时器等其它全局方法,本质上都是“在不改业务代码的前提下,给运行时加上观测点”。
6.3 浏览器兼容性与生产环境要避开的坑
Console API 大多数是各浏览器事实标准,但具体实现细节有差异。console.table 在部分旧版浏览器和移动端 WebView 上可能不支持或渲染异常,遇到低端设备时先打印一遍确认再依赖它。console.timeLog 只在较新的浏览器里可用,老环境会直接报错。console.memory 是 Chrome 专属属性,Firefox 和 Safari 不认识。如果你的代码运行在非 Chromium 内核的浏览器里,用这些 API 之前最好包一层 typeof 判断。
还有一点关乎生产安全:线上环境如果直接把 console 暴露给用户,控制台里打印的信息可能泄露内部数据结构、接口参数甚至敏感字段。我在一些重视安全的项目里会做环境判断,只有开发环境才保留 console 的全部能力,生产环境把 console 替换成空操作或者只保留 error 级别,避免信息外露。这个做法对用户侧体验也更友好,毕竟生产控制台的调试日志对绝大多数终端用户没有意义,只有被攻击者利用的风险。
踩过几次坑之后,我自己在实际调试里最顺手的组合拳其实很简单:怀疑数据格式问题时直接 console.table,怀疑性能瓶颈时用 console.time 包两端,怀疑条件走到没走到时用 console.assert 静默检查,最后再用 console.groupCollapsed 把前面所有日志归拢成一块。整套流程不用装任何面板插件,任何一台电脑的浏览器控制台都够用。最后再分享一个小技巧:如果你在调试过程中发现自己老是找不到某条日志,是因为日志早就滚出可视区域了,打开控制台设置里的 Preserve log,再配合分组合并使用,整个调试过程的日志都能回看,排查效率会高出不少。