JavaScript 核心概念:作用域、闭包、this 与原型链

2024-08-31 前端

引言

JavaScript 是一门既简单又复杂的语言。说它简单,是因为短短几行代码就能写出可运行的交互页面;说它复杂,是因为作用域(Scope)、闭包(Closure)、this 绑定和原型链(Prototype Chain) 这四个核心概念相互交织,构成了语言最深层的运行机制。

理解这四个概念,是从「会用 JavaScript」迈向「懂 JavaScript」的必经之路。无论你是准备面试、阅读源码,还是写出更健壮的生产代码,这篇文章都将为你提供透彻的讲解和完整的可运行示例。


1. 执行上下文与调用栈

1.1 什么是执行上下文

执行上下文(Execution Context)是 JavaScript 引擎在执行代码时创建的内部数据结构,它记录了代码运行所需的全部环境信息。通俗地说,执行上下文就是代码的「运行环境」。

JavaScript 中有三种执行上下文:

上下文类型 说明
全局执行上下文 代码首次运行时创建,每个程序只有一个
函数执行上下文 每次调用函数时创建,可能有多个
eval 执行上下文 eval 函数内部代码的上下文(不推荐使用)
// 全局执行上下文 — 程序启动时创建
const globalVar = '我是全局变量';

function outer() {
  // 调用 outer() 时创建 outer 的函数执行上下文
  const outerVar = '我是 outer 的变量';

  function inner() {
    // 调用 inner() 时创建 inner 的函数执行上下文
    const innerVar = '我是 inner 的变量';
    console.log(innerVar); // '我是 inner 的变量'
    console.log(outerVar); // '我是 outer 的变量' — 通过作用域链访问
    console.log(globalVar); // '我是全局变量' — 通过作用域链访问
  }

  inner();
}

outer();

1.2 创建阶段 vs 执行阶段

每个执行上下文经历两个阶段:

① 创建阶段(Creation Phase):

  • 创建变量对象(Variable Object, VO) / 活动对象(Activation Object, AO)
  • 建立作用域链(Scope Chain)
  • 确定 this 指向

② 执行阶段(Execution Phase):

  • 逐行执行代码
  • 变量赋值、函数调用
console.log(a); // undefined(不是 ReferenceError!)
var a = 10;
console.log(a); // 10

// 上述代码在引擎眼中等同于此:
// 创建阶段:
//   VO = { a: undefined }
// 执行阶段:
//   console.log(a);  → undefined
//   a = 10;
//   console.log(a);  → 10

1.3 变量对象与活动对象

  • 变量对象(VO):全局上下文中,VO 就是全局对象(浏览器中的 window,Node.js 中的 global)
  • 活动对象(AO):函数上下文中,VO 被称为 AO,包含 arguments 对象和所有形参
function greet(name) {
  // 创建阶段的 AO:
  // AO = {
  //   arguments: { 0: 'Alice', length: 1 },
  //   name: 'Alice',
  //   greeting: undefined  // var 声明提升
  // }
  var greeting = 'Hello, ' + name;
  return greeting;
}

greet('Alice'); // 'Hello, Alice'

1.4 调用栈机制

JavaScript 是单线程语言,使用调用栈(Call Stack) 来管理函数调用。每当调用一个函数,引擎就将该函数的执行上下文压入栈顶;函数返回时,从栈顶弹出。

function multiply(a, b) {
  return a * b;
}

function square(n) {
  return multiply(n, n);
}

function main() {
  const result = square(5);
  console.log(result); // 25
}

main();

// 调用栈变化过程:
// 1. [全局上下文]
// 2. [全局上下文, main 上下文]
// 3. [全局上下文, main 上下文, square 上下文]
// 4. [全局上下文, main 上下文, square 上下文, multiply 上下文]
// 5. [全局上下文, main 上下文, square 上下文]  ← multiply 返回
// 6. [全局上下文, main 上下文]                   ← square 返回
// 7. [全局上下文]                                ← main 返回

1.5 栈溢出与尾调用优化

栈溢出(Stack Overflow): 当递归调用过深,调用栈超过浏览器限制时抛出 Maximum call stack size exceeded。

// 危险:无终止条件的递归 → 栈溢出
function infiniteRecursion() {
  return infiniteRecursion();
}

// infiniteRecursion(); // RangeError: Maximum call stack size exceeded

// 安全:有终止条件的递归
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

