前端之JavaScript

272 阅读40分钟

一、原型与继承

1. 核心概念:原型(Prototype)

在 JavaScript 中,我们不谈论传统的“类”,而是谈论“原型”。每个对象都可以是一个“原型”,为其他对象提供共享的属性和方法。

prototype vs __proto__

  • prototype

    • 它是一个函数独有的属性。当你定义一个函数时,这个 prototype 属性就自动被创建了,它指向一个对象,我们称之为原型对象
    • 这个原型对象的作用是:当你使用这个函数作为构造函数(通过 new 关键字)来创建实例时,这些实例将共享该原型对象上的所有属性和方法。
    • 简单记:prototype 是函数的“蓝图储藏室”。
  • __proto__

    • 它是一个对象独有的(内部)属性。当你创建一个对象时,无论是通过字面量还是构造函数,这个对象都会有一个 __proto__ 属性。
    • 它指向创建该对象的构造函数的原型对象
    • 简单记:__proto__ 是实例对象的“寻根链接”。
    • 注意:__proto__ 是一个非标准的历史遗留属性,现在推荐使用 Object.getPrototypeOf() 来获取对象的原型。

构造函数、实例和原型对象的关系

这三者构成了一个“铁三角”关系,我们可以用一段代码和一张图来清晰地展示它:

// 1. 定义一个构造函数
function Person(name) {
  this.name = name;
}

// Person.prototype 是函数自带的,我们可以在上面添加共享方法
Person.prototype.sayHello = function() {
  console.log('Hello, I am ' + this.name);
};

// 2. 创建一个实例
const p1 = new Person('Alice');

// 3. 验证它们的关系
console.log(p1.__proto__ === Person.prototype); // true
console.log(Person.prototype.constructor === Person); // true

这三者的关系可以用下图来表示:

image.png

图解说明:

  • Person 构造函数通过其 .prototype 属性指向 Person.prototype 这个原型对象。
  • p1 实例是通过 new Person() 创建的,它的内部 [[Prototype]] 链接(即 __proto__)指向了 Person.prototype
  • Person.prototype 原型对象通过其 .constructor 属性又指回了 Person 构造函数,形成一个闭环。

原型链(Prototype Chain)

原型链是 JavaScript 实现继承的核心机制。

  • 工作原理:当你试图访问一个对象的属性或方法时(例如 p1.sayHello()):
    1. JavaScript 引擎首先在对象自身p1)上查找。
    2. 如果找不到,它会沿着 __proto__ 链接,去其原型对象Person.prototype)上查找。在我们的例子中,它在这里找到了 sayHello 方法。
    3. 如果还找不到,它会继续沿着原型对象的 __proto__ 链接向上查找,直到找到该属性,或者到达原型链的终点。
  • 链的终点:所有普通对象的原型链最终都会指向 Object.prototype。而 Object.prototype__proto__null,标志着原型链的结束。

所以 p1.toString() 这样的调用之所以能成功,就是因为 p1 -> Person.prototype -> Object.prototype 这条链上,Object.prototype 提供了 toString 方法。

constructor 属性的作用和潜在问题

  • 作用constructor 属性主要用于标识“这个对象是由哪个构造函数创建的”。它存在于原型对象上,并被所有实例继承。所以 p1.constructor === Person 返回 true

  • 潜在问题:当我们想给原型添加很多方法时,可能会直接重写整个 prototype 对象,这会导致 constructor 丢失。

    function Car() {}
    
    // 错误的做法:直接重写 prototype
    Car.prototype = {
      drive: function() { /* ... */ },
      brake: function() { /* ... */ }
      // 此时,Car.prototype.constructor 指向的是 Object,而不是 Car!
    };
    
    const myCar = new Car();
    console.log(myCar.constructor === Car); // false
    console.log(myCar.constructor === Object); // true
    
    // 正确的做法:手动修正 constructor
    Car.prototype = {
      constructor: Car, // 显式地将 constructor 指回 Car
      drive: function() { /* ... */ },
      brake: function() { /* ... */ }
    };
    const myCorrectCar = new Car();
    console.log(myCorrectCar.constructor === Car); // true
    

2. 原型继承的实现方式

JavaScript的继承基于原型链而非类,这是它区别于其他主流语言的根本特性。

ES5 及之前

a. 原型链继承

这是最基础的继承方式,核心思想就是将子类的原型直接设置为父类的一个实例。

  • 实现:

    // 父类
    function Animal() {
      this.species = '动物';
      this.colors = ['black', 'white']; // 引用类型属性
    }
    Animal.prototype.move = function() {
      console.log('Moving...');
    };
    
    // 子类
    function Dog() {
      this.name = '旺财';
    }
    
    // 关键步骤:子类的原型 = 父类的实例
    Dog.prototype = new Animal();
    
    const dog1 = new Dog();
    const dog2 = new Dog();
    
    dog1.colors.push('brown');
    
    console.log(dog1.species); // '动物'
    console.log(dog1.move());  // 'Moving...'
    console.log(dog2.colors);  // ['black', 'white', 'brown'] <-- 问题所在!
    
  • 优点:

    • 实现简单,父类的方法得到了复用。
  • 缺点:

    • 核心问题:所有子类实例共享了同一个父类实例作为原型,因此会共享父类实例中的引用类型属性(如 colors 数组)。一个实例修改了它,会影响到所有其他实例。
    • 创建子类实例时,无法向父类构造函数传递参数。
b. 借用构造函数继承(经典继承 / 伪造对象)

为了解决原型链继承的引用类型共享问题,开发者们想出了在子类构造函数中调用父类构造函数的方法。

  • 实现:

    function Animal(species) {
      this.species = species || '动物';
      this.colors = ['black', 'white'];
    }
    Animal.prototype.move = function() { /* ... */ };
    
    function Dog(name, species) {
      // 关键步骤:使用 .call() 或 .apply() 将父类的 this 指向子类实例
      Animal.call(this, species); // 借用父类的构造函数
      this.name = name;
    }
    
    const dog1 = new Dog('旺财', '犬科');
    const dog2 = new Dog('小黑', '犬科');
    
    dog1.colors.push('brown');
    
    console.log(dog1.species); // '犬科'
    console.log(dog1.colors);  // ['black', 'white', 'brown']
    console.log(dog2.colors);  // ['black', 'white'] <-- 问题解决!
    // console.log(dog1.move()); // TypeError: dog1.move is not a function <-- 新问题!
    
  • 优点:

    • 完美解决了引用类型属性被共享的问题。
    • 可以在子类构造函数中向父类传递参数。
  • 缺点:

    • 只能继承父类实例的属性和方法,无法继承父类原型上的方法(比如 move 方法)。
    • 方法都在构造函数中定义,每次创建实例都会重新创建一遍方法,无法实现函数复用。
