1. 项目概述:为什么“JavaScript 面试陷阱题”不是考记忆力,而是考工程直觉

“JavaScript Interview Questions: Common Gotchas”——这个标题乍看像一份老生常谈的面试题集锦,但真正干过三年以上前端、参与过至少五轮技术终面、亲手写过生产环境内存泄漏代码的人会立刻意识到:它根本不是在问“var 和 let 有什么区别”,而是在模拟一个真实场景——你刚接手一个线上报错率飙升的遗留系统,控制台里满屏 undefined is not a function ,用户反馈点击按钮没反应,而问题就藏在某个被注释掉一半的 setTimeout 闭包里。我带过的实习生里,有两位名校毕业、算法题秒杀的候选人,在被问到“为什么这段 for 循环里所有 setTimeout 输出的都是 5?”时当场卡住,不是不会答,而是脑子里没有建立起“执行上下文栈”和“词法环境记录”的视觉化模型。这恰恰暴露了当前 JavaScript 学习的最大断层:我们花大量时间背诵“事件循环分宏任务微任务”,却从没在 Chrome DevTools 的 Sources 面板里,亲手单步调试过一个 Promise 链是如何被推入微任务队列的。标题里的 “Gotchas”(陷阱)二字,本质是工程经验的压缩包——它封装了变量提升导致的 hoisting 意外、暂时性死区引发的 ReferenceError、this 绑定在箭头函数与普通函数间的撕裂感、以及原型链污染带来的静默失败。这些不是知识点,而是你凌晨两点排查线上 bug 时,浏览器控制台里跳出来的幽灵。所以这篇内容不面向“想突击面试”的人,而是给那些已经写过上万行 JS、开始对 == === 的隐式转换感到生理不适、想把散落的经验碎片焊成系统认知的实战者。核心关键词 JavaScript Interview Questions Gotchas var let ,每一个都对应着一个真实踩坑现场: var 声明的变量在 if 块内能被访问,是因为函数作用域的宽松边界; let 在 for 循环中每次迭代生成新绑定,则是为了解决闭包捕获索引的经典灾难;而所谓“面试题”,不过是把生产环境里那些让监控告警疯狂闪烁的边缘 case,剥掉业务外壳后露出的裸露神经。

2. 核心设计思路:从“背答案”到“建模型”的认知升维

2.1 为什么传统面试题解析注定失效?

市面上绝大多数“JavaScript 面试题解析”文章,其底层逻辑是知识搬运:把 MDN 文档的定义抄下来,配上一段示例代码,再给出标准答案。这种模式在应付初级笔试时或许有效,但面对资深面试官一句“如果我把 let 换成 var ,这段代码在 Node.js v14 和 v18 下行为是否一致?为什么?”,立刻原形毕露。问题出在认知模型上——你记住了“ let 不会变量提升”,但没理解 V8 引擎如何在编译阶段为 let 声明的变量分配“未初始化”状态,并在运行时检查该状态;你背下了“ var 会提升”,却不清楚引擎在创建执行上下文时,如何将 var 变量初始化为 undefined 并挂载到词法环境记录中。真正的“陷阱”从来不在语法表面,而在引擎执行的微观路径上。因此,本内容的设计起点不是罗列题目,而是构建一个可验证、可调试、可迁移的认知框架: 以 Chrome DevTools 为实验场,用单步调试代替死记硬背,用内存快照分析代替概念复述,用真实错误堆栈反向推导执行流 。比如,当遇到 ReferenceError: Cannot access 'x' before initialization ,与其背诵“暂时性死区”,不如直接在 Sources 面板打断点,观察 Scope 面板里 x 的值为何显示为 <uninitialized> ,再对比 var x 时该位置显示为 undefined 。这种基于工具的实证过程,才是对抗“陷阱”的终极武器。

2.2 方案选型:为什么聚焦 var / let / const 这组基石?