console.log(factorial(5)); // 120

尾调用优化(Tail Call Optimization, TCO): 如果函数的最后一步是调用另一个函数(且不依赖当前栈的变量),引擎可以复用当前栈帧,避免栈增长。ES6 规范中规定了尾调用优化,但并非所有浏览器都实现了它。

// 非尾调用:需要在乘法后保留栈帧
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1); // 乘法在递归返回后执行
}

// 尾调用优化版本
function factorialTail(n, acc = 1) {
  if (n <= 1) return acc;
  return factorialTail(n - 1, n * acc); // 最后一步仅是函数调用
}

console.log(factorialTail(5)); // 120

2. 作用域(Scope)

2.1 作用域的类型

作用域决定了变量和函数的可访问范围。JavaScript 有三种主要作用域:

全局作用域

在任何函数或块之外声明的变量属于全局作用域,程序任何位置都可访问。

// 全局作用域
const globalVar = '我在任何地方都能被访问';

function test() {
  console.log(globalVar); // '我在任何地方都能被访问'
}

test();
console.log(globalVar); // '我在任何地方都能被访问'

函数作用域

使用 var 或在函数内声明的变量,只在函数内部可访问。

function funcScope() {
  var message = '我只能在这个函数内部访问';
  console.log(message); // 正常
}

funcScope();
// console.log(message); // ReferenceError: message is not defined

块级作用域(ES6)

let 和 const 将变量限制在最近的 {} 块内(如 if、for、while)。

{
  let blockVar = '我只能在这个块内访问';
  const alsoBlock = '我也是';
  var notBlocked = '我逃出了这个块';
}

// console.log(blockVar);  // ReferenceError
// console.log(alsoBlock); // ReferenceError
console.log(notBlocked); // '我逃出了这个块' — var 无视块作用域

if (true) {
  let x = 10;
  const y = 20;
  var z = 30;
}

console.log(z); // 30
// console.log(x); // ReferenceError
// console.log(y); // ReferenceError

2.2 词法作用域(静态作用域)

JavaScript 采用词法作用域(Lexical Scoping),也叫静态作用域。变量的作用域在编写代码时就已确定,而非在运行时。内部函数可以访问外部函数中声明的变量。

const outerVar = '全局';

function outer() {
  const outerVar = '外部';

  function inner() {
    const innerVar = '内部';
    console.log(outerVar); // '外部' — 依据定义位置,而非调用位置
    console.log(globalVar); // '全局'
  }

  inner();
}

outer();

理解词法作用域的关键:函数的作用域在定义时决定,不在调用时决定。

const value = '全局';

function foo() {
  console.log(value); // 输出什么?
}

function bar() {
  const value = 'bar 内部';
  foo(); // 调用 foo,但 foo 的作用域在定义时就固定了!
}

bar(); // 输出 '全局',不是 'bar 内部'

2.3 作用域链

当访问一个变量时,JavaScript 引擎会从当前作用域开始查找,逐级向上直到全局作用域。这个查找路径就是作用域链(Scope Chain)。

const global = 'GLOBAL';

function outer() {
  const outerVar = 'OUTER';

  function middle() {
    const middleVar = 'MIDDLE';

    function inner() {
      const innerVar = 'INNER';

      // 作用域链:inner → middle → outer → 全局
      console.log(innerVar);  // 'INNER'   — 当前作用域
      console.log(middleVar); // 'MIDDLE'  — 上一级作用域
      console.log(outerVar);  // 'OUTER'   — 上两级作用域
      console.log(global);    // 'GLOBAL'  — 全局作用域
    }

    inner();
  }

  middle();
}

outer();

作用域链的遮蔽效应: 如果内层和外层有同名变量,内层变量会「遮蔽」外层变量。

let name = '全局';

function showName() {
  let name = '局部';
  console.log(name); // '局部' — 局部变量遮蔽了全局变量
}

showName();
console.log(name); // '全局' — 全局变量不受影响

2.4 var vs let/const 全面对比

特性 var let / const
作用域 函数作用域 块级作用域
提升 声明提升,初始值 undefined 声明提升,但存在 TDZ
重复声明 允许 不允许
全局声明挂载到 window 是 否
能否在声明前访问 能(值为 undefined) 不能(ReferenceError)
// 重复声明
var a = 1;
var a = 2; // ✅ 允许
console.log(a); // 2

// let b = 1;
// let b = 2; // ❌ SyntaxError: Identifier 'b' has already been declared

