Skip to content

第 04 章 · JavaScript 核心机制(原型 / this / 闭包 / 作用域)

一句话开篇:本章带你彻底搞懂"为什么 JS 这么诡异"——原型、this、闭包、作用域,这四块是面试的"灵魂四问",也是你写出靠谱代码的内功。


0. 生活类比(先建立直觉)

把 JS 这门语言想象成一栋"宜家公寓楼":

JS 概念生活类比
对象一个个房间
原型链你找东西时,先翻自己房间,没有就去隔壁"祖宗房间"翻,再没有就去更上一级,直到顶楼为止
作用域每个房间都有自己的"小账本"(局部变量),还能看到客厅的"大账本"(全局变量)
作用域链找一个名字,先查本房间,没有就出门往外一层层找
执行上下文你每次进入一个房间时,房管员(JS 引擎)给你发的一份"房间手册"
this现在"是谁在使用这个工具"——同一把扳手,张三拿就帮张三修,李四拿就帮李四修
闭包一个"自带小书包"的函数,离开了原来的房间也能从书包里掏出当时的东西

记住一句话:JS 里所有"奇怪"的现象,几乎都能用"作用域 + 原型链 + this"这三把刷子解释清楚。


1. 执行上下文与变量提升

1.1 什么是执行上下文(Execution Context)

JS 引擎在执行你的代码前,会先"打草稿"。这份草稿就叫执行上下文,里面记录着:

  • 变量环境:声明了哪些变量、函数
  • 词法环境:let/const 的块级作用域
  • this 指向:当前的 this 是谁
  • 作用域链:能看到哪些外层变量
text
全局执行上下文 (GEC)
├── 变量环境: { a: undefined, foo: <function> }
├── 词法环境: { b: <uninitialized> }
├── this: window
└── 作用域链: [ 全局 ]

进入 foo() 时压入栈:
函数执行上下文 (FEC for foo)
├── 变量环境: { x: undefined, arguments: {...} }
├── this: ???
└── 作用域链: [ foo, 全局 ]

1.2 调用栈(Call Stack)

执行上下文按"先进后出"压入一个栈,叫调用栈

text
push foo()  →  push bar()  →  bar 执行完 pop  →  foo 执行完 pop
┌──────┐     ┌──────┐         ┌──────┐
│ bar  │     │ bar  │         │      │
├──────┤     ├──────┤         ├──────┤
│ foo  │     │ foo  │         │ foo  │
├──────┤     ├──────┤         ├──────┤
│ GEC  │     │ GEC  │         │ GEC  │
└──────┘     └──────┘         └──────┘

1.3 变量提升(Hoisting)

JS 引擎"打草稿"时,会把 var 声明和 function 声明提升到作用域顶部

js
console.log(a); // undefined(不是报错!)
console.log(foo); // [Function: foo]
console.log(b); // ReferenceError(暂时性死区 TDZ)

var a = 1;
function foo() {}
let b = 2;

等价于:

js
var a;
function foo() {}
console.log(a); // undefined
console.log(foo);
console.log(b); // 此时 b 在 TDZ 里
let b = 2;
声明方式是否提升提升后初始值是否有 TDZ
varundefined
let✅(但不可访问)未初始化
const✅(但不可访问)未初始化
function✅(连函数体一起)函数本身
class✅(但不可访问)未初始化

💡 函数提升优先级高于变量提升。同名时函数会先定义,但后面 var = ... 的赋值会覆盖函数。


2. 作用域与作用域链

2.1 三种作用域

text
┌─────────────────────────────────────┐
│ 全局作用域 (Global Scope)           │
│ ┌─────────────────────────────────┐ │
│ │ 函数作用域 (Function Scope)     │ │
│ │  ┌───────────────────────────┐  │ │
│ │  │ 块级作用域 (Block Scope)  │  │ │
│ │  │  let / const / { ... }    │  │ │
│ │  └───────────────────────────┘  │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
作用域触发方式例子
全局直接写在文件最外层var x = 1
函数函数内部function foo() { var y }
块级{} + let/constif (true) { let z }

2.2 作用域链(Scope Chain)

查找变量时,从当前作用域向外一层层往上找,找到为止;找到全局还没有,报 ReferenceError

js
const a = 1;
function outer() {
  const b = 2;
  function inner() {
    const c = 3;
    console.log(a, b, c); // 1 2 3
    // 查找顺序:inner → outer → 全局
  }
  inner();
}
outer();

重点:作用域是"词法的"(Lexical Scope),由代码"写在哪儿"决定,而不是"在哪儿被调用"。

js
const x = 'global';
function foo() {
  console.log(x); // 'global',因为 foo 写在全局作用域
}
function bar() {
  const x = 'bar';
  foo(); // 还是打印 'global',不是 'bar'
}
bar();

3. 闭包(Closure)

3.1 通俗理解

闭包 = 函数 + 它出生时所在的环境

类比:你出差去了外地(函数被传出去执行了),但你包里还揣着家里的钥匙(外层变量),这个"小书包"就是闭包。

3.2 经典例子

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

const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3

makeCounter 已经执行完了,但内部的 count 因为被返回的函数"引用着",不会被回收——这就是闭包。

3.3 闭包的用途

用途例子
私有变量用闭包封装 count,外部不能直接修改
函数柯里化add(1)(2)(3)
防抖节流用闭包记住 timer 引用
模块模式IIFE + 闭包实现私有方法
React HooksuseState 内部就是闭包实现的

3.4 闭包内存泄漏?

闭包本身不会内存泄漏,但用得不当会导致变量一直被引用、无法回收:

js
function bad() {
  const huge = new Array(1000000).fill('🍔');
  return function () {
    console.log('hi'); // 没用到 huge,但 huge 仍可能被保留
  };
}
const fn = bad();
// huge 可能一直在内存里

💡 现代 V8 引擎做了优化:只保留闭包真正引用的变量,但你不能完全依赖它。


4. this 指向

4.1 一句话规则

this 指向"调用它的那个对象"——和函数定义在哪、写在哪都没关系,只看"谁调用它"。

4.2 五种绑定规则(按优先级从低到高)

规则触发条件this 指向
默认绑定直接 foo()严格模式 undefined,否则 window
隐式绑定obj.foo()obj
显式绑定foo.call(obj) / foo.apply(obj) / foo.bind(obj)()obj
new 绑定new Foo()新建的实例对象
箭头函数() => {}外层作用域的 this(不可改变)

4.3 例子合集

js
// 1. 默认绑定
function foo() { console.log(this); }
foo(); // window(严格模式下 undefined)

// 2. 隐式绑定
const obj = { name: 'Tom', say() { console.log(this.name); } };
obj.say(); // Tom

// 隐式丢失(坑!)
const fn = obj.say;
fn(); // undefined(this 变成了 window)

// 3. 显式绑定
function greet() { console.log(this.name); }
greet.call({ name: 'Jerry' }); // Jerry

// 4. new 绑定
function Person(name) { this.name = name; }
const p = new Person('Alice');
console.log(p.name); // Alice

// 5. 箭头函数
const obj2 = {
  name: 'Bob',
  say: () => console.log(this.name) // undefined,箭头函数 this 是外层(这里是 window)
};
obj2.say();

4.4 call / apply / bind 三兄弟

方法立即执行参数形式返回值
call一个一个传:fn.call(obj, a, b)函数返回值
apply数组传:fn.apply(obj, [a, b])函数返回值
bind一个一个传:fn.bind(obj, a)新函数
js
function add(a, b) { return this.base + a + b; }
add.call({ base: 10 }, 1, 2);   // 13
add.apply({ base: 10 }, [1, 2]); // 13
const bound = add.bind({ base: 10 }, 1);
bound(2); // 13

5. 原型与原型链

5.1 类比:找东西的"辈分关系"

text
你(实例对象)
 │ __proto__

你爸(构造函数 prototype)
 │ __proto__

你爷爷(Object.prototype)
 │ __proto__

null(祖宗到顶了)

找一个属性时,先翻自己口袋,没有就翻爸的口袋,再没有就翻爷爷的,直到 null

5.2 关键概念

名词解释谁有
prototype显式原型,只有函数才有函数
__proto__隐式原型,所有对象都有对象
constructor指回构造函数本身原型对象上

核心公式:

js
实例.__proto__ === 构造函数.prototype
构造函数.prototype.constructor === 构造函数

5.3 原型链示意

text
function Person(name) { this.name = name }
Person.prototype.sayHi = function() {}

const tom = new Person('Tom')

  tom              Person.prototype          Object.prototype       null
   │ __proto__         │ __proto__                │ __proto__         ▲
   └─────────────────► ├ constructor: Person  ──► ├ toString          │
                       └ sayHi                    └ hasOwnProperty    │

                                                              ────────┘

5.4 验证

js
function Person(name) { this.name = name; }
Person.prototype.sayHi = function () { console.log('hi, ' + this.name); };

const tom = new Person('Tom');

tom.sayHi(); // hi, Tom
tom.__proto__ === Person.prototype; // true
Person.prototype.__proto__ === Object.prototype; // true
Object.prototype.__proto__ === null; // true

5.5 ES6 class 只是语法糖

js
class Animal {
  constructor(name) { this.name = name; }
  eat() { console.log(this.name + ' eating'); }
}

// 等价于
function Animal(name) { this.name = name; }
Animal.prototype.eat = function () { console.log(this.name + ' eating'); };

5.6 instanceof 原理

a instanceof B 的本质:沿着 a 的原型链一路向上找,看能不能找到 B.prototype

js
function myInstanceof(left, right) {
  let proto = Object.getPrototypeOf(left);
  while (proto) {
    if (proto === right.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}

6. new 操作符做了什么

js
function myNew(Constructor, ...args) {
  // 1. 创建一个空对象,原型指向 Constructor.prototype
  const obj = Object.create(Constructor.prototype);
  // 2. 把 this 绑到这个新对象上,执行构造函数
  const result = Constructor.apply(obj, args);
  // 3. 如果构造函数返回的是对象,则用它,否则用新对象
  return (typeof result === 'object' && result !== null) ? result : obj;
}

7. ⚠️ 易踩的坑

  • 坑 1:箭头函数的 this 不能被 call/bind 改变。 箭头函数没有自己的 this,看外层。
  • 坑 2:var 在 for 循环里只有一个! for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)) 打印三个 3。换成 let 就正常了,因为 let 每次循环创建一个新作用域。
  • 坑 3:构造函数返回了对象,new 出来的就是那个对象,不是 this。
  • 坑 4:原型链上找不到属性返回 undefined,不是报错。
  • 坑 5:修改 prototype 会影响所有实例——包括已经创建的(如果是新增方法)。
  • 坑 6:bind 多次绑定,只有第一次生效。 fn.bind(a).bind(b) 的 this 还是 a
  • 坑 7:call(null) 在非严格模式下 this 会变成 window
  • 坑 8:obj.fn 解构赋值后调用,this 丢失。 const { fn } = obj; fn();

8. 一句话总结

JS 的核心 = 执行上下文告诉你"现在在哪",作用域链告诉你"能看见什么",this 告诉你"谁在调用我",原型链告诉你"找不到属性时去哪儿继续找",闭包让函数带着出生地的环境走天下。


9. 延伸阅读