MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript原型链与作用域链的区别

2023-11-277.6k 阅读

JavaScript 原型链

原型的基本概念

在 JavaScript 中,每个对象都有一个 [[Prototype]] 内部属性(在现代 JavaScript 中可以通过 Object.getPrototypeOf() 方法或 __proto__ 属性访问,__proto__ 虽然不是标准的,但是浏览器广泛支持)。这个 [[Prototype]] 指向另一个对象,这个对象就是当前对象的原型。

当我们访问一个对象的属性或方法时,如果该对象自身没有定义这个属性或方法,JavaScript 引擎就会沿着 [[Prototype]] 链向上查找,直到找到该属性或方法,或者到达原型链的顶端(null)。

例如,创建一个简单的对象:

let person = {
    name: 'John'
};
console.log(person.__proto__ === Object.prototype); // true

这里 person 对象的原型就是 Object.prototype,这是所有对象默认的原型(通过 new Object() 创建的对象)。

原型链的构建

原型链是通过构造函数和 prototype 属性来构建的。当我们使用构造函数创建对象时,新对象的 [[Prototype]] 会指向构造函数的 prototype 属性。

function Animal(name) {
    this.name = name;
}
Animal.prototype.speak = function() {
    console.log(this.name +'makes a sound.');
};

let dog = new Animal('Buddy');
console.log(dog.__proto__ === Animal.prototype); // true

在这个例子中,dog 是通过 Animal 构造函数创建的。dog[[Prototype]] 指向 Animal.prototypeAnimal.prototype 本身也是一个对象,它的原型是 Object.prototype。所以形成了一条原型链:dog -> Animal.prototype -> Object.prototype -> null

原型链的实际应用

  1. 代码复用:通过在原型上定义方法,可以让所有通过该构造函数创建的对象共享这些方法,而不需要在每个对象上重复定义。
function Circle(radius) {
    this.radius = radius;
}
Circle.prototype.getArea = function() {
    return Math.PI * this.radius * this.radius;
};

let circle1 = new Circle(5);
let circle2 = new Circle(10);
// circle1 和 circle2 共享 getArea 方法,节省内存
  1. 继承:在 JavaScript 中实现继承主要是通过原型链。通过让一个构造函数的原型指向另一个构造函数的实例,可以实现类似类继承的效果。
function Mammal(name) {
    this.name = name;
}
Mammal.prototype.speak = function() {
    console.log(this.name +'says something.');
};

function Dog(name, breed) {
    Mammal.call(this, name);
    this.breed = breed;
}
Dog.prototype = Object.create(Mammal.prototype);
Dog.prototype.constructor = Dog;

let myDog = new Dog('Max', 'Golden Retriever');
myDog.speak(); // Max says something.

这里 Dog 构造函数继承自 Mammal 构造函数。Dog.prototype 是通过 Object.create(Mammal.prototype) 创建的,这样 Dog 的实例就可以访问 Mammal.prototype 上的方法。

原型链的注意事项

  1. 性能问题:随着原型链的增长,查找属性或方法的时间会增加。因为每次查找都需要沿着原型链一级一级向上查找。
  2. 意外覆盖:如果不小心在对象自身定义了与原型链上同名的属性,会覆盖原型链上的属性,导致原型链上的属性无法访问。
function Person() {}
Person.prototype.age = 30;

let p1 = new Person();
p1.age = 25; // 覆盖了原型上的 age 属性
console.log(p1.age); // 25

JavaScript 作用域链

作用域的概念

作用域是指在程序中定义变量的区域,它规定了变量的生命周期和访问权限。在 JavaScript 中有两种主要的作用域类型:全局作用域和函数作用域(ES6 引入了块级作用域)。

全局作用域:在 JavaScript 代码中,最外层的作用域就是全局作用域。在全局作用域中定义的变量和函数可以在整个脚本中访问。

let globalVar = 'I am global';
function globalFunction() {
    console.log(globalVar);
}
globalFunction(); // I am global

函数作用域:函数内部定义的变量具有函数作用域,这些变量只能在函数内部访问。

function myFunction() {
    let localVar = 'I am local';
    console.log(localVar);
}
myFunction();
// console.log(localVar); // 报错,localVar 在此处未定义

作用域链的形成

当一个函数被调用时,会创建一个执行上下文。执行上下文包含三个部分:变量对象(VO)、作用域链(Scope Chain)和 this 值。

作用域链是由当前执行上下文的变量对象和所有父执行上下文的变量对象组成的链表。当查找一个变量时,JavaScript 引擎会首先在当前执行上下文的变量对象中查找,如果没有找到,就会沿着作用域链向上查找,直到找到该变量或到达全局执行上下文。

function outer() {
    let outerVar = 'outer';
    function inner() {
        let innerVar = 'inner';
        console.log(outerVar); // outer
    }
    inner();
}
outer();

