前端之JavaScript

一、原型与继承

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

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

prototype vs __proto__

  • prototype

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

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

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

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

javascript 复制代码
// 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

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

图解说明:

  • 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 丢失。

    javascript 复制代码
    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. 原型链继承

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

  • 实现:

    javascript 复制代码
    // 父类
    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. 借用构造函数继承(经典继承 / 伪造对象)

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

  • 实现:

    javascript 复制代码
    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 之前最常用的一种继承模式。

  • 实现:

    javascript 复制代码
    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() 正是为此而生。

  • 实现:

    javascript 复制代码
    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 关键字,作为对象的模板。它本质上是上面"寄生组合式继承"的语法糖,让继承的写法更加清晰、更像传统的面向对象语言。

  • 实现:

    javascript 复制代码
    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 引入的重要特性,它让变量的管理更加直观和安全。

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

javascript 复制代码
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)

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

javascript 复制代码
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。这使得代码行为更加可预测。
    javascript 复制代码
    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
    javascript 复制代码
    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. 在词法作用域之外执行:这是识别闭包最核心的特征。

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

javascript 复制代码
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. 创建私有变量(模块模式)

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

javascript 复制代码
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. 循环与异步中的状态保持

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

错误示范:

javascript 复制代码
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 出现前,我们用立即执行函数表达式来为每次循环创建一个新的作用域。

javascript 复制代码
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 循环中有一个特殊的行为:为每一次循环都创建一个新的词法环境,并绑定当前的循环变量。这实际上是隐式地为我们创造了闭包。

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

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

javascript 复制代码
// 一个简单的柯里化 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 元素的事件监听器),那么它所引用的外部作用域就不会被垃圾回收,这可能会导致内存泄漏

一个例子:

javascript 复制代码
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
javascript 复制代码
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 会被绑定到这个对象。

  • 规则:调用位置是否存在一个上下文对象,或者说,函数调用是否被某个对象所"拥有"或"包含"。
javascript 复制代码
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 都不会再改变。
javascript 复制代码
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. 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。
javascript 复制代码
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 一旦被确定,就永远不会改变

javascript 复制代码
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 指向该对象本身时,不应使用箭头函数。

      javascript 复制代码
      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. 代码示例分析

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

javascript 复制代码
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 的结果恢复函数执行。

javascript 复制代码
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 并不是"在指定时间后执行",而是"在指定时间后,将回调函数放入宏任务队列"。回调函数要等到调用栈清空、所有微任务执行完毕后,才有机会被事件循环取出并执行。

javascript 复制代码
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 的回调函数执行时间超过了指定间隔,下一个回调可能会立即执行,导致回调函数堆积执行,而不是按照预期的固定间隔。

javascript 复制代码
// 假设每次回调需要耗时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执行一次,但实际上可能会丢失一些执行。

实际例子解析

javascript 复制代码
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 更可靠,它会在浏览器下一次重绘之前执行。

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

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

    javascript 复制代码
    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:

    javascript 复制代码
    // 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 对象校正

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

    javascript 复制代码
    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):当你创建变量、函数或对象时,语言环境会为你分配内存空间。

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

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

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

2. 核心算法

a. 引用计数(Reference Counting)

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

  • 工作原理

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

    javascript 复制代码
    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 主线程并行执行,从而显著减少主线程的停顿时间。

相关推荐
不一样的少年_7 分钟前
头像组件崩溃、乱序、加载失败?一套队列机制+多级兜底全搞定
前端·vue.js
Code_XYZ15 分钟前
uni-app x开发跨端应用,与web-view的双向通信解决方案
前端
wordbaby17 分钟前
构建时规划,运行时执行:解构 React Router 的 prerender 与 loader
前端·react.js
用户58061393930017 分钟前
【前端工程化】Eslint+Prettier vue项目实现文件保存时自动代码格式化
前端
麦当_17 分钟前
基于 Shadcn 的可配置表单解决方案
前端·javascript·面试
MrSkye25 分钟前
从零到一:我用AI对话写出了人生第一个弹幕游戏 | Prompt编程实战心得
前端·ai编程·trae
Cutey91634 分钟前
使用Canvas实现实时视频处理:从黑白滤镜到高级特效
前端·javascript
前端大卫34 分钟前
前端调试太痛苦?这 6 个技巧直接解决 90% 问题!
前端·javascript
小公主41 分钟前
this 到底指向谁?严格模式和作用域那些坑全讲明白了
前端·javascript
用户91453633083911 小时前
SQL注入攻击:原理分析与防护实战
前端