JavaScript-原型链详解

2024-02-23 javascript

持续补档,发现这块内容其实蛮多的。前面预计还会有两篇(怎么还有两篇啊喂!),别离是 JavaScript执行原理·补 和 JavaScript局部个性,这周不晓得能不能搞定。

先看 JS 原型链吧。

JS 继承机制设计

1994年,网景公司(Netscape)公布了 Navigator v0.9,轰动一时。但过后的网页不具备交互性能,数据的交互全副依赖服务器端,这节约了工夫与服务器资源。

网景公司须要一种网页脚本语言实现用户与浏览器的互动,工程师 Brendan Eich 负责该语言的开发。他认为这种语言不用简单,只需进行一些简略操作即可,比方填写表单。

可能是受过后面向对象编程(object-oriented programming)的影响,Brendan 设计的 JS 外面所有的数据类型都是对象(object)。他须要为 JS 设计一种机制把这些对象连接起来,即“继承”机制。

继承容许子类继承父类的属性和办法,并且能够在子类中增加新的属性和办法,实现代码的重用和扩展性。

出于设计的初衷,即“开发一种简略的网页脚本语言”,Brendan 没有抉择给 JS 引入类(class)的概念,而是发明了基于原型链的继承机制。

在 Java 等面向对象的语言中,个别是通过调用 class 的构造函数(construct)创立实例,如:

class Dog {
    public String name;
    
    public Dog(String name) {
        this.name = name;
    }
}

public class Main {
    public static void main(String[] args) {
        Dog dog = new Dog("Rover");
        System.out.println(dog.name); // Rover
    }
}

Brendam 为 JS 做了简化设计,间接对构造函数应用new创立实例:

function Dog(name) {
    this.name = name;
}

var dog = new Dog("Rover");
console.log(dog.name) // Rover

这种设计防止了在 JS 中引入 class,但这引出一个问题:JS 的实例该如何共享属性和办法?基于构造函数创立的实例都是独立的正本。

先看看 Java 是如何基于 class 实现属性和办法共享的:

class Animal {
    public void eat() {
        System.out.println("Animal is eating");
    }
}

class Dog extends Animal {
    public void bark() {
        System.out.println("Dog is barking");
    }
}

class Cat extends Animal {
    public void meow() {
        System.out.println("Cat is meowing");
    }
}

public class Main {
    public static void main(String[] args) {
        Dog myDog = new Dog();
        Cat myCat = new Cat();
        
        myDog.eat(); // Animal is eating
        myDog.bark(); // Dog is barking
        myCat.eat(); // Animal is eating
        myCat.meow(); // Cat is meowing
    }
}

在这个例子中,DogCat子类继承了Animal父类的eat()办法,并别离增加了bark()meow()办法,这种基于类实现的继承很顺畅也便于了解。

JS 中没有 class,但这种需要切实存在。Brendan 通过为构造函数增加prototype属性解决这个问题。

function Dog(name) {
  this.name = name;
}

Dog.prototype.bark = function() {
    console.log(this.name)
}

var dogA = new Dog("Rover");
var dogB = new Dog("Fido");
dogA.bark(); // Rover
dogB.bark(); // Fido

咱们给构造函数Dogprototype增加了bark()办法,这样做的话,基于Dog创立的实例都能够应用bark()办法,数据共享同理。

那这是如何实现的呢,或者说,prototype是什么,为什么能够在多个实例之间共享属性及办法?这就是咱们接下来要说的内容。

在这里先丢一张图,接下来的内容能够搭配这张图一起看,置信这会对初学者了解 JS 原型链很有帮忙:

prototype 原型

在 JS 中,每个函数都有一个prototype属性,每个对象都有一个__proto__属性。

函数的prototype 属性实质上是一个对象,它蕴含了通过这个函数作为构造函数(即应用 new 关键字)创立的所有实例所共享的属性和办法。

__proto__是所有对象都有的一个属性,它指向了创立这个对象的构造函数的prototype。也就是说,如果咱们有var dog = new Dog(),那么dog.__proto__就是Dog.prototype

