前端人保命指南:从Console.log到断点调试,3天搞定那些让人头秃的

前端人保命指南:从Console.log到断点调试,3天搞定那些让人头秃的Bug

说实话,写这篇文章之前我刚被一个异步Bug折磨了两个小时,最后发现是少写了个await。就是那种你盯着代码看了八百遍,觉得逻辑天衣无缝,结果浏览器就是不给面子的感觉。所以咱们今天聊的这个话题,我是带着真情实感来的——前端调试这玩意儿,真的会要命。


别在那傻乎乎地console.log了,咱们聊聊怎么真正"捉虫"

我先自曝一下黑历史。刚入行那会儿,我的调试工具就三样:console.logalert、以及刷新页面。遇到复杂点的逻辑,代码里能插十几个log,打印出来的东西把控制台淹得跟洪水似的。然后对着那一堆对象展开、折叠、展开、折叠,眼睛都看花了还没找到问题在哪。

那时候觉得自己挺努力的,现在回头看,纯粹是低效勤奋。就像有人用算盘做微积分,不是不能做,是太特么累了。

现在的开发节奏大家都懂,需求像瀑布一样砸下来,产品改个文案都能提个"紧急优化"。你还在那console.log(obj)然后一层层点开看属性?等你找到那个undefined的时候,后端都已经把接口改了三遍了。

我见过最离谱的同事,调试一个数组遍历问题,在for循环里打了五个log,分别打印iarr[i]arr.lengthtypeof arr[i]、还有JSON.stringify(arr)。最后发现问题是因为数组里混进去一个null,而他在第三个log的时候就看到了,但惯性让他把剩下的log都打完了。这大概就是传说中的"调试强迫症"吧。

那些让人怀疑人生的玄学时刻

咱们都遇到过这种情况:代码看着明明没问题,语法也没报错,但结果就是不对。我列几个经典的,看看你有没有中枪:

玄学一号:异步地狱

// 你以为的执行顺序:1 -> 2 -> 3
// 实际的执行顺序:1 -> 3 -> 2

function fetchData() {
    console.log('1. 开始获取数据'); // 这个肯定先打
    
    fetch('/api/data').then(res => {
        console.log('2. 数据回来了'); // 这个...看网速心情
    });
    
    console.log('3. 函数执行完了'); // 这个铁定比2先打
}

// 更坑的是这种混用async/await和Promise的
async function mixedStyle() {
    const data = await fetch('/api/user');
    console.log('拿到用户了', data);
    
    fetch('/api/orders').then(orders => {
        console.log('订单也来了', orders);
    });
    
    console.log('这行到底在哪执行?');
    // 答案是:在await之后,但在Promise回调之前
    // 如果你以为orders已经拿到了,那就等着翻车吧
}

玄学二号:this的量子态

const obj = {
    name: '张三',
    sayHi: function() {
        console.log('你好,我是' + this.name);
    },
    sayHiArrow: () => {
        console.log('你好,我是' + this.name); // 这里的this指向window
    },
    delayedHi: function() {
        setTimeout(function() {
            console.log('延迟问候:' + this.name); // undefined,因为this丢了
        }, 100);
        
        setTimeout(() => {
            console.log('箭头函数问候:' + this.name); // 张三,箭头函数保住了this
        }, 200);
    }
};

// 还有更隐蔽的
const btn = document.getElementById('myBtn');
btn.addEventListener('click', obj.sayHi); // 点击时this变成btn,name变成undefined
btn.addEventListener('click', () => obj.sayHi()); // 这样才对,用箭头函数包一层

玄学三号:闭包陷阱

// 经典面试题,也是经典Bug源
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 打出三个3,不是0,1,2
    }, 100);
}

// 很多新手会"修复"成这样,结果还是错
for (var i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 还是三个3
        let current = i; // 这里用let已经晚了,i已经是3了
    }, 100);
}

// 正确的几种写法
// 写法一:用let声明循环变量,每次迭代都有新作用域
for (let i = 0; i < 3; i++) {
    setTimeout(() => {
        console.log(i); // 0, 1, 2
    }, 100);
}

// 写法二:用闭包把当时的i存住
for (var i = 0; i < 3; i++) {
    (function(capturedI) {
        setTimeout(() => {
            console.log(capturedI); // 0, 1, 2
        }, 100);
    })(i);
}

// 写法三:用setTimeout的第三个参数(很少有人知道)
for (var i = 0; i < 3; i++) {
    setTimeout((idx) => {
        console.log(idx); // 0, 1, 2
    }, 100, i); // 第三个参数会传给回调函数
}

为什么90%的初级前端都在无效调试

我觉得核心问题是:很多人把调试当成"猜谜游戏",而不是"科学实验"。