// 全局挂载
var globalVar = 'hello';
console.log(window.globalVar); // 'hello'(浏览器环境)

const globalConst = 'world';
console.log(window.globalConst); // undefined

2.5 模块作用域

ES6 模块(import / export)中,每个文件都有自己独立的模块作用域。模块顶层声明的变量不会泄露到全局。

// 📁 module-a.js
export const shared = '共享变量';
const privateVar = '仅模块内部可见'; // 不导出,外部无法访问

// 📁 module-b.js
import { shared } from './module-a.js';
console.log(shared); // '共享变量'
// console.log(privateVar); // ReferenceError: privateVar is not defined

3. 变量提升(Hoisting)

3.1 var 声明提升

var 声明的变量会被提升到当前函数(或全局)作用域的顶部,初始值为 undefined。

console.log(age); // undefined(不是 ReferenceError。)
var age = 25;
console.log(age); // 25

// 引擎实际执行的等价代码:
// var age;
// console.log(age); // undefined
// age = 25;
// console.log(age); // 25

3.2 function 声明提升

函数声明会被完整提升——函数名和函数体都会提升到作用域顶部。这意味着你可以在函数定义之前调用它。

sayHello(); // 'Hello!' — 函数声明被完整提升

function sayHello() {
  console.log('Hello!');
}

对比:函数声明 vs 函数表达式

// 函数声明 — 完整提升 ✅
foo(); // '我是函数声明'
function foo() {
  console.log('我是函数声明');
}

// 函数表达式 var — 只有变量提升,函数体不提升 ❌
// bar(); // TypeError: bar is not a function
var bar = function() {
  console.log('我是函数表达式');
};
bar(); // '我是函数表达式' ✅ (赋值之后)

// 函数表达式 let/const — TDZ 阻止访问 ❌
// baz(); // ReferenceError: Cannot access 'baz' before initialization
const baz = function() {
  console.log('我是 const 函数表达式');
};
baz(); // 正常 ✅

3.3 let/const 与暂时性死区(TDZ)

let 和 const 也会被提升,但在声明之前访问它们会抛出 ReferenceError。从作用域开始到声明之间的区域称为暂时性死区(Temporal Dead Zone, TDZ)。

{
  // TDZ 开始 — x 已存在于块作用域,但不可访问
  // console.log(x); // ReferenceError: Cannot access 'x' before initialization

  const y = 10; // TDZ 结束 — 从此处开始可以访问
  console.log(y); // 10

  let x = 5;
  // 还有一点:typeof 在 TDZ 中也会抛出错误
  // console.log(typeof x); // 正常,此时 x 已初始化
}

// 有趣的现象:typeof 对未声明的变量是安全的,但对 TDZ 中的变量不安全
// console.log(typeof notDeclared); // 'undefined' — 安全
// console.log(typeof tdzVar);      // ReferenceError — ❌
// let tdzVar = 'hi';

经典面试题:

var a = 1;
function test() {
  console.log(a); // undefined(不是 1,也不是 ReferenceError)
  var a = 2;
  console.log(a); // 2
}
test();

// 等价于:
// function test() {
//   var a;          // 提升到函数顶部,遮蔽外部 a
//   console.log(a); // undefined
//   a = 2;
//   console.log(a); // 2
// }

4. 闭包(Closure)

4.1 闭包的定义与形成条件

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

形成闭包需要满足三个条件:

  1. 存在函数嵌套(内层函数引用外层函数的变量)
  2. 内层函数引用了外层函数的变量
  3. 内层函数在外层函数执行完毕之后仍可被调用
function createCounter() {
  let count = 0; // 外层函数的变量

  // 内层函数形成了一个闭包
  return function() {
    count++; // 引用外层变量
    return count;
  };
}

const counter = createCounter();
// 此时 createCounter 已执行完毕,但 count 仍然存在
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

4.2 常见闭包场景

场景一:计数器

function makeCounter(initial = 0) {
  let count = initial;

  return {
    increment: () => ++count,
    decrement: () => --count,
    reset: () => { count = initial; return count; },
    getValue: () => count
  };
}

const myCounter = makeCounter(10);
console.log(myCounter.increment()); // 11
console.log(myCounter.increment()); // 12
console.log(myCounter.decrement()); // 11
console.log(myCounter.reset());     // 10

场景二:私有变量

