JavaScript 核心概念与执行机制

中等 🟡Js/Ts
6 个标签
预计阅读时间:164 分钟
JavaScript执行上下文闭包原型链this事件循环

JavaScript 核心概念与执行机制

JavaScript 是一门单线程、非阻塞、异步的脚本语言,理解其核心概念和执行机制对于编写高质量的代码至关重要。这些底层机制——执行上下文、作用域、闭包、原型链、this、事件循环——是几乎所有 JavaScript 面试的必考点,更是排查诡异 bug、写出高性能代码的根基。

为什么必须理解执行机制

很多开发者能写出运行的代码,却说不清"为什么这段代码输出 5 个 5 而不是 0 到 4","为什么箭头函数里 this 变了"。这就像会开车但不懂发动机,一旦抛锚就束手无策。理解执行机制能带来三个实质收益:

快速定位 bug:变量提升、this 丢失、闭包引用等问题一眼看穿。
写出高性能代码:懂原型链查找、GC 机制,才能避免内存泄漏和性能陷阱。
看懂框架源码:React Hooks 依赖闭包,Vue 响应式依赖原型/Proxy,全是这些基础的应用。

可以把 JavaScript 引擎的工作比作一个尽职的秘书:拿到代码后先"通读一遍"(编译阶段,登记所有声明),再"逐行办事"(执行阶段)。理解这个"先通读后执行"的模型,是理解一切的起点。

🔄 执行上下文

执行上下文(Execution Context)是 JavaScript 代码执行的环境,可以理解为代码运行时的"工作空间",里面存放着变量、函数、this 指向等信息。

执行上下文的类型:

全局执行上下文:代码开始执行时创建:全局执行上下文是 JavaScript 代码开始执行时创建的第一个执行上下文,在浏览器环境中全局执行上下文的 this 指向 window 对象,在 Node.js 环境中指向 global 对象,全局执行上下文包含全局变量、全局函数、this 绑定、变量环境、词法环境等,全局执行上下文在页面关闭或程序结束时销毁
函数执行上下文:函数被调用时创建:函数执行上下文是函数被调用时创建的执行上下文,每个函数调用都会创建一个新的执行上下文,函数执行上下文包含函数的参数、局部变量、this 绑定、变量环境、词法环境、外部环境引用(outer)等,函数执行上下文在函数执行完毕后销毁,但闭包会保持对外部环境的引用
eval 执行上下文:eval 函数执行时创建:eval 执行上下文是 eval 函数执行时创建的执行上下文,eval 函数可以动态执行字符串形式的 JavaScript 代码,eval 执行上下文可以访问调用 eval 函数的作用域,eval 函数的使用会影响性能和安全性,不推荐在生产代码中使用 eval 函数,可以使用 Function 构造函数或 JSON.parse 等替代方案

执行上下文的创建过程:

1.创建变量对象 (VO):登记参数、函数声明、变量声明
2.建立作用域链:确定能访问哪些外层变量
3.确定 this 指向:根据调用方式绑定 this

执行上下文栈(调用栈):

后进先出 (LIFO) 结构:像一摞盘子,最后放上去的最先拿走
管理函数调用顺序:当前正在执行的上下文永远在栈顶
函数执行完毕后从栈中弹出:栈溢出(Stack Overflow)就是递归太深、上下文压太多导致的
javascriptCode
// 调用栈演示:观察进出栈顺序
function first() {
  console.log('进入 first');
  second();
  console.log('离开 first');
}
function second() {
  console.log('进入 second');
  third();
  console.log('离开 second');
}
function third() {
  console.log('执行 third');
}
first();
// 输出顺序:进入 first → 进入 second → 执行 third → 离开 second → 离开 first
// 栈变化:[global] → [global,first] → [global,first,second] → [global,first,second,third]
//        → 逐层弹出回到 [global]

💻 代码示例:执行上下文演示

javascriptCode
// 全局执行上下文
var globalVar = 'global';

function outer() {
  // outer 函数执行上下文
  var outerVar = 'outer';

  function inner() {
    // inner 函数执行上下文
    var innerVar = 'inner';
    console.log(innerVar); // 'inner'
    console.log(outerVar); // 'outer'
    console.log(globalVar); // 'global'
  }

  inner();
}

outer();

变量提升示例

变量提升(Hoisting)是"先通读后执行"模型的直接体现:引擎在编译阶段把声明"提"到作用域顶部登记,但赋值留在原地。

javascriptCode
// 变量提升
console.log(a); // undefined,不是 ReferenceError
var a = 10;

console.log(b); // ReferenceError
let b = 20;

// 函数提升
console.log(foo); // 函数定义
foo(); // 'foo'

function foo() {
  console.log('foo');
}

console.log(bar); // undefined
bar(); // TypeError: bar is not a function

var bar = function() {
  console.log('bar');
};

var、let、const 对比

| 特性 | var | let | const |

| --- | --- | --- | --- |

| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |

| 提升 | 提升并初始化为 undefined | 提升但不初始化(TDZ) | 提升但不初始化(TDZ) |

| 重复声明 | 允许 | 报错 | 报错 |

| 重新赋值 | 允许 | 允许 | 不允许 |

| 挂到 window | 会 | 不会 | 不会 |

现代开发建议:默认用 const,需要重新赋值时用 let,避免使用 var。

闭包

什么是闭包:

函数能够访问其词法作用域之外的变量
即使函数在其词法作用域之外执行

用一个类比:闭包就像一个背包。函数被创建时,把它出生环境里用得到的变量装进背包背走,无论走到哪里(在哪里被调用),都能从背包里取出这些变量。这就是为什么函数执行完,被闭包"背走"的变量不会被回收。

为什么重要: 闭包是 JavaScript 实现数据私有、模块化、函数式编程的基石。React 的 useState、防抖节流、柯里化、事件回调,背后全是闭包。

闭包的应用:

实现私有变量
模块化
函数柯里化
防抖和节流

闭包的优缺点:

优点:实现数据封装和模块化:闭包的优点是可以实现数据封装和模块化,通过闭包可以创建私有变量和方法,避免全局命名空间污染,闭包可以保存函数的执行上下文,实现数据的持久化,闭包是 JavaScript 实现模块化、工厂模式、单例模式等设计模式的基础
缺点:可能导致内存泄漏:闭包的缺点是可能导致内存泄漏,因为闭包会保持对外部环境的引用,如果闭包被长时间引用,外部环境的变量和函数不会被垃圾回收,导致内存占用增加,在使用闭包时应该注意及时释放闭包引用,避免内存泄漏

代码示例

闭包实现私有变量

javascriptCode
function createCounter() {
  let count = 0;

  return {
    increment() {
      count++;
      return count;
    },
    decrement() {
      count--;
      return count;
    },
    getCount() {
      return count;
    }
  };
}

const counter = createCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.decrement()); // 1
console.log(counter.getCount()); // 1
// count 变量无法直接访问,实现了私有变量

闭包实现模块模式

javascriptCode
const Module = (function() {
  let privateVar = 'private';

  function privateMethod() {
    console.log('This is a private method');
  }

  return {
    publicMethod() {
      console.log('This is a public method');
      privateMethod();
      console.log(privateVar);
    },
    publicVar: 'public'
  };
})();

Module.publicMethod();
// Module.privateMethod(); // ReferenceError
// Module.privateVar; // undefined

闭包实现防抖

javascriptCode
function debounce(func, wait) {
  let timeout;

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

    clearTimeout(timeout);

    timeout = setTimeout(() => {
      func.apply(context, args);
    }, wait);
  };
}

// 使用示例
const handleInput = debounce((e) => {
  console.log('Input:', e.target.value);
}, 300);

document.getElementById('input').addEventListener('input', handleInput);

闭包实现节流

javascriptCode
function throttle(func, limit) {
  let inThrottle;

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

    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;

      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

// 使用示例
const handleScroll = throttle(() => {
  console.log('Scrolling...');
}, 100);

window.addEventListener('scroll', handleScroll);

闭包实现函数只执行一次(once)

javascriptCode
// 真实场景:确保初始化逻辑只跑一次(如只弹一次引导、只连一次数据库)
function once(fn) {
  let called = false;
  let result;
  return function (...args) {
    if (!called) {
      called = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}

const init = once(() => {
  console.log('初始化,只会打印一次');
  return { ready: true };
});
init(); // 打印
init(); // 不打印,直接返回缓存结果

原型链

原型链的概念:

JavaScript 中对象通过原型链继承属性和方法:JavaScript 使用原型继承机制,每个对象都有一个内部属性 [[Prototype]] 指向其原型对象,当访问对象的属性或方法时,如果对象本身没有该属性,JavaScript 会沿着原型链向上查找直到找到为止或返回 undefined,原型链的顶端是 Object.prototype,这种继承方式称为原型继承,是 JavaScript 面向对象编程的基础
每个对象都有一个原型对象
原型对象也是对象,也有自己的原型
直到 null

用类比理解:原型链就像找东西——先翻自己口袋(对象自身),没有就问爸爸(原型),爸爸没有再问爷爷(原型的原型),一直往上问到祖先(Object.prototype),再往上就是 null(无人可问,返回 undefined)。

原型链的工作原理:

当访问对象的属性时,会先在对象自身查找
如果找不到,会沿着原型链向上查找
直到找到或到达原型链末端

代码示例

原型链示例

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

Person.prototype.sayHello = function() {
  console.log(`Hello, my name is ${this.name}`);
};

const person = new Person('Alice');
person.sayHello(); // 'Hello, my name is Alice'

console.log(person.__proto__ === Person.prototype); // true
console.log(Person.prototype.__proto__ === Object.prototype); // true
console.log(Object.prototype.__proto__ === null); // true

原型继承

javascriptCode
function Animal(name) {
  this.name = name;
}

Animal.prototype.eat = function() {
  console.log(`${this.name} is eating`);
};

function Dog(name, breed) {
  Animal.call(this, name);
  this.breed = breed;
}

// 设置原型链
Dog.prototype = Object.create(Animal.prototype);
Dog.prototype.constructor = Dog;

Dog.prototype.bark = function() {
  console.log(`${this.name} is barking`);
};

const dog = new Dog('Buddy', 'Golden Retriever');
dog.eat(); // 'Buddy is eating'
dog.bark(); // 'Buddy is barking'

ES6 类继承

javascriptCode
class Animal {
  constructor(name) {
    this.name = name;
  }

  eat() {
    console.log(`${this.name} is eating`);
  }
}

class Dog extends Animal {
  constructor(name, breed) {
    super(name);
    this.breed = breed;
  }

  bark() {
    console.log(`${this.name} is barking`);
  }
}

const dog = new Dog('Buddy', 'Golden Retriever');
dog.eat(); // 'Buddy is eating'
dog.bark(); // 'Buddy is barking'

instanceof 的原理

javascriptCode
// instanceof 本质是检查右侧构造函数的 prototype 是否在左侧对象的原型链上
function myInstanceof(obj, Constructor) {
  let proto = Object.getPrototypeOf(obj);
  while (proto) {
    if (proto === Constructor.prototype) return true;
    proto = Object.getPrototypeOf(proto);
  }
  return false;
}
console.log(myInstanceof(dog, Animal)); // true
console.log(myInstanceof(dog, Object)); // true
console.log(myInstanceof(dog, Array));  // false

this 指向

this 是 JavaScript 最容易出错的概念之一。核心口诀:this 的值不看函数在哪里定义,只看函数怎么被调用(箭头函数除外)。

this 的绑定规则:

默认绑定:全局对象或 undefined(严格模式):默认绑定是 this 绑定的默认规则,当函数独立调用时(不作为对象方法、不使用 call/apply/bind、不使用 new),this 指向全局对象(浏览器中的 window,Node.js 中的 global),在严格模式下 this 指向 undefined,默认绑定是 this 绑定中最常见的情况
隐式绑定:调用对象:隐式绑定是 this 绑定的规则之一,当函数作为对象的方法调用时,this 指向调用该函数的对象,隐式绑定依赖于函数的调用方式,如果函数被赋值给其他变量或作为回调函数传递,隐式绑定会丢失,需要使用显式绑定或箭头函数保持 this 绑定
显式绑定:call、apply、bind:显式绑定是通过 call、apply、bind 方法强制指定函数的 this 绑定,call 和 apply 方法立即调用函数,call 方法接收参数列表,apply 方法接收参数数组,bind 方法返回一个新函数,新函数的 this 被永久绑定到指定的对象,显式绑定可以解决隐式绑定丢失的问题
new 绑定:新创建的对象:new 绑定是使用 new 关键字调用构造函数时的 this 绑定规则,this 指向新创建的对象,new 绑定会创建一个新对象,将构造函数的 prototype 属性赋值给新对象的 [[Prototype]] 属性,执行构造函数,如果构造函数没有返回对象则返回新对象,new 绑定是创建对象实例的基础
箭头函数:词法作用域的 this:箭头函数没有自己的 this 绑定,this 继承自外层作用域,箭头函数的 this 在函数定义时就确定,而不是在调用时确定,箭头函数不能作为构造函数使用,不能使用 call、apply、bind 改变 this 绑定,箭头函数适合用于回调函数、事件处理函数等需要保持 this 绑定的场景

四种绑定规则对比

| 调用方式 | this 指向 | 示例 |

| --- | --- | --- |

| 独立调用 | 全局对象 / undefined(严格模式) | fn() |

| 方法调用 | 调用它的对象 | obj.fn() |

| call/apply/bind | 指定的对象 | fn.call(obj) |

| new 调用 | 新创建的实例 | new Fn() |

| 箭头函数 | 外层作用域的 this | () => this |

手写 call / apply / bind

javascriptCode
// 手写 call:理解显式绑定的本质
Function.prototype.myCall = function (context, ...args) {
  context = context || globalThis;
  const key = Symbol('fn');
  context[key] = this;          // 把函数临时挂到 context 上
  const result = context[key](...args); // 以方法形式调用,this 即 context
  delete context[key];
  return result;
};

// 手写 bind:返回绑定了 this 的新函数
Function.prototype.myBind = function (context, ...preArgs) {
  const fn = this;
  return function (...args) {
    return fn.apply(context, [...preArgs, ...args]);
  };
};

function greet(greeting) {
  return `${greeting}, ${this.name}`;
}
console.log(greet.myCall({ name: 'Alice' }, 'Hi')); // 'Hi, Alice'
const boundGreet = greet.myBind({ name: 'Bob' });
console.log(boundGreet('Hello')); // 'Hello, Bob'

深入理解

变量提升的详细机制

变量提升的本质:

JavaScript 引擎在代码执行前进行编译:JavaScript 引擎在执行代码前会先进行编译,编译阶段会进行词法分析生成抽象语法树(AST),进行作用域分析确定变量和函数的声明位置,进行代码优化生成字节码,编译完成后进入执行阶段,执行阶段会创建执行上下文,逐行解释执行字节码,了解编译过程有助于理解变量提升、闭包等核心概念
函数声明和变量声明被提升到作用域顶部
函数声明优先于变量声明
let 和 const 也有提升,但存在暂时性死区

暂时性死区(TDZ):

let 和 const 变量在声明前无法访问
从作用域开始到变量声明之间的区域
避免在声明前使用变量
javascriptCode
// TDZ 演示
{
  // 这里是 x 的暂时性死区
  // console.log(x); // ReferenceError: Cannot access 'x' before initialization
  let x = 10;
  console.log(x); // 10,声明之后才可访问
}

// 函数声明优先于变量声明
console.log(typeof value); // 'function'
var value = 'hello';
function value() {}

作用域链的查找过程

查找顺序:

1.在当前作用域中查找变量
2.如果未找到,向上一级作用域查找
3.重复步骤 1-2,直到全局作用域
4.如果全局作用域也未找到,抛出 ReferenceError

词法作用域 vs 动态作用域:

JavaScript 使用词法作用域(静态作用域):词法作用域是指变量的作用域在代码编写时就确定了,而不是在运行时确定,函数定义时决定了其可以访问哪些变量,嵌套函数可以访问外层函数的变量,这种作用域模型使得代码分析更简单,也是闭包能够工作的基础,与之相对的是动态作用域(如 bash 脚本使用)
作用域在函数定义时确定,而不是调用时
与动态作用域(如某些语言中的 eval)不同
javascriptCode
// 词法作用域证明:value 取定义处的作用域,而非调用处
var value = 1;
function foo() {
  console.log(value); // 永远是 1(定义时的外层作用域)
}
function bar() {
  var value = 2;
  foo(); // 仍然输出 1,而不是 2
}
bar();

闭包的内存管理

闭包的内存占用:

闭包会保持对外部变量的引用
这些变量不会被垃圾回收
长期存在的闭包可能导致内存泄漏

避免内存泄漏的方法:

及时释放闭包引用
避免在循环中创建大量闭包
使用弱引用(WeakMap、WeakSet)
在不需要时手动解除引用

原型链的查找优化

原型链查找的性能:

原型链查找是动态的,每次访问属性都需要查找
深层原型链查找可能影响性能
可以通过缓存属性值来优化

优化策略:

将常用属性直接定义在对象上
避免过深的原型链
使用 Object.create(null) 创建无原型对象(常用于纯数据字典,避免原型污染)

this 绑定的优先级

绑定优先级(从高到低):

1.new 绑定
2.显式绑定(bind > call/apply)
3.隐式绑定
4.默认绑定

特殊情况:

箭头函数的 this 继承自外层作用域
DOM 事件处理函数中的 this 指向事件目标:在 DOM 事件处理函数中,this 关键字指向触发事件的 DOM 元素(event.currentTarget),可以通过 this 访问元素的属性和方法,如 this.value、this.style 等,这种行为与普通函数中的 this 不同,事件处理函数中的 this 绑定是 JavaScript 事件系统自动处理的
setTimeout/setInterval 中的 this 指向全局对象

事件循环的深入理解

事件循环的组成部分:

调用栈(Call Stack)
任务队列(Task Queue / 宏任务队列)
微任务队列(Microtask Queue)
Web APIs(定时器、DOM 事件等):Web APIs 是浏览器提供的 JavaScript 接口,用于与浏览器环境交互,包括 setTimeout/setInterval 定时器 API、DOM 操作 API、fetch 网络请求 API、Storage 存储 API、Canvas 绘图 API 等,这些 API 大多是异步的,callback 会被添加到任务队列中等待执行,是 JavaScript 实现异步编程的重要基础

浏览器环境 vs Node.js 环境:

浏览器:宏任务包括 setTimeout、setInterval、UI 渲染
Node.js:宏任务包括 setTimeout、setInterval、I/O 操作:Node.js 的事件循环与浏览器有所不同,宏任务包括 setTimeout、setInterval、setImmediate、I/O 操作(文件读写、网络请求等),微任务包括 Promise 回调、process.nextTick(优先级最高),Node.js 的事件循环有多个阶段如 timers、pending callbacks、idle/prepare、poll、check 等,了解 Node.js 事件循环对编写高效的 Node.js 应用很重要
微任务在两者中基本相同

宏任务与微任务对比:

| 类型 | 包含的 API | 执行时机 |

| --- | --- | --- |

| 宏任务(macrotask) | setTimeout、setInterval、setImmediate、I/O、UI 渲染 | 每轮事件循环取一个执行 |

| 微任务(microtask) | Promise.then、async/await、queueMicrotask、process.nextTick、MutationObserver | 当前宏任务后、下一个宏任务前,清空全部 |

性能优化:

避免长时间运行的同步代码(会阻塞主线程和渲染)
合理使用 requestAnimationFrame
将大任务拆分为小任务(时间切片)
使用 Web Worker 处理 CPU 密集型任务

实战应用

模块化实现

javascriptCode
// 使用闭包实现模块
const myModule = (function() {
  // 私有变量和方法
  let privateVar = 0;

  function privateMethod() {
    return privateVar * 2;
  }

  // 公共 API
  return {
    increment() {
      privateVar++;
      console.log('Incremented:', privateVar);
    },
    decrement() {
      privateVar--;
      console.log('Decremented:', privateVar);
    },
    getValue() {
      return privateMethod();
    }
  };
})();

// 使用模块
myModule.increment(); // Incremented: 1
myModule.increment(); // Incremented: 2
console.log(myModule.getValue()); // 4
myModule.decrement(); // Decremented: 1

函数柯里化

javascriptCode
// 柯里化函数
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    } else {
      return function(...moreArgs) {
        return curried.apply(this, args.concat(moreArgs));
      };
    }
  };
}