无效调试的典型姿势:

  1. 随机改代码,改完刷新看效果,像买彩票一样期待Bug消失
  2. 遇到问题先百度,复制错误信息到搜索框,然后挨个试Stack Overflow的答案
  3. 在群里问"有人遇到过这个问题吗",发个截图,等待天降大神
  4. 重启编辑器、重启浏览器、重启电脑,俗称"调试三件套"

而大佬们的调试是系统性的:

  1. 先稳定复现Bug,不能复现的问题都是耍流氓
  2. 缩小范围,确定是哪段代码、哪个变量、哪个时机出的问题
  3. 用工具精准定位,而不是靠猜
  4. 理解根本原因,而不是修个表面

说白了,初级前端在"试",大佬们在"查"。就像看病,一个是乱吃药,一个是先做检查再开方。


扒一扒浏览器开发者工具里那些被忽略的神器

Chrome DevTools这玩意儿,我估计90%的人只用过Elements面板改改样式,Console面板打打字。但它真正的威力远不止这些,有些功能用好了能让你少熬好几个夜。

Console面板:不只是打印日志的地方

先说说console.log的进阶玩法。你知道吗,console其实是个全家桶:

// 基础款,但很多人不知道可以带样式
console.log('%c 我是带样式的日志 ', 'background: #222; color: #bada55; font-size: 20px; padding: 5px;');
// 这在调试的时候特别有用,可以把重要的日志高亮出来

// 分组打印,避免控制台被刷爆
console.group('用户登录流程');
console.log('1. 获取表单数据');
console.log('2. 调用登录接口');
console.log('3. 存储token');
console.groupEnd();

// 还可以嵌套
console.group('父级操作');
console.log('父级步骤1');
console.group('子级操作');
console.log('子级步骤1');
console.log('子级步骤2');
console.groupEnd();
console.log('父级步骤2');
console.groupEnd();

// 表格形式展示数组或对象,比展开看舒服多了
const users = [
    { id: 1, name: '张三', role: 'admin' },
    { id: 2, name: '李四', role: 'user' },
    { id: 3, name: '王五', role: 'user' }
];
console.table(users);

// 条件打印,只有条件满足才输出
const debugMode = true;
console.assert(debugMode, '这行只有在debugMode为false时才会打印'); // 实际不会打印
console.assert(!debugMode, '出问题了!debugMode应该是true'); // 会打印,因为条件不满足

// 计时器,测性能用
console.time('数组操作');
const arr = [];
for (let i = 0; i < 1000000; i++) {
    arr.push(i);
}
console.timeEnd('数组操作'); // 输出: 数组操作: 23.456ms

// 追踪函数调用栈
function a() { b(); }
function b() { c(); }
function c() { 
    console.trace('谁调用了我?'); 
    // 会打印出 c -> b -> a 的完整调用链
}
a();

// 统计某个日志出现了多少次
for (let i = 0; i < 5; i++) {
    console.count('循环执行次数');
}
console.countReset('循环执行次数'); // 重置计数器

// 打印对象时,有时候你想看当时的快照,而不是引用
const obj = { count: 1 };
console.log(obj); // 展开时可能显示count已经是2了,因为是引用
console.log(JSON.parse(JSON.stringify(obj))); // 深拷贝后的快照
// 或者更简单
console.log({ ...obj }); // 浅拷贝快照

还有个很多人不知道的技巧:在Console里可以直接写多行代码,按Shift+Enter换行,就像个小编辑器。而且它有自动补全,敲个document.就能看到所有方法。

Sources面板:断点调试的主战场

这才是今天的重头戏。断点调试比console.log强在哪?你可以实时看到程序的执行过程,而不是只看最后的结果。

基础断点大家都会:在代码行号那点一下,变成蓝色就是设了断点,刷新页面执行到那里就会停住。但Sources面板远不止这个。

条件断点:当你的循环要跑1000次,但Bug只在第888次出现,你不可能手动点888次"继续执行"吧?

// 假设你在找数组里的某个特定值
const data = Array.from({length: 1000}, (_, i) => ({
    id: i,
    value: Math.random()
}));

// 普通断点会每次都停,烦死人
// 在行号上右键 -> "Add conditional breakpoint",输入条件:
// item.value > 0.999
// 这样只有满足条件时才会断住
data.forEach(item => {
    console.log(item); // 在这里设条件断点:item.value > 0.999
});

DOM断点:调试事件委托或者动态生成的元素时特别有用。在Elements面板里,右键一个元素 -> Break on -> 可以选择subtree modifications(子树变化)、attributes modifications(属性变化)、node removal(节点移除)。这样当这个元素被改动时,会自动断到对应的代码位置。

