作用域、闭包与this

21 分钟

这三个概念为什么总是绑在一起考

面试中"作用域、闭包、this"几乎是铁三角组合。原因很简单:它们共同决定了一段 JavaScript 代码在运行时能访问哪些变量、变量的值是什么、函数内部的 this 指向谁。理解不到位,写出来的代码就会出现"变量访问不到""闭包内存泄漏""this 指向错乱"这类让人抓狂的问题。

这篇文章按面试复习的视角组织:先把作用域讲透,再用作用域的知识去理解闭包,最后单独拆解 this 的绑定规则。每个部分都会覆盖高频追问点。

词法作用域:代码写在哪里,作用域就定在哪里

JavaScript 采用词法作用域(Lexical Scope),也叫静态作用域。作用域在代码编写阶段就已经确定,和函数在哪里调用无关,只和函数在哪里定义有关。

const greeting = 'hello';

function outer() {
  const greeting = 'hi';
  function inner() {
    console.log(greeting);
  }
  return inner;
}

const fn = outer();
fn(); // 输出 'hi',不是 'hello'

inner 定义在 outer 内部,所以它的作用域链在定义时就绑定了 outer 的作用域。即使 fn() 在全局环境中调用,访问的 greeting 依然是 outer 里的那个。

与词法作用域对应的是动态作用域——函数的作用域在调用时决定。JavaScript 中 this 的行为有点像动态作用域(运行时决定),但语言本身的变量查找机制是纯粹的词法作用域。面试时如果被问到"JavaScript 是词法作用域还是动态作用域",答案是词法作用域,但要补充 this 的动态特性。

作用域链:变量查找的链条

每个执行上下文(Execution Context)都有一个与之关联的作用域链。当代码中引用一个变量时,引擎会沿着作用域链从内向外逐层查找,直到找到为止。如果到全局作用域还没找到,就抛出 ReferenceError

const a = 1;

function foo() {
  const b = 2;
  function bar() {
    const c = 3;
    console.log(a, b, c); // 1 2 3
  }
  bar();
}

foo();

bar 的作用域链:bar 自身作用域 → foo 的作用域 → 全局作用域。变量 abarfoo 中都找不到,最终在全局作用域中找到。

需要注意的是,作用域链的建立发生在函数定义时,而不是调用时。这和词法作用域是一回事——定义时确定的嵌套关系决定了查找路径。

var、let、const:不只是"能不能重新赋值"

面试中这三个关键字的区别是高频考点,但很多人只答到"const 不能重新赋值"就停了。实际上要回答清楚,至少要覆盖四个维度:

作用域粒度

var 的作用域是函数级别的。在一个函数内部,无论 var 声明写在哪个代码块里,整个函数都能访问这个变量。

letconst 的作用域是块级别的。一对花括号 {} 就是一个块,变量只在块内可见。

function example() {
  if (true) {
    var x = 1;
    let y = 2;
    const z = 3;
  }
  console.log(x); // 1
  console.log(y); // ReferenceError
  console.log(z); // ReferenceError
}

变量提升与暂时性死区

var 声明会被提升(hoisting)到函数顶部,但赋值留在原地。在声明语句之前访问变量,得到的是 undefined

console.log(a); // undefined
var a = 10;

// 等价于:
var a;
console.log(a); // undefined
a = 10;

letconst 也会被提升,但在声明语句之前的区域称为暂时性死区(Temporal Dead Zone, TDZ)。在 TDZ 内访问变量会直接抛出 ReferenceError,而不是返回 undefined

console.log(b); // ReferenceError: Cannot access 'b' before initialization
let b = 20;

TDZ 的存在是为了让代码行为更可预测:变量必须先声明后使用,避免 var 时代那种"声明前访问得到 undefined 但不报错"的隐蔽 bug。

重复声明

var 允许在同一作用域内重复声明同名变量,后面的声明会被静默忽略(只有赋值生效)。letconst 在同一作用域内重复声明会直接报 SyntaxError

快速对比

特性varletconst
作用域函数级块级块级
变量提升是(值为 undefined)是(TDZ 内不可访问)是(TDZ 内不可访问)
重复声明允许不允许不允许
重新赋值允许允许不允许