// 使用柯里化
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

// 实际应用:创建可复用的函数
const multiply = (a, b) => a * b;
const double = curry(multiply)(2);
console.log(double(5)); // 10
console.log(double(10)); // 20

高阶函数

javascriptCode
// 高阶函数:接受函数作为参数或返回函数
function withLogging(fn) {
  return function(...args) {
    console.log('Calling function with args:', args);
    const result = fn.apply(this, args);
    console.log('Function returned:', result);
    return result;
  };
}

// 使用高阶函数
function add(a, b) {
  return a + b;
}

const loggedAdd = withLogging(add);
loggedAdd(3, 4); // Calling function with args: [3, 4] // Function returned: 7

// 实际应用:权限检查
function withAuth(fn) {
  return function(...args) {
    if (!isAuthenticated()) {
      throw new Error('Not authenticated');
    }
    return fn.apply(this, args);
  };
}

function deleteUser(userId) {
  // 删除用户逻辑
  console.log('Deleting user:', userId);
}

const protectedDeleteUser = withAuth(deleteUser);

性能优化示例

javascriptCode
// 使用闭包缓存计算结果
function memoize(fn) {
  const cache = new Map();

  return function(...args) {
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log('Cache hit:', key);
      return cache.get(key);
    }

    const result = fn.apply(this, args);
    cache.set(key, result);
    console.log('Cache miss:', key);
    return result;
  };
}

// 使用记忆化
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

const memoizedFibonacci = memoize(fibonacci);
console.log(memoizedFibonacci(10)); // 大幅提升性能

// 使用节流优化滚动事件
function optimizedScrollHandler() {
  // 节流后的处理逻辑
  console.log('Optimized scroll handler');
}

const throttledScroll = throttle(optimizedScrollHandler, 100);
window.addEventListener('scroll', throttledScroll);

记忆化的收益有多大?未优化的递归 fibonacci(40) 会产生约 3.3 亿次函数调用,耗时以秒计;加上记忆化后,每个 n 只算一次,调用次数降到线性级别(约 80 次),毫秒级返回。这就是空间换时间的经典应用。

常见坑与解决方案

循环中的闭包陷阱

这是最经典的面试题,也是真实开发中的高频 bug。

javascriptCode
// 错误示例
for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 输出 5, 5, 5, 5, 5
  }, 100);
}
// 原因:var 是函数作用域,5 个回调共享同一个 i,等回调执行时 i 早已变成 5

// 解决方案1:使用 let(块级作用域,每次迭代都是新的 i)
for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i); // 输出 0, 1, 2, 3, 4
  }, 100);
}

// 解决方案2:使用 IIFE 创建独立作用域
for (var i = 0; i < 5; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j); // 输出 0, 1, 2, 3, 4
    }, 100);
  })(i);
}

this 绑定陷阱

javascriptCode
// 陷阱1:方法中的箭头函数 this
const obj1 = {
  name: 'Alice',
  getName() {
    console.log(this.name); // 正确,'Alice'
  },
  getArrowName: () => {
    console.log(this.name); // 错误,this 指向外层(全局),undefined
  }
};

obj1.getName(); // 'Alice'
obj1.getArrowName(); // undefined

// 陷阱2:隐式绑定丢失(回调中最常见)
const obj2 = {
  name: 'Bob',
  greet() { console.log(this.name); }
};
const fn = obj2.greet;
fn(); // undefined,this 丢失
setTimeout(obj2.greet, 100); // undefined,同样丢失

// 解决方案:bind 或箭头函数包裹
setTimeout(obj2.greet.bind(obj2), 100); // 'Bob'
setTimeout(() => obj2.greet(), 100);     // 'Bob'

// 陷阱3:类事件处理中的 this
class Button {
  constructor() {
    this.count = 0;
    this.button = document.createElement('button');
    // 错误:this 指向 button 元素
    // this.button.addEventListener('click', this.handleClick);
    // 解决方案1:bind
    this.button.addEventListener('click', this.handleClick.bind(this));
    // 解决方案2:箭头函数
    this.button.addEventListener('click', () => this.handleClick());
  }
  handleClick() {
    this.count++;
    console.log('Clicked:', this.count);
  }
}

异步陷阱

javascriptCode
// 陷阱:期望同步执行
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 输出:1, 3, 2(setTimeout 是宏任务,最后执行)

// 陷阱:async 函数里忘记 await
async function bad() {
  const data = fetchData(); // 忘了 await,data 是 Promise 而非结果
  console.log(data); // Promise {<pending>}
}

// 解决方案
async function good() {
  const data = await fetchData();
  console.log(data); // 真实数据
}

// 解决方案:用 async/await 确保顺序执行
async function sequentialLoop() {
  for (let i = 0; i < 3; i++) {
    await new Promise(resolve => {
      setTimeout(() => {
        console.log(i);
        resolve();
      }, 100);
    });
  }
}
sequentialLoop(); // 0, 1, 2(严格按顺序)

常见坑速查表

| 坑 | 现象 | 根因 | 解法 |

| --- | --- | --- | --- |

| 循环闭包 | 输出全是最大值 | var 共享同一变量 | 用 let 或 IIFE |

| this 丢失 | this 变 undefined | 方法被赋值/作回调 | bind 或箭头函数 |

| 箭头函数当方法 | this 指向外层 | 箭头无自己的 this | 对象方法用普通函数 |

| TDZ | 声明前报错 | let/const 未初始化 | 先声明再使用 |

| 忘记 await | 拿到 Promise | 异步未等待 | 加 await |

代码示例

this 绑定规则

javascriptCode
// 默认绑定
function foo() {
  console.log(this); // 全局对象或 undefined(严格模式)
}
foo();

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

// 显式绑定
function greet() {
  console.log(`Hello, ${this.name}`);
}
const person = { name: 'Bob' };
greet.call(person); // 'Hello, Bob'
greet.apply(person); // 'Hello, Bob'
const boundGreet = greet.bind(person);
boundGreet(); // 'Hello, Bob'

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

// 箭头函数
const obj2 = {
  name: 'Alice',
  foo() {
    const arrow = () => {
      console.log(this.name); // 'Alice',继承 foo 的 this
    };
    arrow();
  }
};
obj2.foo();

事件循环

事件循环的概念:

JavaScript 执行机制的核心:事件循环(Event Loop)是 JavaScript 执行机制的核心,通过不断从任务队列中取出任务执行来驱动 JavaScript 程序的运行,事件循环会优先执行微任务队列中的所有任务,然后再执行宏任务队列中的一个任务,这种执行顺序确保了 Promise 等微任务能够快速响应,是理解 JavaScript 异步编程的关键
处理异步操作
由主线程、宏任务队列和微任务队列组成

用类比理解:主线程是一个只有一个窗口的银行柜台(单线程)。同步任务是当场办理的业务;异步任务(如定时器、请求)是"取号排队",办完当前所有业务后再叫号。微任务是 VIP 队列,每办完一件普通业务就先清空所有 VIP。

执行过程:

1.执行主线程(同步)代码
2.执行微任务队列中的所有任务(清空)
3.执行宏任务队列中的一个任务
4.重复步骤 2-3

宏任务和微任务:

宏任务:setTimeout、setInterval、I/O 操作
微任务:Promise、async/await、process.nextTick

代码示例

事件循环示例

javascriptCode
console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

// 输出顺序:1, 4, 3, 2
// 解析:同步先执行 1、4;微任务 3 在本轮宏任务后立即执行;宏任务 2 最后

复杂事件循环

javascriptCode
console.log('1');

setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => {
    console.log('3');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('4');
  setTimeout(() => {
    console.log('5');
  }, 0);
});

console.log('6');

// 输出顺序:1, 6, 4, 2, 3, 5

async/await 与事件循环

javascriptCode
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end'); // await 后的代码相当于微任务
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
Promise.resolve().then(() => console.log('promise'));
console.log('script end');
// 输出:script start → async1 start → async2 → script end
//      → async1 end → promise → setTimeout

执行上下文与作用域链的深入剖析

前面介绍了执行上下文的基本概念,这里我们深入到引擎内部,理解变量对象(VO)、活动对象(AO)、全局对象(GO)以及作用域链(Scope Chain)是如何一步步构建起来的。这些是 ECMAScript 规范(ES3 时代)里最经典的模型,虽然 ES5 之后规范改用了"词法环境(Lexical Environment)+ 环境记录(Environment Record)"的新术语,但 VO/AO 模型对理解变量提升、闭包依然极具解释力。