在这个例子中,当 inner 函数被调用时,它的作用域链包含 inner 函数的变量对象(包含 innerVar)和 outer 函数的变量对象(包含 outerVar)。所以 inner 函数可以访问 outerVar

块级作用域与作用域链

ES6 引入了块级作用域,通过 letconst 关键字实现。块级作用域是由一对花括号 {} 包裹的区域,如 if 语句块、for 循环块等。

{
    let blockVar = 'block';
    console.log(blockVar); // block
}
// console.log(blockVar); // 报错,blockVar 在此处未定义

在块级作用域中定义的变量不会被提升到外部作用域,这与 var 关键字不同。块级作用域也会影响作用域链。例如:

function scopeTest() {
    let localVar = 'function scope';
    if (true) {
        let blockVar = 'block scope';
        console.log(localVar); // function scope
    }
    // console.log(blockVar); // 报错,blockVar 在此处未定义
}
scopeTest();

这里 if 语句块形成了一个块级作用域,blockVar 只能在这个块级作用域内访问。if 块的作用域链包含自身的变量对象和 scopeTest 函数的变量对象。

作用域链的实际应用

  1. 函数闭包:闭包是指有权访问另一个函数作用域中变量的函数。闭包的实现依赖于作用域链。
function outer() {
    let outerVar = 'outer';
    function inner() {
        console.log(outerVar);
    }
    return inner;
}
let closure = outer();
closure(); // outer

在这个例子中,inner 函数形成了一个闭包。即使 outer 函数已经执行完毕,inner 函数仍然可以访问 outer 函数作用域中的 outerVar,因为 inner 函数的作用域链中包含 outer 函数的变量对象。 2. 模块模式:通过利用作用域链和闭包,可以实现模块模式,将一些相关的代码封装起来,避免全局变量的污染。

let myModule = (function() {
    let privateVar = 'private';
    function privateFunction() {
        console.log(privateVar);
    }
    return {
        publicFunction: function() {
            privateFunction();
        }
    };
})();
myModule.publicFunction(); // private
// console.log(privateVar); // 报错,privateVar 在此处未定义

这里通过立即执行函数表达式(IIFE)创建了一个模块。privateVarprivateFunction 只能在 IIFE 的内部访问,通过返回一个包含公开方法的对象,外部代码可以调用公开方法间接访问内部的私有变量和函数。

原型链与作用域链的区别

定义与本质

  1. 原型链:本质上是对象之间的一种继承关系的体现。它是基于对象的 [[Prototype]] 内部属性构建的链表,用于实现对象属性和方法的继承与共享。每个对象都有 [[Prototype]],通过它链接到其原型对象,形成一条链,直到 null
  2. 作用域链:本质上是与函数执行上下文相关的概念。它是在函数执行时创建的,由当前执行上下文的变量对象和所有父执行上下文的变量对象组成的链表。作用域链用于确定变量的查找路径,决定了在何处查找变量以及变量的访问权限。

构建方式

  1. 原型链:通过构造函数的 prototype 属性和对象的 [[Prototype]] 链接构建。当使用 new 关键字调用构造函数创建对象时,新对象的 [[Prototype]] 指向构造函数的 prototype。不同构造函数创建的对象之间通过原型链形成继承关系。
function Parent() {}
function Child() {}
Child.prototype = Object.create(Parent.prototype);
Child.prototype.constructor = Child;

let child = new Child();
// 原型链:child -> Child.prototype -> Parent.prototype -> Object.prototype -> null
  1. 作用域链:在函数调用时构建。每当一个函数被调用,就会创建一个新的执行上下文,该执行上下文的作用域链由当前函数的变量对象(包含函数参数和内部定义的变量)和父执行上下文的变量对象组成。函数嵌套层次决定了作用域链的长度。
function outer() {
    let outerVar = 'outer';
    function inner() {
        let innerVar = 'inner';
        // 作用域链:inner 函数变量对象 -> outer 函数变量对象 -> 全局变量对象
    }
    inner();
}
outer();

查找目标

  1. 原型链:主要用于查找对象的属性和方法。当访问一个对象的属性或方法时,如果对象自身没有定义,就沿着原型链向上查找,直到找到或到达 null。它侧重于对象的行为和状态的继承。
function Person() {}
Person.prototype.speak = function() {
    console.log('I am speaking.');
};

let person = new Person();
person.speak(); // I am speaking.
// 先在 person 对象自身查找 speak 方法,没找到,再沿原型链在 Person.prototype 找到
  1. 作用域链:主要用于查找变量。在函数执行过程中,当使用一个变量时,会从当前执行上下文的作用域链的前端开始查找,直到找到该变量或到达全局作用域。它侧重于变量的访问和作用域范围的界定。
let globalVar = 'global';
function myFunction() {
    let localVar = 'local';
    console.log(globalVar); // global
    // 先在 myFunction 作用域链前端(自身变量对象)查找 globalVar,没找到,再向上在全局作用域找到
}
myFunction();