面试追问const 声明的对象能不能修改属性?——能。const 限制的是绑定(binding),不是值(value)。const obj = {} 之后,obj.name = 'test' 完全合法,但 obj = {} 会报错。

闭包:函数记住了它出生时的环境

闭包的定义

闭包(Closure)是指一个函数能够访问其词法作用域中的变量,即使这个函数在其词法作用域之外执行。

换个说法:当一个内部函数被传递到它定义时所在的词法作用域之外,它依然持有对原始作用域的引用,这个引用就叫闭包。

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

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

createCounter 执行完毕后,按照直觉 count 应该被销毁。但返回的匿名函数持有对 count 的引用,垃圾回收器不会回收它。这就是闭包的本质:函数 + 它引用的外部变量,打包成一个不会被回收的整体。

闭包经典场景一:循环 + setTimeout

这是面试中出场率最高的闭包题之一:

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}
// 输出:3 3 3

原因:var 是函数级作用域,整个循环共享同一个 i。三个 setTimeout 的回调函数都引用的是同一个 i,等到 1 秒后执行时,循环早已结束,i 的值已经是 3。

解法一:用 let 创建块级作用域

for (let i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}
// 输出:0 1 2

let 在每次循环迭代时都会创建一个新的块级作用域,每个回调函数捕获的是各自迭代中独立的 i

解法二:用 IIFE 手动创建闭包

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(function () {
      console.log(j);
    }, 1000);
  })(i);
}
// 输出:0 1 2

IIFE(立即执行函数表达式)在每次循环中创建一个新的函数作用域,ji 的副本,不会受后续循环影响。

闭包经典场景二:私有变量

JavaScript 没有传统的 private 关键字(class 的 # 私有字段是后来加的),闭包是实现数据封装的经典手段:

function createUser(name) {
  let _name = name;

  return {
    getName() {
      return _name;
    },
    setName(newName) {
      if (typeof newName !== 'string' || newName.length === 0) {
        throw new Error('Invalid name');
      }
      _name = newName;
    },
  };
}

const user = createUser('Alice');
console.log(user.getName()); // 'Alice'
user.setName('Bob');
console.log(user.getName()); // 'Bob'
// user._name 是 undefined,外部无法直接访问

_name 只能通过 getNamesetName 访问,外部代码无法绕过这两个方法直接修改。这在模块模式(Module Pattern)中被大量使用。

闭包经典场景三:函数柯里化

柯里化(Currying)是将一个接受多个参数的函数转换为一系列接受单个参数的函数。闭包是实现柯里化的基础:

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    return function (...nextArgs) {
      return curried.apply(this, args.concat(nextArgs));
    };
  };
}

function add(a, b, c) {
  return a + b + c;
}

const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); // 6
console.log(curriedAdd(1, 2)(3)); // 6
console.log(curriedAdd(1)(2, 3)); // 6

每次调用 curried 时,已传入的参数 args 通过闭包被保留,直到参数数量满足原函数的形参个数。

闭包与内存泄漏

闭包本身不会导致内存泄漏,但不当使用闭包会阻止垃圾回收器回收不再需要的变量。

典型场景:事件监听器中引用了大对象,但忘记移除监听器。

function bindHandler() {
  const hugeData = new Array(1000000).fill('data');
  const element = document.getElementById('button');

  element.addEventListener('click', function () {
    // 回调函数闭包引用了 hugeData
    console.log(hugeData.length);
  });
}

bindHandler();
// hugeData 无法被回收,因为 click 回调一直持有对它的引用

避免方式

  • 不再需要时及时移除事件监听器(removeEventListener)。
  • 如果只需要 hugeData 的部分信息,提前提取出来,不要让闭包捕获整个大对象。
  • 使用 WeakRefWeakMap 持有对象引用,允许垃圾回收器在需要时回收。
function bindHandler() {
  const hugeData = new Array(1000000).fill('data');
  const dataLength = hugeData.length; // 只提取需要的值

  document.getElementById('button').addEventListener('click', function () {
    console.log(dataLength); // 闭包只捕获了一个数字,hugeData 可以被回收
  });
}