变量对象 VO 与活动对象 AO

变量对象(Variable Object,VO) 是与执行上下文相关的数据作用域,存储了上下文中定义的变量和函数声明。在不同上下文中,VO 的具体表现不同:

全局上下文中的 VO 就是全局对象(Global Object,GO)本身,浏览器里是 `window`,Node.js 里是 `global`(或 `globalThis`)。
函数上下文中的 VO 用活动对象(Activation Object,AO)来表示。AO 在函数被调用、进入函数上下文时才被激活。

VO/AO 的关键区别在于"激活时机": VO 在函数创建时理论上存在但不可访问,只有在函数被调用、进入执行上下文的那一刻,VO 才被激活成 AO,此时里面的属性才能被访问。这正是变量提升的底层解释。

函数上下文中 AO 的建立分两个阶段:

javascriptCode
// 用一个函数说明 AO 的两个建立阶段
function build(a, b) {
  var c = 10;
  function d() {}
  var e = function () {};
  return a + b + c;
}
build(1, 2);

// 阶段一:进入上下文(编译/预处理),此时 AO 大致为:
// AO = {
//   arguments: { 0: 1, 1: 2, length: 2 },
//   a: 1,              // 形参已赋值
//   b: 2,              // 形参已赋值
//   c: undefined,      // var 变量声明,值为 undefined
//   d: <function d>,   // 函数声明,整体提升
//   e: undefined       // 函数表达式,此刻只是 var 声明
// }

// 阶段二:执行代码,逐行赋值,AO 变为:
// AO = {
//   arguments: { 0: 1, 1: 2, length: 2 },
//   a: 1, b: 2,
//   c: 10,             // 执行到 var c = 10
//   d: <function d>,
//   e: <function>      // 执行到 var e = function(){}
// }

理解了这两个阶段,就能解释为什么函数声明可以在声明前调用,而 var 变量在赋值前只是 undefined。

VO 建立时的三条填充规则与优先级

进入执行上下文、建立 VO/AO 时,引擎按固定顺序填充属性,顺序决定了同名冲突时谁覆盖谁:

1.函数的形参:按形参顺序建立 AO 属性,实参有值则赋值,没有则为 undefined。
2.函数声明(Function Declaration):整体提升,若与已有属性同名(包括形参),直接覆盖。
3.变量声明(var):建立属性并初始化为 undefined,若与已有同名属性(形参或函数)冲突,则不覆盖、忽略这次声明(保留原值)。
javascriptCode
// 验证优先级:函数声明 > 变量声明
function conflict(x) {
  console.log(typeof x); // 'function',不是 'number'

  var x = 5;             // var 声明不覆盖同名函数
  function x() {}        // 函数声明胜出

  console.log(typeof x); // 'number',执行到 var x = 5 后才被赋值覆盖
}
conflict(1);

// 结论:
// 1) 进入上下文时,形参 x=1 → 被函数声明 x 覆盖 → var x 声明被忽略
// 2) 所以第一次 typeof 是 'function'
// 3) 执行阶段 var x = 5 生效,第二次 typeof 是 'number'

作用域链的构建:[[Scopes]] 与 outer 引用

每个函数在被创建时,就会保存一个内部属性 `[[Scopes]]`(或规范里的 `[[Environment]]`),指向它定义时所处的所有父级作用域。当函数被调用、创建执行上下文时,把自己的 AO 放到 `[[Scopes]]` 前面,就形成了完整的作用域链:

codeCode
Scope Chain = [ 当前AO ] + [[Scopes]](父级VO/AO...直到全局VO)
javascriptCode
// 作用域链的分层演示
var globalVar = 'G';

function outer() {
  var outerVar = 'O';

  function inner() {
    var innerVar = 'I';
    // inner 的作用域链:
    // [ inner的AO {innerVar} ] → [ outer的AO {outerVar} ] → [ 全局VO {globalVar} ]
    console.log(innerVar, outerVar, globalVar); // I O G
  }

  inner();
}
outer();

// 关键点:作用域链在【函数定义时】就已经通过 [[Scopes]] 确定了雏形,
// 与函数在哪里被【调用】无关,这就是词法作用域。

词法作用域 vs 动态作用域

这是理解 JavaScript 作用域最核心的一对概念。JavaScript 采用词法作用域(也叫静态作用域):变量的作用域由代码书写的位置决定,在编译阶段就已确定。而动态作用域则由函数调用的位置(调用栈)决定,运行时才确定。

| 对比维度 | 词法作用域(JS 采用) | 动态作用域 |

| --- | --- | --- |

| 确定时机 | 代码编写/编译时 | 函数运行调用时 |

| 依据 | 函数定义的位置 | 函数调用的位置 |

| 查找方向 | 沿定义时的嵌套结构向外 | 沿调用栈向上 |

| 典型语言 | JavaScript、C、Java | Bash、早期 Lisp |

| 可预测性 | 高,静态分析即可确定 | 低,需追踪调用链 |

javascriptCode
// 经典证明题:JS 是词法作用域
var value = 1;

function foo() {
  console.log(value); // 取【定义 foo 时】的外层作用域,value = 1
}

function bar() {
  var value = 2;
  foo();              // 尽管在 bar 内部调用,foo 依然输出 1
}

bar(); // 输出 1(若是动态作用域会输出 2)

// 反证:如果 JavaScript 是动态作用域,foo 会顺着调用栈找到 bar 里的 value=2。
// 但事实是输出 1,证明 JS 是词法作用域。

GO/AO 演进与 ES5 词法环境模型

ES5 之后,规范废弃了 VO/AO 的说法,改用更精确的词法环境(Lexical Environment)模型,但本质思路一致:

| 老模型(ES3) | 新模型(ES5+) | 说明 |

| --- | --- | --- |

| 全局对象 GO | 全局环境记录(对象式 + 声明式) | var/function 挂对象式,let/const/class 挂声明式 |

| 活动对象 AO | 函数环境记录 | 存储局部变量、arguments |

| 作用域链 | 环境记录的 outer 引用链 | 每个环境记录有 outer 指向父环境 |

| VO 激活 | 环境记录实例化 | 进入上下文时创建绑定 |

新模型有个重要细节:一个执行上下文其实包含两个环境组件——变量环境(VariableEnvironment) 存放 var 和 function 声明,词法环境(LexicalEnvironment) 存放 let、const、class 声明。这解释了为什么 let/const 有 TDZ 而 var 没有:它们被登记在不同的环境组件里,且初始化时机不同。

javascriptCode
// 两个环境组件的差异
function demo() {
  // VariableEnvironment: { a: undefined }  ← var 立即初始化为 undefined
  // LexicalEnvironment:  { b: <uninitialized> }  ← let 处于 TDZ,未初始化

  console.log(a); // undefined(可访问)
  // console.log(b); // ReferenceError(TDZ)

  var a = 1;
  let b = 2;
}
demo();

真实案例:作用域链导致的性能问题

在深层嵌套中频繁访问全局变量,会让引擎每次都沿作用域链走到底,产生可测量的开销。经验数据:在 V8 中,访问局部变量比访问跨 5 层作用域链的变量快约 20%~30%(具体数字取决于引擎版本与 JIT 优化)。

javascriptCode
// 反例:循环内反复沿作用域链访问全局/外层变量
function slow(arr) {
  let result = 0;
  for (let i = 0; i < arr.length; i++) {
    // arr.length 每次都要沿作用域链找 arr,再取 length
    result += arr[i] * window.devicePixelRatio; // 每次都查全局 window
  }
  return result;
}

// 优化:把跨作用域的值缓存到局部变量
function fast(arr) {
  let result = 0;
  const len = arr.length;                 // 缓存 length
  const ratio = window.devicePixelRatio;  // 缓存全局访问
  for (let i = 0; i < len; i++) {
    result += arr[i] * ratio;
  }
  return result;
}
// 在 100 万次循环下,fast 通常比 slow 快 15%~40%。

常见坑与最佳实践

坑 1:以为函数在哪调用就用哪的变量。牢记词法作用域,函数用的是定义处的外层变量。
坑 2:全局变量污染。挂在全局 VO/GO 上的变量随处可改,难以维护。用模块、IIFE、块级作用域隔离。
坑 3:过深嵌套 + 频繁访问外层变量。把外层值缓存进局部变量减少作用域链查找。

| 最佳实践 | 说明 |

| --- | --- |

| 优先局部变量 | 减少作用域链查找,提升性能与可读性 |

| 用块级作用域隔离 | let/const 天然块级,避免变量泄漏到外层 |

| 避免修改全局对象 | 减少副作用与命名冲突 |

| 理解两个环境组件 | 解释 var 与 let/const 行为差异的根本 |

小结

| 概念 | 本质 | 关键记忆点 |

| --- | --- | --- |

| VO/AO | 上下文的变量数据作用域 | AO 在进入函数上下文时激活 |

| GO | 全局变量对象 | 浏览器是 window,Node 是 global |

| 作用域链 | AO + 父级 VO/AO 链 | 定义时确定雏形,与调用无关 |

| 词法作用域 | 按书写位置定作用域 | JS 的核心,闭包的基础 |

| 变量环境/词法环境 | var 与 let/const 分开存 | 解释 TDZ 与提升差异 |

变量提升、暂时性死区与 let/const/var 的彻底对比

变量提升(Hoisting)是面试高频考点,但真正的难点在于把 var 的提升、let/const 的 TDZ、函数声明的整体提升三者放在一起理解。本节用大量可运行代码把每一种行为掰开揉碎。

提升的本质:声明前置,赋值留原地

"提升"并不是引擎真的把代码搬到顶部,而是编译阶段先把所有声明登记进对应的环境记录。可以想象成引擎"通读一遍先记名单,再逐行办事"。

javascriptCode
// 你写的代码
console.log(a);
var a = 2;
function f() {}

// 引擎理解成(等价心智模型)
var a;            // 声明登记,初始化为 undefined
function f() {}   // 函数声明整体提升
console.log(a);   // undefined
a = 2;            // 赋值留在原地执行

四种声明的提升行为对照

javascriptCode
// 1) var:提升并初始化为 undefined
console.log(v); // undefined
var v = 1;

// 2) function 声明:整体提升,可提前调用
hoisted(); // 'ok'
function hoisted() { console.log('ok'); }

// 3) let / const:提升但不初始化,进入 TDZ
// console.log(l); // ReferenceError: Cannot access 'l' before initialization
let l = 2;

// 4) 函数表达式 / 箭头函数:只提升变量名,值仍是 undefined
// arrowFn(); // TypeError: arrowFn is not a function
var arrowFn = () => {};

// 5) class:提升但有 TDZ,行为类似 let
// new C(); // ReferenceError
class C {}

暂时性死区 TDZ 深入

暂时性死区(Temporal Dead Zone,TDZ)指从块级作用域开始到 let/const 变量声明语句之间的区域,在这段区域里访问该变量会抛 `ReferenceError`。TDZ 的存在是为了让 const 的"不可变绑定"语义更严格,并帮助开发者尽早发现"声明前使用"的错误。

javascriptCode
// TDZ 的边界演示
let x = 'outer';
{
  // 从这里开始进入内层 x 的 TDZ
  // console.log(x); // ReferenceError!即使外层有 x,也访问不了外层的
  // typeof x;       // 在 TDZ 内 typeof 也会报错(与未声明变量不同)
  let x = 'inner';  // TDZ 到此结束
  console.log(x);   // 'inner'
}

// 对比:完全未声明的变量,typeof 安全返回 'undefined'
console.log(typeof neverDeclared); // 'undefined',不报错

TDZ 有个反直觉的坑——在默认参数中也存在:

javascriptCode
// 默认参数的 TDZ
function bad(a = b, b = 2) {
  // 计算 a 的默认值时用到 b,但 b 还在 TDZ 中
  return a + b;
}
// bad(); // ReferenceError: Cannot access 'b' before initialization

function good(a = 1, b = a + 1) {
  // 计算 b 时 a 已初始化完成,合法
  return a + b;
}
console.log(good()); // 3

var 的函数作用域陷阱

var 只有函数作用域,没有块级作用域,这是无数 bug 的源头:

javascriptCode
// var 在 if / for 块里"泄漏"到函数作用域
function leak() {
  if (true) {
    var secret = 42;
  }
  console.log(secret); // 42,var 无视块级作用域
}
leak();

// for 循环里的 var 泄漏
for (var i = 0; i < 3; i++) {}
console.log(i); // 3,循环结束后 i 依然存在

// let 则不会泄漏
for (let j = 0; j < 3; j++) {}
// console.log(j); // ReferenceError,j 只在循环块内有效

let 在 for 循环中的"每轮新绑定"

let 在 for 循环里有个特殊机制:每次迭代都会创建一个全新的绑定,并把上一轮的值拷贝过来。这正是 let 能解决循环闭包问题的根本原因。

javascriptCode
// let 每轮独立绑定
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 0, 1, 2
}
// 引擎为每次迭代创建独立的 i:i0=0, i1=1, i2=2
// 三个闭包分别捕获了不同的绑定

// var 只有一个绑定,三个闭包共享
for (var k = 0; k < 3; k++) {
  setTimeout(() => console.log(k), 0); // 3, 3, 3
}

const 的"常量"到底锁住了什么

const 锁定的是变量绑定(binding),而不是值本身。对于对象/数组,引用不能变,但内部属性可以改。

javascriptCode
const obj = { a: 1 };
obj.a = 2;        // 合法,改的是属性
obj.b = 3;        // 合法,加属性
// obj = {};      // TypeError: Assignment to constant variable.

const arr = [1, 2];
arr.push(3);      // 合法,[1,2,3]
// arr = [];      // TypeError

// 真正的不可变需要 Object.freeze(且只冻结一层,是浅冻结)
const frozen = Object.freeze({ a: 1, nested: { b: 2 } });
frozen.a = 99;          // 静默失败(严格模式下报错)
frozen.nested.b = 99;   // 生效!freeze 只冻结第一层
console.log(frozen.a, frozen.nested.b); // 1 99

// 深冻结需要递归
function deepFreeze(o) {
  Object.keys(o).forEach((k) => {
    if (typeof o[k] === 'object' && o[k] !== null) deepFreeze(o[k]);
  });
  return Object.freeze(o);
}

var / let / const 全维度对比表

| 维度 | var | let | const |

| --- | --- | --- | --- |

| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |

| 提升 | 提升+初始化 undefined | 提升+TDZ | 提升+TDZ |

