上周同事把一个 bug 甩到我桌上:某页面点击“计数”按钮,界面上永远显示 NaN,控制台却一个红字都没有。我第一反应不是去追逻辑,而是问了一句:代码里开严格模式了吗?他说没有。结果我把文件头部加上'use strict'之后,不到半分钟,一个 ReferenceError 直接蹦出来,问题瞬间定位。这个场景恰好串起了 JavaScript 里三个经常被讲但很少被讲透的机制:错误、变量提升、严格模式。离谱的是,它们仨单独拆开人人都懂,组合起来却能制造出最隐蔽的那类“不报错但结果错”的线上事故。
这篇文章不打算按教科书的顺序讲。我会以“为什么代码没报错,结果却完全不对”为主线,把错误体系、变量提升、严格模式是怎么单独运转的、又是怎么互相掩盖的、以及我在真实项目里如何靠它们定位一个诡异 NaN 的完整链路,一次说清楚。适合刚入门但被这三个概念绕晕的 JS 初学者,也适合写了几年代码、觉得“自己懂但说不透”的进阶开发者。
1. 错误体系:先分清“会报错的”和“不报错但错了的”
很多人一提 JavaScript 错误就想到 try/catch,好像错误处理就是“把代码包起来”。但实际排查问题的时候,第一步不是看 catch,而是先问:这个错误到底是哪一类?它发生在什么阶段?它有没有可能根本不抛出来?
1.1 JavaScript 内置错误类型全家福
JavaScript 把运行时错误分成了几个内置类型,每一种出现的场景其实都很固定。我直接列一个表,方便你对照:
| 错误类型 | 典型触发场景 | 通俗解释 |
|---|---|---|
| SyntaxError | 写错语法、括号不匹配、'use strict'后仍出现八进制字面量 | 语法错误,代码根本没资格运行 |
| ReferenceError | 访问未申明变量、TDZ 期间访问 let/const 变量 | “你引用的东西不存在” |
| TypeError | 对 undefined/null 取属性、不是函数却当作函数调用 | “这个东西不是你以为的类型” |
| RangeError | 数组长度传负数、递归栈溢出、toFixed 位数超范围 | 数值越界 |
| URIError | decodeURIComponent 传入非法编码 | 编码格式不对 |
| AggregateError | Promise.any 里所有 Promise 都失败 | 多个错误被聚合在一起 |
最容易被忽略的是 SyntaxError 和其他错误的区别。SyntaxError 发生在解析阶段,也就是代码一行都没跑之前,引擎就直接罢工了。这种错误反而好修,因为编译器会指出行号。真正让开发者头痛的是运行时错误——代码能跑,跑到某一刻炸了,而且炸的位置往往不是问题根源。
1.2 错误对象里能榨出多少现场信息
捕获错误之后,很多人习惯直接console.log(err)了事,实际上一个 Error 对象里至少有四个信息值得你全部挖干净:
name:错误类型,比如ReferenceError、TypeError。message:人类可读的描述,比如total is not defined。stack:调用栈,V8 等主流引擎都提供,定位问题最核心的字段。cause:ES2022 新增的属性,用来链接底层原因。
想给错误加上业务信息,最实用的做法是继承 Error 写一个子类:
class UserInputError extends Error { constructor(message, code) { super(message); this.name = 'UserInputError'; this.code = code; } } throw new UserInputError('用户名不能包含特殊字符', 'INVALID_NAME');这样 catch 到之后可以根据err.name或err.code做分支处理。很多团队的上报系统只能看到message,排查的时候信息量完全不够——所以我在项目里统一要求:上报错误时必须带name、stack和自定义上下文,三样缺一不可。
1.3 最棘手的其实是不报错的问题
如果你在真实项目里排查过线上 bug,会发现一个扎心的事实:大部分最难的问题不是“抛异常”,而是“一切都正常但结果不对”。比如:
let total = undefined; console.log(total + 1); // NaN,但没有抛任何错误NaN在 JavaScript 里是个合法值,运算完全“成功”了,只是结果不是人话。再比如空 catch:
try { JSON.parse(input); } catch (e) { // 什么都不做 }解析失败了,catch 之后却什么都没干,下游继续拿着 undefined 运算。这种“静默失败”比显式错误可怕得多——错误至少给了你线索,静默失败连线索都没有。所以真正优秀的排错思维,是先搞清楚:这段代码如果出问题,是会老老实实抛一个异常,还是会把错误吞进肚子里继续跑?后者才是需要你花精力去防的。
2. 变量提升:声明被“搬了家”,逻辑还留在原地
如果说错误是“显性的问题”,那变量提升就是“隐性的地雷”。它在绝大多数情况下不报错,但会让代码的语义完全偏离你的直觉。要理解这块,得先钻进 JavaScript 引擎的执行流程里。
2.1 创建阶段:引擎先扫一遍声明,再逐行执行
JavaScript 引擎在执行代码之前,会先做一个“创建阶段”。它会扫描当前作用域里的所有声明,把var变量和函数声明预先注册好。你可以想象一场会议:会前工作人员先把座位表上每个人的名字都贴好,至于谁几点发言、说什么内容,会议开始后才揭晓。
这个过程带来的直接表现是:
console.log(name); // undefined,而不是报错 var name = 'tom';因为引擎在执行第一行之前,已经看到了后面的var name,并且把它初始化成了undefined。所以console.log(name)不会报 ReferenceError,只会打印undefined。看起来就像“变量被提升到了顶部”。
2.2 var、函数声明、let/const 的三种待遇
三种声明方式在创建阶段的行为不一样,我直接列个表:
| 声明方式 | 是否提升 | 是否初始化 | 提升后访问结果 |
|---|---|---|---|
var x | 是 | 是,初始化为 undefined | undefined |
function foo(){} | 是,整个函数体提升 | 是 | 可直接调用 |
let x | 是 | 否,进入暂时性死区 | 抛 ReferenceError |
const x | 是 | 否,进入暂时性死区 | 抛 ReferenceError |
注意一个容易踩坑的细节:let和const其实也“提升”了,只不过它们在被正式初始化之前处于“暂时性死区”(TDZ),任何访问都会抛 ReferenceError。所以准确的说法不是“let 不提升”,而是“let 提升了,但访问被禁止”。这个区别在typeof面前尤其明显:
console.log(typeof notExist); // "undefined",typeof 对未声明变量有特殊豁免 console.log(typeof later); // ReferenceError: Cannot access 'later' before initialization let later = 1;typeof对“完全没声明”的变量反而安全,对“声明了但处于 TDZ”的变量却会报错。很多老手都会在这里翻车,值得记一下。
2.3 提升引发的两个经典现场
第一个经典现场是“局部变量遮蔽全局变量”。看这段代码:
var score = 10; function getScore() { if (!score) { var score = 100; } return score; } console.log(getScore()); // 100,而不是 10函数内部var score被提升到了函数顶部,直接遮蔽了全局 score。你本来想判断“如果全局 score 是 0,就给它赋值 100”,结果因为提升,score永远是函数局部的 undefined,判断条件永远成立。这段代码不会报任何错误,只是结果永远不符合预期。
第二个经典现场是 for 循环里的 var 陷阱:
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // 输出 3 3 3,而不是 0 1 2var没有块级作用域,三个定时器闭包捕获的是同一个i,循环结束后i已经是 3。这个坑在老代码里催生了无数个 IIFE 写法,现在直接用let就能彻底解决。
函数表达式也有类似的迷惑行为:
console.log(fn); // undefined,函数表达式不会像函数声明那样整体提升 var fn = function () {};这三个场景的共同点,我想你已经发现了:它们都不会报错。它们属于“合法但语义不符合预期”的范畴,这也是变量提升最阴险的地方。那么问题来了:严格模式能不能兜住这些坑?答案是:不能,严格模式只管另一类问题。
3. 严格模式:不新增功能,只把旧包袱变成报错
严格模式是 ES5 引入的“兼容性开关”。它不给你加任何新能力,而是把 JavaScript 早期一大堆“看起来能用但迟早出问题”的宽容行为,改成立刻报错。它本质上是在帮代码“大声说出问题”,而不是让问题继续哑巴吃黄连。
3.1 'use strict' 到底是怎么被触发的
最简单的开启方式是在脚本或函数顶部放一行字符串:
'use strict'; function fn() { 'use strict'; // 这里才生效 }但要注意它的位置规则:'use strict'必须出现在作用域的最前面,前面只能有注释,不能有其他语句。否则直接失效,而且不会提示你:
console.log('hello'); 'use strict'; // 无效,前面已经有语句了 total = 1; // 非严格模式下静默创建全局变量全局严格模式影响整个文件,适合现代新项目;函数级严格模式只影响单个函数,适合逐步改造老代码。如果你是老项目迁移,千万别直接给全文件加全局严格模式——很可能把第三方脚本的行为一起改变,引发莫名其妙的问题。稳妥做法是先用 IIFE 包住自己的逻辑:
(function () { 'use strict'; // 自己的代码 })();3.2 行为变化清单:从“容忍”到“报错”
严格模式改动的都是历史遗留的“宽容行为”,下面这张表基本覆盖了 90% 的开发场景:
| 行为 | 非严格模式 | 严格模式 |
|---|---|---|
| 未声明变量直接赋值 | 静默创建全局变量 | ReferenceError |
| 给只读属性赋值 | 静默失败,无提示 | TypeError |
| 删除不可删除的属性 | 返回 false | TypeError |
| 函数参数重名 | 后面的覆盖前面的 | SyntaxError |
| 函数调用时 this | 指向全局对象 | 普通函数调用时 this 为 undefined |
| 使用 with 语句 | 允许 | SyntaxError |
| eval 的作用域 | 变量会泄漏到外层 | 独立作用域,不泄漏 |
| 八进制字面量 010 | 等于十进制的 8 | SyntaxError |
其中两个坑我在团队里反复强调。第一个是未声明变量赋值:非严格模式下,total = 1会直接在全局对象上创建一个属性,当前代码和后续代码都把它当全局变量用,于是两个毫不相干的函数可能在同一个隐式全局变量上互相踩踏。第二个是普通函数的 this:非严格模式下,fn()里的 this 会指向全局对象,写代码的人很容易误以为 this 一定有用;严格模式下 this 变成 undefined,直接杜绝了这种误用。
3.3 模块、class 和现代工具链里的默认严格模式
很多人觉得严格模式是“老古董才需要的东西”,但实际恰恰相反。ES Module 内部默认就是严格模式,class 体内同样默认严格模式。换句话说,只要你用了import或者class,你写的每一行代码其实都已经跑在严格模式下,根本不需要手动加'use strict'。
这也是现代前端开发的真相:不是你要不要选严格模式,而是你只要没写“远古脚本”,就已经身处严格模式之中。那些还在手动拼接脚本、全靠全局变量通信的老页面,才是真正需要手动加严格模式来“体检”的重灾区。
4. 实测排错链路:一个 NaN 计数器背后的三个机制
讲完原理,来看一个我真实处理过的排错过程。这个 bug 最典型的地方在于,它把错误、变量提升、严格模式三个机制全部卷了进来,而且每一层都藏得特别深。
4.1 现象重现与第一轮排查
页面上有个计数按钮,逻辑简化之后是这样:
// app.js 历史遗留代码 function init() { total = 0; // 注意:这里没有 var/let/const } function increase() { total = total + 1; return total; } function saveLabel(label) { total = label; // 某个埋点功能,把字符串塞了进来 } init(); increase(); // 1 saveLabel('abc'); // total 变成了字符串 increase(); // 'abc1'界面一刷新,数字就变成了NaN或者直接带字母。最要命的是整个控制台完全没有任何报错,因为非严格模式下,未声明的total赋值会静默创建一个全局变量,而且total = 'abc'这种赋值也是完全合法的。
第一轮排查,我沿着代码搜了一眼,很快看到saveLabel里对total的赋值。问题的表面原因清楚了:字符串和数字相加变成了'abc1',最终解析成数字时失败变成NaN。但真正想问的是:为什么这个全局变量total没人声明,却能在这么多函数里畅通无阻?
4.2 加严格模式后,第一个 ReferenceError 出现了
我在文件头部加上'use strict',刷新页面,控制台立刻报错:
Uncaught ReferenceError: total is not defined报错位置正好是init()里的total = 0。严格模式把所有“未声明变量直接赋值”的行为全部拦截,这段代码里的隐式全局变量立刻现形。这时我才意识到:整个页面根本没有一个合法的total声明,它只存在于各个函数通过隐式赋值“无中生有”的全局属性里。之前能用,纯粹是非严格模式的宽容在兜底。
修复方式是把全局状态改成显式声明,并且收敛到单独模块:
// store.js export let total = 0; export function init() { total = 0; } export function increase() { total += 1; return total; }saveLabel里的赋值则被完全移除——它本来就不该去碰计数器的变量。
4.3 修复声明后又撞上变量提升的坑
隐式全局清理完之后,我以为问题到此为止,结果某个页面上计数按钮依然偶发 NaN。这次严格模式倒是没报错,因为它合法,就是语义不对。我加了日志在increase()入口打total,发现它居然是undefined。明明全局total已经初始化为 0 了,为什么函数里读到的却是 undefined?
顺着作用域链往上查,我找到了罪魁祸首——某个组件内部有一段老代码:
var total; function refresh() { total = total + 1; // 以为是全局 total,实际是局部遮蔽后的 NaN }这里函数作用域内的var total被提升,并且在创建阶段就初始化成了undefined。于是total = undefined + 1得到NaN。整个过程不违反任何规则,严格模式也不会管它,因为变量提升是“合法但反直觉”的语义。严格的讲,这段代码用了 var 在函数作用域里遮蔽了同名的模块全局变量,任何 lint 手段都能提前发现,但历史代码就是没人管。
4.4 根治方案与验证
最后我把函数内的var total改成let total并在使用前显式初始化,或者直接移除遮蔽变量,让函数明确引用外层导入的total。同时加了 ESLint 规则:
{ "rules": { "no-var": "error", "no-undef": "error" } }改完后回归测试,计数器在所有页面都稳定了。回头看整个排查链路,三个机制各自“贡献”了一环:
| 环节 | 背后机制 | 表现 |
|---|---|---|
| 字符串覆盖计数器 | 非严格模式的隐式全局 | 静默创建 total 属性,无报错 |
| 严格模式暴露根源 | 严格模式禁止未声明赋值 | ReferenceError 立刻定位 |
| 组件内 undefined | 变量提升导致局部遮蔽 | 合法但不报错,结果 NaN |
这个案子给我最大的启发是:排错时如果你只盯着“哪里报错了”,很容易漏掉真正的雷区。变量提升这种合法但不合理的坑,从来不会主动告诉你它存在,你必须靠严格模式、lint 规则和代码审查一起把它拦在门外。
5. 工程实践:把这三个机制变成团队的护城河
一次排错可以靠运气,但整个团队不踩坑,就得靠制度和工具。下面是我在自己的项目里沉淀下来的几条实操经验。
5.1 try/catch 的边界:能恢复才捕获,没把握就上报
很多人下意识给所有代码包一层 try/catch,觉得“不报错就行了”。但这恰恰是错误的源头。try/catch 的正确用法是:你明确知道这里可能出错,并且知道出错后该怎么恢复。如果 catch 住错误之后只是console.log一下然后继续跑,那你其实是在主动吞掉问题的线索。
我的判断标准很简单:catch 块里如果没有“恢复方案”,那这层 catch 就不该存在。最典型的例子是解析 JSON:
function safeParse(json, fallback) { try { return JSON.parse(json); } catch (e) { // 明确知道这里要兜底,才捕获 return fallback; } }对于“不知道该怎么处理”的错误,正确姿势是不拦截,让它冒泡到全局统一处理,该报警报警,该展示错误页展示错误页。
5.2 异步错误处理的分层方案
同步 try/catch 对异步代码基本没用,因为错误发生在另一个事件循环里。我的经验是分三层处理:
- Promise 链必须运行时捕获,所有 async 函数里的业务调用都用 try/catch 包住;
- 事件回调是错误“孤岛”,外层 try/catch 抓不到 setTimeout 里抛出的错,所以给外部回调包一层安全执行函数;
- 全局兜底监听
error和unhandledrejection事件,至少保证任何漏网之鱼都能进入上报系统。
window.addEventListener('unhandledrejection', (event) => { reportError(event.reason); }); function safeExecute(fn) { return function (...args) { try { return fn.apply(this, args); } catch (e) { reportError(e); } }; }这套组合拳之后,异步错误基本没有能逃过监控的。
5.3 变量声明的纪律:const 优先、let 次之、var 退役
变量提升的坑,最彻底的治疗方案就是不让var出现在代码里。var的作用域规则太宽松,函数级作用域加上提升机制,组合出来的坑多到数不清。我给团队的 ESLint 配置直接开了no-var,一切新代码用const为首选,只有确认值会变才用let。
函数声明本身可以用,但最好不要依赖“提升后再调用”的写法。现代模块系统支持静态分析,把函数定义和调用按依赖顺序写清楚,比靠运行时提升“碰巧能跑”要可靠得多。
5.4 自定义错误与统一上报的落地做法
最后,把错误变成一个团队都能读懂的格式。我推荐统一继承 Error 写业务错误类,附加 code 和上下文信息:
class AppError extends Error { constructor(message, code, context = {}) { super(message); this.name = 'AppError'; this.code = code; this.context = context; } } function reportError(error) { // 统一上报入口:name、stack、context 全部带上 fetch('/api/logs/error', { method: 'POST', body: JSON.stringify({ name: error.name, message: error.message, stack: error.stack, context: error.context || {}, url: location.href, }), }).catch(() => {}); }这样处理之后,线上错误不再是零散的一行 message,而是带着完整调用栈和业务上下文的事件。排查效率能提升好几个量级。
回头再看那天的 NaN,本质问题并不在某一行的写法,而是团队对“错误、变量提升、严格模式”这套组合机制没有统一认知。把显式错误处理干净、让严格模式尽早暴露隐患、用 const/let 根除提升干扰,这三件事做到位,JavaScript 里的隐性 bug 至少能少掉一大半。