“援用”是指一个变量或者对象指向内存中的一个地位,这个地位存储了某个值。这里也能够说dog.__proto__Dog.prototype的一个援用。

那么 JS 是如何通过prototype实现继承的呢?

当咱们试图拜访一个对象的属性时,如果该对象自身没有这个属性,JS 就会去它的__proto__(也就是它的构造函数的prototype)中寻找。因为prototype自身也是一个对象,如果 JS 在prototype中也没有找到被拜访的属性,那么它就会去prototype__proto__中寻找,以此类推,直到找到这个属性或者达到原型链的末端null

通过这种形式,JS 就实现了它所须要的继承机制。这种通过对象的__proto__属性逐渐向上查问的机制,就是咱们所说的 JS 原型链。

再拿这个例子做一次解说:

function Dog(name) {
  this.name = name;
}

Dog.prototype = {
  "species": "dog",
} 
  
var dog = new Dog('Rover');

console.log(dog.name); // Rover
console.log(dog.species); // dog
console.log(dog.age); // undefined
console.log(dog.__proto__.__proto__.__proto__); // null

console.log(dog.__proto__ === Dog.prototype) // true
console.log(Dog.prototype.__proto__ === Object.prototype) // true
console.log(Object.prototype.__proto__) // null

调用dog.name时,JS 查找到dog实例有name属性,就返回Rover

调用dog.species时,JS 发现以后实例中没有该属性,就去dog.__proto__中查问,找到species属性并返回dog

调用dog.age时,JS 发现以后实例和以后实例的__proto__属性中都没有该属性,就再向下来寻找,也就到Dog.prototype.__proto__(即Object.prototype)中去寻找,未然没有找到,就持续向上找,但Object.prototype.__proto__是整条原型链的终点——null,JS 查找不到age属性,就会返回一个undefined

如果咱们再向上查问一层,即尝试拜访dog.__proto__.__proto__.__proto__.__proto__,会间接抛出报错,JS 定义null没有原型,yejiu1无法访问到它的prototype属性。

constructor 构造函数

在 JS 中,每个函数对象还有一个非凡的属性叫做constructor。这个属性指向创立该对象的构造函数。当咱们创立一个函数时,JS 会主动为该函数创立一个prototype对象,并且这个prototype对象蕴含一个指向该函数自身的constructor属性。

当咱们应用构造函数创立实例对象时,这些实例对象会继承构造函数的prototype对象,从而造成原型链。因而,通过constructor属性,实例对象就能够拜访到创立它们的构造函数。

间接把constructor当作反向prototype了解即可。以方才的代码举例:

console.log(Dog.prototype.constructor === Dog); // true

前端开发中的原型链

class 语法糖

当初的 Web 前端开发中简直不间接应用原型链了,JS 曾经在 ES6(ECMAScript 2015)中引入了类(Class)的概念,因为这能使得面向对象编程更加直观。

个人感觉这示意着 JS 与 Brendan Eich 当年所构想的“简略的客户端脚本语言”越走越偏了,但这也阐明 JS 始终在蓬勃发展,沉闷的社区生态让 JS 把它的触手伸向了互联网的角角落落,越来越多的开发者将 JS 变得愈来愈欠缺。

但请留神,JS 的 class 在底层上依然是基于原型链的,只是一种语法糖。

class Animal {
  constructor(name) {
    this.name = name;
  }

  speak() {
    console.log(this.name + ' makes a noise.');
  }
}

let animal = new Animal('Simba');
animal.speak(); // Outputs: "Simba makes a noise."

以上代码是一个应用了 class 的 JS 示范,其基于原型链的版本如下:

function Animal(name) {
  this.name = name;
}

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

let animal = new Animal('Simba');
animal.speak(); // Outputs: "Simba makes a noise."

这两个例子在性能上是雷同的,然而它们的写法有所不同。class 语法提供了一种更清晰的形式来创建对象和解决继承。在 class 语法中,你能够间接在类定义外部申明办法,而在原型链中,你须要在原型对象上增加办法。

性能影响