c. 组合继承(最常用的模式)

这种方式结合了原型链继承和借用构造函数继承的优点,是 ES6 之前最常用的一种继承模式。

  • 实现:
    function Animal(species) {
      this.species = species || '动物';
      this.colors = ['black', 'white'];
    }
    Animal.prototype.move = function() {
      console.log('Moving...');
    };
    
    function Dog(name, species) {
      // 第一次调用 Animal 构造函数:继承实例属性
      Animal.call(this, species);
      this.name = name;
    }
    
    // 第二次调用 Animal 构造函数:继承原型方法
    Dog.prototype = new Animal();
    // 修正 constructor 指向
    Dog.prototype.constructor = Dog;
    
    const dog1 = new Dog('旺财', '犬科');
    dog1.move(); // 'Moving...'
    dog1.colors.push('brown');
    
    const dog2 = new Dog('小黑', '犬科');
    console.log(dog2.colors); // ['black', 'white']
    
    console.log(dog1 instanceof Dog);    // true
    console.log(dog1 instanceof Animal); // true
    
  • 优点:
    • 既能继承实例属性(保证不共享),又能继承原型方法(保证可复用)。
    • 保留了 instanceofisPrototypeOf 的能力。
  • 缺点:
    • 调用了两次父类构造函数:一次在 Animal.call(this),一次在 new Animal()。这会导致子类实例和子类原型上都有一份多余的父类实例属性。虽然不影响功能,但略有性能浪费。
d. 寄生组合式继承(最理想的方案)

为了解决组合继承调用两次父类构造函数的问题,大神道格拉斯·克罗克福德提出了这种模式,它被认为是 ES6 之前最理想的继承方案。

核心在于:我们继承父类的原型,其实不需要执行父类的构造函数,我们只需要一个干净的、链接到父类原型的对象。Object.create() 正是为此而生。

  • 实现:
    function inheritPrototype(subType, superType) {
      // 1. 创建一个继承了父类原型的干净对象
      const prototype = Object.create(superType.prototype);
      // 2. 修正新对象的 constructor 指向
      prototype.constructor = subType;
      // 3. 将这个干净的对象赋值给子类的原型
      subType.prototype = prototype;
    }
    
    // 父类(同上)
    function Animal(species) { /* ... */ }
    Animal.prototype.move = function() { /* ... */ };
    
    // 子类
    function Dog(name, species) {
      // 只调用一次父类构造函数
      Animal.call(this, species);
      this.name = name;
    }
    
    // 关键步骤:用寄生组合方式完成继承
    inheritPrototype(Dog, Animal);
    
    const dog1 = new Dog('旺财');
    console.log(dog1.species); // '动物'
    dog1.move();
    
  • 优点:
    • 只调用一次父类构造函数,避免了在子类原型上创建不必要的属性。
    • 完美实现了继承,保持了原型链的完整。
    • 堪称完美。

ES6 class 语法

ES6 引入了 class 关键字,作为对象的模板。它本质上是上面“寄生组合式继承”的语法糖,让继承的写法更加清晰、更像传统的面向对象语言。

  • 实现:

    class Animal {
      constructor(species) {
        this.species = species || '动物';
        this.colors = ['black', 'white'];
      }
    
      move() {
        console.log('Moving...');
      }
    }
    
    class Dog extends Animal {
      constructor(name, species) {
        // super() 在这里就相当于 Animal.call(this, species)
        super(species);
        this.name = name;
      }
    
      bark() {
        console.log('Woof!');
      }
    }
    
    const dog1 = new Dog('旺财', '犬科');
    dog1.colors.push('brown');
    const dog2 = new Dog('小黑', '犬科');
    
    console.log(dog1.colors); // ['black', 'white', 'brown']
    console.log(dog2.colors); // ['black', 'white']
    dog1.move(); // Moving...
    
  • extendssuper

    • extends 关键字负责实现继承,它的背后逻辑非常类似于寄生组合继承。
    • super 关键字既可以作为函数调用(在 constructor 中),代表父类的构造函数;也可以作为对象使用(在普通方法中),代表父类的原型。

尽管 class 写法更友好,但它的底层实现依然是原型和原型链


二、作用域与闭包

1. 作用域(Scope)

什么是作用域?

作用域是指程序中定义变量的区域,它决定了变量的可访问性和生命周期。

JavaScript 采用的是词法作用域(Lexical Scope),也叫静态作用域。这意味着,变量的作用域在代码编写时就已经确定了,并且不会在运行时改变。无论函数在哪里被调用,它的词法作用域只由函数被声明时所处的位置决定。

作用域的类型

在 JavaScript 中,主要有三种作用域:

  1. 全局作用域(Global Scope)

    • 在代码的最外层定义的变量拥有全局作用域。
    • 在任何地方都可以访问到。
    • 在浏览器环境中,全局对象是 window;在 Node.js 中是 global。未经声明直接赋值的变量会自动成为全局变量(在严格模式下会报错),这是一个应该极力避免的坏习惯。
  2. 函数作用域(Function Scope)

    • 在函数内部定义的变量,只能在该函数内部访问。
    • 这是 var 声明变量时遵循的作用域规则。
  3. 块级作用域(Block Scope)

    • {} 包裹的代码块(例如 if 语句、for 循环、或者一个独立的 {})所创建的作用域。
    • 通过 letconst 声明的变量会遵循块级作用域规则。这是 ES6 引入的重要特性,它让变量的管理更加直观和安全。

看个例子来对比一下函数作用域和块级作用域:

function testScope() {
  // 函数作用域
  var a = 1;
  let b = 2;
  const c = 3;

  if (true) {
    // 块级作用域
    var a = 10; // 这里会覆盖外层的 a
    let b = 20; // 这是一个新的变量 b,只活在这个 if 块里
    const c = 30; // 同上,新的变量 c
    console.log('In block:', a, b, c); // In block: 10 20 30
  }

  console.log('Out of block:', a, b, c); // Out of block: 10 2 3
}

testScope();

作用域链(Scope Chain)

当代码在一个作用域中需要查找一个变量时,如果当前作用域没有找到,它就会向外层作用域继续查找,直到找到该变量,或者到达最外层的全局作用域为止。这个由内向外、逐级查找的链条,就叫做作用域链

let globalVar = 'I am global';

function outerFunc() {
  let outerVar = 'I am outer';

  function innerFunc() {
    let innerVar = 'I am inner';
    // 查找 innerVar: 在当前作用域找到
    // 查找 outerVar: 当前作用域没有,去外层 outerFunc 作用域找,找到了
    // 查找 globalVar: 当前和 outerFunc 作用域都没有,去最外层全局作用域找,找到了
    console.log(innerVar, outerVar, globalVar);
  }

  innerFunc();
}

outerFunc(); // 输出 "I am inner I am outer I am global"