XHR/Fetch断点:在Sources面板右侧的XHR/fetch Breakpoints里,可以添加URL匹配规则。比如填api/user,任何请求这个接口的地方都会自动断住,不用在代码里找调用位置了。

// 假设这个接口有问题
fetch('/api/user/profile')
    .then(res => res.json())
    .then(data => {
        console.log(data); // 不用在这里打断点,XHR断点会自动停
    });

// 甚至可以在没源码的情况下拦截请求
// 在Network面板里,右键请求 -> Copy -> Copy as fetch
// 然后到Console里修改参数重新发送,用于快速测试

异步调试:这是最头疼的,但也是Sources面板最强的地方。

async function complexAsync() {
    console.log('1. 开始');
    
    const user = await fetchUser(); // 在这里打个断点
    console.log('2. 拿到用户', user);
    
    const orders = await fetchOrders(user.id); // 再打个断点
    console.log('3. 拿到订单', orders);
    
    return { user, orders };
}

// 当你在await那里断住时,Call Stack显示的是完整的异步调用链
// 不只是当前的Promise,还包括是谁调用的complexAsync
// 这得益于Chrome的"Async stack traces"功能,默认开启的

Network面板:不只是看接口快慢

很多人用Network面板就两件事:看接口返回了啥,看接口花了多长时间。但它还能干很多脏活累活。

// 模拟慢网络,测试加载状态
// 在Network面板里,有个下拉框默认是"Online",改成"Slow 3G"
// 然后刷新页面,看看你的loading效果是不是正常显示
// 还可以自定义网速:Add... -> 设置上传下载速度和延迟

// 重放请求,不用去点UI
// 右键任何一个请求 -> Replay XHR,直接重新发送
// 或者Copy as cURL,在终端里调试

// 修改请求参数再发送
// 右键请求 -> Copy -> Copy as fetch,到Console里粘贴
// 然后改参数,比如把page=1改成page=2,直接看效果

// 本地覆盖(Local Overrides),这个超实用
// 假设后端接口还没好,但你需要特定数据测试前端逻辑
// 1. 在Network面板找到那个请求
// 2. 右键 -> Save for overrides
// 3. 在Sources面板的Overrides标签里找到保存的文件
// 4. 修改JSON内容,比如把status从pending改成completed
// 5. 刷新页面,浏览器会直接用你修改后的本地文件,而不是真实请求
// 这对Mock数据、测试边界条件特别有用

Performance和Memory面板:专治各种卡顿和泄漏

页面卡成PPT?内存占用越来越高直到崩溃?这两个面板能救命。

// Performance面板使用示例
// 假设你有个列表滚动很卡
function renderList(items) {
    const container = document.getElementById('list');
    
    // 糟糕的做法:频繁操作DOM
    items.forEach(item => {
        const div = document.createElement('div');
        div.innerHTML = `<span>${item.name}</span><button>删除</button>`;
        container.appendChild(div); // 每次append都会触发重排重绘
    });
    
    // 好的做法:用DocumentFragment
    const fragment = document.createDocumentFragment();
    items.forEach(item => {
        const div = document.createElement('div');
        div.innerHTML = `<span>${item.name}</span><button>删除</button>`;
        fragment.appendChild(div); // 这里不会触发重排
    });
    container.appendChild(fragment); // 一次性插入,只触发一次重排
}

// 打开Performance面板,点录制按钮,滚动列表,停止录制
// 看"Frames"行,如果有红色表示掉帧了
// 看"Main"线程,找那些长任务(黄色长条),优化它们

// Memory面板检测泄漏
// 假设你有个单页应用,路由切换时内存应该释放
// 1. 在Memory面板,选Heap snapshot,拍一张"基线"快照
// 2. 做一些操作,比如打开关闭某个页面10次
// 3. 再拍一张快照,对比
// 4. 如果内存没回到基线,说明有泄漏
// 5. 在"Comparison"视图里,看哪些对象增加了但没减少

// 常见的内存泄漏代码
function leakExample() {
    const bigData = new Array(1000000).fill('x');
    
    // 错误:事件监听没移除,bigData一直被引用
    document.getElementById('btn').addEventListener('click', () => {
        console.log(bigData.length); // 闭包引用了bigData
    });
    
    // 正确:记得removeEventListener,或者用WeakMap
    const handler = () => {
        console.log(bigData.length);
    };
    const btn = document.getElementById('btn');
    btn.addEventListener('click', handler);
    
    // 在组件销毁时
    btn.removeEventListener('click', handler);
}

把Debugger语句玩明白,别让断点断在奇怪的地方

debugger这语句,有人觉得鸡肋,有人觉得神器。我觉得关键是知道什么时候用它,什么时候用Sources面板的断点。