标题中明确点出 var let ,这不是偶然。这组声明方式是 JavaScript 执行模型最锋利的解剖刀。 var 代表旧时代的函数作用域与宽松提升, let / const 则是块级作用域与严格初始化的现代范式。它们的碰撞点,正是所有“陷阱”的高发区。选择它们作为主线,是因为:

  • 覆盖度最高 :90% 以上的经典陷阱题(如循环闭包、重复声明、TDZ)都围绕这三者展开;
  • 可验证性最强 :每个差异都能在 DevTools 中实时观测,Scope 面板清晰显示变量状态,Call Stack 显示执行上下文切换;
  • 迁移价值最大 :理解了 let 的块级绑定机制,就能自然推导出 for...of 循环中 const 声明的每次迭代为何安全;掌握了 var 的函数作用域,就能预判 IIFE(立即执行函数表达式)为何曾是模块化的救命稻草;
  • 工程关联最紧 :现代框架(React/Vue)的响应式原理、状态管理库(Redux/Zustand)的更新机制,其底层都依赖对词法环境与闭包的精准操控,而 let / const 是构建这种精确性的语法基石。

放弃讲解 class 语法糖或 Proxy 陷阱,并非因其不重要,而是因为它们属于“上层建筑”,而 var / let 是地基。地基不稳,任何上层结构都可能在某个 this 绑定的瞬间轰然倒塌。

2.3 结构设计:拒绝线性罗列,采用“问题驱动-原理深挖-工具实证”三维闭环

传统题集按“作用域→闭包→this→原型”线性排列,看似系统,实则割裂。一个真实的 this 陷阱,往往同时牵扯作用域链查找、箭头函数的词法绑定、以及 call / apply 的显式绑定优先级。因此,本内容采用问题驱动的螺旋式结构:

  • 第一层:问题现场 ——呈现一个极简但致命的代码片段,如 for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); } ,并给出其反直觉输出(全部输出 3);
  • 第二层:原理深挖 ——不直接给答案,而是拆解 V8 执行流程: var i 如何被提升至函数顶部并初始化为 undefined for 循环体如何被当作单一执行单元处理; setTimeout 回调如何在循环结束后才执行,此时 i 已变为 3;为何 let i 能解决此问题(每次迭代创建新绑定);
  • 第三层:工具实证 ——指导你在 Chrome 中设置断点,观察 Scope 面板中 i 的值变化,用 Performance 面板录制事件循环,定位 setTimeout 回调的入队时机。

这种三维闭环,确保每个“陷阱”都不是孤立的知识点,而是一个可触摸、可调试、可举一反三的工程实体。它迫使你从“我知道答案”升级为“我能现场证明”。

3. 核心细节解析: var let const 的微观世界与宏观影响

3.1 var :函数作用域的“宽松公民”及其代价

var 声明的变量,其生命周期由函数作用域决定,而非代码块。这意味着在 if for while 等块内声明的 var 变量,其声明会被提升(hoist)至所在函数的顶部,并初始化为 undefined 。这种设计源于 JavaScript 早期对“简单性”的追求,但它埋下了无数隐患。例如:

function example() {
  console.log(a); // undefined
  var a = 1;
  if (true) {
    var b = 2;
  }
  console.log(b); // 2 —— b 在 if 块外仍可访问
}
example();

这里的关键在于“提升”的实质:V8 引擎在进入 example 函数执行上下文时,会扫描所有 var 声明,为其在词法环境记录(Lexical Environment Record)中分配内存空间,并统一初始化为 undefined console.log(a) 能执行而不报错,正是因为 a 已存在,只是值为 undefined 。而 b if 块内声明,却能在块外访问,是因为 var 的作用域边界是函数,不是 {} 。这种宽松性在大型项目中极易导致命名冲突和意外覆盖。我曾维护过一个 5000 行的旧版购物车脚本,其中 var total 被分散在 7 个不同函数中声明,当某次重构合并时,一个 var total = 0 的初始化语句被误删,导致整个结算流程因 total undefined 而静默失败,错误日志里只有一行 NaN ,排查耗时两天。 var 的另一个代价是全局污染。在非严格模式下,全局作用域中的 var 声明会成为 window 对象的属性:

var globalVar = "I'm global";
console.log(window.globalVar); // "I'm global"

这使得全局变量极易被第三方脚本或恶意代码篡改,是 XSS 攻击的温床之一。现代模块化开发(ESM)虽已隔离全局作用域,但 var 的历史包袱仍在大量遗留代码中作祟。

3.2 let const :块级作用域的“严谨卫士”与 TDZ 的铁壁

let const 的引入,是对 var 缺陷的系统性修正。它们共享块级作用域(Block Scope),即变量仅在声明它的 {} 代码块内有效。更重要的是,它们引入了“暂时性死区”(Temporal Dead Zone, TDZ)——从块开始到变量声明语句执行前的区域,对该变量的任何访问(读或写)都会抛出 ReferenceError 。这并非语法限制,而是引擎的主动防护机制。以 let 为例,其执行流程分为三步:

  1. 声明阶段 :在进入块时,引擎在词法环境记录中为 let 变量预留槽位,但标记为 <uninitialized>
  2. 初始化阶段 :执行到 let x = 1 语句时,才将 1 写入该槽位;
  3. 访问阶段 :只有在初始化完成后,变量才可被安全访问。
{
  console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 1;
}

在 Chrome DevTools 的 Sources 面板中,若在此处设置断点,Scope 面板会清晰显示 x 的值为 <uninitialized> ,这是 TDZ 最直观的证据。 const 的规则更严格:它不仅要求 TDZ 安全,还禁止重新赋值。但需注意, const 仅保证绑定不可变,而非值不可变。对于对象或数组,其内部属性仍可修改:

const obj = { name: "Alice" };
obj.name = "Bob"; // 合法,obj 引用未变
obj = {}; // TypeError: Assignment to constant variable.

let / const 的块级作用域,彻底解决了 var 的循环闭包陷阱。经典的 for 循环问题:

// 使用 var —— 全部输出 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

// 使用 let —— 正确输出 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}

其原理在于: let i 在每次 for 迭代开始时,都会在当前迭代的块级词法环境中创建一个 全新的绑定 (binding)。 setTimeout 回调捕获的,是各自迭代中独立的 i 绑定,而非共享的同一个变量。这背后是 V8 引擎对 let 声明的特殊处理——它为每次迭代生成独立的词法环境记录,而非复用同一记录。这种机制,是现代 JavaScript 构建可靠异步逻辑的底层保障。

3.3 this 绑定:箭头函数的“词法继承”与普通函数的“动态绑定”撕裂

this 的混乱,是 JavaScript 最著名的“陷阱”源泉。其根源在于 this 的值并非由函数定义位置决定,而是由 函数调用方式 动态确定。 var / let / const 的差异,直接影响 this 的绑定行为。普通函数(function declaration/expression)的 this 绑定遵循四条规则:

  • 默认绑定 :独立调用时(如 foo() ),非严格模式下 this 指向 window ,严格模式下为 undefined
  • 隐式绑定 :作为对象方法调用时(如 obj.foo() ), this 指向该对象;
  • 显式绑定 :通过 call / apply / bind 强制指定 this
  • new 绑定 :使用 new 调用时, this 指向新创建的实例。

而箭头函数( => )则完全不同:它 没有自己的 this ,而是从外层词法作用域继承 this 值。这种“词法继承”是 let / const 块级作用域思想的延伸——它将 this 视为一个需要被词法环境捕获的变量,而非动态绑定的上下文。这导致了一个经典陷阱:

function Person() {
  this.age = 0;
  // 错误:setInterval 回调是普通函数,this 指向全局
  setInterval(function growUp() {
    this.age++; // this.age 是 NaN,因为 this 是 window
  }, 1000);

  // 正确:箭头函数继承 Person 构造函数的 this
  setInterval(() => {
    this.age++; // this 指向 Person 实例
  }, 1000);
}