作用域链是在函数定义时创建的,它保证了函数能够访问到其定义时所处环境中的变量。这是理解闭包的关键。

变量提升(Hoisting)与函数提升

JavaScript 引擎在执行代码前会先进行“编译”,在这个阶段,变量和函数的声明会被“提升”到它们各自作用域的顶部。

  • 变量提升

    • 使用 var 声明的变量会被提升,但只有声明被提升,赋值操作会留在原地。所以,在赋值前访问 var 变量会得到 undefined
    • 使用 letconst 声明的变量虽然也有类似“提升”的行为,但它们存在一个暂时性死区(Temporal Dead Zone, TDZ)。在声明语句之前访问这些变量,会抛出 ReferenceError,而不是得到 undefined。这使得代码行为更加可预测。
    console.log(x); // undefined (var 提升了)
    var x = 5;
    
    // console.log(y); // ReferenceError: Cannot access 'y' before initialization (TDZ)
    let y = 10;
    
  • 函数提升

    • 使用函数声明function foo() {})的方式创建的函数,整个函数体都会被提升。这意味着你可以在声明之前调用它。
    • 使用函数表达式var foo = function() {})创建的函数,遵循变量提升的规则,只有变量名 foo 被提升并赋值为 undefined,所以在赋值前调用它会报 TypeError
    sayHello(); // "Hello!" (函数声明被完整提升)
    
    function sayHello() {
      console.log('Hello!');
    }
    
    // sayHi(); // TypeError: sayHi is not a function (变量 sayHi 被提升了,但值是 undefined)
    var sayHi = function() {
      console.log('Hi!');
    };
    

好的,我们来揭开**闭包(Closure)**的神秘面纱。实际上,如果你已经理解了词法作用域,那么你离理解闭包只有一步之遥。

2. 闭包(Closure)

闭包的定义

让我们来看一个权威且精准的定义:

当一个函数能够记住并访问其所在的词法作用域时,就产生了闭包,即使该函数在其词法作用域之外执行。

这个定义有两层关键意思:

  1. 记住和访问:这得益于我们刚才讨论的作用域链。函数天生就能“记住”它被定义时所处的环境。
  2. 在词法作用域之外执行:这是识别闭包最核心的特征。

我们来看一个最经典的闭包例子:

function createCounter() {
  let count = 0; // 这个变量属于 createCounter 的词法作用域

  // 这个返回的匿名函数,就是一个闭包
  return function() {
    count++;
    console.log(count);
  };
}

const counter1 = createCounter(); // createCounter 执行完毕,它的作用域理应被销毁
const counter2 = createCounter(); // 创建了另一个独立的闭包环境

// 在 createCounter 的词法作用域之外,执行了内部函数
counter1(); // 输出: 1
counter1(); // 输出: 2
counter1(); // 输出: 3

counter2(); // 输出: 1  (证明了每个闭包都有自己独立的作用域)

在这个例子中,createCounter 函数执行完毕后,它的执行上下文(包括变量 count)本应该被垃圾回收机制销毁。但是,因为它返回的那个匿名函数(我们赋值给了 counter1)仍然引用createCounter 的作用域,所以这个作用域就一直存活在内存中,没有被释放。counter1 函数“记住”了它的出生地,并且可以随时回去访问那里的变量 count。这就是闭包。

闭包的经典应用场景

a. 创建私有变量(模块模式)

闭包是实现模块化、避免全局变量污染的绝佳工具。我们可以把一些变量和方法封装在一个函数作用域里,只暴露我们想暴露的接口。

const myModule = (function() {
  // --- 私有作用域 ---
  const privateVariable = 'I am private';
  let counter = 0;

  function privateMethod() {
    console.log(privateVariable);
  }

  // --- 公共接口 ---
  return {
    increment: function() {
      counter++;
      privateMethod();
    },
    getCount: function() {
      return counter;
    }
  };
})(); // 使用 IIFE (立即执行函数表达式) 来立即创建模块

myModule.increment(); // 输出: "I am private"
myModule.increment();
console.log(myModule.getCount()); // 输出: 2
// console.log(myModule.privateVariable); // undefined (无法直接访问)

myModule 对象就是我们模块的公共 API,而 privateVariableprivateMethod 则被完美地保护在闭包中,外部无法访问,实现了数据的私有化。

b. 循环与异步中的状态保持

这是一个非常经典的面试题,完美地展示了闭包“记住”状态的能力。

错误示范:

for (var i = 1; i <= 5; i++) {
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}

你可能期望它会每隔一秒依次输出 1, 2, 3, 4, 5。但实际结果是,它会在 1-5 秒后,连续输出五个 6

原因setTimeout 是异步的。当 for 循环瞬间执行完毕时,setTimeout 里的回调函数 timer 还没有一个被执行。它们共享着同一个全局作用域(或外层函数作用域)下的变量 i。当循环结束时,i 的值已经变成了 6。等到 1-5 秒后,timer 函数开始执行,它们去查找变量 i,找到的都是那个最终值为 6 的 i

解决方案 1:利用闭包(IIFE)

在 ES6 出现前,我们用立即执行函数表达式来为每次循环创建一个新的作用域。

for (var i = 1; i <= 5; i++) {
  (function(j) { // IIFE 创建了一个新的闭包作用域
    setTimeout(function timer() {
      console.log(j); // 这里的 j 是每次循环传入的 i 的“快照”
    }, j * 1000);
  })(i); // 把当前的 i 作为参数传进去
}
解决方案 2:使用 let(推荐)

ES6 的 let 带来了块级作用域,它在 for 循环中有一个特殊的行为:为每一次循环都创建一个新的词法环境,并绑定当前的循环变量。这实际上是隐式地为我们创造了闭包。

for (let i = 1; i <= 5; i++) { // let 会为每次循环创建一个新的 i
  setTimeout(function timer() {
    console.log(i);
  }, i * 1000);
}
c. 函数柯里化(Currying)与高阶函数

闭包也是实现函数柯里化等高级函数技巧的基础。

// 一个简单的柯里化 add 函数
function add(x) {
  // 返回的这个函数是一个闭包,它记住了 x
  return function(y) {
    return x + y;
  };
}

const add5 = add(5); // add5 是一个记住了 x=5 的新函数
const result = add5(3); // 8

console.log(result);

闭包的潜在问题:内存泄漏

由于闭包会使其外部函数的作用域一直存活,如果这个闭包被长期持有(例如,赋值给一个全局变量,或者作为一个 DOM 元素的事件监听器),那么它所引用的外部作用域就不会被垃圾回收,这可能会导致内存泄漏

一个例子:

function setupEventListener() {
  let someLargeData = new Array(1000000).join('*'); // 假设这是一个很大的数据

  const element = document.getElementById('myButton');

  // 事件监听器是一个闭包,它引用了 someLargeData
  element.addEventListener('click', function onClick() {
    // 即使这个函数内部没有使用 someLargeData,
    // 但它所在的整个作用域都被闭包持有了。
    console.log('Button clicked!');
  });
}

setupEventListener();

在这个例子中,只要 #myButton 元素存在,onClick 函数就存在,它对 setupEventListener 作用域的引用就存在,因此 someLargeData 这块巨大的内存就永远不会被释放。

避免方法:当不再需要这个闭包时,解除对它的引用。例如,当元素被移除时,要手动调用 removeEventListener。如果只是临时需要外部变量,可以在闭包内部的逻辑执行完后,手动将不再需要的外部变量引用设为 null


三、this 指向与箭头函数

1. this 的动态指向

首先要牢记:this 的值取决于函数是如何被调用的,而不是在哪里被定义的。

为了确定 this 的值,我们需要根据函数调用的方式,应用下面四条规则。

a. 默认绑定(Default Binding)

这是最常见的,也是最容易出错的规则。当一个函数是独立调用时,没有应用其他任何规则,就会触发默认绑定。

  • 非严格模式下this 指向全局对象(在浏览器中是 window)。
  • 严格模式下 ('use strict')this 的值是 undefined
function sayHi() {
  console.log(this);
}

sayHi(); // 非严格模式: Window {...}
         // 严格模式: undefined

const obj = {
  name: 'Alice',
  sayHi: function() {
    console.log(this.name);
  }
};

const func = obj.sayHi; // 只是把函数地址赋给 func,没有调用
func(); // 这里是独立调用!触发默认绑定
        // 非严格模式: 'this' 是 window,window.name 是空字符串 ""
        // 严格模式: 报错,因为 this 是 undefined,无法读取 undefined.name

陷阱:回调函数(如 setTimeout 里的函数)如果未经特殊处理,其 this 也通常会应用默认绑定规则。

b. 隐式绑定(Implicit Binding)

当函数是作为一个对象的方法来调用时,this 会被绑定到这个对象。

  • 规则:调用位置是否存在一个上下文对象,或者说,函数调用是否被某个对象所“拥有”或“包含”。
function sayHi() {
  console.log('Hello, ' + this.name);
}

const person1 = {
  name: 'Bob',
  greet: sayHi // 同一个函数
};

const person2 = {
  name: 'Charlie',
  greet: sayHi // 同一个函数
};

person1.greet(); // Hello, Bob  (调用时,this 被绑定到 person1)
person2.greet(); // Hello, Charlie (调用时,this 被绑定到 person2)

陷阱:隐式丢失 当我们将一个对象方法赋值给一个变量,或者作为回调函数传递时,它会“丢失”与原对象的绑定关系,回到默认绑定。这就是我们上面 func() 那个例子所展示的情况。

c. 显式绑定(Explicit Binding)

如果我们不想根据调用位置来确定 this,而是想强制指定函数执行时的 this 值,就可以使用 call, applybind

  • call(thisArg, arg1, arg2, ...):立即执行函数,this 被绑定到 thisArg,参数以逗号分隔依次传入。
  • apply(thisArg, [argsArray]):立即执行函数,this 被绑定到 thisArg,参数以一个数组的形式传入。
  • bind(thisArg, arg1, ...)不立即执行,而是返回一个新函数,这个新函数的 this永久绑定thisArg,无论之后如何调用它,this 都不会再改变。
function introduce(hobby1, hobby2) {
  console.log(`I am ${this.name}, I like ${hobby1} and ${hobby2}.`);
}

const user = { name: 'David' };
const hobbies = ['reading', 'coding'];

// call
introduce.call(user, 'reading', 'coding');
// 输出: I am David, I like reading and coding.

// apply
introduce.apply(user, hobbies);
// 输出: I am David, I like reading and coding.

// bind
const boundIntroduce = introduce.bind(user, 'swimming'); // bind 也可以预先设置部分参数
boundIntroduce('gaming');
// 输出: I am David, I like swimming and gaming.

d. new 绑定(new Binding)

当函数与 new 关键字一起使用时(即作为构造函数调用),会发生 new 绑定。此时,this 会被绑定到新创建的那个实例对象。

  • new 操作符做了四件事:
    1. 创建一个全新的空对象。
    2. 这个新对象的 __proto__ 被链接到构造函数的 prototype
    3. 构造函数的 this 被绑定到这个新对象。
    4. 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。
function Car(brand) {
  // 这里的 this 指向即将被创建的 myCar 对象
  this.brand = brand;
  this.start = function() {
    console.log(`Starting the ${this.brand} car.`);
  };
}

const myCar = new Car('Toyota');
console.log(myCar.brand); // Toyota
myCar.start(); // Starting the Toyota car.

绑定规则的优先级

当多种规则同时出现时,它们的优先级如下:

new 绑定 > 显式绑定 (bind) > 隐式绑定 > 默认绑定

  • new 的优先级最高。new (fn.bind(obj))this 仍然是新创建的对象,而不是 obj
  • bind 创建的函数,即使作为对象的方法调用(隐式绑定),其 this 也不会改变。

2. 箭头函数(Arrow Functions)

ES6 引入的箭头函数,彻底改变了 this 的游戏规则。

箭头函数的 this 指向:词法 this

箭头函数没有自己的 this 它会像普通变量一样,捕获其定义时所在上下文(外层作用域)的 this 值。这个 this 一旦被确定,就永远不会改变

const myObject = {
  name: 'My Object',
  regularMethod: function() {
    console.log('regularMethod this:', this.name); // 'My Object'

    // 使用普通函数作为回调
    setTimeout(function() {
      // 这里的 this 触发默认绑定,指向 window
      console.log('setTimeout (regular) this:', this.name); // '' (window.name)
    }, 500);

    // 使用箭头函数作为回调
    setTimeout(() => {
      // 箭头函数没有自己的 this,它捕获了外层 regularMethod 的 this
      console.log('setTimeout (arrow) this:', this.name); // 'My Object'
    }, 1000);
  }
};

myObject.regularMethod();

这个例子完美地展示了箭头函数在处理回调函数 this 指向问题上的巨大优势。

箭头函数与普通函数的区别

  1. this 指向:最核心的区别,如上所述。
  2. 没有 arguments 对象:箭头函数内部没有自己的 arguments 对象。如果需要获取所有参数,可以使用剩余参数 (...args)。
  3. 不能用作构造函数:不能对箭头函数使用 new 操作符,否则会抛出错误。
  4. 没有 prototype 属性:既然不能当构造函数,自然也就不需要 prototype
  5. 没有 supernew.target 绑定