debugger vs 断点,怎么选?

Sources面板的断点是"临时"的,页面一刷新就没了(除非你开了"Preserve log")。而代码里的debugger是"永久"的,提交到仓库里,别人也能断住。

适用场景:

  • 临时调试:用Sources面板断点,快速、灵活,不影响代码
  • 团队协作:用debugger,特别是那种"这里很容易出错,大家注意"的地方
  • 复杂条件:用Sources的条件断点,比在代码里写if方便
  • 生产环境:…这个后面说
// 一个实用的debugger技巧:配合条件判断
function processData(data) {
    // 只在数据异常时断住,方便排查
    if (data.length > 10000 || data.some(item => !item.id)) {
        debugger; // 数据有问题,自动断住
    }
    
    // 正常逻辑
    return data.map(item => transform(item));
}

// 还可以配合console.assert
console.assert(data.isValid, '数据不合法');
// 但assert只是打印,不会断住,所以可以:
if (!data.isValid) {
    debugger;
    throw new Error('数据验证失败');
}

异步代码的断点技巧

异步是调试的噩梦,因为执行顺序不按代码顺序来。几个实用技巧:

// 问题:在async函数里打断点,await之后的代码执行时,Call Stack已经变了
async function fetchAndProcess() {
    const raw = await fetch('/api/data'); // 在这里断住
    // 等你再往下执行,已经不在原始的调用上下文了
    const data = await raw.json();
    return process(data);
}

// 解决方案1:在Chrome DevTools设置里开启"Async stack traces"
// 这样await之后的代码也能看到完整的调用链

// 解决方案2:如果浏览器不支持,手动传递上下文
async function fetchAndProcessWithContext(context) {
    console.log('执行上下文:', context); // 打印当时的变量
    debugger;
    
    const raw = await fetch('/api/data');
    const data = await raw.json();
    return process(data);
}

// 调用时
fetchAndProcessWithContext({ 
    userId: currentUser.id, 
    timestamp: Date.now(),
    source: 'dashboard'
});

// 解决方案3:对于Promise链,用.then的第二个参数捕获错误
fetch('/api/data')
    .then(
        res => res.json(),
        err => {
            debugger; // 在这里断住看错误详情
            throw err;
        }
    )
    .then(
        data => process(data),
        err => {
            debugger; // 处理阶段的错误
            console.error('处理失败', err);
        }
    );

// 解决方案4:对于复杂的async/await,可以临时改成同步风格调试
// 把
const result = await complexOperation();
// 改成
const promise = complexOperation();
debugger; // 在这里看promise的状态
const result = await promise;

Source Map:没有它,调试就是盲人摸象

现代前端都打包压缩,线上的代码和本地的源码天差地别。Source Map就是这两者之间的翻译器。

// webpack配置示例
module.exports = {
    devtool: 'source-map', // 开发环境用这个,质量高但慢
    // 或者
    devtool: 'eval-source-map', // 更快,但映射没那么准
    // 或者
    devtool: 'cheap-module-source-map', // 生产环境推荐,没有列映射但文件小
    
    // 生产环境隐藏Source Map,但保留用于错误监控
    devtool: 'hidden-source-map', // 浏览器不会自动加载,但错误监控平台可以用
};

// Vite配置更简单
export default {
    build: {
        sourcemap: true, // 或者 'hidden' 或 'inline'
    }
};

// 验证Source Map有没有生效
// 在Sources面板,打开一个JS文件,如果看到是原始的ES6+/TS代码,说明生效了
// 如果看到是一坨压缩后的代码,说明没生效或者配置错了

// 常见问题:断点错位
// 现象:你在第10行打了断点,但执行时断在第8行或者第12行
// 原因:Source Map映射不准,通常是cheap-source-map导致的
// 解决:开发环境用source-map或eval-source-map,别用cheap的

// 常见问题:Vue/React组件断不住
// 现象:在.vue或.jsx文件里打断点,刷新后断点变灰,显示"could not load content"
// 原因:webpack的source map配置和devServer的publicPath不匹配
// 解决:检查devtool配置,确保和构建工具一致

Webpack和Vite打包后的代码怎么调试

有时候你必须直接调试打包后的代码,比如线上环境出问题了,而本地复现不了。

// 在Sources面板,如果Source Map没生效,你会看到类似这样的代码:
// (function(e){var t={};function n(r){if(t[r])return t[r].... 一堆乱码

// 技巧1:Pretty Print(格式化打印)
// 在Sources面板左下角,有个{}按钮,点击后会把压缩代码格式化
// 虽然变量名还是a、b、c,但至少能看逻辑了