这里的 this 陷阱,本质上是作用域模型的冲突: var 声明的 growUp 函数拥有独立的执行上下文,其 this 与外层无关;而箭头函数则像 let 变量一样,被“捕获”进外层词法环境。在 React 类组件中,这一差异更为致命。若在 render 方法中为按钮写 onClick={function() { this.setState(...) }} this 将丢失,必须用箭头函数或 bind 。而函数组件配合 Hooks,则天然规避了此问题,因为 useState 的 setter 函数本身就是一个闭包,其 this 语义已被函数式编程范式消解。理解 this 的撕裂,就是理解 JavaScript 从命令式到函数式演进的阵痛。

3.4 作用域链与闭包: var 的“宽泛”与 let 的“精确”如何塑造内存模型

作用域链(Scope Chain)是 JavaScript 解析标识符的路径,它由当前执行上下文的词法环境记录及其外层环境记录链接而成。 var 的函数作用域,使得作用域链相对扁平;而 let / const 的块级作用域,则催生了更深层、更精细的作用域链。闭包(Closure)正是这种作用域链的产物:当一个函数引用了其词法作用域外的变量,并被返回或传递到外部时,该变量的词法环境记录会被保留,形成闭包。 var 的宽松性,常常导致闭包捕获到“过期”的变量值。例如:

function createFunctions() {
  var result = [];
  for (var i = 0; i < 3; i++) {
    result[i] = function() {
      return i; // 所有函数都捕获同一个 i 变量
    };
  }
  return result;
}
var funcs = createFunctions();
funcs[0](); // 3
funcs[1](); // 3

这里, result 数组中的三个函数,都共享同一个 i 变量(由 var 声明),当循环结束时, i 的值为 3 ,因此所有函数都返回 3 。而 let 的块级绑定,为每次迭代创建独立的 i 绑定,从而生成真正的闭包:

function createFunctions() {
  var result = [];
  for (let i = 0; i < 3; i++) {
    result[i] = function() {
      return i; // 每个函数捕获各自迭代的 i
    };
  }
  return result;
}
var funcs = createFunctions();
funcs[0](); // 0
funcs[1](); // 1

这种差异直接影响内存占用。 var 版本中,只有一个 i 变量被长期持有; let 版本中,三个独立的 i 绑定被三个闭包分别持有,内存占用更高,但语义更精确。在大型应用中,滥用 var 闭包可能导致内存泄漏——一个本应被垃圾回收的 DOM 元素,因其事件处理器闭包中引用了 var 声明的变量,而无法被释放。Chrome 的 Memory 面板中,通过 Heap Snapshot 对比,可以清晰看到 var 闭包持有的变量数量远超 let 闭包,这是优化性能的关键切入点。

4. 实操过程:用 Chrome DevTools 亲手“看见”陷阱的诞生与消亡

4.1 实战一:单步调试 var 提升与 let TDZ 的微观差异

让我们用 Chrome DevTools 亲手验证 var let 的执行差异。打开任意网页,按 F12 进入 DevTools,切换到 Sources 面板,点击右上角 {} 图标美化代码(若为压缩版),然后在 Console 中粘贴以下代码:

function testHoisting() {
  console.log("Before var:", a); // 断点1
  var a = 1;
  console.log("After var:", a); // 断点2
  
  console.log("Before let:", b); // 断点3
  let b = 2;
  console.log("After let:", b); // 断点4
}
testHoisting();

console.log("Before var:", a) 这一行左侧灰色区域点击,设置断点1。刷新页面,代码将在断点1暂停。此时,打开右侧的 Scope 面板,你会看到 Local 作用域下, a 已存在,其值为 undefined 。这就是 var 提升的铁证——变量已声明,但尚未初始化。继续按 F8 (Resume script execution),代码执行到 console.log("After var:", a) ,此时 a 的值变为 1