适用与不适用的场景

  • 适用场景

    • 需要一个 this 指向固定、不随调用方式改变的函数时,特别是用作回调函数,如 setTimeout, map, filter 等。
    • 代码简洁,对于简单的、没有复杂 this 需求的函数。
  • 不适用场景

    • 对象的方法:当你需要 this 指向该对象本身时,不应使用箭头函数。
      const person = {
        name: 'Eve',
        sayName: () => {
          console.log(this.name); // this 会是 window 或 undefined,而不是 person
        }
      };
      
    • 需要动态 this 的地方:例如给 DOM 元素添加事件监听器,通常我们希望 this 指向那个触发事件的元素。
    • 构造函数
    • 原型上的方法

四、事件循环机制

1. 基础概念

JavaScript 是单线程语言,但通过事件循环机制实现了非阻塞的异步执行模型。事件循环负责协调调用栈、Web API 和任务队列之间的工作,使得 JavaScript 能够处理大量并发操作而不阻塞主线程。

2. 核心组件详解

"事件循环涉及几个关键组件:

  • 调用栈:追踪当前执行的代码位置
  • Web API:由浏览器提供的异步功能接口
  • 任务队列:分为宏任务队列和微任务队列
  • 事件循环:持续监控调用栈和任务队列的状态"

3. 宏任务vs微任务

JavaScript 中的任务分为宏任务和微任务两种:

宏任务包括:

  • 整体代码(script)
  • setTimeout/setInterval
  • setImmediate(Node.js环境)
  • I/O操作
  • UI渲染
  • MessageChannel

微任务包括:

  • Promise.then/catch/finally
  • MutationObserver
  • queueMicrotask
  • process.nextTick(Node.js环境,优先级最高)

执行顺序是:先执行一个宏任务,然后清空所有微任务,再执行下一个宏任务,如此循环。

4. 代码示例分析

面试中通常会让你分析代码输出顺序,展示你的实际应用能力:

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。

  1. 首先执行同步代码,输出 1 和 6
  2. 然后清空微任务队列,执行第一个 Promise.then,输出 4,并注册一个新的 setTimeout 宏任务
  3. 接着执行宏任务队列中的第一个 setTimeout 回调,输出 2,并注册一个新的 Promise.then 微任务
  4. 立即清空微任务队列,输出 3
  5. 最后执行第二个 setTimeout 回调,输出 5"

5. async/await 的执行机制

async/await 本质上是 Promise 的语法糖。当执行到 await 表达式时,会暂停当前 async 函数的执行,等待 Promise 解决,然后以 Promise 的结果恢复函数执行。

async function example() {
  console.log('1');
  await Promise.resolve();
  console.log('2'); // 这行相当于在 Promise.then 中执行
}

example();
console.log('3');
// 输出顺序: 1, 3, 2

await 之后的代码实际上被放入了微任务队列,这就是为什么它会在同步代码之后、下一个宏任务之前执行。

6. Node.js 事件循环的差异

Node.js 的事件循环与浏览器有些不同,它基于 libuv 库实现,具有多个阶段:

  • timers: 执行 setTimeout 和 setInterval 的回调
  • pending callbacks: 执行某些系统操作的回调
  • idle, prepare: 内部使用
  • poll: 获取新的 I/O 事件
  • check: 执行 setImmediate 的回调
  • close callbacks: 执行关闭事件的回调

另外,process.nextTick 在 Node.js 中拥有特殊的优先级,它会在每个阶段之间执行,甚至比 Promise 微任务还要优先。

7. 实际应用场景

理解事件循环对优化应用性能至关重要。例如:

  • 将耗时计算拆分成小块,通过 setTimeout 错开执行,避免长时间阻塞主线程
  • 利用微任务的优先级,在 UI 渲染前完成关键更新
  • 识别和解决宏任务/微任务引起的执行顺序问题
  • 在处理大量数据时,使用 Web Workers 避免阻塞事件循环

8. 常见面试陷阱

需要注意的误区:

  • setTimeout(fn, 0) 不会立即执行,而是在下一轮事件循环才执行
  • Promise 构造函数中的代码是同步执行的,只有 then/catch/finally 中的回调是微任务
  • async 函数总是返回 Promise,即使函数体内没有 await
  • 在循环中创建的定时器和 Promise,它们的执行顺序可能与创建顺序不同"

9.为什么 setTimeout 和 setInterval 不准确

主要原因

1. 事件循环的工作机制

setTimeoutsetInterval 并不是"在指定时间后执行",而是"在指定时间后,将回调函数放入宏任务队列"。回调函数要等到调用栈清空、所有微任务执行完毕后,才有机会被事件循环取出并执行。

console.log('开始');
setTimeout(() => {
  console.log('定时器回调');
}, 0);

// 模拟耗时操作
const start = Date.now();
while(Date.now() - start < 300) {
  // 阻塞约300ms
}

console.log('结束');

// 输出: 
// 开始
// 结束
// 定时器回调 (实际延迟远超过0ms)
2. 最小时间间隔限制

浏览器对 setTimeoutsetInterval 设置了最小时间间隔(最小延迟时间),一般为4ms。即使你设置的是 setTimeout(fn, 0),实际上也会被调整为 setTimeout(fn, 4)。这是浏览器出于性能考虑的优化。

在非活跃标签页中,这个最小值可能会增加到1000ms(1秒),以节省资源。

3. 嵌套定时器的特殊处理

对于嵌套的 setTimeout(在 setTimeout 回调中再调用 setTimeout),从第5层嵌套开始,时间间隔至少为4ms,即使指定的是0ms。

4. 调用栈阻塞

JavaScript 是单线程的,如果主线程上有长时间运行的任务,会阻塞定时器回调的执行。定时器的倒计时会继续,但回调函数无法在指定时间执行。

5. 系统级别延迟

定时器精度还受操作系统时间片分配、CPU负载等系统级因素影响。

setInterval 特有的问题

除了上述共有的问题外,setInterval 还有自己独特的不精确性:

1. 回调堆积问题

如果一个 setInterval 的回调函数执行时间超过了指定间隔,下一个回调可能会立即执行,导致回调函数堆积执行,而不是按照预期的固定间隔。

// 假设每次回调需要耗时40ms
setInterval(() => {
  console.log('开始', Date.now());
  // 模拟耗时操作
  const start = Date.now();
  while(Date.now() - start < 40) {}
  console.log('结束', Date.now());
}, 30); // 设定间隔为30ms

// 由于执行时间(40ms)>间隔时间(30ms)
// 会导致回调堆积,执行频率远低于预期
2. 丢失间隔

如果浏览器忙于其他任务,当多个 setInterval 回调到期时,可能只有一个会被执行,其余的会被跳过。这意味着如果你期望的是每隔10ms执行一次,但实际上可能会丢失一些执行。

实际例子解析

console.log('开始计时', performance.now());

setTimeout(() => {
  console.log('setTimeout 100ms', performance.now());
}, 100);

