Skip to content

执行上下文、调用栈与 this ​

难度等级:🟡 中级 分类:JavaScript 归档日期:2026-10-10


说说什么是执行上下文?什么是调用栈,和事件循环有什么关系?this 是如何确定的?ES Module 的顶层代码有独立的执行上下文吗?

共 4 道题,每道包含考察点、口述答案和常见追问。


题 1:说说什么是执行上下文? ​

难度与考察点 ​

中级。考察对 JS 运行机制的理解,是否知道变量提升、作用域链、this 的来源。资深候选人应能区分 ES5 的说法和 ES6 规范的说法。

口述答案 ​

执行上下文是 JS 引擎执行一段代码时创建的运行环境,记录这段代码能访问哪些变量、作用域链是什么、this 指向谁。

按规范的代码类型,有全局、函数、eval,以及 ES6 新增的模块执行上下文。函数执行上下文是在每次调用时创建的,不是定义时。

它内部主要包括三部分:词法环境,存 let、const 和函数声明,同时持有外部环境的引用,作用域链就是这样一层层连起来的;变量环境,存 var 声明;还有 this 绑定。

生命周期分两个阶段。创建阶段建立环境记录,做变量提升[1]:var 初始化成 undefined,let/const 虽然也被创建但没有初始化,处于暂时性死区,函数声明整体提升;同时确定外部环境引用和 this。执行阶段才逐行执行、给变量赋值。

可能追问 ​

1. 为什么 let 访问会报错而 var 是 undefined? 两者都在创建阶段被创建,区别在初始化时机:var 立即初始化为 undefined,let/const 要执行到声明语句才初始化,在此之前访问就是 TDZ 报错。所以"let 不提升"的说法不准确。

2. 作用域链是在什么时候确定的? 外部环境引用取决于函数定义的位置(函数创建时记录在 [[Environment]] 中),是词法作用域;而 this 取决于调用方式,两者容易混淆。详见 作用域、作用域链与闭包。

3. 闭包和执行上下文的关系? 函数执行完上下文出栈,但如果内部函数还引用着它的词法环境,这个环境就不会被回收,这就是闭包。


题 2:什么是调用栈?和事件循环有什么关系? ​

难度与考察点 ​

中级。考察执行上下文的管理方式,能否关联到栈溢出、异步机制和实际排查。

口述答案 ​

调用栈是引擎管理执行上下文的栈结构,后进先出。代码开始执行时全局上下文入栈;每调用一个函数,就创建它的上下文压到栈顶;函数返回后出栈,控制权回到下一层。栈顶永远是当前正在执行的代码。

JS 主线程只有一个调用栈,所以同一时刻只能执行一段代码。异步任务的回调不会直接进栈,而是放进任务队列,事件循环会等调用栈清空后,先清空微任务队列,再取下一个宏任务放入栈中执行。

实际开发中,调用栈主要和两类问题有关:一是无限递归导致栈溢出,报 Maximum call stack size exceeded;二是报错时的堆栈信息,本质上就是出错那一刻的调用栈快照,是排查问题的第一手线索。

可能追问 ​

1. 深递归如何避免栈溢出? 改成循环加显式栈、分片执行,或用蹦床函数。尾调用优化规范里有,但主流引擎中只有 Safari 实现了,不能依赖。

2. 异步代码的报错堆栈为什么不完整? 回调执行时原来的调用栈已经清空。现代 DevTools 提供 async stack trace 来补全;线上监控通常要结合 source map 还原。

3. 长任务卡页面的原因? 调用栈被一个长任务占着,事件循环拿不到机会去渲染和响应交互,可用 Performance 面板定位,并通过任务拆分、scheduler.yield()、Web Worker 缓解。


题 3:this 是如何确定的? ​

难度与考察点 ​

中级,高频。考察四种绑定规则、优先级、箭头函数,以及隐式丢失这类实际坑。

口述答案 ​

普通函数的 this 是在调用时确定的,取决于怎么调用,不取决于在哪定义。它是执行上下文的一部分,每次调用都会重新确定。

规则按优先级从高到低: 第一是 new 绑定,指向新创建的实例; 第二是显式绑定,通过 call、apply、bind 指定,bind 返回的函数 this 被永久固定,再 call 也改不了,但 new 可以覆盖它; 第三是隐式绑定,作为对象方法调用时,指向点前面的那个对象; 最后是默认绑定,独立调用时非严格模式指向全局对象,严格模式是 undefined。

箭头函数是例外,它没有自己的 this,在定义时捕获外层作用域的 this,之后 call、bind、new 都改变不了。