接着,在 console.log("Before let:", b) 设置断点3。再次刷新,代码会在断点3暂停。此时,Scope 面板的 Local 作用域下, 根本找不到 b 这个变量 !这是因为 let 声明尚未执行,变量还未被创建。按 F8 继续执行,代码会立即抛出 ReferenceError: Cannot access 'b' before initialization ,并在 Console 中高亮显示错误行。这完美印证了 TDZ 的存在:在 let b = 2 执行前, b 不仅不可读,甚至不存在于作用域中。这种“眼见为实”的调试,比任何文字描述都更具冲击力。

4.2 实战二:可视化 for 循环中 var let 的闭包绑定

现在,我们深入 for 循环的闭包陷阱。在 Sources 面板新建一个空白文件( + 号 -> New snippet ),命名为 loop-closure ,粘贴以下代码:

function testLoopClosure() {
  console.log("=== Using var ===");
  for (var i = 0; i < 3; i++) {
    setTimeout(() => {
      console.log("var i:", i); // 断点A
    }, 0);
  }

  console.log("=== Using let ===");
  for (let j = 0; j < 3; j++) {
    setTimeout(() => {
      console.log("let j:", j); // 断点B
    }, 0);
  }
}
testLoopClosure();

console.log("var i:", i) console.log("let j:", j) 两行分别设置断点A和断点B。刷新页面,由于 setTimeout 是异步的,代码会先执行完 testLoopClosure 函数,然后才触发回调。因此,你需要耐心等待,直到第一个 setTimeout 回调在断点A暂停。此时,观察 Scope 面板:

  • Local 作用域下, i 的值为 3 (循环已结束);
  • Closure 作用域下,有一个名为 testLoopClosure 的闭包,其内部 i 的值也为 3

这证明了 var i 的单一绑定被所有回调共享。按 F8 继续,第二个回调在断点A暂停, Local Closure i 的值依然是 3 。同理,第三个回调亦如此。

接着,等待第一个 let j 的回调在断点B暂停。此时,Scope 面板的 Closure 作用域下,会出现一个名为 j 的闭包,其值为 0 。按 F8 继续,第二个回调暂停时, Closure j 的值变为 1 ;第三个为 2 。这清晰地展示了 let 为每次迭代创建独立绑定的机制。你可以点击 Closure 旁的 > 展开,看到每个闭包的完整词法环境记录,这是理解闭包内存模型的黄金入口。

4.3 实战三:Performance 面板追踪 setTimeout 的事件循环入队时机

要彻底理解 for 循环陷阱,必须看到 setTimeout 回调何时被推入任务队列。打开 Performance 面板,点击左上角的圆形录制按钮(●),然后在 Console 中执行 testLoopClosure() 。等待几秒后,点击停止录制(□)。在下方的火焰图(Flame Chart)中,找到 Timer Fired 事件,展开它,你会看到:

  • 所有 setTimeout 回调都被归类在 setTimeout 事件下;
  • 它们的开始时间(Start Time)几乎完全重叠,且都在 testLoopClosure 函数执行完毕之后;
  • 每个回调的调用栈(Call Stack)都指向 setTimeout 的注册点,而非 for 循环内部。

这证实了关键一点: setTimeout 的回调不是在循环体内“即时”注册的,而是在循环结束后,作为一个整体被推入宏任务队列。 var 的单一 i 变量,在循环结束时已定格为 3 ,因此所有回调都读取到这个最终值。而 let 的每次迭代绑定,则确保了每个回调读取到各自迭代时的 i 值。Performance 面板的可视化,将抽象的“事件循环”概念,变成了可测量、可分析的工程数据。

4.4 实战四:Memory 面板对比 var let 闭包的内存占用

最后,我们量化 var let 闭包的内存差异。在 Sources 面板创建新 snippet memory-comparison

function createVarClosures() {
  var closures = [];
  for (var i = 0; i < 1000; i++) {
    closures.push(function() { return i; });
  }
  return closures;
}

function createLetClosures() {
  var closures = [];
  for (let i = 0; i < 1000; i++) {
    closures.push(function() { return i; });
  }
  return closures;
}