| 声明前访问 | undefined | ReferenceError | ReferenceError |

| 重复声明 | 允许 | 报错 | 报错 |

| 重新赋值 | 允许 | 允许 | 不允许 |

| 必须初始化 | 否 | 否 | 是(声明即赋值) |

| 挂到 window/global | 是 | 否 | 否 |

| for 循环每轮绑定 | 单一共享 | 每轮独立 | 不适用(不能自增) |

| typeof 声明前 | 'undefined' | 报错 | 报错 |

真实案例:老代码迁移 var 到 let 的意外

一个真实场景:团队把 var 批量替换为 let 后,某个依赖"var 提升 + 重复声明"的模块崩了。

javascriptCode
// 老代码(依赖 var 可重复声明,且提升不报错)
function processLegacy(items) {
  for (var i = 0; i < items.length; i++) { /* ... */ }
  // 后面又来一段,再次 var i —— var 允许,静默复用
  for (var i = 0; i < items.length; i++) { /* ... */ }
}

// 直接改成 let 会报 "Identifier 'i' has already been declared"
// 正确迁移:合并声明或改用不同变量名
function processFixed(items) {
  for (let i = 0; i < items.length; i++) { /* 第一段 */ }
  for (let i = 0; i < items.length; i++) { /* 第二段 */ }
  // let 在不同的 for 块里各自独立,反而更安全
}

最佳实践

| 实践 | 理由 |

| --- | --- |

| 默认用 const | 表达"不变"意图,减少意外重新赋值 |

| 需要重新赋值才用 let | 明确可变性 |

| 彻底弃用 var | 避免函数作用域泄漏与提升坑 |

| 变量声明尽量靠近使用处 | 缩小 TDZ 与作用域,提升可读性 |

| 对象常量配合 Object.freeze | 需要真正不可变时使用(注意浅冻结) |

小结

变量提升的三条铁律:函数声明整体提升且优先级最高;var 提升为 undefined;let/const/class 提升但有 TDZ。理解"两个环境组件 + TDZ"就能秒杀所有相关面试题。现代工程一律 const 优先、let 次之、彻底告别 var。

闭包的深入原理、陷阱与实战

闭包是 JavaScript 最强大也最容易被误解的特性。前面已经介绍了闭包的基本用法,本节从引擎实现的角度深挖闭包的原理,系统梳理经典陷阱、内存泄漏排查和性能考量。

闭包的引擎级定义

从规范角度,闭包 = 函数 + 该函数被创建时所处的词法环境。当一个内部函数被返回或以其他方式在其定义作用域之外被引用时,它依然保留着对外层环境记录的引用,导致这些环境记录无法被垃圾回收——这就是闭包。

javascriptCode
// 闭包的最小模型
function makeAdder(x) {
  // x 存活在 makeAdder 的环境记录中
  return function (y) {
    return x + y; // 内部函数引用了外层的 x → 形成闭包
  };
}

const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(2));  // 7
console.log(add10(2)); // 12
// 关键:makeAdder(5) 执行完毕,但它的环境记录 {x:5} 没被回收,
// 因为 add5 还引用着它。add5 和 add10 各自持有独立的 x。

用 Chrome DevTools 的 Sources 面板打断点,能在 Scope → Closure 里亲眼看到被闭包捕获的变量,这是验证闭包最直观的方式。

闭包只捕获"变量"而非"值"

一个高频误解:闭包捕获的是变量的引用,不是创建闭包那一刻的值快照。变量后续变化,闭包读到的也是新值。

javascriptCode
function counter() {
  let n = 0;
  const read = () => n;   // 捕获变量 n(引用)
  const inc = () => { n++; };
  return { read, inc };
}
const c = counter();
console.log(c.read()); // 0
c.inc();
c.inc();
console.log(c.read()); // 2 —— read 读到的是变化后的 n,证明捕获的是变量本身

经典循环陷阱的三种解法与原理

循环 + 异步 + var 的组合是最经典的闭包陷阱。这里给出三种解法并解释各自原理。

javascriptCode
// 陷阱:三个回调共享同一个 var i
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('bug:', i), 100); // 3, 3, 3
}

// 解法 1:let 每轮新绑定(最推荐,ES6+)
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log('let:', i), 100); // 0, 1, 2
}

// 解法 2:IIFE 立即执行函数,用参数固化当前值
for (var i = 0; i < 3; i++) {
  ((j) => {
    setTimeout(() => console.log('iife:', j), 100); // 0, 1, 2
  })(i);
}

// 解法 3:setTimeout 第三参数传值(较少用但有效)
for (var i = 0; i < 3; i++) {
  setTimeout((j) => console.log('arg:', j), 100, i); // 0, 1, 2
}

原理对比:let 靠每轮独立绑定;IIFE 靠函数调用创建新的执行上下文/环境记录,把 i 的当前值作为实参固化在参数 j 上;第三参数则由 setTimeout 在注册时立即求值并保存。

闭包实现柯里化与偏函数

柯里化(Currying)把多参函数转换为一系列单参函数,本质是层层闭包保存已收集的参数。

javascriptCode
// 通用柯里化
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    // 参数不够,返回新函数继续收集(闭包保存已有 args)
    return (...rest) => curried.apply(this, [...args, ...rest]);
  };
}

const sum3 = (a, b, c) => a + b + c;
const cSum = curry(sum3);
console.log(cSum(1)(2)(3));   // 6
console.log(cSum(1, 2)(3));   // 6
console.log(cSum(1)(2, 3));   // 6

// 偏函数(Partial Application):固定部分参数
function partial(fn, ...preset) {
  return (...later) => fn(...preset, ...later);
}
const log = (level, msg) => `[${level}] ${msg}`;
const errorLog = partial(log, 'ERROR');
console.log(errorLog('磁盘满了')); // [ERROR] 磁盘满了

闭包实现真正的私有状态与模块

在 class 私有字段(#field)普及前,闭包是实现私有状态的唯一手段,至今仍广泛用于模块模式。

javascriptCode
// 用闭包做带私有状态的计数器工厂
function createStore(initial) {
  let state = initial;          // 完全私有,外部无法直接触碰
  const listeners = [];

  return {
    getState: () => state,
    setState(next) {
      state = typeof next === 'function' ? next(state) : next;
      listeners.forEach((fn) => fn(state));
    },
    subscribe(fn) {
      listeners.push(fn);
      // 返回取消订阅函数,同样依赖闭包
      return () => {
        const idx = listeners.indexOf(fn);
        if (idx > -1) listeners.splice(idx, 1);
      };
    },
  };
}

const store = createStore(0);
const unsub = store.subscribe((s) => console.log('变化:', s));
store.setState((s) => s + 1); // 变化: 1
store.setState(10);           // 变化: 10
unsub();
store.setState(20);           // 无输出,已取消订阅
// 这正是 Redux/Zustand 等状态库的核心思路。

闭包导致的内存泄漏场景与排查

闭包会延长变量生命周期,用不好就成内存泄漏。以下是四类高频泄漏场景。

javascriptCode
// 场景 1:闭包意外持有大对象
function attach() {
  const bigData = new Array(1000000).fill('x'); // 约占几 MB
  return function () {
    // 即使这里只用一个小值,只要引用了 bigData 所在作用域的任何东西,
    // 整个环境记录(含 bigData)都可能被保留
    return bigData.length;
  };
}
// 修复:只闭包保留需要的最小数据
function attachFixed() {
  const bigData = new Array(1000000).fill('x');
  const len = bigData.length; // 只留下需要的
  return () => len;           // bigData 可被回收
}

// 场景 2:DOM 事件监听未移除,回调闭包持有 DOM
function bind() {
  const el = document.getElementById('btn');
  const handler = () => console.log(el.id);
  el.addEventListener('click', handler);
  // 泄漏:不 removeEventListener,el 与 handler 互相引用永不释放
  return () => el.removeEventListener('click', handler); // 提供清理函数
}

// 场景 3:定时器未清理
function poll() {
  const data = { huge: new Array(100000) };
  const id = setInterval(() => console.log(data.huge.length), 1000);
  return () => clearInterval(id); // 必须提供清理,否则 data 永不释放
}

// 场景 4:缓存无上限增长
const cache = new Map();
function memoLeak(key, val) {
  cache.set(key, val); // 永远只增不减 → 内存持续上涨
}
// 修复:用 LRU 或 WeakMap(键为对象时自动回收)
const weakCache = new WeakMap();

排查内存泄漏的标准流程:

1.Chrome DevTools → Memory → Heap snapshot,操作前后各拍一张快照。
2.用 "Comparison" 视图对比两张快照,看哪些对象数量只增不减。
3.关注 Retainers(保留链),顺着链找到是哪个闭包/监听器/定时器在持有。
4.用 Performance 面板录制,看 JS Heap 曲线是否持续上升不回落(锯齿正常,只涨不跌是泄漏)。

闭包的性能考量

闭包本身开销很小,但需注意:

创建闭包会分配环境记录内存,在超高频(如每帧、每次滚动)创建大量闭包会增加 GC 压力。
把不变的函数提到循环外,避免每轮重新创建闭包。
javascriptCode
// 反例:每次渲染都新建闭包(React 中常见的重渲染诱因)
items.forEach((item) => {
  el.addEventListener('click', () => handle(item)); // 每次都新建
});

// 优化:事件委托,只用一个闭包
el.addEventListener('click', (e) => {
  const id = e.target.dataset.id;
  if (id) handle(id);
});

闭包对比表

| 用途 | 核心机制 | 典型场景 | 风险 |

| --- | --- | --- | --- |

| 私有变量 | 外层变量不暴露 | 计数器、状态库 | 引用未释放 |

| 模块化 | IIFE 返回公共 API | UMD、老式模块 | 全局污染(若忘记 IIFE) |

| 柯里化/偏函数 | 分层保存参数 | 函数式编程 | 过度嵌套难调试 |

| 防抖节流 | 保存 timer/标志 | 输入、滚动优化 | 忘清定时器泄漏 |

| 记忆化 | 保存 cache | 昂贵计算缓存 | 缓存无上限膨胀 |

最佳实践与小结

只在闭包里保留真正需要的最小数据,避免无意间拖住大对象。
事件监听、定时器、订阅都要成对提供清理函数。
高频路径优先事件委托、把稳定函数提出循环,减少闭包创建。
缓存类闭包设上限(LRU)或用 WeakMap,防止无限增长。

闭包是 JavaScript 函数式能力的根基,理解"函数 + 词法环境"这一定义,就能同时解释私有状态、模块化、柯里化和内存泄漏,做到既会用又用得安全。

原型与原型链的系统全解

原型(prototype)是 JavaScript 面向对象的基石。本节系统梳理 new 的执行步骤、instanceof 原理、`__proto__`/`prototype`/`constructor` 三者关系、Object.create、六种继承方式对比、ES6 class 与原型的关系,以及 Symbol、getter/setter。

new 操作符的四个步骤

理解 new 到底做了什么,是理解构造函数与原型的关键。new 一个构造函数分四步:

javascriptCode
// 手写 new,还原 new 的四步
function myNew(Constructor, ...args) {
  // 步骤 1:创建一个空对象,其原型指向构造函数的 prototype
  const obj = Object.create(Constructor.prototype);
  // 步骤 2:以新对象为 this 执行构造函数
  const result = Constructor.apply(obj, args);
  // 步骤 3:如果构造函数返回了一个对象,则用它;否则用新建的 obj
  // 步骤 4:返回对象
  return result !== null && (typeof result === 'object' || typeof result === 'function')
    ? result
    : obj;
}

function Person(name, age) {
  this.name = name;
  this.age = age;
}
Person.prototype.hello = function () {
  return `我是 ${this.name},${this.age} 岁`;
};

const p1 = myNew(Person, 'Alice', 30);
const p2 = new Person('Bob', 25);
console.log(p1.hello()); // 我是 Alice,30 岁
console.log(p2.hello()); // 我是 Bob,25 岁
console.log(p1 instanceof Person); // true

第三步的返回值判断很关键:构造函数若显式 return 一个对象,new 的结果就是那个对象;若 return 原始值或不 return,则返回新建的实例。

javascriptCode
function Weird() {
  this.a = 1;
  return { b: 2 }; // 返回对象,覆盖了 this
}
console.log(new Weird()); // { b: 2 },而不是 { a: 1 }

function Normal() {
  this.a = 1;
  return 42; // 返回原始值,被忽略
}
console.log(new Normal()); // { a: 1 }

__proto__、prototype、constructor 三角关系

这三者的关系是原型链的核心,一图胜千言,用代码验证:

javascriptCode
function Foo() {}
const f = new Foo();

// prototype:函数特有的属性,指向"实例的原型对象"
console.log(typeof Foo.prototype); // 'object'

// __proto__:每个对象都有,指向"自己的原型"(即创建它的构造函数的 prototype)
console.log(f.__proto__ === Foo.prototype); // true

// constructor:原型对象上的属性,指回构造函数
console.log(Foo.prototype.constructor === Foo); // true
console.log(f.constructor === Foo);             // true(f 自身没有,沿原型链找到)

// 完整链条
console.log(f.__proto__ === Foo.prototype);                    // true
console.log(Foo.prototype.__proto__ === Object.prototype);     // true
console.log(Object.prototype.__proto__ === null);              // true 顶端

// 函数也是对象,函数的 __proto__ 指向 Function.prototype
console.log(Foo.__proto__ === Function.prototype);             // true
console.log(Function.prototype.__proto__ === Object.prototype);// true

一个容易绕晕的经典结论:`Function.__proto__ === Function.prototype` 为 true(Function 由自身构造),且 `Object instanceof Function` 和 `Function instanceof Object` 都为 true。

javascriptCode
console.log(Function.__proto__ === Function.prototype); // true
console.log(Object instanceof Function);  // true(Object 是构造函数,由 Function 造)
console.log(Function instanceof Object);  // true(Function 最终原型是 Object.prototype)

推荐用标准 API 而非 __proto__

`__proto__` 是浏览器早期私有实现,虽被 ES6 纳入规范附录,但生产代码应使用标准方法:

javascriptCode
const obj = {};
// 读取原型
Object.getPrototypeOf(obj);           // 推荐,替代 obj.__proto__
// 设置原型(性能差,慎用)
Object.setPrototypeOf(obj, proto);    // 替代 obj.__proto__ = proto
// 创建时指定原型
const child = Object.create(proto);   // 推荐
// 创建无原型对象(纯字典,防原型污染)
const dict = Object.create(null);
console.log(dict.toString);           // undefined,没有从 Object.prototype 继承任何东西

instanceof 的原理与手写

instanceof 检查右侧构造函数的 prototype 是否出现在左侧对象的原型链上:

javascriptCode
function myInstanceof(obj, Ctor) {
  if (obj === null || (typeof obj !== 'object' && typeof obj !== 'function')) {
    return false; // 原始值直接 false
  }
  let proto = Object.getPrototypeOf(obj);
  const target = Ctor.prototype;
  while (proto !== null) {
    if (proto === target) return true;
    proto = Object.getPrototypeOf(proto); // 逐级向上
  }
  return false;
}

class A {}
class B extends A {}
const b = new B();
console.log(myInstanceof(b, B));      // true
console.log(myInstanceof(b, A));      // true(继承链上)
console.log(myInstanceof(b, Object)); // true
console.log(myInstanceof(b, Array));  // false

instanceof 的局限:跨 iframe/window 时构造函数不同会失效;对原始值无效。更稳妥的类型判断常用 `Object.prototype.toString.call`:

javascriptCode
const type = (v) => Object.prototype.toString.call(v).slice(8, -1);
console.log(type([]));        // 'Array'
console.log(type(null));      // 'Null'
console.log(type(/x/));       // 'RegExp'
console.log(type(new Date()));// 'Date'

六种继承方式对比

JavaScript 在 ES6 class 之前有多种继承实现,各有优劣。逐一给出代码。

javascriptCode
// 1) 原型链继承:子类原型指向父类实例
function Parent1() { this.list = [1, 2]; }
function Child1() {}
Child1.prototype = new Parent1();
// 缺点:引用类型属性被所有实例共享;无法向父类传参
const a1 = new Child1(), b1 = new Child1();
a1.list.push(3);
console.log(b1.list); // [1,2,3] 被污染

// 2) 借用构造函数(经典继承):子类中调用父类构造
function Parent2(name) { this.name = name; this.list = [1, 2]; }
function Child2(name) { Parent2.call(this, name); }
// 优点:解决共享问题、可传参;缺点:父类原型方法无法继承
const a2 = new Child2('x');
a2.list.push(3);
console.log(new Child2('y').list); // [1,2] 不受影响

// 3) 组合继承(最常用的经典方案):构造函数 + 原型链
function Parent3(name) { this.name = name; this.list = [1, 2]; }
Parent3.prototype.say = function () { return this.name; };
function Child3(name, age) {
  Parent3.call(this, name); // 第二次调用父构造
  this.age = age;
}
Child3.prototype = new Parent3(); // 第一次调用父构造(缺点:调了两次)
Child3.prototype.constructor = Child3;

// 4) 原型式继承:Object.create 的原理
function objCreate(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 5) 寄生式继承:在原型式基础上增强对象
function parasitic(o) {
  const clone = Object.create(o);
  clone.extra = function () { return 'enhanced'; };
  return clone;
}

// 6) 寄生组合继承(最理想方案,class 底层就是它)
function inherit(Child, Parent) {
  // 不调用父构造,只继承原型,避免组合继承调两次的缺点
  Child.prototype = Object.create(Parent.prototype);
  Child.prototype.constructor = Child;
}
function Parent6(name) { this.name = name; }
Parent6.prototype.say = function () { return this.name; };
function Child6(name, age) {
  Parent6.call(this, name);
  this.age = age;
}
inherit(Child6, Parent6);
const c6 = new Child6('Alice', 30);
console.log(c6.say(), c6.age); // Alice 30

| 继承方式 | 能否传参 | 引用属性共享问题 | 继承原型方法 | 父构造调用次数 | 推荐度 |

| --- | --- | --- | --- | --- | --- |

| 原型链继承 | 否 | 有(被污染) | 能 | 1 | 低 |

| 借用构造函数 | 能 | 无 | 不能 | 1 | 中 |

| 组合继承 | 能 | 无 | 能 | 2(冗余) | 高 |

| 原型式继承 | 否 | 有 | 能 | 0 | 中 |

| 寄生式继承 | 否 | 有 | 能 | 0 | 中 |

| 寄生组合继承 | 能 | 无 | 能 | 1 | 最高 |

ES6 class 与原型的关系

class 只是语法糖,底层依然是原型和寄生组合继承。用代码揭示:

javascriptCode
class Animal {
  constructor(name) { this.name = name; }
  eat() { return `${this.name} 在吃`; }
  static create(name) { return new Animal(name); } // 静态方法挂在类本身
}
class Dog extends Animal {
  constructor(name, breed) {
    super(name);          // 相当于 Animal.call(this, name)
    this.breed = breed;
  }
  bark() { return `${this.name} 汪汪`; }
}

const d = new Dog('旺财', '柴犬');
// 验证底层就是原型链
console.log(d.__proto__ === Dog.prototype);                 // true
console.log(Dog.prototype.__proto__ === Animal.prototype);  // true
console.log(typeof Dog);                                    // 'function',class 本质是函数
// 实例方法挂在 prototype 上,不可枚举
console.log(Object.getOwnPropertyNames(Dog.prototype));     // ['constructor','bark']
// class 内部默认严格模式;类声明不提升(有 TDZ)

class 与函数构造的关键差异:

| 特性 | ES6 class | function 构造函数 |

| --- | --- | --- |

| 提升 | 有 TDZ,不可提前用 | 可提前调用(函数声明) |

| 严格模式 | 内部强制严格模式 | 取决于外部 |

| 方法可枚举 | 不可枚举 | 可枚举 |

| 必须 new 调用 | 是(直接调用报错) | 否 |

| super | 支持 | 需手动 Parent.call |

Symbol 与原型/属性

Symbol 是 ES6 引入的第 7 种原始类型,值唯一,常用于定义不冲突的属性键和"知名 Symbol"来定制对象行为。

javascriptCode
// Symbol 作为唯一属性键
const id = Symbol('id');
const user = { [id]: 123, name: 'Alice' };
console.log(user[id]);                       // 123
console.log(Object.keys(user));              // ['name'],Symbol 键不被常规枚举
console.log(Object.getOwnPropertySymbols(user)); // [Symbol(id)]

// 知名 Symbol 定制行为
class Range {
  constructor(start, end) { this.start = start; this.end = end; }
  // 定制 instanceof
  static [Symbol.hasInstance](inst) {
    return typeof inst === 'number';
  }
  // 定制迭代
  *[Symbol.iterator]() {
    for (let i = this.start; i <= this.end; i++) yield i;
  }
}
console.log(5 instanceof Range);       // true,被 Symbol.hasInstance 改写
console.log([...new Range(1, 3)]);     // [1, 2, 3]

// Symbol.toPrimitive 定制类型转换
const money = {
  amount: 100,
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `¥${this.amount}`;
    return this.amount;
  },
};
console.log(+money);       // 100
console.log(`${money}`);  // ¥100

