共计 6262 个字符,预计需要花费 16 分钟才能阅读完成。
持续补档,发现这块内容其实蛮多的。前面预计还会有两篇(怎么还有两篇啊喂!),别离是 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}
}
在这个例子中,Dog
和 Cat
子类继承了 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
咱们给构造函数 Dog
的prototype
增加了 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 中,往往都是去找一些可能管制对象键名的操作,比方 merge
、clone
等,这其中 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