// 执行并保留引用,防止 GC
window.varClosures = createVarClosures();
window.letClosures = createLetClosures();

执行此代码。然后切换到 Memory 面板,点击 Take heap snapshot 拍摄快照。在快照列表中,点击刚生成的快照,然后在搜索框输入 closure ,查看 Closure 类型的对象数量。你会发现 letClosures 生成的闭包数量远多于 varClosures ,因为每个 let 迭代都创建了独立的词法环境。再搜索 i ,可以看到 varClosures 中只有一个 i 变量被所有闭包共享,而 letClosures 中有 1000 个独立的 i 变量。这解释了为何 let 版本内存占用更高——它用空间换来了时间上的精确性。在内存敏感的场景(如长时间运行的 Electron 应用),这种权衡必须被工程师主动评估。

5. 常见问题与排查技巧实录:来自生产环境的 7 个血泪教训

5.1 问题速查表:高频陷阱与一键诊断法

问题现象 可能原因 一键诊断法 快速修复
ReferenceError: xxx is not defined let / const 在 TDZ 内访问; var 声明遗漏 在报错行设断点,检查 Scope 面板是否存在该变量 确保变量在访问前已声明;用 var 替代(不推荐)或调整声明顺序
undefined is not a function var 声明的函数被提升,但赋值语句未执行; this 绑定丢失导致方法未定义 查看 Call Stack,确认调用栈中 this 指向;检查函数是否为 undefined 使用函数声明( function foo(){} )替代函数表达式;用箭头函数或 .bind(this) 修复 this
NaN Infinity 计算结果异常 var 声明的数值变量被意外覆盖为字符串或 undefined 在计算前设断点,用 typeof 检查变量类型 使用 let / const 限定作用域;添加类型校验 if (typeof x === 'number')
setTimeout / setInterval 回调输出相同值 var 循环变量被共享 在回调内设断点,检查 i 的值 var i 改为 let i ;或用 IIFE 包裹 setTimeout
控制台报 Uncaught SyntaxError: Identifier 'xxx' has already been declared let / const 重复声明; var let 同名声明 搜索整个文件,定位重复声明行 删除重复声明;统一使用 let / const
this 指向 window undefined 普通函数独立调用;事件处理器中 this 未绑定 在函数内 console.log(this) 使用箭头函数;或 element.addEventListener('click', handler.bind(this))
内存占用持续增长,GC 频繁 var 闭包持有大对象; setTimeout 未清除 使用 Memory 面板拍摄快照,筛选 Detached DOM tree 清除无用定时器 clearTimeout(id) ;用 let 替代 var 减少闭包持有

5.2 血泪教训一:“ var 声明的函数提升”导致的静默失败

去年,我负责一个金融风控后台的重构。一个核心的 calculateRiskScore 函数,被分散在多个 if 分支中定义:

function processLoan(loan) {
  if (loan.type === 'mortgage') {
    var calculateRiskScore = function() { /* ... */ };
  } else if (loan.type === 'car') {
    var calculateRiskScore = function() { /* ... */ };
  }
  // ... 其他逻辑
  return calculateRiskScore(loan); // 报错:TypeError: calculateRiskScore is not a function
}

问题在于, var calculateRiskScore 的声明被提升至 processLoan 函数顶部,但赋值语句并未提升。因此,当 loan.type 既不是 'mortgage' 也不是 'car' 时(如 'student' ), calculateRiskScore 变量存在,但值为 undefined ,调用时自然报错。更糟的是,这个分支在测试用例中从未覆盖,上线后用户提交学生贷款申请时,整个风控流程静默失败,日志里只有一行 Cannot read property 'call' of undefined 教训 :永远不要在条件分支中用 var 声明函数。修复方案是:要么用函数声明( function calculateRiskScore(){} ,它会被完全提升),要么用 let 声明并确保所有分支都有赋值,或者——最好的方案——将函数提取为独立的、命名清晰的模块导出。