JavaScript 没有真正的「私有字段」(ES2022 之前),闭包是实现私有变量的经典方式。

function createPerson(name, age) {
  // name 和 age 是「私有」变量
  let _name = name;
  let _age = age;

  return {
    getName: () => _name,
    setName: (newName) => { _name = newName; },
    getAge: () => _age,
    setAge: (newAge) => {
      if (newAge > 0 && newAge < 150) {
        _age = newAge;
      } else {
        console.error('无效年龄');
      }
    }
  };
}

const person = createPerson('张三', 30);
console.log(person.getName()); // '张三'
person.setAge(31);
console.log(person.getAge());  // 31
// 无法直接访问 _name 或 _age
// console.log(person._name); // undefined

场景三:柯里化(Currying)

柯里化将多参数函数转换为一系列单参数函数,闭包是其实现基础。

// 普通版本
function add(a, b, c) {
  return a + b + c;
}

// 柯里化版本
function curryAdd(a) {
  return function(b) {
    return function(c) {
      return a + b + c; // a 和 b 通过闭包保留
    };
  };
}

console.log(add(1, 2, 3));        // 6
console.log(curryAdd(1)(2)(3));   // 6

// 实用例子:预配置函数
function multiply(x) {
  return function(y) {
    return x * y;
  };
}

const double = multiply(2);
const triple = multiply(3);

console.log(double(5));  // 10
console.log(triple(5));  // 15

场景四:事件监听中的闭包

function setupButtons() {
  const buttons = document.querySelectorAll('button');

  // 错误方式:点击所有按钮都打印 3
  for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
      console.log('Button ' + i + ' clicked'); // 总是 3
    });
  }

  // 正确方式(IIFE 闭包)
  for (var i = 0; i < buttons.length; i++) {
    (function(index) {
      buttons[index].addEventListener('click', function() {
        console.log('Button ' + index + ' clicked');
      });
    })(i);
  }

  // 更佳方式:使用 let
  for (let i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
      console.log('Button ' + i + ' clicked');
    });
  }
}

4.3 经典问题:for 循环 + var 的闭包陷阱

这是面试中最经典的闭包问题:

// 问题:打印出来全是 3
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 3, 3, 3
  }, 100);
}

原因: var i 是函数作用域,循环结束后 i 已经变为 3。所有 setTimeout 回调共享同一个 i 变量。

四种解决方案:

// 方案 1:IIFE(立即执行函数)— 为每次循环创建独立作用域
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 0, 1, 2
    }, 100);
  })(i);
}

// 方案 2:let — 为每次迭代创建独立的块级作用域
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i); // 0, 1, 2
  }, 100);
}

// 方案 3:setTimeout 第三个参数(将参数传递给回调函数)
for (var i = 0; i < 3; i++) {
  setTimeout(function(j) {
    console.log(j); // 0, 1, 2
  }, 100, i);
}

// 方案 4:用 bind 创建新函数
for (var i = 0; i < 3; i++) {
  setTimeout(console.log.bind(console, i), 100); // 0, 1, 2
}

4.4 闭包内存管理

闭包会导致外部函数的变量不会被垃圾回收(因为内层函数仍然持有引用),如果不加控制可能造成内存泄漏。

function createLargeArray() {
  const large = new Array(1000000).fill('数据');

  return {
    getData: function() {
      return large; // large 数组被闭包引用,无法被回收
    },
    getLength: function() {
      return large.length; // 同样持有引用
    }
  };
}

const obj = createLargeArray();
// large 数组会一直存在于内存中
console.log(obj.getLength()); // 1000000

// 当不再需要时,释放引用
// obj = null; // 解除对返回对象的引用 → large 可被回收

最佳实践:

  • 闭包只引用必要的变量,不要无意识地捕获整个作用域
  • 闭包不再使用时,主动解除引用(设为 null)
  • 在现代浏览器中,引擎已经优化了闭包的内存管理,但大型数据仍需注意

4.5 实际应用:防抖与节流

防抖(Debounce)

防抖将多次高频触发合并为最后一次执行。闭包用于保存 timer 变量。

function debounce(fn, delay = 300) {
  let timer = null; // 闭包:保存定时器 ID

  return function(...args) {
    const context = this;

    // 每次调用都清除上一次的定时器
    if (timer) clearTimeout(timer);

    // 重新设置定时器
    timer = setTimeout(() => {
      fn.apply(context, args);
      timer = null;
    }, delay);
  };
}

