前端之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 主线程并行执行,从而显著减少主线程的停顿时间。

相关推荐
黄智勇3 分钟前
xlsx-handlebars 一个用于处理 XLSX 文件 Handlebars 模板的 Rust 库,支持多平台使
前端
brzhang1 小时前
为什么 OpenAI 不让 LLM 生成 UI?深度解析 OpenAI Apps SDK 背后的新一代交互范式
前端·后端·架构
brzhang2 小时前
OpenAI Apps SDK ,一个好的 App,不是让用户知道它该怎么用,而是让用户自然地知道自己在做什么。
前端·后端·架构
爱看书的小沐2 小时前
【小沐学WebGIS】基于Three.JS绘制飞行轨迹Flight Tracker(Three.JS/ vue / react / WebGL)
javascript·vue·webgl·three.js·航班·航迹·飞行轨迹
井柏然2 小时前
前端工程化—实战npm包深入理解 external 及实例唯一性
前端·javascript·前端工程化
IT_陈寒3 小时前
Redis 高性能缓存设计:7个核心优化策略让你的QPS提升300%
前端·人工智能·后端
aklry3 小时前
elpis之动态组件机制
javascript·vue.js·架构
井柏然3 小时前
从 npm 包实战深入理解 external 及实例唯一性
前端·javascript·前端工程化
羊锦磊4 小时前
[ vue 前端框架 ] 基本用法和vue.cli脚手架搭建
前端·vue.js·前端框架
brzhang4 小时前
高通把Arduino买了,你的“小破板”要变“AI核弹”了?
前端·后端·架构