实际开发中最常见的坑是隐式丢失,比如把 obj.method 赋值给变量或作为回调传给 setTimeout、事件监听,调用时前面没有对象了,就退化成默认绑定。

可能追问 ​

1. 下面输出什么?

js
const obj = { name: 'obj', say() { console.log(this.name) } };
const fn = obj.say;
fn();                       // 非严格模式 this 是 window,输出 window.name(浏览器中通常是 '');严格模式 this 为 undefined,报 TypeError
(0, obj.say)();             // 同上,逗号表达式返回的是函数值
obj.say.bind({name:'a'}).call({name:'b'}); // 'a'

2. 箭头函数什么时候不该用? 对象方法、原型方法、需要动态 this 的事件回调(要用 this 拿当前元素时)、构造函数(箭头函数不能 new)。

3. React class 组件里为什么要 bind? 方法作为 onClick 回调传出去后会发生隐式丢失,所以要在构造函数中 bind,或者用类字段 + 箭头函数。这也是理解 this 规则的典型工程场景。


题 4:ES Module 的顶层代码有独立的执行上下文吗? ​

难度与考察点 ​

高级。考察是否了解 ES6 规范的更新,而不是停留在 ES5 的"三种执行上下文"。也常以"模块和普通脚本有什么区别"的形式出现。

口述答案 ​

有。"全局、函数、eval 三种执行上下文"是 ES5 的说法。ES6 引入模块后,规范中执行模块时会创建一个独立的模块执行上下文,它的词法环境是模块环境记录,外层才是全局环境。

和全局脚本相比有几个区别: 一是作用域独立,模块顶层声明的变量,包括 var,都不会挂到 window 上; 二是顶层 this 是 undefined; 三是默认严格模式; 四是 import 是实时的只读绑定,不是值拷贝,导出方修改,导入方能看到最新值,但导入方不能重新赋值; 五是每个模块只求值一次,结果被缓存; 六是支持顶层 await,模块上下文可以像 async 函数一样被挂起再恢复。

执行顺序上,模块会先解析出完整依赖图,再链接各模块的导入导出绑定,最后按依赖的深度优先后序依次求值,所以被依赖的模块总是先执行。

可能追问 ​

1. ESM 和 CommonJS 的区别? ESM 是静态结构、编译时确定依赖,支持 tree shaking,导出是实时绑定;CommonJS 是运行时 require,导出的是值的拷贝(对象则是引用的拷贝),同步加载。

2. 循环依赖时会发生什么? ESM 中由于链接阶段已建立绑定,访问尚未求值的 let/const 导出会报 TDZ 错误;CommonJS 中会拿到未执行完的部分导出对象。

3. 顶层 await 有什么风险? 会阻塞依赖它的所有模块的求值,在关键依赖链上使用可能拖慢首屏,应只用于真正需要的初始化逻辑。


串联表达 ​

面试官让你一起讲时:

调用函数时,引擎创建执行上下文,在其中确定变量、作用域链和 this,然后压入调用栈执行,执行完出栈。所以这三个问题本质上是一条主线,this 只是执行上下文的一个属性。


代码输出练习 ​

以下题目默认运行环境为浏览器、普通 <script>、非严格模式(另有说明的除外)。

一、变量提升 ​

练习 1

js
console.log(a);
var a = 1;
function a() {}
console.log(a);

答案:ƒ a() {},1 创建阶段函数声明优先级高于 var,var a 不会覆盖已存在的同名函数。执行到 a = 1 时才被赋值。

练习 2

js
var x = 1;
function f() {
  console.log(x);
  var x = 2;
}
f();

答案:undefined 函数内的 var x 被提升到函数顶部,遮蔽了外层的 x,此时尚未赋值。

练习 3

js
let y = 1;
{
  console.log(y);
  let y = 2;
}

答案:ReferenceError: Cannot access 'y' before initialization 块内的 let y 已被创建,遮蔽外层 y,但处于暂时性死区。这说明 let 并非"不提升"。

练习 4

js
console.log(foo);
foo();
var foo = function () { console.log('expr'); };

答案:undefined,然后 TypeError: foo is not a function 函数表达式只有变量名按 var 提升,函数体不提升。注意是 TypeError 而非 ReferenceError。

练习 5

js
function f(a) {
  console.log(a);
  var a = 2;
  console.log(a);
}
f(1);

答案:1,2 形参 a 已存在,var a 重复声明会被忽略,不会重置为 undefined。

练习 6