面试追问:如何排查闭包导致的内存泄漏?——使用 Chrome DevTools 的 Memory 面板,通过 Heap Snapshot 对比两次快照的差异,找到 Retained Size 异常大的对象,查看其 Retainers 链路,定位是哪个闭包持有了引用。

this:运行时绑定,不是定义时绑定

this 是 JavaScript 中最让人困惑的概念之一。和作用域链不同,this 的值不是在函数定义时确定的,而是在函数调用时根据调用方式动态决定的。

绑定规则一:默认绑定

当函数以独立调用的方式执行(没有任何修饰),this 指向全局对象(浏览器中是 window,Node.js 中是 global)。严格模式下,thisundefined

function showThis() {
  console.log(this);
}

showThis(); // 非严格模式:window / global
// 严格模式:undefined

绑定规则二:隐式绑定

当函数作为对象的方法被调用时,this 指向调用它的对象。

const person = {
  name: 'Alice',
  greet() {
    console.log(this.name);
  },
};

person.greet(); // 'Alice'

隐式绑定丢失是面试中的经典坑:

const greetFn = person.greet;
greetFn(); // undefined(非严格模式下 this 指向 window,window.name 是空字符串或 undefined)

把方法赋值给变量后,调用方式变成了独立调用,隐式绑定失效,退回到默认绑定。回调函数、setTimeout 中传入方法引用时,同样会发生隐式绑定丢失:

setTimeout(person.greet, 1000); // this 不再指向 person

绑定规则三:显式绑定(call / apply / bind)

通过 callapplybind 可以显式指定函数执行时的 this

function introduce(hobby1, hobby2) {
  console.log(`I'm ${this.name}, I like ${hobby1} and ${hobby2}`);
}

const user = { name: 'Bob' };

// call:参数逐个传递
introduce.call(user, 'coding', 'reading');

// apply:参数以数组形式传递
introduce.apply(user, ['coding', 'reading']);

// bind:返回一个新函数,this 被永久绑定
const boundIntroduce = introduce.bind(user, 'coding');
boundIntroduce('reading');

三者区别

方法执行时机参数形式返回值
call立即执行逐个传递函数执行结果
apply立即执行数组传递函数执行结果
bind不立即执行逐个传递(支持偏函数)新函数

绑定规则四:new 绑定

使用 new 调用构造函数时,this 指向新创建的实例对象。

function Person(name) {
  this.name = name;
}

const alice = new Person('Alice');
console.log(alice.name); // 'Alice'

new 操作符的执行过程:

  1. 创建一个空对象。
  2. 将这个空对象的 [[Prototype]] 指向构造函数的 prototype
  3. 将构造函数的 this 绑定到这个新对象,执行构造函数。
  4. 如果构造函数返回一个对象,则返回该对象;否则返回新创建的对象。

四种绑定的优先级

当多种绑定规则同时存在时,优先级从高到低为:

new 绑定 > 显式绑定 > 隐式绑定 > 默认绑定

function foo() {
  console.log(this.a);
}

const obj1 = { a: 1, foo };
const obj2 = { a: 2 };

// 隐式 vs 显式:显式赢
obj1.foo.call(obj2); // 2

// 显式 vs new:new 赢
const BoundFoo = foo.bind(obj1);
const instance = new BoundFoo();
console.log(instance.a); // undefined(this 指向新实例,不是 obj1)

箭头函数的 this

箭头函数没有自己的 this。它的 this 继承自外层最近的非箭头函数的 this,在定义时就确定了,之后无法通过 callapplybind 修改。

const team = {
  name: 'Frontend',
  members: ['Alice', 'Bob'],
  printMembers() {
    this.members.forEach((member) => {
      // 箭头函数的 this 继承自 printMembers 的 this
      console.log(`${member} belongs to ${this.name}`);
    });
  },
};

team.printMembers();
// Alice belongs to Frontend
// Bob belongs to Frontend

如果把箭头函数换成普通函数:

printMembers() {
  this.members.forEach(function (member) {
    // 普通函数独立调用,this 指向 window 或 undefined
    console.log(`${member} belongs to ${this.name}`); // this.name 不是 'Frontend'
  });
}

面试追问:箭头函数能不能当构造函数?——不能。箭头函数没有 [[Construct]] 内部方法,也没有 prototype 属性,使用 new 调用会抛出 TypeError