// 技巧2:添加Source Map
// 如果你知道对应的.map文件在哪,可以在Network面板找到那个JS请求
// 右键 -> Override content,然后手动添加//# sourceMappingURL=xxx.map
// 但这招比较麻烦,通常用于紧急排查

// 技巧3:用console.log定位
// 在格式化后的代码里,找到关键位置,右键"Add logpoint"
// 这不会断住程序,但会在控制台打印当时的变量值
// 相当于不用改代码的console.log

// 技巧4:对于Webpack,可以用webpack-bundle-analyzer分析
// 但这主要是看体积,调试的话还是靠Source Map

// 技巧5:Vue和React的DevTools插件
// 虽然不算Source Map,但能看组件树和状态,也算是一种"映射"
// 安装Vue DevTools或React DevTools,配合Sources面板使用

遇到报错别慌,这套排查思路能让你少加半小时班

看到控制台一片红,心跳加速、手心冒汗?别慌,报错其实是程序在"说话",只是说得比较技术性。学会"听"它说什么,比盲目百度有效得多。

先看懂堆栈信息

一个典型的错误堆栈长这样:

TypeError: Cannot read property 'name' of undefined
    at UserCard.render (UserCard.vue:45)
    at VueComponent.Vue._render (vue.runtime.esm.js:3548)
    at VueComponent.updateComponent (vue.runtime.esm.js:4066)
    at Watcher.get (vue.runtime.esm.js:4479)
    at Watcher.run (vue.runtime.esm.js:4554)
    at flushSchedulerQueue (vue.runtime.esm.js:4310)

很多人看到这一堆就头大,直接复制第一行去搜索。但其实堆栈是倒着看的,最上面的是错误发生的位置,越往下是调用链

正确阅读姿势:

  1. 第一行UserCard.vue:45是罪魁祸首,点进去看
  2. 发现是在访问user.name,而user是undefined
  3. 往上看调用链,是render函数调用的,说明是渲染时数据还没准备好
  4. 解决:加v-if判断,或者给user设默认值
// 错误代码
export default {
    data() {
        return {
            user: null // 初始是null
        };
    },
    template: `
        <div>
            <h1>{{ user.name }}</h1> <!-- 这里报错,因为user是null -->
        </div>
    `
};

// 修复方案1:条件渲染
template: `
    <div v-if="user">
        <h1>{{ user.name }}</h1>
    </div>
    <div v-else>加载中...</div>
`

// 修复方案2:默认值
data() {
    return {
        user: { name: '' } // 给个空对象,至少不会undefined.name
    };
}

// 修复方案3:可选链(现代浏览器和构建工具支持)
template: `
    <div>
        <h1>{{ user?.name || '未知用户' }}</h1>
    </div>
`

复现步骤是关键

不能稳定复现的Bug都是耍流氓。如果你说"偶尔会出现",那等于没说。

记录复现步骤的模板:

  1. 环境:浏览器版本、操作系统、是否无痕模式、有没有装插件
  2. 前置条件:登录状态、数据状态、缓存情况
  3. 操作步骤:点击A -> 输入B -> 滚动到C -> 点击D,越详细越好
  4. 预期结果:应该发生什么
  5. 实际结果:实际发生了什么,最好有截图或录屏
// 有时候Bug只在特定数据下出现,学会构造测试数据
// 比如这个数组越界的Bug
function processList(items) {
    return items.map((item, index) => {
        if (item.children) {
            return item.children[index].name; // 假设children和items长度一样
        }
    });
}

// 正常数据没问题
processList([
    { children: [{ name: 'A1' }] },
    { children: [{ name: 'B1' }] }
]); // 正常

// 但如果有空children就会炸
processList([
    { children: [{ name: 'A1' }, { name: 'A2' }] },
    { children: [] } // 这里children.length是0,但index是1
]);
// 报错:Cannot read property 'name' of undefined

// 所以测试时要考虑边界情况:空数组、超长数组、嵌套层级不一致

二分法注释:土但好用

当代码量很大,不确定Bug在哪时,二分法是最快的。

// 假设你有100行代码,不确定哪行出问题
// 不要一行行注释,太慢了

// 步骤1:注释掉后50行,看Bug还在不在
// 如果在,说明Bug在前50行;如果不在,说明Bug在后50行

// 步骤2:假设Bug在前50行,再注释掉后25行
// 以此类推,最多7次就能定位到具体行(因为2^7=128>100)

// 实际例子:某个函数执行后页面卡死
function heavyOperation() {
    step1(); // 可能这里
    step2(); // 或者这里
    step3(); // 或者这里
    step4(); // 或者这里
    step5(); // 或者这里
}

// 快速定位:
// 1. 注释step3-step5,如果还卡,问题在step1-2
// 2. 假设还卡,注释step2,如果还卡,就是step1的问题
// 3. 进入step1,继续二分

