引擎把代码变成人能懂的运行结果,靠的可不是魔法,而是几个环环相扣的底层机制。执行上下文管着代码的“运行现场”,作用域链管着变量“去哪找”,闭包让函数“记住过去”,this则决定函数执行时“以谁的身份说话”。这四个概念没打通,读框架源码基本是猜谜,还特别容易被面试官两三个追问就打回原形。
这篇“上篇”先集中拆解执行上下文、作用域链、闭包和this的完整运行逻辑。内容不搞面试题速记,而是沿着JavaScript引擎实际执行代码的路径走一遍,每到一个关键点就停下来看看内部机制、写写Demo、聊聊我实际踩过的坑。不管你是刚能把页面写跑的新手,还是已经写了一年业务、想系统补齐内功的进阶开发者,这篇都适合慢慢读。读完你会发现,哪里报错、为什么输出那个值、怎么写出更稳的函数,其实都有迹可循。
1. 先把地基打牢:执行上下文是怎么回事
1.1 一段普通代码背后的“执行流水线”
先想一个问题:你在script标签里写下var name = 'jack'和一句console.log(name),浏览器到底做了哪些事才把这行字打到控制台?
我第一次认真钻研这个问题时,以为JS就是从上到下按行读、按行跑,其实远没这么简单。JavaScript引擎在执行任何代码之前,会先创建一个叫做“执行上下文”(Execution Context)的东西。你可以把它理解成一台设备的“运行现场记录表”,上面写清楚了当前代码有哪些变量、函数,this指向谁,以及遇到下一个函数调用时该去哪找外层数据。
这个过程分两个阶段。
阶段一是“创建阶段”。引擎扫描当前上下文里的变量声明和函数声明,把var声明的变量提出来登记到一个“变量环境”里,初始值置为undefined;把let、const声明的变量登记到“词法环境”里,但不初始化,只占个坑;再把整个函数声明放到内存里直接可用。这就是“变量提升”和“函数声明提升”的物理来源。
阶段二是“执行阶段”。代码开始真正逐行执行,遇到给变量赋值的语句,就把之前那个undefined替换成真实值。遇到函数调用,就在当前上下文之上再压一个新的函数执行上下文,继续重复“创建 + 执行”的过程。多个上下文在内存里堆叠起来,就构成了我们常说的“调用栈”。
运行到function baz() { return f() }里的f()如果层层嵌套调用太深,超过栈的空间上限,就会抛出“Maximum call stack size exceeded”。我在本地写递归时曾经因为忘了写结束条件,亲眼看着一整屏的报错刷下来。这个报错本质上就是“调用栈被填满溢出了”,不是引擎出Bug,是上下文堆积太深。
一个执行上下文内部大概长这样:
ExecutionContext = { 词法环境: { let/const 声明的变量, 外部引用 outer }, 变量环境: { var 声明的变量, 函数声明 }, this: 当前环境里 this 指向的对象 }词法环境和变量环境唯一的区别,就是前者用来盛放let/const(块级绑定的数据),后者用来盛放var和整个函数声明。这样设计是ES6之后为了让let/const拥有块级作用域,同时保持老代码的行为兼容才做的分家。
1.2 三种上下文:全局、函数、块级
执行上下文不是一个单一体,按作用范围可以分为三大类。
第一类是全局执行上下文。整个HTML页面或一个JS文件开始运行时,最先创建的上下文就是它,且整个页面周期里只有一个。它会把顶层代码里的var声明的变量挂到window(浏览器环境下)上,function声明的函数也挂到window上。这就是为什么全局声明的函数可以“随处调用”,因为它已经挂在了全局对象身上。
第二类是函数执行上下文。每次调用一个函数,引擎就会为这次调用创建一个新的函数上下文。每个函数上下文都有的自己的arguments对象、形参、局部变量。函数执行完毕,上下文通常会被从调用栈顶部弹出销毁,局部变量随之释放。注意这里有个“通常”,闭包搞的鬼后面我会专门讲。
第三类是块级上下文。这里的“块”不是函数,而是由{}包裹的代码块,比如if、for、while的花括号。let和const声明的变量被约束在这些块里,块跑完了这些变量也就消失了。这是ES6之后才有的精细控制,以前的var可不管你是块还是函数,只管你在不在函数里。
看一个最简单的对比:
if (true) { var a = 1; let b = 2; } console.log(a); // 1,var 无视块级,直接跑到外层 console.log(b); // ReferenceError: b is not defined,let 被块关住了这个实验我推荐新手多跑几次。a正常输出,b直接报错。表面上只是 var 和 let 的关键字差异,背后却是变量环境和词法环境两套机制在共同起作用。
1.3 上下文被“销毁”了,闭包却能“偷渡”变量
到这里你可能会觉得:既然函数执行完上下文就销毁了,那函数内部的局部变量是不是一定没了?正常情况下是,但只要内层函数在定义时引用了外层函数的变量,情况就复杂了。
举一个最常见的计数器例子:
function createCounter() { let count = 0; return function () { count += 1; return count; }; } const counter = createCounter(); console.log(counter()); // 1 console.log(counter()); // 2createCounter()执行完之后,它自己的执行上下文按正常逻辑应该弹出栈并销毁。但返回的那个匿名函数还捏着对count的引用不放。JavaScript引擎发现“还有函数在引用这个变量”,就把count连同它所在的词法环境保留下来,而不是跟着调用栈一起回收。这就是闭包的雏形。
为了把闭包讲透,我先把执行上下文这条主线继续走完:作用域链决定了这些“被保留的变量”是怎么被一层层找到的。
2. 作用域链:变量查找的“规则说明书”
2.1 词法作用域:代码写在哪儿,作用域就在哪儿
作用域链的建立依据不是“函数从哪里调用”,而是“函数在代码里写在哪里”。这个规则叫词法作用域,也叫静态作用域。意思是 JavaScript 在代码编译阶段就能确定每个函数能访问哪些外层变量。
看一下这个经典嵌套例子:
var name = 'global'; function outer() { var name = 'outer'; function inner() { console.log(name); } return inner; } outer()(); // 输出什么?答案是outer。因为inner函数定义在outer内部,它写代码时的物理位置决定了它能访问到outer里的name,哪怕运行时inner已经跑到全局环境被调用,它的作用域链也没有变。
如果你拿“函数在哪里调用”来猜,就会掉进坑里。有经验的开发者都会说:“作用域是定义时决定的,this是调用时决定的。”这句话是区分这两个概念的钥匙,后面讲this的时候还会复用。
2.2 作用域链的构建与查找顺序
每个执行上下文都有一个“外部引用”(规范里叫[[OuterEnv]]),指向它外层的作用域。当代码访问一个变量时,引擎会先在当前作用域里找;找不到,就顺着外部引用往外层找;再找不到,就继续往外,一直到全局作用域为止;全局也没有,就报ReferenceError: xxx is not defined。
这条“当前作用域 -> 外层作用域 -> 更外层 -> 全局”的链路,就是作用域链。
从里向外的查找顺序意味着内层变量可以“遮蔽”外层变量,同名情况下内层优先生效。这里有个细节,很多人会忽略:遮蔽的是变量名,不是变量值。外层同名变量并没有消失,只是被挡住了。想在外层作用域直接访问内层变量,做不到;浏览器调试时可以留意,作用域面板会把一层层变量都列出来,但代码里它们同名的状态是“内侧遮蔽外侧”。
我调试时经常用浏览器的 Sources 面板来观察作用域链。在函数内部某一行打断点,右侧 Scope 面板会展开当前的局部变量、闭包变量、全局变量。你能一眼看到三层作用域分别有什么,也能看着调用栈逐层跳转。
2.3 let/const与var的差别:块级作用域实战
作用域链在实际工作中最容易翻车的场景,就是for循环和定时器组合出的“循环闭包经典题”。
我先展示最常见的错误写法:
for (var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出 5 5 5 5 5为什么全是5?因为var i的作用域是整个函数或全局环境,循环每次迭代没有为i创建独立的块级作用域。循环结束后i变成5,等定时器回调执行时,顺着作用域链找到的i就是同一个变量,自然全是5。
改成let后行为立刻变了:
for (let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }, 100); } // 输出 0 1 2 3 4原因是let在每次循环迭代时,都会为i创建一个独立的词法环境,回调函数捕获的是当次迭代的i拷贝。这个机制背后正是块级执行上下文在起作用。
ES5时代没有let,大家用 IIFE(立即执行函数表达式)来制造独立作用域:
for (var i = 0; i < 5; i++) { (function (i) { setTimeout(function () { console.log(i); }, 100); })(i); }IIFE 生成一个函数执行上下文,把i作为参数拷贝一份进去,内部回调捕获的就不再是循环变量本身。原理清楚了,你再回头看框架里的源码,会发现不少这类“另起作用域”的代码模式,理解成本会降低很多。
3. 闭包:不是“魔法”,是“引用”加“环境”的组合
3.1 闭包的本质:函数记住了定义时的词法环境
在1.3的计数器例子里,count之所以能在createCounter执行完之后继续存在,是因为返回函数对象的内部属性[[Environment]]存放着创建它时的词法环境引用。每次调用返回函数时,引擎会把[[Environment]]作为新上下文的外部引用,从而找回count所在的词法环境。
闭包并不是把变量值复制一份存起来,而是持续“引用”原变量。所以闭包里的变量是活的,不是快照。这有个实际影响:同一个外层函数生成多个闭包,它们各自引用各自的词法环境,互不干扰。
function makeCounter(start = 0) { let count = start; return { add() { count += 1; return count; }, get() { return count; } }; } const c1 = makeCounter(); const c2 = makeCounter(100); c1.add(); // 1 c1.add(); // 2 c2.get(); // 100,c2 自己的 count 没被 c1 影响这有点像一个人手上拿了一份“独立账号的余额条”,另一个人的余额条完全分开。闭包拿的是同一个词法环境的引用,不是变量值的副本,但不同函数调用产生的词法环境天然彼此独立。
3.2 闭包的五种高频应用场景
闭包如果只停留在“记住变量”的程度上,价值有限。实际开发里,它几乎撑起了 JavaScript 函数式编程的半边天。
第一个高频场景是数据私有化。没有 class 的年代,开发者用闭包模拟私有变量的效果。外层函数里定义一个变量,只暴露getter和setter,外部读写都要经过中间层,能加校验也能做日志记录。现在虽然有了 class 的#私有字段,但闭包方案在工具函数里依然常见。
第二个高频场景是柯里化和偏函数。核心思路是用一个函数接收一部分参数,返回一个新函数,新函数通过闭包记住已传入的参数,等参数凑齐再执行最终逻辑。
function add(a) { return function (b) { return a + b; }; } const add5 = add(5); console.log(add5(3)); // 8 console.log(add5(10)); // 15第三个高频场景是回调与事件处理器。事件回调里如果你想在每次点击时累计点击次数,又不想污染全局变量,闭包天然适合。addEventListener回调经常“记住”它定义时所在函数里的变量,这个特性用处极广。
第四个高频场景是模块模式。用 IIFE 包一层,对外只暴露有限接口,内部变量全部藏在闭包里。Vue、React 的源码里到处是这种模式。我自己在一些老项目中维护公共工具库时,也常把状态变量包在const store = (function () { ... })()里,外部只能调用暴露的方法,内部数据不会被随意篡改。
第五个高频场景是函数工厂。像前面makeCounter那样,通过外层函数的参数制造不同行为的函数。防抖、节流本质上也是靠闭包保存定时器和在执行时间状态:
function debounce(fn, delay = 300) { let timer = null; return function (...args) { if (timer) clearTimeout(timer); timer = setTimeout(() => fn.apply(this, args), delay); }; }timer变量被返回函数闭包持有,不会在每次调用后销毁,从而实现“上一次的定时器还能取消”的防抖效果。
3.3 闭包的代价与常见内存误区
很多文章一提到闭包就喊“会内存泄漏”,其实这个说法在绝大多数场景下是不准确的。JavaScript引擎(尤其是 V8)对闭包持有变量是有优化的。闭包只会保留被引用到的变量,如果一个外层函数变量没有被任何内层函数引用,它不会为闭包而单独存活。
真正需要小心的是下面两种写法。
第一种是“外部持有了一个很大的闭包环境”。比如闭包里挂着一个巨大的数组或DOM节点,如果这个闭包被全局引用且长时间不释放,那块内存就很难被垃圾回收。典型场景是给全局变量赋值了一个返回闭包的函数,而这个闭包内部引用了大体积的数据。
第二种是“事件监听器上挂了闭包却没移除”。在一个需要反复创建和销毁的组件中,如果绑定了addEventListener回调,而回调闭包引用了组件实例,组件销毁时忘记移除监听,组件实例就被回调一直拽着,内存回收不了。
实际排查可以借助浏览器 Memory 面板记录堆快照。操作步骤是:打开无痕窗口,录制一段交互前后的堆快照,对比同步长、查看 Detached DOM 节点和保留对象。这比空谈“闭包泄漏”管用得多。
如果确定某个闭包不再使用了,最直接的释放手段是把持有闭包的变量置为null,切断引用。比如上面debounce返回的函数如果长期不用,可以const fn = debounce(...); // 不需要了 fn = null;让引擎有机会回收。
4. this:控制“主角视角”的规则全集
4.1 this不是词法概念,而是“动态绑定”的产物
作用域链看代码写在哪儿,this却看函数怎么被调用。这是 JavaScript 里最容易混的一个点。我在面试候选人的时候发现,很多人能把闭包背得滚瓜烂熟,一到 this 的调用场景就彻底懵,根源就是把“词法”和“动态”两套逻辑搞混了。
简单打个比方:每个函数执行时都像主持人拿到一个“话筒”。这个话筒递给谁,this就指向谁。话筒不是函数定义时写好的,而是函数被调用的那一刻现场确定的。谁调用它,话筒就递到谁手里,没有特殊情况,this就是那个调用者。
四种基本调用方式对应四种绑定规则,优先级从低到高排是:
| 调用方式 | 示例 | this 指向 |
|---|---|---|
| 默认绑定 | fn() | 非严格模式指向 window,严格模式为 undefined |
| 隐式绑定 | obj.fn() | 调用者对象 obj |
| 显式绑定 | fn.call(ctx)/fn.apply(ctx)/fn.bind(ctx) | 显式指定的 ctx |
| new 绑定 | new fn() | 新创建的实例对象 |
4.2 四条绑定规则逐一拆解
默认绑定是最容易判断的:函数裸调用,没有任何前缀、没有 new、没有 call/apply,那么非严格模式下this指向全局对象(浏览器里是 window),严格模式下是undefined。
function showThis() { console.log(this); } showThis(); // window(非严格模式)如果文件开头写了'use strict',输出就是undefined。这个规则常被忽略,因为很多人习惯了非严格模式下的宽泛行为,一旦在模块或组件代码里开了严格模式,默认绑定的行为会直接反转,回调里 this 一打印发现是undefined就懵了。
隐式绑定发生在以对象方法形式调用时,如obj.fn()。这时的this指向obj。
const person = { name: 'alice', greet() { console.log(this.name); } }; person.greet(); // alice但隐式绑定有个非常隐蔽的坑叫“隐式丢失”。把方法从对象里取出来,独立调用一下,this 就丢掉了:
const greet = person.greet; greet(); // undefined 或 window,取决于严格模式因为greet()是裸调用,默认绑定生效,和person没关系了。这就是为什么很多人把person.greet当作回调传给setTimeout后发现 this 变了:
setTimeout(person.greet, 100); // this 不再是 person显式绑定用call、apply、bind强制指定 this。call和apply只是传参方式不同,bind则是返回一个新的函数,把 this 绑定成固定值,无论后面怎么调用,this 都保持不变。这种用 bind 实现的“硬绑定”在事件回调里很实用,可以确保组件的 this 不被调用方式的差异带跑。
const unbound = person.greet; const bound = unbound.bind(person); bound(); // alice,怎么调用都是 alicenew 绑定的规则是:用new调用函数时,函数里的 this 指向正在被构造的新对象。这种场景下,即使函数之前被bind硬绑定了,new也会创建一个新对象并把 bind 的结果覆盖掉,因为 new 的优先级是最高的。这个细节面试题里经常出,很多初级开发只记得bind是硬绑定,不知道new能击穿它。
判断 this 顺序时可以这样记:先用new判断,再用call/apply/bind判断,然后是“以对象方法形式调用”的隐式绑定,最后才是裸调用的默认绑定。
4.3 箭头函数:没有自己的this
箭头函数跟前面四类规则完全无关。它不参与动态绑定,没有自己的 this,而是在定义时直接捕获外层词法环境的 this,并且后续无论如何调用都不会被改变。
这就带来两个非常实用的性质:一是箭头函数不能用来做构造函数,new它直接报错;二是不能对它使用bind、call改变 this 指向,因为压根没有自己的 this 可以改。
最常见的应用场景是回调函数中保留外层 this:
const component = { name: 'counter', init() { setTimeout(() => { console.log(this.name); // counter,箭头函数捕获 init 的 this }, 100); } }; component.init();如果把箭头函数换成普通匿名函数,输出就会变成undefined或 window,因为setTimeout回调是裸调用,默认绑定生效。这也是为什么 React 类组件里事件处理器偶尔需要用箭头函数或 bind 来处理这一下:都是为了锁定 this。
但箭头函数捡了 this 的控制权,也会在某些场景带来反效果。比如你想让一个对象方法里的 this 指向调用者对象,用箭头函数反而会得到外层作用域的 this,而不是对象本身:
const brand = { name: 'brand', getName: () => { console.log(this.name); } }; brand.getName(); // undefined,箭头函数 this 来自外层全局这种写法在对象方法上就是反模式。所以记住一个规律:箭头函数适合“需要保留定义处上下文”的回调场景,不适合“期望由调用者动态决定 this”的对象方法场景。浏览器事件监听绑定里也适用同样规则,比如给按钮绑定点击事件,回调里想用按钮元素自身的 this,就不该用箭头函数。
4.4 this相关高频坑与调试
写业务代码时最容易踩的坑往往集中在回调函数、定时器、解构赋值这几类场景。我整理一下自己遇到过的坑和排查方法。
第一个高频坑是“方法解构后调用丢失 this”。前面const greet = person.greet; greet()就是一种,ES6 解构也会同样触发:
const { greet } = person; greet(); // this 丢了解决方式也很简单,要么调用时保留person.greet()的形式,要么用greet.bind(person)绑定好再调用。
第二个高频坑是“回调函数中的 this 污染”。比如数组方法forEach、map中的回调,默认 this 是 undefined(严格模式)或全局,如果回调里引用了 this,最好传第二个参数给forEach或直接改用箭头函数捕获外层 this。
const admin = { items: [1, 2, 3], sum() { return this.items.reduce(function (total, val) { return total + val; }, 0); // 普通函数 this 在非严格模式下指向 window } };把 reduce 的回调改成箭头函数即可稳定拿到this.items。
第三个高频坑是“函数参数默认值或高级函数包裹时 this 被转移”。比如下面的写法,本质上是把obj.fn拿出来包了一层,再以裸调用方式执行:
const obj = { name: 'obj', fn() { console.log(this); } }; function run(callback) { callback(); // 裸调用,默认绑定 } run(obj.fn); // window/undefined调试这种问题最直接的方式是打断点。打开浏览器 DevTools 的 Sources 面板,在函数内部console.log(this)那一行打断点,执行时在 Scope 面板里能看到当前 this 到底是什么。如果想快速实验不同调用方式的 this 指向,也可以在 Console 面板里直接输入表达式观察结果,比在业务代码里反复刷新方便得多。
日常判断 this 可以套用我自己的口诀:先看有没有new,再看有没有call/apply/bind,然后看是不是对象.方法()形式,都不是就是默认绑定,箭头函数除外,箭头函数直接看定义处的外层 this。这套流程走下来,绝大多数 this 问题不会判断错。
写在最后的小经验
执行上下文、作用域链、闭包和 this 这套组合拳,最忌讳的就是死记硬背。我自己的学习路径是:先在浏览器里把调用栈和 Scope 面板用熟,把“上下文创建、压栈、弹栈”这个过程肉眼看到;再拿着闭包和 this 的各种坑,亲手把每个错误写法跑一遍,再改成正确写法,对比输出结果。踩过的坑比看过的文章更能长记性。
作用域链是“静态地图”,代码写完就定下来了;this 是一把“动态钥匙”,函数被怎么调用,钥匙就怎么转。理解这两个概念的差异,再回头看闭包时,你会意识到闭包并不是玄学,它只是让函数把定义时的地图带在身上而已。
“上篇”到这里先把运行机制这条主线走完。下一篇我会接着聊异步与事件循环、原型链与继承,以及这些机制在框架源码里的实际应用。把这些底层内容串起来之后,你会发现读源码、写复杂业务都不再像以前那样处处都是黑盒。下篇见。