// 模拟主线程繁忙
const start = performance.now();
while (performance.now() - start < 200) {
  // 阻塞约200ms
}

console.log('阻塞结束', performance.now());

理论上,定时器应该在约100ms时触发,但由于主线程阻塞了200ms,实际上定时器回调会在阻塞结束后立即执行,总延迟约为200ms+。

解决方案

  1. 使用 requestAnimationFrame

    对于与视觉更新相关的定时任务,使用 requestAnimationFramesetTimeout 更可靠,它会在浏览器下一次重绘之前执行。

    function animateWithRAF() {
      // 执行动画逻辑
      requestAnimationFrame(animateWithRAF);
    }
    requestAnimationFrame(animateWithRAF);
    
  2. 递归 setTimeout 代替 setInterval

    为避免 setInterval 的回调堆积问题,可以使用递归的 setTimeout

    function accurateInterval(callback, interval) {
      let expected = Date.now() + interval;
      
      function step() {
        const drift = Date.now() - expected;
        // 执行回调
        callback();
        // 计算下一次执行时间,考虑偏差
        expected += interval;
        const adjustedInterval = Math.max(0, interval - drift);
        setTimeout(step, adjustedInterval);
      }
      
      setTimeout(step, interval);
    }
    
  3. Web Workers

    对于需要精确计时但又不想阻塞主线程的场景,可以考虑使用 Web Workers:

    // worker.js
    let intervalId;
    
    onmessage = function(e) {
      if (e.data.start) {
        intervalId = setInterval(() => {
          postMessage('tick');
        }, e.data.interval);
      } else if (e.data.stop) {
        clearInterval(intervalId);
      }
    }
    
  4. 使用 Date 对象校正

    对于需要高精度的定时器,可以使用当前时间与目标时间的比较来校正:

    function preciseTimer(callback, targetTime) {
      const start = Date.now();
      
      function checkTime() {
        const now = Date.now();
        if (now >= start + targetTime) {
          callback();
        } else {
          // 剩余时间小于一定值时,使用更精细的检查间隔
          const remaining = start + targetTime - now;
          if (remaining < 15) {
            setTimeout(checkTime, 0);
          } else {
            setTimeout(checkTime, remaining - 15);
          }
        }
      }
      
      setTimeout(checkTime, Math.max(0, targetTime - 15));
    }
    

其他

  1. 高分辨率时间APIperformance.now() 提供比 Date.now() 更高的精度(微秒级),可用于更精确的时间测量。

  2. 浏览器节流:现代浏览器为减少能耗对后台标签页的定时器进行节流的策略。

  3. requestIdleCallback:这是一个实验性API,可以在浏览器空闲时段执行低优先级任务,适合替代某些不需要精确定时的 setTimeout 用例。

五、垃圾回收机制(Garbage Collection, GC)

1. 内存生命周期

在 JavaScript 中,以及大多数编程语言中,内存的生命周期都遵循以下三个阶段:

  1. 内存分配(Allocation):当你创建变量、函数或对象时,语言环境会为你分配内存空间。

    let name = 'Alice'; // 为字符串分配内存
    let person = { age: 30 }; // 为对象及其属性分配内存
    let numbers = [1, 2, 3]; // 为数组分配内存
    
  2. 内存使用(Usage):在代码中对这些已分配内存进行读取和写入操作。

    console.log(name); // 读取内存
    person.age = 31;   // 写入内存
    
  3. 内存释放(Release):当内存不再被需要时,释放它以供后续使用。在 JavaScript 中,这个过程是自动的,由垃圾回收器(Garbage Collector)来完成。

核心问题是:垃圾回收器如何判断一块内存“不再被需要”?这就引出了不同的回收算法。

2. 核心算法

a. 引用计数(Reference Counting)

这是一种比较早期的、简单的垃圾回收算法。

  • 工作原理

    • 系统会跟踪每个对象被引用的次数。
    • 当一个对象被一个变量引用时,其引用计数加 1。
    • 当引用该对象的变量被修改,指向了其他对象时,原对象的引用计数减 1。
    • 当一个对象的引用计数变为 0 时,垃圾回收器就认为这个对象“不再被需要”,可以立即回收其占用的内存。
  • 致命缺陷:循环引用 引用计数算法无法解决对象之间相互引用的问题。

    function createCircularReference() {
      let objA = {};
      let objB = {};
    
      objA.b = objB; // objB 的引用计数为 1
      objB.a = objA; // objA 的引用计数为 1
      
      // 函数执行完毕后,objA 和 objB 的引用都消失了
      // 但是,objA.b 仍然指向 objB,objB.a 仍然指向 objA
      // 导致它们的引用计数永远不会变为 0
    }
    
    createCircularReference();
    // 在引用计数算法下,objA 和 objB 的内存将永远不会被回收,造成内存泄漏。
    

    由于这个致命缺陷,现代浏览器已经不再使用引用计数算法作为主要的垃圾回收策略(尽管在某些特定场景下,如 COM 对象管理中仍在使用)。

b. 标记-清除(Mark-and-Sweep)

这是现代浏览器(包括 V8、SpiderMonkey 等)采用的主流垃圾回收算法。

  • 工作原理

    1. 可达性(Reachability):算法的核心思想是,从一组根(Roots)对象(在浏览器中通常是全局的 window 对象)开始,判断哪些对象是“可达的”。
    2. 标记阶段(Mark)
      • 垃圾回收器从根对象出发,遍历所有从根可以访问到的对象,并在这些对象上打上一个“存活”的标记。
      • 它会递归地遍历这些存活对象的引用,将所有能访问到的对象都标记为“存活”。
    3. 清除阶段(Sweep)
      • 遍历堆内存中的所有对象,检查它们的标记。
      • 如果一个对象没有被标记为“存活”,那么它就是“不可达”的,被认为是垃圾。
      • 垃圾回收器会回收这些未被标记的对象所占用的内存。
  • 如何解决循环引用问题: 在上面的 createCircularReference 例子中,当函数执行完毕后,objAobjB 无法从全局的 window 对象(根)出发被访问到。因此,在标记阶段,它们都不会被标记为“存活”。在清除阶段,它们自然就会被当作垃圾回收掉。

3. V8 引擎的优化