咱们后面说过,JS 在原型链中查找以后对象不存在的属性时,须要一级级的向上查找。如果咱们要查找的属性在较深层的对象中,就会拖慢咱们程序的运行速度;如果指标属性不存在中,JS 就会遍历整个原型链,这无疑会对程序的性能造成负面影响。

此外,在遍历对象的属性时,原型链中的每个可枚举属性都将被枚举。如果咱们想要查看一个对象是否具备某个属性,并且这个属性是间接定义在该对象上的,而不是定义在它的原型链上的,那么咱们须要应用hasOwnProperty办法或Object.hasOwn办法。

hasOwnProperty能够用来查看一个对象是否具备特定的本身属性(也就是该属性不是从原型链上继承来的)。这个办法是定义在Object.prototype上的,所以除非一个对象的原型链被设置为null(或者在原型链深层被笼罩),否则所有的对象都会继承这个办法。

该办法的应用办法如下:

let obj = {
    prop: 'value'
};
console.log(obj.hasOwnProperty('prop')); // 输入:true

let objWithNoProto = Object.create(null);
console.log(objWithNoProto.hasOwnProperty); // 输入:undefined

此外,除非是为了与新的 JS 个性兼容,否则永远不应扩大原生原型。如果要应用 JS 原型链操作,也要对用户的输出进行严格校验,因为 JS 原型链有着独特的平安问题。

JS 原型链净化

JS 原型链净化举荐 phithon 大佬的 深刻了解 JavaScript Prototype 净化攻打,以下merge示范代码就来自这篇文章。

出于设计上的因素,JS 原型链操作容易产生独特的平安问题——JS 原型链净化。

原理很简略,就是 JS 基于原型链实现的继承机制。如果咱们能管制某个对象的原型,那咱们就能够管制所有基于该原型创立的对象。以下是一个简略的示范案例:

// 创立一个空对象 userA
let userA = {};

// 给 userA 增加一个属性 isAdmin
userA.isAdmin = false;
console.log(userA.isAdmin); // false

// 当初咱们想让所有用户都有这个属性,咱们能够应用原型
userA.__proto__.isAdmin = true;
console.log(userA.isAdmin); // false

// 当初咱们创立一个新用户 userB
let userB = {};
// userB 会继承 userA 的 isAdmin 属性
console.log(userB.isAdmin); // true

在 CTF 中,往往都是去找一些可能管制对象键名的操作,比方mergeclone等,这其中merge又是最常见的可操纵键名操作。最一般的merge函数如下:

function merge(target, source) {
  for (let key in source) {
      if (key in source && key in target) {
          merge(target[key], source[key])
      } else {
          target[key] = source[key]
      }
  }
}

此时,咱们运行以下代码,以 JSON 格局创立o2,在与o1合并的过程中,通过赋值操作target[key] = source[key],实现了一个根本的原型链净化,被净化的对象是Object.prototype

let o1 = {};
let o2 = JSON.parse('{"a": 1, "__proto__": {"b": 2}}');

merge(o1, o2); // 1 2
console.log(o1.a, o1.b);

o3 = {};
console.log(o3.b); // 2
console.log(Object.prototype); // [Object: null prototype] { b: 2 }

还有一个值得思考的问题,如果咱们创立o2应用的语句是:let o2 = {a: 1, "__proto__": {b: 2}},则不会实现原型链净化,能够思考一下起因。

后话

读到这里,应该就能大抵了解什么是 JS 原型链了,也对开发和平安中的 JS 原型链有了一个根本的意识。

但还有一个疑难没有解决:JS 原型链的实质是什么,它是一种机制,还是一种数据结构?

原型链(Prototype Chain)从实质上来讲是一种机制,而不是某种非凡的数据结构。只是从习惯上来讲,咱们会把从实例对象到 Object 这两头的 __proto__ 调用称为“原型链”,下面说过的dog.__proto__.__proto__.__proto__就是例子——因为这的确很形象。

参阅文章

  • Javascript继承机制的设计思维,by 阮一峰的网络日志
  • 該來了解 JavaScript 的原型鍊了,by Huli’s Blog
  • 继承与原型链,by MDN Web Docs
  • 深刻了解 JavaScript Prototype 净化攻打,by phithon

相关文章