getter/setter 与属性描述符

访问器属性(getter/setter)让属性访问变成函数调用,是响应式框架(如 Vue2)的核心。

javascriptCode
const person = {
  firstName: '张',
  lastName: '三',
  get fullName() {
    return this.firstName + this.lastName;
  },
  set fullName(value) {
    [this.firstName, this.lastName] = [value[0], value.slice(1)];
  },
};
console.log(person.fullName); // 张三
person.fullName = '李四';
console.log(person.firstName, person.lastName); // 李 四

// 用 Object.defineProperty 精确控制属性描述符
const obj = {};
let internal = 0;
Object.defineProperty(obj, 'count', {
  get() { return internal; },
  set(v) { console.log('拦截到赋值:', v); internal = v; },
  enumerable: true,
  configurable: true,
});
obj.count = 5;          // 拦截到赋值: 5
console.log(obj.count); // 5
// 这正是 Vue2 响应式的原理;Vue3 改用 Proxy 解决了 defineProperty 无法监听新增属性、数组索引等局限。

小结

| 概念 | 一句话本质 | 关键点 |

| --- | --- | --- |

| new 四步 | 建对象、连原型、执行、返回 | 返回对象会覆盖 this |

| prototype | 函数指向实例原型的属性 | 实例方法挂这里 |

| __proto__ | 对象指向自己原型 | 用 getPrototypeOf 替代 |

| constructor | 原型指回构造函数 | 重写原型要修回它 |

| instanceof | 检查原型链上是否有某 prototype | 跨 iframe 会失效 |

| 寄生组合继承 | 最优继承,class 底层实现 | 只调一次父构造 |

| Symbol | 唯一值,定制对象行为 | 知名 Symbol 是元编程入口 |

| getter/setter | 属性访问变函数调用 | Vue2 响应式基础 |

this 指向的完整解析

this 是 JavaScript 最让人头疼的概念,因为它的值取决于函数被调用的方式而非定义的位置。本节系统讲清五种绑定规则、优先级,手写 call/apply/bind,剖析箭头函数,并给出 this 丢失的诊断与修复。

五种绑定规则逐一拆解

规则一:默认绑定(独立调用)

javascriptCode
function show() {
  console.log(this);
}
show();
// 非严格模式:this 指向全局对象(浏览器 window / Node global)
// 严格模式 'use strict':this 是 undefined

规则二:隐式绑定(作为对象方法调用)

javascriptCode
const obj = {
  name: 'Alice',
  greet() { return this.name; },
};
console.log(obj.greet()); // 'Alice',this 指向调用它的 obj

// 只看"调用那一刻点号前面是谁",多层嵌套只认最后一层
const outer = { name: 'O', inner: { name: 'I', greet() { return this.name; } } };
console.log(outer.inner.greet()); // 'I'

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

javascriptCode
function intro(city, job) {
  return `${this.name},来自${city},职业${job}`;
}
const person = { name: 'Bob' };
console.log(intro.call(person, '北京', '工程师'));   // 参数逐个传
console.log(intro.apply(person, ['上海', '设计师'])); // 参数用数组
const bound = intro.bind(person, '广州');            // 返回新函数,可预置参数
console.log(bound('产品经理'));

规则四:new 绑定

javascriptCode
function Person(name) {
  this.name = name; // this 指向 new 出来的新实例
}
const p = new Person('Charlie');
console.log(p.name); // 'Charlie'

规则五:箭头函数(词法 this)

javascriptCode
const obj = {
  name: 'Alice',
  regular() {
    // 普通函数里的箭头,this 继承 regular 的 this(即 obj)
    const arrow = () => this.name;
    return arrow();
  },
};
console.log(obj.regular()); // 'Alice'
// 箭头函数没有自己的 this,call/apply/bind 都改不了它

五种绑定的优先级

当多条规则同时可能适用时,优先级从高到低为:new 绑定 > 显式绑定(bind/call/apply)> 隐式绑定 > 默认绑定。箭头函数是特例,它在定义时就锁定 this,不参与上面的比较。

javascriptCode
// 验证:显式绑定 > 隐式绑定
function f() { return this.tag; }
const objA = { tag: 'A', f };
const objB = { tag: 'B' };
console.log(objA.f.call(objB)); // 'B',call 赢过隐式

// 验证:new > 显式(bind)
function Foo(v) { this.v = v; }
const bound = Foo.bind({ v: 'bound' });
const inst = new bound(42);
console.log(inst.v); // 42,new 赢过 bind

// 验证:箭头函数无视一切
const arrow = () => this;
console.log(arrow.call({ any: 1 }) === arrow()); // true,call 无效

手写 call / apply / bind

彻底理解显式绑定,最好的方式是自己实现一遍。

javascriptCode
// 手写 call
Function.prototype.myCall = function (context, ...args) {
  // context 为 null/undefined 时指向全局对象
  context = context == null ? globalThis : Object(context);
  const key = Symbol('fn');       // 用 Symbol 避免覆盖已有属性
  context[key] = this;            // this 是被调用的函数
  const result = context[key](...args); // 以方法形式调用,this 自然指向 context
  delete context[key];            // 清理临时属性
  return result;
};

// 手写 apply(区别仅在参数是数组)
Function.prototype.myApply = function (context, args) {
  context = context == null ? globalThis : Object(context);
  const key = Symbol('fn');
  context[key] = this;
  const result = Array.isArray(args) ? context[key](...args) : context[key]();
  delete context[key];
  return result;
};

// 手写 bind(要处理 new 调用的情况)
Function.prototype.myBind = function (context, ...preArgs) {
  const fn = this;
  function bound(...laterArgs) {
    // 若 bound 被 new 调用,this 是新实例,应忽略绑定的 context
    const isNew = this instanceof bound;
    return fn.apply(isNew ? this : context, [...preArgs, ...laterArgs]);
  }
  // 维持原型链,使 new bound() 的实例能访问原函数原型上的方法
  if (fn.prototype) {
    bound.prototype = Object.create(fn.prototype);
  }
  return bound;
};

// 验证
function greet(greeting, punc) {
  return `${greeting}, ${this.name}${punc}`;
}
console.log(greet.myCall({ name: 'Alice' }, 'Hi', '!'));      // Hi, Alice!
console.log(greet.myApply({ name: 'Bob' }, ['Hello', '.']));  // Hello, Bob.
const bg = greet.myBind({ name: 'Carol' }, 'Hey');
console.log(bg('~'));                                         // Hey, Carol~

this 丢失的四大场景与修复

javascriptCode
const obj = {
  name: 'Alice',
  greet() { return this.name; },
};

// 场景 1:方法赋值给变量后独立调用
const g = obj.greet;
console.log(g());              // undefined(默认绑定)
console.log(g.bind(obj)());   // 'Alice' 修复

// 场景 2:作为回调传入(setTimeout / 数组方法 / 事件)
setTimeout(obj.greet, 0);            // undefined
setTimeout(() => obj.greet(), 0);    // 'Alice' 修复:箭头包裹
setTimeout(obj.greet.bind(obj), 0);  // 'Alice' 修复:bind

[1].forEach(function () { /* this 默认 undefined */ });
[1].forEach(function () { /* ... */ }, obj); // forEach 支持第二参数指定 this
[1].forEach(() => obj.greet());              // 箭头继承外层 this

// 场景 3:类方法作为事件处理器
class Counter {
  count = 0;
  // 方案 A:类字段 + 箭头函数,定义即绑定 this
  handleClick = () => { this.count++; };
  // 方案 B:构造函数里 bind
  constructor() {
    this.handleClickB = this.handleClickB.bind(this);
  }
  handleClickB() { this.count++; }
}