生命周期

  1. 原型链:一旦对象创建,其原型链就确定了,除非通过代码显式修改对象的 [[Prototype]](如使用 Object.setPrototypeOf() 方法)。原型链在对象的整个生命周期内保持相对稳定,除非进行动态修改。
function Animal() {}
let animal = new Animal();
// animal 的原型链在创建时确定为 animal -> Animal.prototype -> Object.prototype -> null
// 除非使用 Object.setPrototypeOf(animal, newPrototype) 修改
  1. 作用域链:作用域链是在函数调用时创建,在函数执行完毕后销毁(除了闭包情况,闭包会使作用域链中的部分变量对象保持活动状态)。不同的函数调用会创建不同的作用域链,其生命周期与函数的执行周期紧密相关。
function myFunction() {
    let localVar = 'local';
    // 函数调用时创建作用域链,执行完毕后作用域链销毁(若没有闭包)
}
myFunction();

对性能的影响

  1. 原型链:随着原型链的增长,查找属性和方法的时间会增加,因为每次查找都需要沿着原型链一级一级向上查找。这可能会对性能产生一定影响,特别是在频繁访问原型链上的属性和方法时。
  2. 作用域链:作用域链的长度也会影响变量查找的性能。较长的作用域链意味着需要更多的查找步骤来找到变量。此外,闭包可能会导致作用域链中的变量对象无法被垃圾回收,从而占用更多内存,影响性能。

应用场景

  1. 原型链:主要用于实现对象的继承和代码复用。通过在原型上定义属性和方法,可以让多个对象共享这些行为,减少内存开销。常用于构建对象层次结构,如创建类继承体系(虽然 JavaScript 没有传统的类)。
function Shape() {
    this.color = 'black';
}
Shape.prototype.draw = function() {
    console.log('Drawing a shape.');
};

function Circle(radius) {
    Shape.call(this);
    this.radius = radius;
}
Circle.prototype = Object.create(Shape.prototype);
Circle.prototype.constructor = Circle;
Circle.prototype.draw = function() {
    console.log('Drawing a circle.');
};

let circle = new Circle(5);
circle.draw(); // Drawing a circle.
// 利用原型链实现 Circle 继承自 Shape,复用 Shape 的部分代码
  1. 作用域链:主要用于变量的访问控制和闭包的实现。通过合理利用作用域链,可以实现模块化编程,避免全局变量的污染,同时通过闭包实现一些特殊的功能,如数据的私有性和函数的记忆化。
function counter() {
    let count = 0;
    return function() {
        return ++count;
    };
}
let myCounter = counter();
console.log(myCounter()); // 1
console.log(myCounter()); // 2
// 通过闭包和作用域链实现计数器功能,count 变量保持私有

示例对比

// 原型链示例
function Vehicle() {
    this.wheels = 4;
}
Vehicle.prototype.move = function() {
    console.log('Vehicle is moving.');
};

function Car(model) {
    Vehicle.call(this);
    this.model = model;
}
Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.constructor = Car;
Car.prototype.drive = function() {
    console.log('Driving a'+ this.model +'car.');
};

let myCar = new Car('Toyota');
myCar.move(); // Vehicle is moving.
myCar.drive(); // Driving a Toyota car.
// 原型链查找:myCar -> Car.prototype -> Vehicle.prototype -> Object.prototype -> null

// 作用域链示例
let globalVar = 'global';
function outer() {
    let outerVar = 'outer';
    function inner() {
        let innerVar = 'inner';
        console.log(globalVar); // global
        console.log(outerVar); // outer
        // 作用域链查找:inner 函数变量对象 -> outer 函数变量对象 -> 全局变量对象
    }
    inner();
}
outer();

在上述示例中,原型链示例展示了对象之间的继承关系以及通过原型链查找属性和方法的过程。作用域链示例展示了函数嵌套时,变量在作用域链中的查找过程。可以清晰地看到两者在概念、构建和应用上的不同。

综上所述,原型链和作用域链是 JavaScript 中两个非常重要但截然不同的概念。原型链侧重于对象的继承和属性方法的查找,而作用域链侧重于变量的访问和作用域的界定。理解它们的区别对于编写高效、正确的 JavaScript 代码至关重要。无论是在面向对象编程还是模块化编程中,准确把握这两个概念都能帮助开发者更好地实现业务逻辑,避免潜在的错误和性能问题。在实际开发中,根据具体的需求,合理运用原型链和作用域链的特性,可以创建出更加健壮和可维护的代码。例如,在构建大型应用程序时,通过原型链实现对象的继承体系可以提高代码的复用性,而利用作用域链和闭包实现模块的封装和数据的私有性,可以增强代码的安全性和可维护性。深入理解这两个概念,并不断在实践中运用,将有助于开发者提升自己的 JavaScript 编程能力,更好地应对各种复杂的开发场景。