// 对于异步代码,可以用类似思想
async function complexFlow() {
    await init(); // 阶段1
    await loadData(); // 阶段2
    await render(); // 阶段3
    await bindEvents(); // 阶段4
}

// 在每个阶段后加debugger或console.log,看执行到哪一步出问题
// 或者用Performance面板看哪个阶段耗时异常

橡皮鸭调试法:对着空气讲代码

这招听起来很傻,但真的有效。原理是:当你试图向"傻子"解释代码时,你会重新梳理逻辑,往往就能发现之前忽略的问题。

操作步骤:

  1. 找个对象(可以是橡皮鸭、 teddy bear、或者你的猫)
  2. 一行行解释代码在干什么
  3. 当说到某一行时,如果发现"解释不通"或者"解释起来很别扭",那里很可能就是Bug
// 假设你在解释这段代码
function calculateTotal(items) {
    let total = 0;
    items.forEach(item => {
        total += item.price * item.quantity;
        // 你说:"这里把商品的价格乘以数量,加到总价里"
        // 然后你突然想到:如果quantity是字符串"2"呢?
        // 或者price是undefined?
        // 这就是发现Bug的时刻!
    });
    return total;
}

// 修复
function calculateTotalSafe(items) {
    return items.reduce((total, item) => {
        const price = Number(item.price) || 0;
        const quantity = Number(item.quantity) || 0;
        return total + price * quantity;
    }, 0);
}

真实项目里的血泪教训,这些坑我替你踩过了

理论说再多,不如看看真实项目里怎么死人的。以下都是我或同事流过的泪,希望大家引以为戒。

线上环境调试:隔空取物术

最绝望的情况:本地一切正常,线上就是有问题。或者Bug只在特定用户那里出现,你根本接触不到那台设备。

方案1:代理工具(Charles/Fiddler/Whistle)

// 原理:把线上请求代理到本地,或者修改请求响应
// 比如线上接口返回的数据结构变了,但前端还没更新

// Charles配置:
// 1. Tools -> Map Remote,把线上API地址映射到本地服务
// 2. 或者Map Local,直接用本地JSON文件替换线上响应

// Whistle(推荐,更轻量):
// 安装:npm install -g whistle
// 启动:w2 start
// 配置规则:
// www.example.com/api/user file://./mock/user.json
// 这样访问线上域名时,/api/user会返回本地mock数据

// 手机调试时,设置代理为电脑IP:8899,就能抓包和修改

方案2:vConsole和Eruda(移动端调试神器)

// 线上H5页面白屏,但电脑模拟器正常?多半是某个API不支持
// 而手机浏览器又看不到控制台,这时候需要"移动端控制台"

// vConsole(微信出品,轻量)
// 安装:npm install vconsole
// 使用:
import VConsole from 'vconsole';
const vConsole = new VConsole();
// 页面上会出现一个按钮,点击展开控制台,看报错信息

// Eruda(功能更全,像桌面DevTools)
// 安装:npm install eruda
// 使用:
import eruda from 'eruda';
eruda.init();
// 有Elements、Console、Network、Resources面板,几乎和桌面一样

// 生产环境建议只在特定条件下加载
if (location.search.includes('debug=1') || localStorage.getItem('debug')) {
    import('vconsole').then(VConsole => {
        new VConsole.default();
    });
}
// 这样平时用户看不到,需要调试时加?debug=1或设localStorage

方案3:Sentry等错误监控平台

// 被动等待用户报错,不如主动收集
// Sentry可以捕获线上错误,包括堆栈、用户信息、浏览器环境等

// 安装:npm install @sentry/browser
// 配置:
import * as Sentry from '@sentry/browser';

Sentry.init({
    dsn: 'your-dsn-url',
    environment: 'production',
    release: '1.0.0', // 对应你的版本号,方便回溯
    beforeSend(event) {
        // 可以在这里过滤敏感信息
        if (event.exception) {
            // 添加额外上下文
            event.extra = {
                ...event.extra,
                userActions: getRecentActions() // 记录用户最近操作
            };
        }
        return event;
    }
});

// 主动上报
try {
    riskyOperation();
} catch (err) {
    Sentry.captureException(err, {
        tags: { section: 'checkout' },
        extra: { cartId: currentCart.id }
    });
}

// 结合Source Map,Sentry能直接显示原始代码的报错位置
// 需要在构建时上传Source Map到Sentry
// webpack配置:
const SentryWebpackPlugin = require('@sentry/webpack-plugin');
module.exports = {
    plugins: [
        new SentryWebpackPlugin({
            authToken: process.env.SENTRY_AUTH_TOKEN,
            org: 'your-org',
            project: 'your-project',
            include: './dist',
            release: process.env.RELEASE_VERSION
        })
    ]
};

