大多数人学习 JavaScript 的起点都是通过动手实践。你连接一个按钮,获取一些数据,然后观察 DOM 的变化。随后,抽象开始失效。一些莫名其妙的 Bug 开始浮现:变量在声明之前就存在了,函数记住了它们本不该访问的变量,而 this 关键字一会儿指向 window,一会儿指向按钮,或者干脆什么都不指向。通常就在那一刻,你会意识到需要深入底层,去理解引擎到底在做什么。

执行上下文:两个阶段的设置

当浏览器或 Node.js 中的 JavaScript 引擎遇到脚本时,它并不会像人扫视页面一样简单地从上到下读取文件。相反,它会构建一个执行上下文(execution context),这是一个包含运行特定代码块所需一切内容的容器。每个执行上下文都会经历两个截然不同的阶段。

内存创建阶段(Memory Creation Phase)。 在这第一次遍历期间,引擎会扫描整个作用域,并为它找到的每个变量和函数声明分配内存。如果它看到 var,它会预留空间并存储 undefined 作为占位符。如果它看到函数声明,它会存储完整的函数体。这就是为什么使用传统的 function 关键字声明的函数,可以在同一作用域中看起来比它更早的代码行中被调用。引擎在开始执行之前就已经知道它的存在了。

代码执行阶段(Code Execution Phase)。 现在引擎开始逐行运行你的代码。赋值操作发生在这里。表达式被求值。函数被调用。如果你写了 var name = "Alice";,第一阶段的占位符最终会被字符串替换。理解这种“两遍扫描”的行为可以消除大量的困惑。引擎并不草率,它是在遵循一套严格的设置流程。

变量与暂时性死区

varletconst 之间做出选择不仅仅是风格偏好问题。var 是函数作用域(function-scoped),这意味着它完全忽略大括号。如果你在 if 块内声明它,它会泄露到外部。这种行为在 JavaScript 早期可能是有意义的,但在现代应用中,它会导致严重的维护难题。letconst 是块级作用域(block-scoped)。它们遵循大括号的规则,并在块结束时消失。

在提升(hoisting)对待它们的方式也存在细微但至关重要的区别。var 声明会被提升并立即初始化为 undefinedletconst 技术上也会被提升;引擎在到达声明行之前就知道它们的存在。但它们不会被初始化。它们处于一种被称为“暂时性死区”(temporal dead zone)的中间状态。如果你尝试在声明行执行之前读取它们,你会得到一个硬性的 ReferenceError,而不是一个隐蔽的 undefined。这种崩溃实际上是有帮助的,它防止了逻辑在未初始化的数据之上继续运行。

词法作用域与闭包

作用域回答了一个简单的问题:我可以在哪里访问这个变量?JavaScript 使用词法作用域(lexical scope),这意味着一个函数的访问权限是由它在源代码中物理编写的位置决定的,而不是由它最终被调用的位置决定的。如果你在一个函数内部定义另一个函数,内部函数可以向外延伸以读取其父函数的变量。而外部函数无法向内延伸。这种关系是静态的。你可以将那个内部函数传递到其他模块,将其存储在全局变量中,或者从一个完全不同的文件中调用它。它仍然记得它诞生时的那个作用域中的变量。

这种行为自然而然地产生了闭包(closures)。当内部函数保留了对其外部作用域中变量的引用时,闭包就形成了。即使外部函数执行完毕,其局部变量本该被垃圾回收(garbage collected),JavaScript 仍会将它们保留在内存中,因为内部函数仍然需要它们。内部函数随身携带了它周围的环境。

这不仅仅是一个学术细节。闭包为你提供了一种在缺乏显式访问修饰符(access modifiers)的语言中创建私有状态的实用方法。

function makeCounter() {
  let count = 0;
  return function() {
    count = count + 1;
    return count;
  };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2

在这里,count 被隐藏了。返回的函数之外的任何东西都无法重置它或直接读取它。这仅仅通过作用域机制就构建了一个私有变量。

提升的实践

人们常说 JavaScript 会“将声明提升到顶部”。这是一种很有用的思维模型,但代码实际上并没有被重写。在内存创建阶段,引擎只是在执行开始前注册了声明。像 var x = 5; 这样的语句,其行为表现得像是声明和初始化是解耦的。声明 var x; 会被提前处理并初始化为 undefined。而赋值语句 x = 5; 则保持在你编写的原处,并在执行阶段运行。

正因如此,var 可能会导致令人意外的结果。一个在函数顶部附近使用的变量,即使底部有一个巨大的赋值语句,它也可能持有 undefined。使用 letconst 可以消除这种特定的“踩坑”风险,因为暂时性死区(temporal dead zone)会强制要求你将声明放在使用之前。

this 的值是如何确定的

如果说执行上下文和作用域决定了变量存放在哪里,那么 this 则决定了当前由哪个对象掌管。与词法变量不同,this 的取值并不取决于函数编写的位置,而是完全取决于函数是如何被调用的。

默认绑定(Default binding) 发生在调用一个普通的、独立的函数时。在非严格模式下,this 会回退到全局对象。在浏览器中,那就是 window。如果不带任何上下文调用一个函数,你可能会在不知不觉中触碰到全局状态。

隐式绑定(Implicit binding) 发生在将函数作为对象的方法进行调用时。如果你编写 user.sayName(),点符号会悄悄地告诉引擎,在调用期间将 this 设置为 user。调用发生的位置比定义的位置更为重要。

显式绑定(Explicit binding) 允许你手动覆盖一切。call()apply() 会立即调用函数,同时强制将 this 设置为你提供的特定对象。它们之间唯一的区别在于参数的传递方式:call 接收以逗号分隔的列表,而 apply 接收一个数组。bind() 的工作方式则不同。它不会立即调用函数,而是返回一个新函数,并将 this 永久锁定为你提供的数值。这对于那些在传递过程中可能会丢失上下文的回调函数来说非常有用。

new 绑定(New binding) 在你在函数调用前使用 new 关键字时发挥作用。引擎会构建一个全新的空对象,设置其原型链链接,并将构造函数内部的 this 指向这个新实例。

编写持久的代码

理解底层机制只是手艺的一半。另一半是编写在六个月后人类仍能读懂的代码。

DRY (Don't Repeat Yourself) 听起来显而易见,但它经常被违反。如果你发现自己在三个不同的文件中编写相同的校验逻辑或 API 调用模式,请将其提取出来。写成一个函数。单一的事实来源意味着当需求变更时,只需在一个地方进行更新,这所节省的时间是难以用言语衡量其价值的。

KISS (Keep It Simple, Stupid) 是对抗自负的防御手段。嵌套的三元运算符和单行闭包看起来很聪明,但在调试时会耗费数小时。我之前展示的闭包模式很强大,但仅仅因为“可以”就嵌套五层深度是一个错误。简洁的代码能够经受住团队人员变动、生产事故,以及那些在深夜发生、且没人记得原因的故障报警。

真正的回报

研究执行