// 使用示例
function search(query) {
  console.log('搜索:', query);
}

const debouncedSearch = debounce(search, 500);
debouncedSearch('J');           // 定时器重置
debouncedSearch('Ja');          // 定时器重置
debouncedSearch('Jav');         // 定时器重置
debouncedSearch('Java');        // 定时器重置
// 只有最后一次调用在 500ms 后真正执行

节流(Throttle)

节流确保函数在指定时间间隔内最多执行一次。闭包保存 lastTime 或 timer。

function throttle(fn, interval = 300) {
  let lastTime = 0; // 闭包:保存上次执行时间

  return function(...args) {
    const context = this;
    const now = Date.now();

    if (now - lastTime >= interval) {
      lastTime = now;
      fn.apply(context, args);
    }
  };
}

// 使用示例
function handleScroll() {
  console.log('滚动时间:', Date.now());
}

const throttledScroll = throttle(handleScroll, 1000);
// 触发滚动时,每秒最多执行一次
window.addEventListener('scroll', throttledScroll);

模块模式(Module Pattern)

在 ES6 模块出现之前,闭包是实现模块化的标准方式。


5. this 关键字

this 是 JavaScript 中最令人困惑的概念之一。它的指向不是在定义时确定的,而是在函数调用时根据调用方式动态确定(箭头函数除外)。

5.1 默认绑定

非严格模式: this 指向全局对象(浏览器中的 window,Node.js 中的 global)。

严格模式: this 为 undefined。

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

showThis(); // 浏览器: window | Node: global

// 严格模式
function showThisStrict() {
  'use strict';
  console.log(this);
}

showThisStrict(); // undefined

5.2 隐式绑定

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

const user = {
  name: '张三',
  greet() {
    console.log(`你好,我是 ${this.name}`);
  }
};

user.greet(); // '你好,我是 张三' — this → user

// 但注意:方法赋给变量后再调用,隐式绑定会丢失
const greetFn = user.greet;
greetFn(); // '你好,我是 undefined' — this → window/undefined

链式调用时的 this:

const obj = {
  a: 1,
  b: {
    a: 2,
    getA() {
      console.log(this.a);
    }
  }
};

obj.b.getA(); // 2 — this → obj.b(点号左侧的最后一个对象)

5.3 显式绑定:call、apply、bind

我们可以用 call、apply 和 bind 手动指定函数的 this 指向。

function introduce(greeting, punctuation) {
  console.log(`${greeting},我是 ${this.name}${punctuation}`);
}

const person1 = { name: 'Alice' };
const person2 = { name: 'Bob' };

// call:参数列表形式
introduce.call(person1, '你好', '!'); // '你好,我是 Alice!'

// apply:参数数组形式
introduce.apply(person2, ['Hello', '!']); // 'Hello,我是 Bob!'

// bind:返回新函数,this 永久绑定
const boundIntroduce = introduce.bind(person1, '嗨');
boundIntroduce('~'); // '嗨,我是 Alice~'

bind 的「永久绑定」特性:

const obj1 = { x: 1 };
const obj2 = { x: 2 };

function getX() {
  console.log(this.x);
}

const bound = getX.bind(obj1);
bound();          // 1

// 即使再次 bind 或 call,也无法改变 this
bound.call(obj2); // 1 — 还是 1!bind 的 this 不可覆盖
bound.bind(obj2)(); // 1

// 只有 new 可以覆盖 bind 的 this(构造函数场景)

call/apply 的实际应用:借用方法

// 借用数组方法操作类数组对象
const arrayLike = { 0: 'a', 1: 'b', 2: 'c', length: 3 };
const realArray = Array.prototype.slice.call(arrayLike);
console.log(realArray); // ['a', 'b', 'c']

// 借用 Object.prototype.toString 判断类型
function getType(value) {
  return Object.prototype.toString.call(value);
}

console.log(getType([]));     // '[object Array]'
console.log(getType({}));     // '[object Object]'
console.log(getType(''));     // '[object String]'
console.log(getType(null));   // '[object Null]'

5.4 new 绑定

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

function Person(name, age) {
  // 创建新对象 const this = {};(引擎自动完成)
  this.name = name;
  this.age = age;
  // return this;(引擎自动完成)
}

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

// 如果没有 new,this 会指向全局对象(非严格模式)或 undefined(严格模式)
const bob = Person('Bob', 30); // 忘记 new
console.log(bob); // undefined — 函数没有 return