// 场景 4:嵌套普通函数导致 this 丢失
const timer = {
  seconds: 0,
  start() {
    // setInterval(function () { this.seconds++; }, 1000); // this 错误
    setInterval(() => { this.seconds++; }, 1000);          // 箭头继承 start 的 this
  },
};

箭头函数不能做的事

javascriptCode
// 1) 不能作构造函数
const Arrow = () => {};
// new Arrow(); // TypeError: Arrow is not a constructor

// 2) 没有自己的 arguments
const fn = () => {
  // console.log(arguments); // ReferenceError(或取到外层的)
};
const rest = (...args) => args; // 用剩余参数替代
console.log(rest(1, 2, 3)); // [1,2,3]

// 3) 不适合做对象方法(this 指向外层而非对象)
const bad = {
  name: 'X',
  greet: () => this.name, // this 是外层(模块顶层多为 undefined)
};

// 4) 不能用作 generator(没有 yield)

this 绑定规则对比表

| 调用方式 | this 指向 | 优先级 | 示例 |

| --- | --- | --- | --- |

| 默认(独立调用) | 全局对象 / undefined | 最低 | fn() |

| 隐式(方法调用) | 点号前的对象 | 低 | obj.fn() |

| 显式(call/apply/bind) | 指定对象 | 高 | fn.call(o) |

| new 绑定 | 新实例 | 最高 | new Fn() |

| 箭头函数 | 定义时外层 this | 不参与 | () => this |

小结

判断 this 的口诀:先看是不是箭头函数(是则取外层);否则看是不是 new(是则新实例);再看有没有 call/apply/bind(有则指定对象);再看有没有对象调用(有则该对象);都没有就是默认绑定(全局或 undefined)。掌握这个决策顺序,任何 this 题都能秒答。

事件循环的深入:浏览器与 Node.js

事件循环(Event Loop)是 JavaScript 单线程实现非阻塞异步的核心机制。前面讲过基本模型,本节深入到宏任务/微任务的完整清单、浏览器与 Node.js 的差异、requestAnimationFrame 的时机,并用多道经典输出题彻底吃透执行顺序。

宏任务与微任务的完整清单

任务分两大类,执行时机截然不同:每执行完一个宏任务,就会清空所有微任务队列,然后才取下一个宏任务。

| 分类 | 具体 API(浏览器) | 具体 API(Node.js) |

| --- | --- | --- |

| 宏任务(macrotask) | setTimeout、setInterval、MessageChannel、UI 渲染、I/O、用户交互事件 | setTimeout、setInterval、setImmediate、I/O |

| 微任务(microtask) | Promise.then/catch/finally、queueMicrotask、MutationObserver、await 之后 | Promise 回调、queueMicrotask、process.nextTick |

关键规则:微任务优先级高于宏任务;process.nextTick 优先级又高于普通微任务(Promise)。

浏览器事件循环的完整流程

codeCode
一轮事件循环(tick):
1. 从宏任务队列取【一个】最老的宏任务执行
2. 执行过程中产生的微任务进入微任务队列
3. 宏任务执行完,清空【所有】微任务(包括微任务里又产生的微任务)
4. 需要则执行渲染(requestAnimationFrame → 样式计算 → 布局 → 绘制)
5. 回到步骤 1

经典输出题逐题精讲

javascriptCode
// 题 1:基础宏微任务
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出:1 4 3 2
// 解析:同步 1、4 → 清空微任务 3 → 下一个宏任务 2
javascriptCode
// 题 2:微任务里再生微任务,宏任务里生微任务
console.log('start');
setTimeout(() => {
  console.log('timeout');
  Promise.resolve().then(() => console.log('promise in timeout'));
}, 0);
Promise.resolve().then(() => {
  console.log('promise1');
  Promise.resolve().then(() => console.log('promise2'));
});
console.log('end');
// 输出:start end promise1 promise2 timeout "promise in timeout"
// 解析:
// 同步:start、end
// 清空微任务:promise1 → 执行时又产生 promise2,继续清空 → promise2
// 下一个宏任务:timeout,产生新微任务
// 该宏任务后清空微任务:promise in timeout
javascriptCode
// 题 3:async/await 的拆解
async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end'); // 相当于 async2().then(() => {...}),是微任务
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise executor'); // 同步执行!
  resolve();
}).then(() => console.log('promise then'));
console.log('script end');
// 输出:
// script start
// async1 start
// async2
// promise executor
// script end
// async1 end
// promise then
// setTimeout
// 要点:Promise 的 executor 是同步执行的;await 后面的代码进微任务队列
javascriptCode
// 题 4:await 后跟 Promise 与直接值的差异(现代规范已优化)
async function f() {
  console.log('a');
  await Promise.resolve();
  console.log('b'); // 微任务
}
f();
Promise.resolve().then(() => console.log('c'));
console.log('d');
// 输出:a d b c
// 现代 V8(TC39 优化后)await Promise.resolve() 只多一个微任务 tick

requestAnimationFrame 的时机

requestAnimationFrame(rAF)既不是宏任务也不是普通微任务,它在每次重绘之前、布局之前执行,与显示器刷新率同步(通常 60Hz,约每 16.7ms 一次)。

javascriptCode
// rAF vs setTimeout 的执行时机
console.log('sync');
setTimeout(() => console.log('timeout'), 0);
requestAnimationFrame(() => console.log('raf'));
Promise.resolve().then(() => console.log('microtask'));
// 典型输出:sync microtask raf timeout(或 timeout 与 raf 顺序视浏览器帧时机)
// 要点:微任务先于 rAF;rAF 在渲染前、通常先于 0ms 的 setTimeout

// 用 rAF 做流畅动画(比 setTimeout 更稳,掉帧时自动跳过)
function animate(el) {
  let start = null;
  function step(timestamp) {
    if (!start) start = timestamp;
    const progress = timestamp - start;
    el.style.transform = `translateX(${Math.min(progress / 10, 200)}px)`;
    if (progress < 2000) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

Node.js 事件循环的六个阶段

Node.js 基于 libuv,事件循环分为六个阶段,每个阶段有自己的宏任务队列,阶段之间会清空微任务(Promise 与 process.nextTick)。

| 阶段 | 处理内容 |

| --- | --- |

| timers | 执行到期的 setTimeout / setInterval 回调 |

| pending callbacks | 执行延迟到下一轮的 I/O 回调 |

| idle, prepare | 内部使用 |

| poll | 获取新 I/O 事件,执行 I/O 回调(可能阻塞等待) |

| check | 执行 setImmediate 回调 |

| close callbacks | 执行 close 事件回调(如 socket.on('close')) |

javascriptCode
// Node 中 setTimeout 与 setImmediate 的顺序(主模块中不确定)
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 主模块里顺序不确定,取决于进程启动耗时

// 但在 I/O 回调内部,setImmediate 一定先于 setTimeout
const fs = require('fs');
fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
  // 输出稳定:immediate 先,因为 poll 阶段结束直接进 check 阶段
});

process.nextTick 与微任务优先级

在 Node.js 中,process.nextTick 的回调在当前操作完成后、事件循环继续之前立即执行,优先级高于 Promise 微任务。

javascriptCode
// Node.js 优先级演示
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
console.log('sync');
// 输出:sync → nextTick → promise → timeout/immediate
// nextTick 队列优先于 Promise 微任务队列

注意 nextTick 的坑:递归调用 process.nextTick 会让事件循环"饿死",永远进不到下一个阶段,I/O 被完全阻塞。

浏览器 vs Node.js 差异总结

| 维度 | 浏览器 | Node.js |

| --- | --- | --- |

| 微任务队列 | 单一微任务队列 | nextTick 队列 + Promise 队列 |

| 微任务清空时机 | 每个宏任务后 | 每个阶段之间 |

| 特有 API | requestAnimationFrame、MutationObserver | setImmediate、process.nextTick |

| 定时器精度 | 嵌套 5 层后最小 4ms | 最小 1ms |

| 阶段划分 | 无显式阶段 | 六个明确阶段 |

常见坑与最佳实践

坑:长同步任务阻塞渲染。单个任务超过约 50ms 用户就会感到卡顿。用时间切片(把任务拆成多个 setTimeout/requestIdleCallback)或 Web Worker。
坑:微任务无限增殖。微任务里不断产生新微任务会让当前 tick 永不结束,阻塞渲染和宏任务。
坑:以为 setTimeout(fn, 0) 立刻执行。它至少要等本轮同步代码和所有微任务清空,且有最小延迟。
javascriptCode
// 时间切片:把 10 万条数据分批渲染,避免长任务卡顿
function renderInChunks(list, chunkSize = 500) {
  let i = 0;
  function work() {
    const end = Math.min(i + chunkSize, list.length);
    for (; i < end; i++) { /* 渲染 list[i] */ }
    if (i < list.length) {
      // 让出主线程给浏览器渲染,避免一次性阻塞
      requestAnimationFrame(work);
    }
  }
  work();
}

小结

| 概念 | 关键点 |

| --- | --- |

| 微任务优先 | 每个宏任务后清空所有微任务 |

| Promise executor | 同步执行,then 回调才是微任务 |

| await 后代码 | 等价于 then 回调,是微任务 |

| requestAnimationFrame | 渲染前执行,与刷新率同步 |

| Node 六阶段 | timers/poll/check 等,阶段间清微任务 |

| process.nextTick | Node 中优先级最高,慎防饿死 |

内存管理与垃圾回收

JavaScript 是自动内存管理语言,开发者不手动分配和释放内存,但理解垃圾回收(Garbage Collection,GC)机制能帮你避免内存泄漏、写出更省内存的代码。本节讲清引用计数、标记清除、V8 分代 GC,以及内存泄漏的定位方法。

内存生命周期

任何语言的内存都经历三个阶段:分配 → 使用 → 释放。JavaScript 的分配和释放大多是自动的。

javascriptCode
// 分配:声明变量、创建对象/函数时引擎自动分配内存
const num = 123;                 // 栈上分配原始值
const obj = { a: 1 };            // 堆上分配对象,栈上存引用
const arr = new Array(1000);     // 堆上分配数组
const fn = function () {};        // 堆上分配函数对象

// 使用:读写这些值
obj.a = 2;

// 释放:当没有任何引用指向对象时,GC 回收它
// obj = null; // 解除引用,使原对象可被回收

原始类型(number、string、boolean 等)通常存在栈中,引用类型(对象、数组、函数)存在堆中,栈里只放指向堆的引用地址。

引用计数算法

早期 GC 策略:记录每个对象被引用的次数,计数为 0 时回收。缺点是无法处理循环引用。

javascriptCode
// 引用计数的致命缺陷:循环引用
function leak() {
  const a = {};
  const b = {};
  a.ref = b; // b 被引用 +1
  b.ref = a; // a 被引用 +1
  // 函数结束后,a 和 b 都不再被外部引用,但它们互相引用,
  // 引用计数永远不为 0 → 在纯引用计数下永远不会被回收(泄漏)
}
leak();
// 现代浏览器早已改用标记清除,不受此问题影响。

标记清除算法

现代 JavaScript 引擎(V8 等)主流采用标记清除(Mark-and-Sweep)。它从一组"根"(全局对象、当前调用栈上的变量等)出发,标记所有可达对象,未被标记的即为垃圾,予以清除。

codeCode
标记清除三步:
1. 标记(Mark):从根出发,遍历所有可达对象并打标记
2. 清除(Sweep):遍历堆,回收所有未被标记的对象
3. 整理(Compact,可选):移动存活对象,压缩碎片空间

因为判断标准是"从根是否可达",循环引用只要整体从根不可达,就会被一起回收,彻底解决了引用计数的缺陷。

V8 的分代式垃圾回收

V8 基于"大多数对象很快死亡(弱代假说)",把堆分为新生代和老生代,采用不同策略,大幅提升效率。

| 分代 | 大小(约) | 特点 | 回收算法 |

| --- | --- | --- | --- |

| 新生代(Young) | 1~8 MB | 存放新对象,回收频繁 | Scavenge(复制算法,From/To 空间) |

| 老生代(Old) | 数百 MB~上 GB | 存活久的对象 | 标记清除 + 标记整理 |

新生代 Scavenge:把新生代分成 From 和 To 两半,只用一半。GC 时把 From 中存活对象复制到 To,然后交换角色,清空原 From。对象经历两次 Scavenge 仍存活则"晋升"到老生代。这种复制算法速度快但浪费一半空间,适合小而多的新生代。

老生代:对象多、存活久,用标记清除回收垃圾,再用标记整理消除内存碎片。V8 还引入了增量标记(把标记拆成小段穿插执行)、惰性清除、并发/并行回收,尽量减少 GC 造成的主线程停顿(Stop-The-World)。

javascriptCode
// 观察内存(Node.js)
const used = process.memoryUsage();
console.log({
  rss: (used.rss / 1024 / 1024).toFixed(2) + ' MB',        // 常驻内存
  heapTotal: (used.heapTotal / 1024 / 1024).toFixed(2) + ' MB', // V8 堆总量
  heapUsed: (used.heapUsed / 1024 / 1024).toFixed(2) + ' MB',   // 已用堆
  external: (used.external / 1024 / 1024).toFixed(2) + ' MB',   // C++ 对象
});
// 浏览器可用 performance.memory(非标准)观察 usedJSHeapSize

常见内存泄漏场景

javascriptCode
// 泄漏 1:意外的全局变量
function leak1() {
  leaked = 'I am global'; // 忘写 var/let/const,挂到了全局,永不回收
}
// 防御:文件顶部加 'use strict',未声明赋值会报错

// 泄漏 2:被遗忘的定时器
let data = { huge: new Array(100000) };
const timerId = setInterval(() => {
  console.log(data.huge.length); // 闭包持有 data,只要定时器在就不回收
}, 1000);
// 修复:clearInterval(timerId); data = null;

// 泄漏 3:脱离 DOM 的元素引用
const cache = {};
function keepRef() {
  const el = document.getElementById('big-table');
  cache.el = el; // JS 里存了引用
}
// 即使从 DOM 移除了该元素,cache.el 仍持有它 → DOM 内存泄漏
// 修复:cache.el = null;

// 泄漏 4:闭包无意持有大对象(见闭包章节)

// 泄漏 5:事件监听未移除
function bindLeak(node) {
  const handler = () => {};
  node.addEventListener('click', handler);
  // 未 removeEventListener,node 被移除后监听器仍引用它
}

WeakMap / WeakSet 避免泄漏

WeakMap 和 WeakSet 对键持弱引用,当键对象没有其他引用时会被自动回收,非常适合做"附加数据"缓存。