V8(Chrome 和 Node.js 的 JavaScript 引擎)在标记-清除算法的基础上,做了一系列非常重要的优化,以提高垃圾回收的效率和性能。

  • 分代回收(Generational Collection): V8 观察到一个现象:大部分对象存活的时间都很短。基于这个“分代假说”,V8 将堆内存分为了两个主要区域:

    • 新生代(Young Generation):存放新创建的、存活时间短的对象。这个区域空间较小,但垃圾回收非常频繁。
    • 老生代(Old Generation):存放从新生代中“晋升”上来的、存活时间长的对象。这个区域空间较大,垃圾回收频率较低。
  • Scavenge 算法(用于新生代)

    • 新生代内部又被分为两个等大的空间:From 空间和 To 空间。
    • 新对象总是被分配在 From 空间。
    • 当 From 空间快满时,触发一次 Scavenge 回收。
    • 回收过程会检查 From 空间中的存活对象,并将它们复制到 To 空间。
    • 复制完成后,From 空间和 To 空间的角色会互换
    • 如果一个对象经过多次复制后仍然存活,或者 To 空间的使用率超过一定限制,它就会被晋升到老生代。
    • 这个算法的优点是速度快,因为它只需要处理存活对象,并且通过复制-交换的方式避免了内存碎片化。
  • 减少“全停顿”(Stop-the-world): 传统的标记-清除算法在执行时,需要暂停 JavaScript 应用的执行,这被称为“全停顿”。如果垃圾回收时间过长,会造成页面卡顿。V8 引入了多种技术来优化这个问题:

    • 增量标记(Incremental Marking):将标记工作“切片”,分布在多个小时间段内执行,与 JavaScript 应用代码交替运行,而不是一次性完成。
    • 并发标记(Concurrent Marking):让垃圾回收的标记工作在辅助线程中进行,与 JavaScript 主线程并行执行,从而显著减少主线程的停顿时间。

六、浏览器渲染:触发重排 (Reflow) 与重绘 (Repaint)

为了将内容呈现到屏幕上,浏览器需要经过一系列步骤(如解析 HTML、构建 DOM 树、构建渲染树、布局、绘制)。其中,重排重绘是两个非常消耗性能的环节,也是前端优化的重点关注对象。

重排 (Reflow / Layout)

当元素的几何属性(如尺寸、位置、布局)发生变化时,浏览器需要重新计算元素在文档中的位置和大小,这个过程称为“重排”。重排一定会触发重绘。由于它涉及到整个文档或部分文档的结构重新计算,因此重排的成本非常高

会触发重排的常见操作:

  1. 页面首次渲染:这是不可避免的一次重排。

  2. 添加或删除可见的 DOM 元素:例如,使用 appendChild(), removeChild(), insertBefore()

  3. 元素位置改变:修改 position, top, left, right, bottom 等。

  4. 元素尺寸改变:修改 width, height, margin, padding, border-width 等。

  5. 内容改变:例如,文本内容改变或图片被另一张不同尺寸的图片替换,导致盒子尺寸变化。

  6. 浏览器窗口尺寸改变resize 事件发生时。

  7. 字体大小改变:修改 font-size

  8. 激活 CSS 伪类:例如 :hover 状态可能会改变元素的样式和尺寸。

  9. 查询某些布局属性或调用某些方法:这是最容易被忽视的一种情况,被称为强制同步布局 (Forced Synchronous Layout)。当你读取以下属性时,浏览器为了返回一个精确的值,必须立即执行一次重排:

    • offsetTop, offsetLeft, offsetWidth, offsetHeight
    • scrollTop, scrollLeft, scrollWidth, scrollHeight
    • clientTop, clientLeft, clientWidth, clientHeight
    • getComputedStyle()

    一个典型的反模式是在循环中交替读写这些属性:

    // 反例:每次循环都会触发一次强制重排
    for (let i = 0; i < elements.length; i++) {
      const newWidth = elements[i].offsetWidth + 10; // 读(触发重排)
      elements[i].style.width = newWidth + 'px'; // 写(导致下一次读触发重排)
    }
    
重绘 (Repaint / Redraw)

当元素的非几何视觉属性(如颜色、背景、可见性)发生变化,但其布局不发生改变时,浏览器会跳过布局阶段,直接进入绘制阶段,这个过程称为“重绘”。重绘的成本比重排低

会触发重绘(但不一定触发重排)的常见操作:

  • 修改 color, background-color, background-image
  • 修改 outline, outline-color
  • 修改 border-radius, box-shadow, text-decoration
  • 修改 visibility
性能优化启示:层合成 (Compositing)

现代浏览器为了进一步优化性能,引入了层合成机制。某些特殊的 CSS 属性(如 transform, opacity, will-change)的改变,既不会触发重排,也不会触发重绘,而是会被提升到一个独立的“合成层”中,由 GPU 直接处理。这是实现高性能动画的首选方案。

怎么做
  • 合并 DOM 样式操作: cssText、className

  • 用文档碎片(DocumentFragment)批量操作 DOM

  • 离线操作 脱离文档流后操作 DOM

    • position: absolute/fixed:脱离文档流,但仍在渲染树中
    • display: none:直接从渲染树中移除(完全不可见)
  • 避免强制同步布局、分离读写操作、缓存布局信息(如 el.offsetWidth)

    浏览器有个 “怪癖”:读取布局属性(如 offsetTop、clientWidth)时,会强制刷新渲染队列,确保拿到最新的布局数据。如果 “读 - 写 - 读 - 写” 交错执行,会触发多次重排。

  • 使用 transform 和 opacity 进行动画,善用 will-change

  • 使用 requestAnimationFrame做动画

  • 对滚动和缩放事件进行防抖(Debounce)或节流(Throttle)

七、Promiseasync/await 的区别与使用场景

第一,核心关系:async/awaitPromise 的语法糖

这是理解两者的基石。async/await 并非一个全新的、与 Promise 并列的概念,而是建立在 Promise 之上的,旨在让我们能用更接近同步代码的、更线性的方式来编写异步逻辑。

  • async 函数的返回值永远是一个 Promise 对象
  • await 关键字后面跟的就是一个 Promise(或者其他类型的值,它会被自动包装成一个 resolved 的 Promise),它会“暂停”async 函数的执行,直到这个 Promise 状态变为 fulfilledrejected

所以说,async/await 是对 Promise 用法的一种优雅封装。

第二,主要区别:代码表现和处理方式的差异

async/await 的出现,极大地优化了开发体验,主要体现在以下几点:

  1. 代码可读性

    • Promise:使用 .then() 方法链式调用。当异步任务之间有强依赖关系且流程复杂时,容易形成“回调地狱”的变种(Promise Chaining Hell),代码横向发展,可读性下降。
    • async/await:代码结构与同步代码非常相似,采用从上到下的线性执行顺序,逻辑更清晰,可读性和维护性都远超 Promise 链。
  2. 错误处理机制

    • Promise:通过 .catch() 方法捕获异步链中抛出的错误。这种方式将正常的业务逻辑和错误处理逻辑分离开来。
    • async/await:使用标准的 try...catch 块。这种方式的巨大优势在于,它可以同时捕获异步错误和同步错误,使得错误处理的模式更加统一和强大。
  3. 中间值的传递

    • Promise:在 .then 链中,如果后续的逻辑需要使用前面某个 then 的结果,必须通过函数参数和 return 语句层层传递,非常繁琐。
    • async/await:非常简单。可以直接将 await 表达式的结果赋值给变量,后续代码可以在同一作用域内直接使用,就像操作同步方法的返回值一样。
  4. 调试体验

    • Promise:由于执行栈不连续,调试时很难跟踪代码的完整执行流,错误堆栈信息有时也不够直观。
    • async/await:代码执行流程是线性的,可以像调试同步代码一样,方便地设置断点、单步调试,极大地提升了调试效率。