new 操作符的内部机制:

function myNew(Constructor, ...args) {
  // 1. 创建一个新对象,原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);

  // 2. 执行构造函数,this 指向新对象
  const result = Constructor.apply(obj, args);

  // 3. 如果构造函数返回了对象,返回该对象;否则返回新创建的对象
  return (typeof result === 'object' && result !== null) ? result : obj;
}

function Car(brand) {
  this.brand = brand;
}

const myCar = myNew(Car, 'Tesla');
console.log(myCar.brand); // 'Tesla'
console.log(myCar instanceof Car); // true

5.5 箭头函数:词法 this

箭头函数没有自己的 this,它的 this 继承自定义时的外层作用域(词法作用域)。箭头函数的 this 是静态的,在定义时就已确定,且无法通过 call、apply、bind 修改。

const obj = {
  name: '箭头函数测试',
  regularFunc: function() {
    console.log('普通函数:', this.name);

    const arrow = () => {
      console.log('箭头函数:', this.name);
    };

    arrow();
  },
  arrowFunc: () => {
    console.log('对象方法箭头函数:', this.name);
  }
};

obj.regularFunc();
// 普通函数: 箭头函数测试
// 箭头函数: 箭头函数测试 — 继承了 regularFunc 的 this

obj.arrowFunc();
// 对象方法箭头函数: undefined — this 指向外层(全局),不是 obj

箭头函数的 this 不可被修改:

const obj1 = { name: 'obj1' };
const obj2 = { name: 'obj2' };

const arrow = () => {
  console.log(this.name);
};

// 定义时 this 指向全局(name 为 undefined)
arrow.call(obj1); // undefined
arrow.apply(obj2); // undefined
arrow.bind(obj1)(); // undefined

// 这与普通函数形成鲜明对比
function normal() {
  console.log(this.name);
}

normal.call(obj1); // 'obj1'

箭头函数的典型应用场景:

5.6 this 绑定优先级

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

new 绑定 > 显式绑定(bind/call/apply) > 隐式绑定(对象方法) > 默认绑定(全局/undefined)

// 验证优先级
function foo() {
  console.log(this.name);
}

const obj1 = { name: 'obj1' };
const obj2 = { name: 'obj2' };

// 隐式 vs 显式:显式更高
foo.bind(obj1).call(obj2); // 'obj1' — bind 优先级高于 call

// 标准验证
const boundFoo = foo.bind(obj1);
boundFoo(); // 'obj1' — 显式绑定

// new vs bind:new 更高
function Bar(name) {
  this.name = name;
}

const BoundBar = Bar.bind({ name: 'bind 对象' });
const instance = new BoundBar('实例对象');
console.log(instance.name); // '实例对象' — new 覆盖了 bind 的 this

// 箭头函数例外:箭头函数优先级无意义(它没有自己的 this)

5.7 绑定丢失的常见场景

const user = {
  name: '张三',
  greet() {
    console.log(`你好,我是 ${this.name}`);
  }
};

// 场景 1:方法赋值给变量
const fn = user.greet;
fn(); // '你好,我是 undefined' — this 丢失

// 场景 2:作为回调传入
setTimeout(user.greet, 100); // '你好,我是 undefined'

// 场景 3:作为事件处理器
// element.onclick = user.greet; // 点击时 this → element

// 解决方式:
// 1. 用 bind 绑定
const boundGreet = user.greet.bind(user);
boundGreet(); // '你好,我是 张三'
setTimeout(user.greet.bind(user), 100); // '你好,我是 张三'

// 2. 用箭头函数包裹
setTimeout(() => user.greet(), 100); // '你好,我是 张三'

6. 原型与原型链

6.1 proto 与 prototype 的关系

这是 JavaScript 中最容易混淆的一对概念:

属性 属于谁 指向谁
prototype 函数(构造函数) 该函数的「原型对象」
__proto__ 所有对象(包括函数) 该对象的「构造函数的 prototype」
// 构造函数
function Animal(name) {
  this.name = name;
}

// 往构造函数的 prototype 上添加方法
Animal.prototype.sayName = function() {
  console.log(`我是 ${this.name}`);
};

const dog = new Animal('旺财');

// 关系图:
// dog.__proto__ === Animal.prototype           // true
// Animal.prototype.constructor === Animal       // true
// Animal.__proto__ === Function.prototype      // true(函数是 Function 的实例)