javascriptCode
// 用 WeakMap 给 DOM 节点关联数据,节点移除后数据自动回收
const nodeData = new WeakMap();
function setData(node, data) {
  nodeData.set(node, data); // node 被 GC 后,这条记录自动消失
}
function getData(node) {
  return nodeData.get(node);
}
// 对比普通 Map:Map 强引用 key,node 移除后记录仍在 → 泄漏

// WeakMap 实现真正私有属性
const _private = new WeakMap();
class Account {
  constructor(balance) {
    _private.set(this, { balance });
  }
  getBalance() {
    return _private.get(this).balance;
  }
}
const acc = new Account(100);
console.log(acc.getBalance()); // 100,balance 外部无法直接访问

内存泄漏定位流程

1.复现:找到会导致内存增长的操作(如反复打开/关闭某弹窗)。
2.Performance 录制:DevTools → Performance 勾选 Memory,录制操作过程,看 JS Heap 曲线。正常应呈锯齿状(涨后 GC 回落),若基线持续抬高就是泄漏。
3.Heap Snapshot 对比:操作前拍快照 A,操作若干次后拍快照 B,用 Comparison 视图看哪类对象 "Delta" 为正且持续增长。
4.顺 Retainers 找根因:选中泄漏对象,看 Retainers 保留链,定位到底是哪个闭包、数组、Map 或监听器在持有它。
5.Allocation instrumentation:录制分配时间线,蓝色竖条表示分配后未被回收的内存,直接定位分配点。

减少内存占用的实践

| 实践 | 说明 |

| --- | --- |

| 及时解除引用 | 大对象用完置 null,尤其是缓存和闭包 |

| 用 WeakMap/WeakSet | 关联数据随对象自动回收 |

| 清理定时器与监听 | 组件卸载时 clearInterval/removeEventListener |

| 避免全局变量膨胀 | 严格模式 + 模块化隔离 |

| 分片处理大数据 | 避免一次性构造超大数组/字符串 |

| 对象池复用 | 高频创建销毁的小对象可复用,减轻 GC 压力 |

小结

| 概念 | 关键点 |

| --- | --- |

| 引用计数 | 简单但无法处理循环引用 |

| 标记清除 | 从根判断可达性,现代主流 |

| 新生代 Scavenge | 复制算法,快,晋升机制 |

| 老生代 | 标记清除+整理,增量/并发优化 |

| WeakMap/WeakSet | 弱引用,自动回收,防泄漏 |

| 泄漏定位 | 快照对比 + Retainers 保留链 |

类型系统与类型转换

JavaScript 是动态弱类型语言,类型转换规则是无数"诡异 bug"的来源。本节讲清 7 种原始类型、包装对象、显式与隐式类型转换规则(含 == 强制转换表)、typeof/instanceof 的适用边界,以及深浅拷贝的实现。

七种原始类型与一种引用类型

javascriptCode
// 7 种原始类型(primitive)
const a = 42;                    // number
const b = 42n;                   // bigint(ES2020,大整数)
const c = 'hi';                  // string
const d = true;                  // boolean
const e = undefined;             // undefined
const f = null;                  // null
const g = Symbol('id');          // symbol

// 引用类型:object(数组、函数、Date、RegExp 等都是 object)
const obj = {};
const arr = [];
const fn = () => {};

原始类型的值不可变(immutable),存储在栈中,按值传递;引用类型可变,存储在堆中,按引用传递(传的是地址)。

javascriptCode
// 按值 vs 按引用
let x = 1;
let y = x; // 拷贝值
y = 2;
console.log(x); // 1,互不影响

let o1 = { n: 1 };
let o2 = o1;   // 拷贝的是引用地址
o2.n = 2;
console.log(o1.n); // 2,指向同一对象

typeof 的返回值与坑

javascriptCode
console.log(typeof 42);          // 'number'
console.log(typeof 42n);         // 'bigint'
console.log(typeof 'hi');        // 'string'
console.log(typeof true);        // 'boolean'
console.log(typeof undefined);   // 'undefined'
console.log(typeof Symbol());    // 'symbol'
console.log(typeof {});          // 'object'
console.log(typeof []);          // 'object'(数组也是 object!)
console.log(typeof function(){});// 'function'(函数特殊)
console.log(typeof null);        // 'object'(历史遗留 bug!)

typeof null === 'object' 是 JavaScript 诞生之初的 bug,因兼容性无法修复。判断数组要用 `Array.isArray`,判断 null 直接 `=== null`,精确判断类型用 `Object.prototype.toString.call`。

包装对象

原始类型没有属性和方法,但我们能写 `'hi'.length`。这是因为引擎在访问时临时创建了包装对象(String、Number、Boolean),用完即销毁。

javascriptCode
const str = 'hello';
console.log(str.length);          // 5
console.log(str.toUpperCase());   // 'HELLO'
// 引擎背后做了:new String('hello').length,取完值再丢弃包装对象

// 证据:给原始值加属性无效(包装对象转瞬即逝)
str.custom = 123;
console.log(str.custom); // undefined,每次访问都是新的临时包装对象

// 不要显式 new 包装对象,会得到 object 而非原始值
const bad = new String('x');
console.log(typeof bad);         // 'object'
console.log(bad == 'x');         // true(值相等)
console.log(bad === 'x');        // false(类型不同)

显式类型转换

javascriptCode
// 转数字
Number('123');    // 123
Number('12px');   // NaN
Number('');       // 0
Number(true);     // 1
Number(null);     // 0
Number(undefined);// NaN
Number([]);       // 0
Number([5]);      // 5
Number([1, 2]);   // NaN
parseInt('12px'); // 12(宽松,从左解析到非法字符)
parseFloat('3.14abc'); // 3.14

// 转字符串
String(123);      // '123'
String(null);     // 'null'
String(undefined);// 'undefined'
String([1, 2]);   // '1,2'
String({});       // '[object Object]'
(123).toString(); // '123'

// 转布尔(记住 falsy 值即可)
Boolean(0);       // false
Boolean('');      // false
Boolean(NaN);     // false
Boolean(null);    // false
Boolean(undefined);// false
Boolean(false);   // false
// 以上 6 个(及 0n)是全部 falsy 值,其余全部 truthy
Boolean('0');     // true(非空字符串)
Boolean([]);      // true(空数组也是 truthy!)
Boolean({});      // true

隐式类型转换与 == 强制转换

隐式转换发生在运算符两侧类型不一致时。最复杂的是 `==` 的强制转换(coercion)。

== 的转换规则(简化版):

1.类型相同:直接比较(等价于 ===)。
2.null == undefined 为 true,且它们与其他任何值比较都是 false。
3.number 与 string 比较:string 转 number。
4.有 boolean:boolean 先转 number。
5.object 与原始值比较:object 转原始值(调用 valueOf/toString)。
6.NaN 与任何值(含自身)都不相等。
javascriptCode
console.log(1 == '1');        // true('1' → 1)
console.log(true == 1);       // true(true → 1)
console.log(true == '1');     // true(true → 1,'1' → 1)
console.log(null == undefined);// true(特殊规定)
console.log(null == 0);       // false(null 只等于 undefined)
console.log(undefined == 0);  // false
console.log(NaN == NaN);      // false(NaN 谁都不等)
console.log([] == false);     // true([] → '' → 0,false → 0)
console.log([] == ![]);       // true(![] 是 false → 0,[] → 0)
console.log('' == 0);         // true('' → 0)
console.log([1] == 1);        // true([1] → '1' → 1)
console.log({} == {});        // false(不同引用)

经典陷阱 `[] == ![]` 解析:`![]` 先算,`[]` 是 truthy 所以 `![]` 为 false;然后 `[] == false`,false 转 0,`[]` 转原始值为 `''` 再转 0,`0 == 0` 为 true。

== 强制转换速查表(部分组合):

| 左 | 右 | 结果 | 转换过程 |

| --- | --- | --- | --- |

| 1 | '1' | true | 字符串转数字 |

| 0 | '' | true | 空串转 0 |

| 0 | '0' | true | '0' 转 0 |

| false | 0 | true | false 转 0 |

| null | undefined | true | 特殊规定 |

| null | 0 | false | null 不转数字比较 |

| NaN | NaN | false | NaN 恒不等 |

| [] | false | true | []→''→0,false→0 |

| [] | ![] | true | ![]→false→0,[]→0 |

结论:除非明确需要类型转换,永远用 === 严格相等,避开 == 的所有坑。

对象转原始值的顺序

对象参与运算时会转原始值,规则是先调 `Symbol.toPrimitive`(若有),否则按 hint 顺序调 valueOf 和 toString。

javascriptCode
const obj = {
  valueOf() { console.log('valueOf'); return 10; },
  toString() { console.log('toString'); return 'str'; },
};
console.log(obj + 1);       // hint 'default':先 valueOf → 11
console.log(`${obj}`);      // hint 'string':先 toString → 'str'
console.log(obj * 2);       // hint 'number':先 valueOf → 20

// 数组的 toString 是 join(',')
console.log([1, 2, 3] + ''); // '1,2,3'
console.log({} + '');        // '[object Object]'

深拷贝与浅拷贝

浅拷贝只复制第一层,嵌套对象仍共享引用;深拷贝递归复制所有层级。

javascriptCode
// 浅拷贝
const obj = { a: 1, nested: { b: 2 } };
const shallow1 = { ...obj };            // 展开运算符
const shallow2 = Object.assign({}, obj);
shallow1.nested.b = 99;
console.log(obj.nested.b); // 99,嵌套对象被共享(浅拷贝的局限)

// 深拷贝方案 1:structuredClone(现代浏览器/Node 17+ 内置,推荐)
const deep1 = structuredClone(obj);
deep1.nested.b = 100;
console.log(obj.nested.b); // 99,不受影响
// 支持 Date、RegExp、Map、Set、循环引用;不支持函数、Symbol 键、DOM

// 深拷贝方案 2:JSON(简单但有局限)
const deep2 = JSON.parse(JSON.stringify(obj));
// 局限:丢失 undefined、函数、Symbol;Date 变字符串;不支持循环引用(报错);BigInt 报错

// 深拷贝方案 3:手写递归(处理循环引用)
function deepClone(target, map = new WeakMap()) {
  if (target === null || typeof target !== 'object') return target;
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target);
  if (map.has(target)) return map.get(target); // 处理循环引用
  const clone = Array.isArray(target) ? [] : {};
  map.set(target, clone);
  Reflect.ownKeys(target).forEach((key) => {
    clone[key] = deepClone(target[key], map);
  });
  return clone;
}
const a = { x: 1 };
a.self = a; // 循环引用
const cloned = deepClone(a);
console.log(cloned.self === cloned); // true,循环引用被正确处理

| 拷贝方式 | 层级 | 函数 | 循环引用 | Date/RegExp | 性能 |

| --- | --- | --- | --- | --- | --- |

| 展开/Object.assign | 浅 | 保留 | 保留 | 保留 | 最快 |

| JSON 方法 | 深 | 丢失 | 报错 | 变字符串 | 中 |

| structuredClone | 深 | 报错 | 支持 | 支持 | 快 |

| 手写递归 | 深 | 可保留 | 可处理 | 可处理 | 视实现 |

小结

| 概念 | 关键点 |

| --- | --- |

| 7 种原始类型 | number/string/boolean/undefined/null/symbol/bigint |

| typeof null | 返回 'object',历史 bug |

| 包装对象 | 原始值临时借用方法,转瞬即逝 |

| == 强制转换 | 规则复杂,一律用 === |

| falsy 值 | 0/''/NaN/null/undefined/false/0n |

| 深浅拷贝 | 浅拷贝共享嵌套引用,深拷贝优先 structuredClone |

异步编程的演进:从回调到 async/await

JavaScript 的异步方案经历了回调函数 → Promise → Generator → async/await 的演进。本节按这条脉络逐一剖析,手写符合 Promise/A+ 规范的关键片段,并给出并发控制的实战代码。

回调函数与回调地狱

最早的异步用回调函数,多层嵌套导致"回调地狱"(Callback Hell),代码横向发展、难以维护、错误处理繁琐。

javascriptCode
// 回调地狱:层层嵌套,俗称"厄运金字塔"
getUser(userId, (err, user) => {
  if (err) return handle(err);
  getOrders(user.id, (err, orders) => {
    if (err) return handle(err);
    getOrderDetail(orders[0].id, (err, detail) => {
      if (err) return handle(err);
      getShipping(detail.shipId, (err, shipping) => {
        if (err) return handle(err);
        console.log(shipping); // 嵌套四层,每层都要重复错误处理
      });
    });
  });
});

回调的三大问题:嵌套过深难读;错误处理靠约定(error-first callback),每层都要写;控制反转(把回调交给第三方,无法保证只调用一次、调用时机、异常吞没)。

Promise 解决了什么

Promise 是对异步结果的封装,有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦从 pending 变为 fulfilled 或 rejected 就不可再变(immutable)。它用链式调用把嵌套变成扁平。

javascriptCode
// Promise 链式改写回调地狱
getUser(userId)
  .then((user) => getOrders(user.id))
  .then((orders) => getOrderDetail(orders[0].id))
  .then((detail) => getShipping(detail.shipId))
  .then((shipping) => console.log(shipping))
  .catch((err) => handle(err)); // 统一错误处理,任一环节出错都进这里
javascriptCode
// Promise 基本用法
const p = new Promise((resolve, reject) => {
  // executor 同步执行
  setTimeout(() => {
    const ok = Math.random() > 0.5;
    ok ? resolve('成功数据') : reject(new Error('失败'));
  }, 100);
});
p.then(
  (data) => console.log('成功:', data),
  (err) => console.log('失败:', err.message)
).finally(() => console.log('无论成败都执行'));

手写符合 Promise/A+ 规范的核心片段

理解 Promise 最好的方式是自己实现。下面是一个覆盖 A+ 关键点(状态机、then 异步、值穿透、链式返回新 Promise)的实现。

javascriptCode
class MyPromise {
  static PENDING = 'pending';
  static FULFILLED = 'fulfilled';
  static REJECTED = 'rejected';

