一、原型与继承
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()
):- JavaScript 引擎首先在对象自身 (
p1
)上查找。 - 如果找不到,它会沿着
__proto__
链接,去其原型对象 (Person.prototype
)上查找。在我们的例子中,它在这里找到了sayHello
方法。 - 如果还找不到,它会继续沿着原型对象的
__proto__
链接向上查找,直到找到该属性,或者到达原型链的终点。
- JavaScript 引擎首先在对象自身 (
- 链的终点 :所有普通对象的原型链最终都会指向
Object.prototype
。而Object.prototype
的__proto__
是null
,标志着原型链的结束。
所以 p1.toString()
这样的调用之所以能成功,就是因为 p1
-> Person.prototype
-> Object.prototype
这条链上,Object.prototype
提供了 toString
方法。
constructor
属性的作用和潜在问题
-
作用 :
constructor
属性主要用于标识"这个对象是由哪个构造函数创建的"。它存在于原型对象上,并被所有实例继承。所以p1.constructor === Person
返回true
。 -
潜在问题 :当我们想给原型添加很多方法时,可能会直接重写整个
prototype
对象,这会导致constructor
丢失。javascriptfunction 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. 借用构造函数继承(经典继承 / 伪造对象)
为了解决原型链继承的引用类型共享问题,开发者们想出了在子类构造函数中调用父类构造函数的方法。
-
实现:
javascriptfunction 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 之前最常用的一种继承模式。
-
实现:
javascriptfunction 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
-
优点:
- 既能继承实例属性(保证不共享),又能继承原型方法(保证可复用)。
- 保留了
instanceof
和isPrototypeOf
的能力。
-
缺点:
- 调用了两次父类构造函数 :一次在
Animal.call(this)
,一次在new Animal()
。这会导致子类实例和子类原型上都有一份多余的父类实例属性。虽然不影响功能,但略有性能浪费。
- 调用了两次父类构造函数 :一次在
d. 寄生组合式继承(最理想的方案)
为了解决组合继承调用两次父类构造函数的问题,大神道格拉斯·克罗克福德提出了这种模式,它被认为是 ES6 之前最理想的继承方案。
核心在于:我们继承父类的原型,其实不需要执行父类的构造函数 ,我们只需要一个干净的、链接到父类原型的对象。Object.create()
正是为此而生。
-
实现:
javascriptfunction 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
关键字,作为对象的模板。它本质上是上面"寄生组合式继承"的语法糖,让继承的写法更加清晰、更像传统的面向对象语言。
-
实现:
javascriptclass 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...
-
extends
和super
:extends
关键字负责实现继承,它的背后逻辑非常类似于寄生组合继承。super
关键字既可以作为函数调用(在constructor
中),代表父类的构造函数;也可以作为对象使用(在普通方法中),代表父类的原型。
尽管 class
写法更友好,但它的底层实现依然是原型和原型链。
二、作用域与闭包
1. 作用域(Scope)
什么是作用域?
作用域是指程序中定义变量的区域,它决定了变量的可访问性和生命周期。
JavaScript 采用的是词法作用域(Lexical Scope) ,也叫静态作用域。这意味着,变量的作用域在代码编写时就已经确定了,并且不会在运行时改变。无论函数在哪里被调用,它的词法作用域只由函数被声明时所处的位置决定。
作用域的类型
在 JavaScript 中,主要有三种作用域:
-
全局作用域(Global Scope):
- 在代码的最外层定义的变量拥有全局作用域。
- 在任何地方都可以访问到。
- 在浏览器环境中,全局对象是
window
;在 Node.js 中是global
。未经声明直接赋值的变量会自动成为全局变量(在严格模式下会报错),这是一个应该极力避免的坏习惯。
-
函数作用域(Function Scope):
- 在函数内部定义的变量,只能在该函数内部访问。
- 这是
var
声明变量时遵循的作用域规则。
-
块级作用域(Block Scope):
- 由
{}
包裹的代码块(例如if
语句、for
循环、或者一个独立的{}
)所创建的作用域。 - 通过
let
和const
声明的变量会遵循块级作用域规则。这是 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
。 - 使用
let
和const
声明的变量虽然也有类似"提升"的行为,但它们存在一个暂时性死区(Temporal Dead Zone, TDZ) 。在声明语句之前访问这些变量,会抛出ReferenceError
,而不是得到undefined
。这使得代码行为更加可预测。
javascriptconsole.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
。
javascriptsayHello(); // "Hello!" (函数声明被完整提升) function sayHello() { console.log('Hello!'); } // sayHi(); // TypeError: sayHi is not a function (变量 sayHi 被提升了,但值是 undefined) var sayHi = function() { console.log('Hi!'); };
- 使用函数声明 (
好的,我们来揭开**闭包(Closure)**的神秘面纱。实际上,如果你已经理解了词法作用域,那么你离理解闭包只有一步之遥。
2. 闭包(Closure)
闭包的定义
让我们来看一个权威且精准的定义:
当一个函数能够记住并访问其所在的词法作用域时,就产生了闭包,即使该函数在其词法作用域之外执行。
这个定义有两层关键意思:
- 记住和访问 :这得益于我们刚才讨论的作用域链。函数天生就能"记住"它被定义时所处的环境。
- 在词法作用域之外执行:这是识别闭包最核心的特征。
我们来看一个最经典的闭包例子:
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,而 privateVariable
和 privateMethod
则被完美地保护在闭包中,外部无法访问,实现了数据的私有化。
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
, apply
或 bind
。
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
操作符做了四件事:- 创建一个全新的空对象。
- 这个新对象的
__proto__
被链接到构造函数的prototype
。 - 构造函数的
this
被绑定到这个新对象。 - 如果构造函数没有显式返回一个对象,则自动返回这个新创建的对象。
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
指向问题上的巨大优势。
箭头函数与普通函数的区别
this
指向:最核心的区别,如上所述。- 没有
arguments
对象 :箭头函数内部没有自己的arguments
对象。如果需要获取所有参数,可以使用剩余参数 (...args
)。 - 不能用作构造函数 :不能对箭头函数使用
new
操作符,否则会抛出错误。 - 没有
prototype
属性 :既然不能当构造函数,自然也就不需要prototype
。 - 没有
super
和new.target
绑定。
适用与不适用的场景
-
适用场景:
- 需要一个
this
指向固定、不随调用方式改变的函数时,特别是用作回调函数 ,如setTimeout
,map
,filter
等。 - 代码简洁,对于简单的、没有复杂
this
需求的函数。
- 需要一个
-
不适用场景:
-
对象的方法 :当你需要
this
指向该对象本身时,不应使用箭头函数。javascriptconst 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 和 6
- 然后清空微任务队列,执行第一个 Promise.then,输出 4,并注册一个新的 setTimeout 宏任务
- 接着执行宏任务队列中的第一个 setTimeout 回调,输出 2,并注册一个新的 Promise.then 微任务
- 立即清空微任务队列,输出 3
- 最后执行第二个 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. 事件循环的工作机制
setTimeout
和 setInterval
并不是"在指定时间后执行",而是"在指定时间后,将回调函数放入宏任务队列"。回调函数要等到调用栈清空、所有微任务执行完毕后,才有机会被事件循环取出并执行。
javascript
console.log('开始');
setTimeout(() => {
console.log('定时器回调');
}, 0);
// 模拟耗时操作
const start = Date.now();
while(Date.now() - start < 300) {
// 阻塞约300ms
}
console.log('结束');
// 输出:
// 开始
// 结束
// 定时器回调 (实际延迟远超过0ms)
2. 最小时间间隔限制
浏览器对 setTimeout
和 setInterval
设置了最小时间间隔(最小延迟时间),一般为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+。
解决方案
-
使用
requestAnimationFrame
对于与视觉更新相关的定时任务,使用
requestAnimationFrame
比setTimeout
更可靠,它会在浏览器下一次重绘之前执行。javascriptfunction animateWithRAF() { // 执行动画逻辑 requestAnimationFrame(animateWithRAF); } requestAnimationFrame(animateWithRAF);
-
递归
setTimeout
代替setInterval
为避免
setInterval
的回调堆积问题,可以使用递归的setTimeout
:javascriptfunction 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); }
-
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); } }
-
使用
Date
对象校正对于需要高精度的定时器,可以使用当前时间与目标时间的比较来校正:
javascriptfunction 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)); }
其他
-
高分辨率时间API :
performance.now()
提供比Date.now()
更高的精度(微秒级),可用于更精确的时间测量。 -
浏览器节流:现代浏览器为减少能耗对后台标签页的定时器进行节流的策略。
-
requestIdleCallback
:这是一个实验性API,可以在浏览器空闲时段执行低优先级任务,适合替代某些不需要精确定时的setTimeout
用例。
五、垃圾回收机制(Garbage Collection, GC)
1. 内存生命周期
在 JavaScript 中,以及大多数编程语言中,内存的生命周期都遵循以下三个阶段:
-
内存分配(Allocation):当你创建变量、函数或对象时,语言环境会为你分配内存空间。
javascriptlet name = 'Alice'; // 为字符串分配内存 let person = { age: 30 }; // 为对象及其属性分配内存 let numbers = [1, 2, 3]; // 为数组分配内存
-
内存使用(Usage):在代码中对这些已分配内存进行读取和写入操作。
javascriptconsole.log(name); // 读取内存 person.age = 31; // 写入内存
-
内存释放(Release) :当内存不再被需要时,释放它以供后续使用。在 JavaScript 中,这个过程是自动的,由垃圾回收器(Garbage Collector)来完成。
核心问题是:垃圾回收器如何判断一块内存"不再被需要"?这就引出了不同的回收算法。
2. 核心算法
a. 引用计数(Reference Counting)
这是一种比较早期的、简单的垃圾回收算法。
-
工作原理:
- 系统会跟踪每个对象被引用的次数。
- 当一个对象被一个变量引用时,其引用计数加 1。
- 当引用该对象的变量被修改,指向了其他对象时,原对象的引用计数减 1。
- 当一个对象的引用计数变为 0 时,垃圾回收器就认为这个对象"不再被需要",可以立即回收其占用的内存。
-
致命缺陷:循环引用 引用计数算法无法解决对象之间相互引用的问题。
javascriptfunction 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 等)采用的主流垃圾回收算法。
-
工作原理:
- 可达性(Reachability) :算法的核心思想是,从一组根(Roots)对象(在浏览器中通常是全局的
window
对象)开始,判断哪些对象是"可达的"。 - 标记阶段(Mark) :
- 垃圾回收器从根对象出发,遍历所有从根可以访问到的对象,并在这些对象上打上一个"存活"的标记。
- 它会递归地遍历这些存活对象的引用,将所有能访问到的对象都标记为"存活"。
- 清除阶段(Sweep) :
- 遍历堆内存中的所有对象,检查它们的标记。
- 如果一个对象没有被标记为"存活",那么它就是"不可达"的,被认为是垃圾。
- 垃圾回收器会回收这些未被标记的对象所占用的内存。
- 可达性(Reachability) :算法的核心思想是,从一组根(Roots)对象(在浏览器中通常是全局的
-
如何解决循环引用问题 : 在上面的
createCircularReference
例子中,当函数执行完毕后,objA
和objB
无法从全局的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 主线程并行执行,从而显著减少主线程的停顿时间。