console.log(dog.__proto__ === Animal.prototype); // true
console.log(Animal.prototype.constructor === Animal); // true
console.log(Animal.__proto__ === Function.prototype); // true

图解原型关系:

Animal (构造函数)
  ├── Animal.prototype ──────────→ { constructor: Animal, sayName: fn, __proto__: Object.prototype }
  └── Animal.__proto__ ──────────→ Function.prototype

dog (实例)
  └── dog.__proto__ ─────────────→ Animal.prototype
                                    └── Animal.prototype.__proto__ ──→ Object.prototype
                                                                       └── Object.prototype.__proto__ ──→ null

6.2 原型链查找机制

当访问对象的属性或方法时,JavaScript 引擎按以下顺序查找:

  1. 对象自身(自有属性)
  2. 对象.__proto__(构造函数的 prototype)
  3. 对象.__proto__.__proto__(上一级原型)
  4. ... 直到 Object.prototype.__proto__ 为 null(查找结束)
const grandParent = { inherited: '来自祖父' };
const parent = Object.create(grandParent);
parent.own = '来自父';
const child = Object.create(parent);
child.own = '来自子';

console.log(child.own);       // '来自子' — 自有属性
console.log(child.parentOwn); // undefined — 不存在
console.log(child.inherited); // '来自祖父' — 沿着原型链找到

// 原型链可视化
// child → parent → grandParent → Object.prototype → null

完整原型链示例:

const arr = [1, 2, 3];

// arr 的原型链
console.log(arr.__proto__ === Array.prototype);       // true
console.log(Array.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__);              // null

// 因此 arr 可以访问:
arr.push(4);           // Array.prototype 的方法
arr.toString();        // Object.prototype 的方法(被 Array 重写)
arr.hasOwnProperty(0); // Object.prototype 的方法

6.3 instanceof 原理

instanceof 运算符检查构造函数的 prototype 属性是否出现在实例的原型链上。

function MyClass() {}
const obj = new MyClass();

console.log(obj instanceof MyClass); // true
console.log(obj instanceof Object);  // true
console.log(obj instanceof Array);   // false