  constructor(executor) {
    this.status = MyPromise.PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCbs = []; // 缓存 pending 期间注册的回调
    this.onRejectedCbs = [];

    const resolve = (value) => {
      if (this.status === MyPromise.PENDING) {
        this.status = MyPromise.FULFILLED;
        this.value = value;
        this.onFulfilledCbs.forEach((fn) => fn());
      }
    };
    const reject = (reason) => {
      if (this.status === MyPromise.PENDING) {
        this.status = MyPromise.REJECTED;
        this.reason = reason;
        this.onRejectedCbs.forEach((fn) => fn());
      }
    };
    try {
      executor(resolve, reject); // executor 同步执行
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    // 值穿透:非函数则透传
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : (v) => v;
    onRejected = typeof onRejected === 'function' ? onRejected : (e) => { throw e; };

    // then 必须返回新 Promise 以支持链式
    const promise2 = new MyPromise((resolve, reject) => {
      const handleFulfilled = () => {
        // 用微任务模拟异步(规范要求 then 回调异步执行)
        queueMicrotask(() => {
          try {
            const x = onFulfilled(this.value);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };
      const handleRejected = () => {
        queueMicrotask(() => {
          try {
            const x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (err) {
            reject(err);
          }
        });
      };

      if (this.status === MyPromise.FULFILLED) handleFulfilled();
      else if (this.status === MyPromise.REJECTED) handleRejected();
      else {
        // pending:缓存回调,等状态改变再执行
        this.onFulfilledCbs.push(handleFulfilled);
        this.onRejectedCbs.push(handleRejected);
      }
    });
    return promise2;
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }
}

// 处理 then 返回值可能是 thenable/Promise 的情况(A+ 核心)
function resolvePromise(promise2, x, resolve, reject) {
  if (promise2 === x) {
    return reject(new TypeError('循环引用'));
  }
  if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    let called = false;
    try {
      const then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          (y) => { if (!called) { called = true; resolvePromise(promise2, y, resolve, reject); } },
          (r) => { if (!called) { called = true; reject(r); } }
        );
      } else {
        resolve(x);
      }
    } catch (err) {
      if (!called) { called = true; reject(err); }
    }
  } else {
    resolve(x);
  }
}

// 验证
new MyPromise((resolve) => setTimeout(() => resolve(1), 50))
  .then((v) => { console.log(v); return v + 1; })
  .then((v) => console.log(v)); // 依次输出 1、2

Promise 的静态组合方法

javascriptCode
const p1 = Promise.resolve(1);
const p2 = new Promise((r) => setTimeout(() => r(2), 100));
const p3 = Promise.reject(new Error('boom'));

// all:全部成功才成功,一个失败即失败(短路)
Promise.all([p1, p2]).then((vals) => console.log(vals)); // [1, 2]

// allSettled:等所有完成,返回每个的状态(不短路)
Promise.allSettled([p1, p3]).then((results) => console.log(results));
// [{status:'fulfilled',value:1}, {status:'rejected',reason:Error}]

// race:第一个敲定(无论成败)的结果
Promise.race([p2, Promise.resolve('fast')]).then((v) => console.log(v)); // 'fast'

// any:第一个成功的结果,全部失败才失败(AggregateError)
Promise.any([p3, p1]).then((v) => console.log(v)); // 1

| 方法 | 成功条件 | 失败条件 | 返回 |

| --- | --- | --- | --- |

| all | 全部成功 | 任一失败(短路) | 结果数组 |

| allSettled | 总是敲定 | 从不失败 | 状态对象数组 |

| race | 第一个敲定成功 | 第一个敲定失败 | 单个结果 |

| any | 任一成功 | 全部失败 | 单个结果 |

Generator:可暂停的函数

Generator(生成器)通过 `function*` 和 `yield` 实现函数的暂停与恢复,是 async/await 的底层基础。

javascriptCode
function* gen() {
  const a = yield 1; // 在 yield 处暂停,next(x) 传入的 x 成为 a
  const b = yield a + 1;
  return b;
}
const g = gen();
console.log(g.next());   // { value: 1, done: false }
console.log(g.next(10)); // a=10,{ value: 11, done: false }
console.log(g.next(20)); // b=20,{ value: 20, done: true }

// 用 Generator 手动管理异步流程(async/await 的雏形)
function run(genFn) {
  const it = genFn();
  function step(nextVal) {
    const { value, done } = it.next(nextVal);
    if (done) return Promise.resolve(value);
    return Promise.resolve(value).then(step); // 自动驱动
  }
  return step();
}
run(function* () {
  const a = yield Promise.resolve(1);
  const b = yield Promise.resolve(a + 1);
  console.log(a, b); // 1 2
});

async/await:异步的终极形态

async/await 是 Generator + Promise 的语法糖,让异步代码写起来像同步一样直观。

javascriptCode
// async 函数总是返回 Promise;await 等待 Promise 敲定
async function loadData(userId) {
  try {
    const user = await getUser(userId);
    const orders = await getOrders(user.id);
    const detail = await getOrderDetail(orders[0].id);
    return detail; // 被包装成 resolved Promise
  } catch (err) {
    // 用同步的 try/catch 捕获所有 await 的错误
    console.error('出错:', err);
    throw err;
  }
}

并发控制实战

javascriptCode
// 陷阱:串行 await 让本可并发的请求变慢
async function slow() {
  const a = await fetch('/a'); // 等 a 完成
  const b = await fetch('/b'); // 才开始 b(总耗时 = a + b)
  return [a, b];
}
// 优化:并发发起,用 Promise.all 等待(总耗时 = max(a, b))
async function fast() {
  const [a, b] = await Promise.all([fetch('/a'), fetch('/b')]);
  return [a, b];
}

// 限制并发数:有 1000 个任务但最多同时跑 5 个
async function runWithLimit(tasks, limit = 5) {
  const results = [];
  const executing = new Set();
  for (const [i, task] of tasks.entries()) {
    const p = Promise.resolve().then(() => task());
    results[i] = p;
    executing.add(p);
    const clean = () => executing.delete(p);
    p.then(clean, clean);
    if (executing.size >= limit) {
      await Promise.race(executing); // 等任一完成腾出名额
    }
  }
  return Promise.all(results);
}

// 用法:控制并发请求,避免瞬间打爆服务器
const urls = Array.from({ length: 100 }, (_, i) => `/api/item/${i}`);
runWithLimit(urls.map((u) => () => fetch(u)), 5)
  .then(() => console.log('全部完成,最多同时 5 个请求'));
javascriptCode
// 带重试与超时的健壮请求封装
async function fetchWithRetry(url, { retries = 3, timeout = 5000 } = {}) {
  for (let i = 0; i <= retries; i++) {
    try {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), timeout);
      const res = await fetch(url, { signal: controller.signal });
      clearTimeout(timer);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      if (i === retries) throw err; // 重试用尽,抛出
      // 指数退避:200ms、400ms、800ms...
      await new Promise((r) => setTimeout(r, 200 * 2 ** i));
    }
  }
}

异步方案演进对比

| 方案 | 可读性 | 错误处理 | 组合能力 | 出现时间 |

| --- | --- | --- | --- | --- |

| 回调函数 | 差(嵌套地狱) | error-first,繁琐 | 弱 | ES3 时代 |

| Promise | 中(链式扁平) | .catch 统一 | 强(all/race 等) | ES6 |

| Generator | 中(需驱动器) | try/catch | 中 | ES6 |

| async/await | 好(近似同步) | try/catch,直观 | 强(配合 Promise) | ES2017 |

常见坑与最佳实践

坑:忘记 await,拿到 Promise 而非结果。
坑:循环里 await 造成不必要的串行,能并发的用 Promise.all。
坑:forEach 里用 async 不会等待,改用 for...of + await 或 Promise.all。
坑:未处理 rejected,产生 UnhandledPromiseRejection,务必 catch。
javascriptCode
// forEach 不等待异步(坑)
[1, 2, 3].forEach(async (n) => { await task(n); });
console.log('这行会先于所有 task 完成打印'); // forEach 不等 await

// 正确:需要顺序用 for...of,需要并发用 map + Promise.all
for (const n of [1, 2, 3]) { await task(n); } // 顺序
await Promise.all([1, 2, 3].map((n) => task(n))); // 并发

小结

| 概念 | 关键点 |

| --- | --- |

| 回调地狱 | 嵌套深、错误处理繁、控制反转 |

| Promise | 三状态不可逆,链式扁平化 |

| Promise/A+ | then 异步、返回新 Promise、值穿透 |

| 组合方法 | all/allSettled/race/any 各有语义 |

| Generator | 可暂停函数,async 的底层 |

| async/await | 语法糖,同步写法,try/catch 处理错误 |

| 并发控制 | Promise.all 并发,限流用 race 腾名额 |

综合实战:贯通所有核心机制的输出题

真正检验对执行机制的理解,是把作用域、闭包、this、原型、事件循环揉在一起的综合题。本节精选若干"面试杀手题",逐行拆解,帮你把前面所有知识串成一张网。

综合题一:闭包 + 循环 + 事件循环

javascriptCode
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log('A', i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log('B', j), 0);
}
// 输出:A 3 / A 3 / A 3 / B 0 / B 1 / B 2
// 解析:
// var i 是函数作用域,3 个回调共享同一 i,等宏任务执行时 i 已是 3
// let j 每轮新绑定,3 个回调各自捕获 0/1/2
// 两组都是宏任务,按注册顺序执行

综合题二:this + 原型 + 隐式绑定丢失

javascriptCode
const obj = {
  val: 'obj',
  getVal() { return this.val; },
  getValArrow: () => this.val,
};
const fn = obj.getVal;

console.log(obj.getVal());      // 'obj'(隐式绑定)
console.log(fn());              // undefined(默认绑定,this 丢失)
console.log(fn.call(obj));      // 'obj'(显式绑定)
console.log(obj.getValArrow()); // undefined(箭头 this 是模块顶层)

// 原型链上的方法同样遵循调用规则
function Widget(val) { this.val = val; }
Widget.prototype.getVal = function () { return this.val; };
const w = new Widget('w');
const extracted = w.getVal;
console.log(w.getVal());   // 'w'
console.log(extracted());  // undefined,方法脱离对象即丢 this

综合题三:微任务、宏任务与 async 的交织

javascriptCode
console.log('1 script start');

setTimeout(() => console.log('2 setTimeout'), 0);

async function asyncFn() {
  console.log('3 async start');
  await null; // await 非 Promise 也会产生微任务切点
  console.log('4 async after await');
}
asyncFn();

new Promise((resolve) => {
  console.log('5 promise executor');
  resolve();
}).then(() => console.log('6 promise then'));

console.log('7 script end');

// 输出顺序:
// 1 script start
// 3 async start
// 5 promise executor
// 7 script end
// 4 async after await
// 6 promise then
// 2 setTimeout
// 解析:同步部分 1、3、5、7;微任务队列 4、6 依注册顺序;宏任务 2 最后

综合题四:变量提升 + 函数作用域

javascriptCode
var name = '全局';
function show() {
  console.log(name); // undefined,不是 '全局'
  var name = '局部';  // 提升:函数内 var name 声明被提到顶部
  console.log(name); // '局部'
}
show();
// 解析:函数内有 var name 声明,整个函数作用域内 name 都指向局部变量
// 第一次 console 时局部 name 已声明但未赋值,为 undefined(遮蔽了全局)

综合题五:立即执行 + 闭包缓存

javascriptCode
const funcs = [];
(function () {
  for (let i = 0; i < 3; i++) {
    funcs.push(() => i);
  }
})();
console.log(funcs.map((f) => f())); // [0, 1, 2]

// 若把 let 换成 var,则输出 [3, 3, 3]
// 因为 IIFE 只创建一个函数作用域,var i 被三个闭包共享

综合题六:类型转换的连环坑

javascriptCode
console.log([] + []);       // ''(两个空数组转空串拼接)
console.log([] + {});       // '[object Object]'
console.log(1 + '2' + 3);   // '123'(遇字符串转拼接)
console.log(1 + 2 + '3');   // '33'(先算 1+2=3 再拼)
console.log('5' - 2);       // 3(减法强制转数字)
console.log('5' * '2');     // 10(乘法转数字)
console.log(true + true);   // 2(true 转 1)
console.log([1, 2] + [3]);  // '1,23'(数组转 '1,2' 和 '3' 拼接)
console.log(+'');           // 0(一元加转数字)
console.log(+[]);           // 0
console.log(+{});           // NaN

自测清单

用这份清单快速自检对核心机制的掌握程度:

| 能否解释 | 涉及机制 |

| --- | --- |

| 为什么循环里 setTimeout 输出全是最大值 | 闭包 + var 作用域 + 事件循环 |

| 为什么方法赋值给变量后 this 变了 | 隐式绑定丢失 |

| 为什么 typeof null 是 'object' | 历史遗留 bug |

| 为什么 [] == ![] 是 true | == 强制转换 |

| async 函数里 await 之后的代码何时执行 | 微任务机制 |

| 为什么闭包会导致内存泄漏 | 环境记录被引用不回收 |

| new 一个构造函数发生了什么 | new 四步 + 原型链 |

| let 和 var 在 for 循环里的区别 | 块级作用域 + 每轮绑定 |

能流畅回答以上全部问题,说明你已经真正掌握了 JavaScript 的执行机制,从"会写"进阶到了"理解"。

核心概念总结

| 概念 | 一句话本质 | 关键点 |

| --- | --- | --- |

| 执行上下文 | 代码运行的工作空间 | 创建 VO、作用域链、this |

| 调用栈 | 管理上下文的 LIFO 栈 | 后进先出,过深会溢出 |

| 变量提升 | 声明提前登记,赋值留原地 | var 提升为 undefined,let/const 有 TDZ |

| 闭包 | 函数背走出生环境的变量 | 数据私有、模块化,注意内存泄漏 |

| 原型链 | 逐级向上查找属性 | 顶端是 Object.prototype,尽头 null |

| this | 看调用方式而非定义位置 | new > 显式 > 隐式 > 默认,箭头继承外层 |

| 事件循环 | 单线程调度异步的机制 | 同步 → 清空微任务 → 一个宏任务 → 循环 |

最佳实践

理解执行上下文和作用域,用 const/let 替代 var
合理使用闭包实现封装,及时释放引用避免内存泄漏
理解原型链和继承,优先使用 ES6 class 语法
正确处理 this 指向,回调中用箭头函数或 bind
掌握异步编程模式,理解宏任务与微任务的执行顺序
用 async/await 写出清晰的异步代码,别忘了 await 和错误处理
编写清晰、可维护、可预测的代码

掌握这些底层机制,你就从"会用 JavaScript"进阶到了"理解 JavaScript",无论是排查疑难 bug、优化性能,还是阅读框架源码,都会游刃有余。基础越扎实,走得越远。