跨域问题的临时绕过

开发时经常遇到:本地localhost:3000,要调后端api.company.com,浏览器直接拦截。

// 方案1:开发服务器代理(推荐,不影响后端)
// vite.config.js
export default {
    server: {
        proxy: {
            '/api': {
                target: 'http://api.company.com',
                changeOrigin: true,
                // 可以重写路径
                rewrite: (path) => path.replace(/^\/api/, '')
            }
        }
    }
};

// webpack devServer配置类似
module.exports = {
    devServer: {
        proxy: {
            '/api': {
                target: 'http://api.company.com',
                changeOrigin: true,
                pathRewrite: { '^/api': '' }
            }
        }
    }
};

// 这样前端代码里写fetch('/api/user'),实际会代理到后端

// 方案2:后端开启CORS(需要后端配合)
// 如果后端是Node.js,加个头:
app.use((req, res, next) => {
    res.header('Access-Control-Allow-Origin', '*'); // 生产环境别用*,指定具体域名
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
    res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    next();
});

// 方案3:浏览器插件临时禁用CORS(仅本地开发)
// Chrome插件:CORS Unblock 或 Allow CORS
// 一键开启,方便但危险,记得用完关掉

// 方案4:修改hosts文件,让本地域名看起来像线上
// 127.0.0.1 local.company.com
// 然后访问local.company.com:3000,和后端同域
// 需要配置devServer的allowedHosts或disableHostCheck

“在我本地是好的”——环境差异排查

这是最经典的甩锅语录,但有时候是真的。常见差异:

// 1. Node版本差异
// 你本地Node 18,服务器Node 14,有些API不支持
// 比如Array.prototype.at()在Node 14就没有
// 解决:.nvmrc文件锁定版本,CI也用这个版本

// 2. 环境变量差异
// 本地.env文件里有配置,服务器上漏了
// 建议:启动时检查必需的环境变量
const requiredEnv = ['API_URL', 'DB_HOST', 'SECRET_KEY'];
requiredEnv.forEach(key => {
    if (!process.env[key]) {
        throw new Error(`缺少环境变量: ${key}`);
    }
});

// 3. 时区/时间差异
// 本地是北京时间,服务器是UTC,时间计算差8小时
// 用dayjs或date-fns处理时区,别自己算
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault('Asia/Shanghai');

// 4. 大小写敏感
// 本地Windows/Mac不区分大小写,服务器Linux区分
// 比如import './utils',实际文件是'./Utils.js'
// 本地能跑,服务器报错"Cannot find module"
// 解决:统一用驼峰命名,或者用lint规则强制检查

// 5. 缓存差异
// 本地有热更新,服务器是冷启动
// 或者CDN缓存了旧资源
// 解决:构建时加hash,或者清CDN缓存

// 调试技巧:在服务器上打印详细环境信息
app.get('/health', (req, res) => {
    res.json({
        nodeVersion: process.version,
        platform: process.platform,
        env: process.env.NODE_ENV,
        // 但别暴露敏感信息如数据库密码
        config: {
            apiUrl: process.env.API_URL,
            // 其他非敏感配置
        }
    });
});

几个让老板觉得你技术很深的小技巧

这部分纯属锦上添花,但有时候"看起来专业"和"真的专业"一样重要(别打我,我说的是职场生存术)。

黑盒脚本(Blackbox)

调试时最烦人的是什么?你点进一个函数,结果跳到了Vue/React的源码里,或者跳到了lodash的内部实现。你本来只想看自己的代码逻辑。

// 在Sources面板,右键那个你不关心的文件 -> Blackbox script
// 或者在设置里配置Blackbox patterns:
// 添加 "node_modules/" 和 "webpack://"
// 这样Step Into时,会自动跳过这些库的内部,只在你自己的代码里断

// 效果:
function myFunction() {
    const result = _.map(data, item => { // 在这里Step Into
        return transform(item); // 会直接进入这里,而不是lodash内部
    });
}

// 也可以临时blackbox:
// 在Call Stack里,右键某个帧(比如vue.runtime.esm.js),选择Blackbox

自定义Console样式

前面提过一点,这里再深入下。你可以做出很炫酷的日志输出:

// 基础样式
console.log('%c 成功 ', 'background: green; color: white; padding: 2px 5px; border-radius: 3px;', '操作已完成');

// 高级玩法:CSS渐变(现代浏览器支持)
console.log('%c 警告 ', 
    'background: linear-gradient(to right, #ff9966, #ff5e62); color: white; font-weight: bold; padding: 3px 10px; border-radius: 5px;',
    '内存使用率过高'
);