// 手动实现 instanceof
function myInstanceof(instance, constructor) {
  let proto = Object.getPrototypeOf(instance);
  const prototype = constructor.prototype;

  while (proto !== null) {
    if (proto === prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }

  return false;
}

console.log(myInstanceof(obj, MyClass)); // true
console.log(myInstanceof(obj, Object));  // true

// 一个有趣的例子
const str = 'hello';
console.log(str instanceof String); // false — 原始类型不是对象

const strObj = new String('hello');
console.log(strObj instanceof String); // true

6.4 hasOwnProperty vs in 操作符

操作 检查范围
hasOwnProperty 仅检查对象自身的属性
in 操作符 检查对象自身 + 原型链上的属性
function Parent() {
  this.parentProp = '父属性';
}

Parent.prototype.sharedMethod = function() {
  console.log('共享方法');
};

function Child() {
  Parent.call(this);
  this.childProp = '子属性';
}

Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

const child = new Child();

console.log(child.hasOwnProperty('childProp'));    // true — 自有属性
console.log(child.hasOwnProperty('parentProp'));   // true — Parent.call 添加的,也是自有
console.log(child.hasOwnProperty('sharedMethod')); // false — 原型上的方法

console.log('childProp' in child);    // true
console.log('parentProp' in child);   // true
console.log('sharedMethod' in child); // true — in 操作符会沿原型链查找

// 安全的 hasOwnProperty 调用(防止对象重写该方法)
const obj = { hasOwnProperty: null };
// obj.hasOwnProperty('xxx'); // TypeError
console.log(Object.prototype.hasOwnProperty.call(obj, 'xxx')); // false ✅

6.5 原型继承的实现

原型继承有多种实现方式,以下是几种常见模式:

原型链继承

function Parent(name) {
  this.name = name;
  this.hobbies = ['读书', '跑步'];
}

Parent.prototype.sayHello = function() {
  console.log(`你好,我是 ${this.name}`);
};

function Child(name, age) {
  this.age = age;
}

// 子类原型指向父类实例
Child.prototype = new Parent();
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() {
  console.log(`我 ${this.age} 岁`);
};

const child1 = new Child('小明', 10);
const child2 = new Child('小红', 12);

// ⚠️ 问题:引用类型属性被所有实例共享
child1.hobbies.push('画画');
console.log(child2.hobbies); // ['读书', '跑步', '画画'] — 被意外修改了!

构造函数继承(经典继承)

function Parent(name) {
  this.name = name;
  this.hobbies = ['读书', '跑步'];
}

function Child(name, age) {
  // 在子类构造函数中调用父类构造函数
  Parent.call(this, name);
  this.age = age;
}

const child1 = new Child('小明', 10);
const child2 = new Child('小红', 12);

child1.hobbies.push('画画');
console.log(child1.hobbies); // ['读书', '跑步', '画画']
console.log(child2.hobbies); // ['读书', '跑步'] — 互不影响 ✅

// ⚠️ 问题:无法继承父类原型上的方法
// child1.sayHello(); // TypeError: child1.sayHello is not a function

组合继承(最常用)

function Parent(name) {
  this.name = name;
  this.hobbies = ['读书', '跑步'];
}

Parent.prototype.sayHello = function() {
  console.log(`你好,我是 ${this.name}`);
};

function Child(name, age) {
  Parent.call(this, name); // 第二次调用 Parent(实例属性)
  this.age = age;
}

Child.prototype = new Parent();  // 第一次调用 Parent(原型方法)
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() {
  console.log(`我 ${this.age} 岁`);
};

const child = new Child('小明', 10);
child.sayHello(); // '你好,我是 小明' ✅
child.hobbies.push('画画');

// ⚠️ 小缺点:Parent 构造函数被调用了两次

寄生组合继承(最优方案)

function Parent(name) {
  this.name = name;
  this.hobbies = ['读书', '跑步'];
}

Parent.prototype.sayHello = function() {
  console.log(`你好,我是 ${this.name}`);
};

function Child(name, age) {
  Parent.call(this, name); // 只调用一次 Parent
  this.age = age;
}

// 关键:使用 Object.create 避免调用父类构造函数
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;
Child.prototype.sayAge = function() {
  console.log(`我 ${this.age} 岁`);
};

const child = new Child('小明', 10);
console.log(child instanceof Child);  // true
console.log(child instanceof Parent); // true
child.sayHello(); // '你好,我是 小明'

6.6 ES6 class 语法糖

ES6 的 class 本质上是原型继承的语法糖,底层机制完全相同。

class 与构造函数的对比:

// class 写法
class Rectangle {
  constructor(w, h) {
    this.w = w;
    this.h = h;
  }

  area() {
    return this.w * this.h;
  }
}

// 等价的构造函数写法
function RectangleConstructor(w, h) {
  this.w = w;
  this.h = h;
}

RectangleConstructor.prototype.area = function() {
  return this.w * this.h;
};

// 用法完全一致
const r1 = new Rectangle(3, 4);
const r2 = new RectangleConstructor(3, 4);
console.log(r1.area()); // 12
console.log(r2.area()); // 12

class 的独有特性(语法糖之外的增强):

class Example {
  // 1. 类体中的代码自动在严格模式下运行
  // 2. 类声明不会被提升
  // 3. 必须用 new 调用,否则报错
  constructor() {}
}

// Example(); // TypeError: Class constructor Example cannot be invoked without 'new'

// 4. 类方法不可枚举
console.log(Object.keys(Example.prototype)); // [](普通构造函数的方法是可枚举的)

总结:四大核心概念的关系

这四个概念并非孤立存在,它们交织形成了 JavaScript 的运行时骨架:

  1. 执行上下文 是代码的运行环境,作用域决定了变量在该环境中如何组织与访问
  2. 作用域链 让闭包成为可能——函数可以「记住」创建时的作用域,即使在该作用域之外执行
  3. this 绑定 提供了动态的上下文切换,而箭头函数用词法 this 弥合了闭包与 this 之间的鸿沟
  4. 原型链 是 JavaScript 的继承机制,所有对象通过 __proto__ 连接成链,实现了属性查找和方法共享

一句话总结:

执行上下文管理运行环境,作用域与闭包控制变量的可访问性和生命周期,this 决定函数执行时的上下文对象,原型链是对象间的继承机制。四者共同构成了 JavaScript 语言的核心运行模型。

掌握了这四个概念,你就能真正理解 JavaScript 代码「为什么这样运行」,而不仅仅是「这样能运行」。


本文发布于 2024-08-31,更新于 2024-08-31。