js
var a = 10;
function test() {
  console.log(a);
  a = 20;
  console.log(a);
  function a() {}
}
test();
console.log(a);

答案:ƒ a() {},20,10 函数内的 function a 提升为局部变量,a = 20 修改的是局部的 a,全局 a 不受影响。

练习 7(进阶)

js
console.log(fn);
if (true) {
  function fn() {}
}
console.log(fn);

答案:undefined,ƒ fn() {} 块级函数声明在非严格模式下有历史兼容行为(规范附录 B):外层会提升一个 var fn(值为 undefined),执行到块内声明时才把函数赋给外层。严格模式下第一处会报 ReferenceError,块外访问不到 fn。实际开发中应避免在块内声明函数。

二、this 绑定 ​

练习 8

js
var name = 'window';
const obj = {
  name: 'obj',
  say() { console.log(this.name); },
  arrow: () => console.log(this.name),
};
obj.say();
obj.arrow();
const s = obj.say;
s();

答案:obj,window,window 对象字面量不形成作用域,所以 arrow 捕获的是全局 this。s() 是隐式丢失,走默认绑定。

练习 9

js
var name = 'window';
const obj = {
  name: 'obj',
  fn() {
    return function () { console.log(this.name); };
  },
  fn2() {
    return () => console.log(this.name);
  },
};
obj.fn()();
obj.fn2()();
obj.fn2.call({ name: 'other' })();

答案:window,obj,otherfn 返回的普通函数被独立调用,走默认绑定。fn2 返回的箭头函数捕获 fn2 调用时的 this。第三行 call 改变的是 fn2 本身的 this,箭头函数随之捕获 other。

练习 10

js
const o1 = {
  v: 1,
  o2: { v: 2, get() { return this.v; } },
};
console.log(o1.o2.get());

答案:2 隐式绑定只看最后一个点前面的对象,即 o2。

练习 11

js
function show() { console.log(this.v); }
const a = show.bind({ v: 1 }).bind({ v: 2 });
a();

答案:1bind 返回的函数 this 已永久固定,再次 bind 无效。

练习 12

js
function Foo(name) { this.name = name; }
const Bound = Foo.bind({ name: 'bound' });
const f = new Bound('new');
console.log(f.name);

答案:newnew 的优先级高于 bind,this 指向新实例,bind 的对象被忽略(但预置的参数仍然有效)。

练习 13

js
var v = 'global';
const obj = {
  v: 'obj',
  run() {
    setTimeout(function () { console.log(this.v); }, 0);
    [1].forEach(function () { console.log(this.v); });
    [1].forEach(function () { console.log(this.v); }, this);
  },
};
obj.run();

答案:global,obj,globalforEach 是同步的,先输出。第一个 forEach 回调独立调用,走默认绑定;第二个通过 thisArg 参数指定了 this。setTimeout 回调最后执行,浏览器中以 window 作为 this 调用。

练习 14(进阶)

js
class A {
  name = 'A';
  say() { return this?.name; }
  arrow = () => this.name;
}
const a = new A();
const { say, arrow } = a;
console.log(say());
console.log(arrow());

答案:undefined,A class 内部始终是严格模式,解构出的 say 独立调用时 this 为 undefined(用了 ?. 所以不报错)。arrow 是类字段,在构造时定义,捕获的是实例,所以解构后依然有效。这也是 React class 组件用箭头函数类字段的原因。

练习 15(经典陷阱)

js
var length = 10;
function fn() { console.log(this.length); }
const obj = {
  length: 5,
  method(f) {
    f();
    arguments[0]();
  },
};
obj.method(fn, 1);

答案:10,2f() 独立调用,this 是 window,window.length 被 var length = 10 赋值为 10。arguments[0]() 相当于以 arguments 对象调用方法,隐式绑定到 arguments,其 length 是实际传入的参数个数 2。


  1. 变量提升与暂时性死区(TDZ):

    1. 基本现象

    js
    console.log(a); // 抛出 ReferenceError
    let a = 10;     // 声明并初始化,此处脱离 TDZ

    2. 与 var 的对比

    • var 存在变量提升(Hoisting),在声明前访问会返回 undefined。
    • let / const 存在提升,但不会被初始化,在声明前处于 TDZ 无法访问。

    3. typeof 不再安全

    在 ES5 中,对未声明的变量使用 typeof 会返回 "undefined"。但在 TDZ 中,对 let 或 const 声明前的变量使用 typeof 同样会抛出 ReferenceError。 ↩︎

个人面试题归档,持续更新中