第三,使用场景:优势互补,各司其职

尽管 async/await 在多数情况下是首选,但 Promise 依然有其不可替代的用武之地。

我会优先使用 async/await 的场景,覆盖了绝大部分业务需求:

  • 处理串行的异步任务:当一系列异步操作需要按顺序、且后一个依赖前一个的结果时,async/await 是不二之选。它的代码结构清晰,完美契合这类需求。
  • 复杂的条件判断和循环:在 if-elsefor 循环中嵌入异步逻辑时,async/await 能让代码结构保持简单,而用 Promise.then 实现会非常绕。

我仍然会直接使用 Promise 的场景:

  • 处理并行的异步任务:当需要一次性发起多个独立的异步请求,并等待它们全部完成后再进行下一步操作时,Promise.all() 是最佳方案。同理,当需要等待其中任意一个任务完成时,使用 Promise.race()Promise.any()。虽然这些通常会和 await 结合使用(如 await Promise.all(...)),但能力的核心是由 Promise 提供的。
  • 在不支持 async 的环境或回调中:在一些旧的库或者回调函数设计中,你必须返回一个 Promise 实例,这时就需要手动 new Promise(...) 并使用 .then

八、JS 中深拷贝和浅拷贝的区别?实现浅拷贝和深拷贝?

1. 区别:深拷贝 vs. 浅拷贝

这个问题的核心在于 JavaScript 的数据类型分为基本类型String, Number, Boolean, null, undefined, Symbol, BigInt)和引用类型Object, Array, Function 等)。

  • 基本类型的值存储在栈内存中。
  • 引用类型的值(即对象本身)存储在堆内存中,而栈内存中只存储一个指向该对象的引用地址

浅拷贝 (Shallow Copy)

  • 只复制对象或数组的第一层
  • 如果一个属性的值是基本类型,那么就复制这个值。
  • 如果一个属性的值是引用类型(比如一个嵌套的对象),那么只复制它的引用地址,而不是对象本身。
  • 结果:新旧对象的第一层是独立的,但它们内部的引用类型属性指向的是同一个内存地址。修改其中一个对象的嵌套内容,会影响到另一个对象。

打个比方:你复制了一串钥匙(浅拷贝),这串新钥匙能打开的还是原来那个房子。你用新钥匙进房子里挪动了家具,用老钥匙开门进去看,家具也变了。

深拷贝 (Deep Copy)

  • 递归地复制一个对象的所有层级。
  • 它会为所有嵌套的引用类型都开辟新的内存空间,并把内容完全复制过去。
  • 结果:新旧对象是完全独立的,互不影响。

打个比方:你照着一个房子的图纸,用新材料盖了一个一模一样的新房子(深拷贝)。你在新房子里怎么装修,都和老房子没关系。


2. 实现浅拷贝

浅拷贝非常简单,有很多内置方法。

对于对象:

const originalObj = { a: 1, b: { c: 2 } };

// 1. 使用扩展运算符 (...)
const shallowCopy1 = { ...originalObj };

// 2. 使用 Object.assign()
const shallowCopy2 = Object.assign({}, originalObj);

console.log(originalObj.b === shallowCopy1.b); // true (引用地址相同)

对于数组:

const originalArr = [1, 2, [3, 4]];

// 1. 使用扩展运算符 (...)
const shallowCopy1 = [...originalArr];

// 2. 使用 Array.prototype.slice()
const shallowCopy2 = originalArr.slice();

// 3. 使用 Array.from()
const shallowCopy3 = Array.from(originalArr);

console.log(originalArr[2] === shallowCopy1[2]); // true (引用地址相同)

3. 实现深拷贝

深拷贝的实现方式多样,各有优劣。

方法一:JSON.parse(JSON.stringify(obj))

这是最简单、最偷懒的方法,但不推荐在生产环境随意使用,因为它有很多坑。

const originalObj = {
  a: 1,
  b: { c: 2 },
  d: new Date(),
  e: undefined,
  f: function() {},
  g: Symbol('foo')
};

const deepCopy = JSON.parse(JSON.stringify(originalObj));

console.log(deepCopy);
// 输出: { a: 1, b: { c: 2 }, d: "2023-..." }
// e (undefined), f (function), g (Symbol) 都丢失了
// d (Date) 变成了字符串
  • 优点:代码极其简单。
  • 缺点
    • 会忽略 undefinedSymbolfunction 类型的属性。
    • Date 对象会转成字符串,RegExpError 会转成空对象。
    • 不能处理循环引用(对象内部有属性指向自己),会报错。
方法二:structuredClone() (现代、推荐)

这是一个现代浏览器和 Node.js v17+ 内置的全局函数,专门用于深拷贝。

const originalObj = { a: 1, b: { c: 2 }, d: new Date() };
const deepCopy = structuredClone(originalObj);

console.log(originalObj.b === deepCopy.b); // false
console.log(originalObj.d === deepCopy.d); // false (Date对象也被深拷贝了)
  • 优点
    • 是官方标准,专门为此而生。
    • 支持循环引用。
    • 支持多种数据类型,如 Date, RegExp, Map, Set 等。
  • 缺点
    • 仍然不能拷贝 function(会报错)。
    • 不能拷贝 ErrorDOM 节点等。
    • 在旧环境中不兼容。
方法三:手写递归函数 (面试终极答案)

这是最能体现你对深拷贝理解程度的方法,核心是递归,并处理循环引用。

function deepClone(target, map = new WeakMap()) {
  // 基本类型和 null 直接返回
  if (target === null || typeof target !== 'object') {
    return target;
  }

  // 处理循环引用
  if (map.has(target)) {
    return map.get(target);
  }

  // 处理 Date 和 RegExp
  if (target instanceof Date) return new Date(target);
  if (target instanceof RegExp) return new RegExp(target.source, target.flags);

  // 创建一个新的容器 (对象或数组)
  const cloneTarget = Array.isArray(target) ? [] : {};

  // 存入 map,防止循环引用
  map.set(target, cloneTarget);

  // 递归拷贝
  for (const key in target) {
    // 只拷贝对象自身的属性
    if (Object.prototype.hasOwnProperty.call(target, key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}
  • 优点:功能最强大,可定制,能处理循环引用和多种数据类型。
  • 缺点:实现最复杂。