5.3 血泪教训二:“ this 绑定丢失”在 React 类组件中的连锁崩溃

在一个电商项目中,我们有一个商品列表组件,其 render 方法中为每个商品卡片的“加入购物车”按钮写了内联事件处理器:

class ProductList extends React.Component {
  addToCart = (productId) => {
    this.props.dispatch(addToCart(productId)); // this 指向组件实例
  };

  render() {
    return (
      <div>
        {this.props.products.map(product => (
          <div key={product.id}>
            <button onClick={function() { this.addToCart(product.id); }}>
              加入购物车
            </button>
          </div>
        ))}
      </div>
    );
  }
}

onClick 中的 function() { this.addToCart(...) } 是一个普通函数,当它被 DOM 事件系统调用时, this 指向 button 元素,而非 ProductList 组件。因此, this.addToCart undefined ,调用时报错。这个错误在开发环境被 React 的严格模式捕获,但在生产环境,它导致整个组件树卸载,用户看到白屏。 教训 :在 JSX 中,永远不要用普通函数作为事件处理器。修复方案有三:1) 改用箭头函数 onClick={() => this.addToCart(product.id)} ;2) 在 render 外预先绑定 onClick={this.addToCart.bind(this, product.id)} ;3) (最佳实践)使用函数组件 + useCallback ,从根本上消除 this 的歧义。

5.4 血泪教训三:“ let 的块级作用域”在 try/catch 中的意外表现

try/catch 语句的 catch 子句,其参数具有块级作用域。这在某些场景下会引发意外:

try {
  throw new Error("Oops");
} catch (err) {
  console.log(err.message); // "Oops"
}
console.log(err.message); // ReferenceError: err is not defined

这看起来很合理,但问题出在 err 参数的 let 语义上。 catch (err) 等价于 let err = thrownError ,因此 err 仅在 catch 块内有效。然而,许多开发者习惯在 catch 块内定义一个 let error = err ,然后在 finally 块中使用 error ,这会导致 error finally 中为 undefined 教训 try/catch 的作用域边界是严格的。若需在 finally 中访问错误,应在 catch 块内将其赋值给一个 var 声明的变量(作用域为函数),或在 try 块外声明 let error ,然后在 catch 中赋值。更优雅的方案是使用 async/await + try/catch ,并将错误处理逻辑封装在独立函数中,避免跨作用域访问。

5.5 血泪教训四:“ const 对象属性可变”导致的状态管理灾难

在一个 Vue 2 项目中,我们用 const 声明了一个全局配置对象:

const CONFIG = {
  API_BASE_URL: 'https://api.example.com',
  TIMEOUT: 5000,
  FEATURES: {
    enableNewCheckout: true
  }
};

// 后来在某个组件中
CONFIG.FEATURES.enableNewCheckout = false; // 合法!

这看似无害,但 CONFIG 被注入到多个组件的 data 中,当 CONFIG.FEATURES 被修改时,所有依赖它的组件都收到了响应式更新,导致部分用户界面意外降级。 const 只保证 CONFIG 引用不变,不保证其内容不变。 教训 :对配置对象,必须使用 Object.freeze() 深度冻结:

const CONFIG = Object.freeze({
  API_BASE_URL: 'https://api.example.com',
  TIMEOUT: 5000,
  FEATURES: Object.freeze({
    enableNewCheckout: true
  })
});

这样,任何对 CONFIG.FEATURES.enableNewCheckout 的赋值都会在严格模式下抛出 TypeError ,在非严格模式下静默失败,但至少能阻止意外修改。

5.6 血泪教训五:“ var 全局污染”引发的第三方脚本冲突

一个客户网站集成了两个不同的统计 SDK,它们都使用 var 声明了一个名为 tracker 的全局变量:

// SDK A
var tracker = { init: function() { ... } };

// SDK B
var tracker = { send: function() { ... } }; // 覆盖了 SDK A 的 tracker

结果是,SDK A 的初始化逻辑被

更多推荐