一、作用域:起点
在讲执行上下文之前,我们先从作用域(Scope)入手。作用域简单说就是“变量起作用的范围”。
function bar() { var x = 10 console.log(x) // 10 } bar() console.log(x) // ReferenceError: x is not definedx只在bar函数体内有效,这就是函数作用域。
ES6 之后还引入了块级作用域(let / const):
if (true) { let y = 20 } console.log(y) // ReferenceError作用域规定了如何查找变量,也就是“在哪里找,怎么找”。
二、执行上下文 :代码运行的“环境”
执行上下文(简称 EC)是 JavaScript 引擎在执行代码时创建的一个抽象环境,它包含了当前代码运行所需的所有信息:变量、函数、this指向、作用域链等。
2.1 三种执行上下文
1. 全局执行上下文(Global EC)
- 最外层环境,在浏览器中就是window对象。
- 只存在一个,程序启动时创建。
2. 函数执行上下文(Function EC)
- 每当调用一个函数时,都会为该函数创建一个全新的执行上下文。
- 数量不限,函数调用结束则销毁(闭包除外)。
3. Eval 执行上下文(了解即可)
2.2 执行上下文栈(ECS):调用栈
JavaScript 是单线程的,同一时间只能做一件事。引擎用执行上下文栈(也叫调用栈)来管理所有的执行上下文。
function first() { console.log("first") second() } function second() { console.log("second") third() } function third() { console.log("third") } first()执行过程如下:
1. 压入全局上下文(栈底)。
2. 调用 first() ,压入 first 上下文。
3. first 中调用 second(),压入 second。
4. second 中调用 third(),压入 third。
5. third 执行完毕,弹出;接着 second 弹出;最后 first 弹出。
6. 栈中只剩下全局上下文。
这就是我们常说的 “调用栈”,栈顶永远是当前正在执行的上下文。
三、执行上下文的生命周期:创建与执行
每个执行上下文都会经历两个阶段:
1. 创建阶段(进入上下文)
2. 执行阶段(代码执行)
在创建阶段,引擎会做三件重要的事:
- 创建 变量对象(Variable Object,VO)
- 建立 作用域链
- 确定 this 的指向
下面我们逐一拆解。
3.1 变量对象(VO)与活动对象(AO)
变量对象 是执行上下文中用于存储变量、函数声明和函数形参的容器。
- 在全局上下文中,变量对象就是全局对象本身(浏览器中为 window)。
- 在函数上下文中,变量对象被称为 活动对象(Activation Object,AO),因为它额外包含了 arguments 对象和形参。
创建阶段,VO/AO 会进行变量提升:
function test(a) { console.log(a) // 1 console.log(b) // undefined console.log(c) // undefined var b = 2 function c() {} } test(1) // 在创建阶段,AO 大致如下: AO = { arguments: { 0: 1, length: 1 }, a: 1, b: undefined, c: reference to function c() {} }注意:函数声明会被完整提升(赋值),而变量声明只提升声明,赋值留在原地。
3.2 作用域链: 查找变量的“路线图”
作用域链 是一个由当前上下文及其所有父级上下文的变量对象按顺序组成的链表。当访问一个变量时,引擎会沿着这条链从顶端(当前 AO)开始查找,找不到则向上层查找,直到全局对象,如果还找不到则抛出 ReferenceError。
3.2.1 函数的 [[Scope]] 属性
每个函数在定义(声明)时,就会创建一个内部属性 [[Scope]],它保存了当前作用域链的快照。当函数被调用时,新生成的执行上下文的作用域链 = [[Scope]] + 自己的 AO。
这正是作用域链是静态(词法)作用域的原因 —— 它取决于函数定义的位置,而非调用位置。
var x = 10 function foo() { console.log(x) // 输出 10 还是 20 ? } function bar() { var x = 20 foo() } bar() // 10因为 foo 定义在全局,其 [[Scope]] 中只有全局变量 x,即使被 bar 调用,也访问不到 bar 的局部变量。这就是词法作用域的精髓。
3.2.2 作用域链的完整构建过程
我们用一个综合性例子来走一遍:
var globalVar = 'global' function outer(outerParam) { var outerVar = 'outer' function inner(innerParam) { var innerVar = 'inner' console.log(globalVar, outerParam, outerVar, innerParam, innerVar) } inner('innerArg') } outer('outerArg')步骤拆解:
1. 全局创建阶段:全局 VO = { globalVar: undefined, outer: function },作用域链 = [全局VO]。
2. 执行 outer('outerArg'):
- 创建 outer 的 AO:{ arguments: {0:'outerArg'}, outerParam: 'outerArg', outerVar: undefined, inner: function }
- outer 的作用域链 = [[Scope]](即全局VO) + 自己的 AO = [全局VO, outer AO]
- 执行代码,赋值 outerVar='outer',并定义 inner 函数。此时 inner 的 [[Scope]] = outer 当前的作用域链(即 [全局VO, outer AO])。
3. 执行 inner('innerArg'):
- 创建 inner 的 AO:{ arguments: {0:'innerArg'}, innerParam: 'innerArg', innerVar: undefined }
- inner 的作用域链 = [[Scope]](即 [全局VO, outer AO])+ 自己的 AO = [全局VO, outer AO, inner AO]
- 执行 console.log,沿链查找各变量,找到后输出。
运行结果:global outerArg outer innerArg inner
四、经典闭包:作用域链的“保鲜”
闭包的定义:函数 + 其词法环境的组合。更直白说,闭包是指一个函数能够记住并访问其定义时的作用域,即使该函数在其作用域之外执行。
闭包的产生,正是因为函数定义时的 [[Scope]] 被保留了下来,即使外层函数执行完毕并弹出栈,其 AO 依然被内部函数的 [[Scope]] 引用,不会被垃圾回收。
function createCounter() { let count = 0 return function() { count++ console.log(count) } } const counter = createCounter() counter() // 1 counter() // 2分析:
- createCounter 执行后,返回匿名函数,该函数的 [[Scope]] 包含了 createCounter 的 AO(其中count=0)。
- createCounter 执行完毕,其 EC 弹出栈,但它的 AO 因被内部函数引用而不被销毁。
- 每次调用 counter,都会沿着作用域链找到那个 count,并修改它。
这就是闭包的本质 —— 作用域链的持久化引用。
五、进阶:块级作用域与 let / const 的“特殊”行为
ES6 引入了块级作用域,let 和 const 没有变量提升(实际上是有提升,但存在暂时性死区TDZ)。
console.log(a) // undefined var a = 1 console.log(b) // ReferenceError: Cannot access 'b' before initialization let b = 2在块级作用域的执行上下文中,let/const 声明的变量会绑定到块级作用域的“环境记录”中,但初始化为 uninitialized,进入 TDZ,直到赋值语句才变为可访问。这并不影响作用域链的结构,只是查找规则多了一个“是否已初始化”的判断。
六、分析
题目一:
var arr = [] for (var i = 0; i < 3; i++) { arr.push(function() { console.log(i) }) } arr[0]() // ? arr[1]() // ?/ arr[2]() // ?答案:3 3 3(所有函数都打印 3)
解析:var i 是全局变量,循环结束后 i=3。三个函数共享同一个 i,执行时从全局 VO 中找到 i=3。
若想打印 0,1,2,可用闭包或 let:
- 闭包版:给每个函数绑定一个独立的作用域。
- let 版:for (let i=0; ...) 每次迭代会创建新的块级作用域,保存当时的 i。
题目二:
function Foo() { this.a = 1 return {} } var f = new Foo() console.log(f.a) // undefined解析:构造函数如果显式返回一个对象,则 new 的结果为该对象,而非 this。这里 return {} 覆盖了默认的 this。
题目三(闭包与循环):
function test() { var x = 10 function inner() { console.log(x) } x = 20 return inner } var fn = test() fn() // 20解析:inner 的 [[Scope]] 捕获的是 test 的 AO 的引用,x 在 test 执行阶段被修改为 20,所以输出 20(闭包保存的是引用,不是快照)。
七、总结
| 概念 | 核心 |
| 执行上下文 | 代码运行的环境,分为全局、函数、eval;由栈管理 |
| 变量对象/活动对象 | 存储变量、函数声明、形参等;创建阶段完成提升 |
| 作用域链 | 由当前 VO 及所有外层 VO 构成;查找变量时自上而下 |
| 词法作用域 | 作用域由函数定义位置决定,与调用位置无关 |
| 闭包 | 函数保留其定义时的作用域链引用,使得外层 AO 得以持久 |
| this | 与作用域链无关,由调用方式决定(在此不展开) |
理解执行上下文和作用域链,你就掌握了 JavaScript 的“内力”。后续无论学习异步、原型链还是框架源码,都会事半功倍。
核心:代码在编写时确定作用域,在运行时创建上下文,在栈中切换,在链中查找。