手写 call、apply、bind

面试中经常要求手写这三个方法的实现,考察的是对 this 绑定和函数调用机制的理解。

手写 call

核心思路:把函数作为目标对象的临时方法调用,调用完删掉。

Function.prototype.myCall = function (context, ...args) {
  if (typeof this !== 'function') {
    throw new TypeError('myCall must be called on a function');
  }

  context = context !== null && context !== undefined ? Object(context) : globalThis;
  const uniqueKey = Symbol('fn');
  context[uniqueKey] = this;
  const result = context[uniqueKey](...args);
  delete context[uniqueKey];
  return result;
};

Symbol 作为临时属性名,避免和对象已有属性冲突。

手写 apply

call 几乎一样,区别是参数以数组形式传入:

Function.prototype.myApply = function (context, argsArray) {
  if (typeof this !== 'function') {
    throw new TypeError('myApply must be called on a function');
  }

  context = context !== null && context !== undefined ? Object(context) : globalThis;
  const uniqueKey = Symbol('fn');
  context[uniqueKey] = this;
  const result = argsArray
    ? context[uniqueKey](...argsArray)
    : context[uniqueKey]();
  delete context[uniqueKey];
  return result;
};

手写 bind

bind 比前两个复杂,需要处理两件事:返回新函数 + 支持 new 调用。

Function.prototype.myBind = function (context, ...outerArgs) {
  if (typeof this !== 'function') {
    throw new TypeError('myBind must be called on a function');
  }

  const originalFn = this;

  const boundFn = function (...innerArgs) {
    // 如果通过 new 调用,this 应该指向新实例而不是 context
    const isCalledWithNew = this instanceof boundFn;
    return originalFn.apply(
      isCalledWithNew ? this : context,
      outerArgs.concat(innerArgs)
    );
  };

  // 维护原型链,让 new boundFn() 的实例能访问原函数 prototype 上的方法
  if (originalFn.prototype) {
    boundFn.prototype = Object.create(originalFn.prototype);
  }

  return boundFn;
};

关键点:boundFn 内部通过 this instanceof boundFn 判断是否被 new 调用。如果是,this 保持指向新实例;如果不是,使用 bind 时传入的 context

面试高频追问汇总

Q:闭包和作用域链是什么关系? 闭包依赖作用域链实现。函数定义时,引擎会把当前的作用域链保存到函数的 [[Environment]] 内部属性中。当函数在其他位置被调用时,它通过 [[Environment]] 访问定义时的作用域链,这就是闭包。

Q:所有函数都是闭包吗? 从技术角度看,是的。每个函数在创建时都会捕获其词法环境。但实际讨论中,"闭包"通常指的是函数引用了外部变量并且在外部作用域之外被执行的场景。

Q:let 在 for 循环中为什么每次迭代都是新的绑定? ECMAScript 规范规定,for 循环体的每次迭代都会创建新的词法环境,并将上一次迭代的循环变量值复制到新环境中。这是 let 的特殊行为,var 没有这个机制。

Q:this 绑定丢失怎么解决? 三种常见方案:用箭头函数(继承外层 this);用 bind 提前绑定;用变量保存 thisconst self = this,较老的写法)。在 React class 组件中,this.handleClick = this.handleClick.bind(this) 就是为了解决事件回调中 this 丢失的问题。

Q:严格模式对 this 有什么影响? 严格模式下,独立调用函数时 thisundefined,而不是全局对象。这能帮助开发者更早发现 this 指向错误的问题。ES Module 默认运行在严格模式下。

总结

作用域、闭包和 this 是 JavaScript 运行机制的三根支柱,理解它们需要抓住几个核心判断:

  • 变量在哪里能被访问:看词法作用域和作用域链,代码写在哪里就决定了作用域。
  • varletconst 怎么选:默认用 const,需要重新赋值时用 let,不要用 var
  • 闭包是什么:函数 + 它引用的外部变量。只要函数引用了外部变量且可能在外部执行,闭包就产生了。
  • this 指向谁:看调用方式。new > 显式绑定 > 隐式绑定 > 默认绑定。箭头函数没有自己的 this,看外层。
  • call/apply/bind 的区别:前两个立即执行(参数形式不同),bind 返回新函数。