// 甚至可以带图标(用Unicode)
console.log('%c ⚡ 性能提示 ', 
    'background: #4CAF50; color: white; font-size: 14px; padding: 5px;',
    '渲染时间超过16ms'
);

// 封装一个日志工具
const logger = {
    success: (msg) => console.log(`%c ✓ ${msg} `, 'background: #4CAF50; color: white;'),
    error: (msg) => console.log(`%c ✗ ${msg} `, 'background: #f44336; color: white;'),
    warning: (msg) => console.log(`%c ⚠ ${msg} `, 'background: #ff9800; color: white;'),
    info: (msg) => console.log(`%c ℹ ${msg} `, 'background: #2196F3; color: white;'),
    
    // 分组展示对象
    group: (label, data) => {
        console.group(`%c ${label} `, 'background: #9c27b0; color: white;');
        console.log(data);
        console.groupEnd();
    }
};

// 使用
logger.success('数据保存成功');
logger.error('网络请求失败');
logger.group('用户信息', { name: '张三', age: 25 });

XHR断点拦截Mock数据

不用改代码,不用等后端,前端自己就能Mock。

// 在Sources面板的XHR/fetch Breakpoints里,添加URL匹配:api/orders

// 当请求匹配时,会断住。这时候在Console里执行:
fetch('/api/orders').then(res => res.json()).then(data => {
    // 这里被断住了
    // 你可以修改data,然后让程序继续
    // 但更简单的是用Overrides功能(前面提过)
});

// 或者使用Chrome的Local Overrides:
// 1. Network面板找到请求,右键Save for overrides
// 2. 修改保存的JSON文件
// 3. 刷新页面,浏览器会用本地文件代替真实请求

// 更高级的:用Service Worker拦截
// 注册一个SW,拦截特定请求返回Mock数据
// 适合需要持久化Mock,或者团队共享Mock场景

录制用户操作

这个功能可以记录用户的所有操作(点击、输入、滚动等),然后重放。甩锅神器。

// Chrome DevTools -> Performance面板 -> 点击录制按钮旁边的"Web Vitims"(其实是Performance insights)
// 或者更简单:在Console输入
const recorder = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log('性能条目:', entry);
    }
});
recorder.observe({ entryTypes: ['measure', 'navigation'] });

// 但更好的工具是Chrome的"Recorder"面板(较新版本才有)
// 1. 打开Recorder面板
// 2. 点击开始录制
// 3. 在页面上操作
// 4. 停止录制,会生成一个可重放的脚本
// 5. 可以导出为Puppeteer或@devtools/record脚本

// 这样当测试说"这里有Bug"时,你可以说"把录制文件发我"
// 而不是"我怎么复现不了"

最后唠两句,Bug是写不完的,但心态不能崩

写到这里,我想起刚入行时 mentor 跟我说的一句话:"写代码就是写Bug,区别在于有的Bug你能找到,有的不能。"当时觉得挺丧的,现在觉得是大实话。

有些Bug,缘分未到

真的,有些问题你死磕三小时没头绪,出去抽根烟、喝杯水、或者睡一觉,回来再看,卧槽这明显是少写了个括号啊。这不是玄学,是大脑的后台进程在跑。你一直盯着,前台CPU占用100%,后台没资源了。放空一下,后台开始工作,反而能串起来。

所以遇到卡壳的Bug,设个时间限制,比如30分钟没进展就:

  1. 在代码里加TODO和FIXME注释,记录当前思路
  2. 提交到分支,写清楚复现步骤
  3. 去干点别的,或者摇人

求助不丢人

别觉得问别人显得自己菜。真正菜的是那种闷头搞一整天,最后延期还不好意思说的人。而且很多时候,你描述问题的过程就是梳理思路的过程,还没等对方回答,你自己就想通了(这就是前面说的橡皮鸭调试法的人类版)。

能跑就行,别追求完美

我见过有人为了"优雅地"解决一个Bug,引入了整个设计模式,加了三层抽象,最后Bug是解决了,但代码变成了一坨谁也看不懂的东西。何必呢?先让它跑起来,再考虑优化。记住:

“能跑起来的代码就是好代码,哪怕它长得像坨屎。不能跑的代码,架构再优雅也是废物。”

当然,这不是让你一直写屎山,而是说调试阶段别纠结代码好不好看,先找到问题。重构是下一步的事。

最后的最后

调试能力其实是开发能力的一部分,而且是很重要的一部分。写代码谁不会?但能在Bug堆里快速定位、解决、并且不引入新问题,这才是老手和新手的区别。

希望这篇"保命指南"能让你下次遇到红色报错时,少慌一点,多几分从容。毕竟,咱们前端er的命也是命啊。

(全文完,共计约7200字)

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值