深入对象系列(二)——原型与原型链
前言
这是深入对象系列的第二篇,第一篇# 深入对象系列(一)——对象的属性和特性中提到 JavaScript 中对象的特性有三点,分别是对象的原型(prototype)、对象的类(class)、对象的扩展标记(extensible flag),并且详细讲解了其扩展性,这篇文章接着讲解对象的原型。
在第一篇中我们讨论了 JavaScript 实际上还是一门面向于对象的语言,之所以显得比较“另类”是因为它的面向对象编程范式与其他基于类的主流编程语言如 Java、C++ 等不同:基于原型,但是因为一些公司政治原因,JavaScript 在设计之初就被要求模仿 Java,因此 Brendan Eich 又提出了 new、this 等关键字使之更加接近Java 的语言特性,但是由于本质上面向对象编程范式的不同使得 JavaScript 并不具备 Java 的继承、多态等特性,因此 JavaScript 的开发社区出现了各种针对模仿基于类面向对象的封装,直到ES6正式提出class关键字,因此原型与类都是JavaScript对象的三大特性之一,此处我想与大家共同探讨 JavaScript 如何基于原型实现面向对象的。
基于类的面向对象编程提倡使用一个关注分类和类之间关系的开发模型,在这类语言中总是先有类,再从类实例化一个对象,类与类之间又可能会形成继承、组合等关系,类又往往与语言的类型系统整合,形成一定编译时的能力。 与此相对,基于原型的编程更为提倡去关注一系列对象实例的行为,而后才去关心如何将这些对象,划分到最近的使用方式相似的原型对象,而不是将他们分成类。
基于原型的面向对象通过“复制”的方式创建新对象,一些语言的实现中还允许复制一个空对象,这实际上就是创建一个全新的对象。 原型系统的“复制操作”有两种实现思路:
- 一个是并不真的去复制一个原型对象,而是使得新对象持有一个原型的引用;
- 另一个是切实地复制对象,从此两个对象再无关联。
JavaScript 选择了第一种。
一、函数对象
想要了解 JavaScript 中的基于原型面向对象的实现首先需要了解普通对象与函数对象,JavaScript 中万物皆对象,但对象也是有区别的。分为普通对象和函数对象(在JavaScript 语言规范中明确指出函数是对象类型的一员),Object、Function 是 JavaScript 自带的函数对象。
举个🌰:
var o1 = {};
var o2 = new Object();
var o3 = new f1();
function f1(){};
var f2 = function(){};
var f3 = new Function('str');
typeof Object;//function
typeof Function;//function
typeof f1;//function
typeof f2;//function
typeof f3;//function
typeof o1;//object
typeof o2;//object
typeof o3;//object
在上面的例子中 o1、o2、o3 为普通对象,f1、f2、f3 为函数对象。怎么区分,其实很简单,凡是通过 new Function()
创建的对象都是函数对象,其他的都是普通对象。f1、f2 归根结底都是通过 new Function()
的方式进行创建的,Function、Object 等对象实际上也都是通过 New Function()
创建的。
二、构造函数
ECMAScript 中专门定义了构造函数用于创建特定类型的对象,对于构造函数的官方定义为:
构造函数是个用于创建对象的函数对象。每个构造函数都有一个 prototype 对象,用以实现原型式继承,作属性共享用。
像 Object 和 Array 这样的原生构造函数,在运行时会自动出现在执行环境中,此外我们也可以创建自定义的构造函数,从而定义自定义对象类型的属性和方法。
举个🌰
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
this.sayName = function() { alert(this.name) }
}
var person1 = new Person('Zaxlct', 28, 'Software Engineer');
var person2 = new Person('Mick', 23, 'Doctor');
上面的例子中 person1 和 person2 都是 Person 的实例。我们注意到为了创建 Person 的新实例我们使用了 new 操作符,以这种方式调用构造函数实际上会经历了以下四个步骤:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(因此this就指向了这个新对象)
- 执行构造函数中的代码(为这个新对象添加属性)
- 返回新对象
这两个实例都有一个 constructor (构造函数)属性,该属性(是一个指针)指向 Person。 即:
console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true
对象的 constructor 属性最初是用来标识对象类型,但是,提到检测对象类型,还是 instanceof 操作符要更可靠一些。我们在这个例子中创建的所有对象既是 Object 的实例,也是 Person 的实例(后面会介绍原因):
console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true
注意构造函数也是函数,为了区分,构造函数的函数名首字母大写(不是必须,但最好),不过,构造函数毕竟是函数,不存在定义构造函数的特殊语法,任何函数只要通过 new 操作符来调用,那它就可以作为构造函数;而任何函数,如果不通过 new 操作符来调用,那么它跟普通函数也不会有区别,示例:
//当作构造函数使用
var person = new Person('Zaxlct', 28, 'Software Engineer');
person.sayName() //'Zaxlct'
//作为普通函数调用
Person('Mick', 23, 'Doctor') //添加到window
window.getName() // 'Mick'
//在另一个对象的调用域中调用
var object = new Object()
Person.call(object, "Kristen", 25, "Nurse")
object.sayName() // "Kristen"
这是为什么呢?
事实上,JavaScript 为这一类对象预留了私有字段机制,并规定了抽象的函数对象与构造器对象的概念:
函数对象的定义是具有 [[call]]
私有字段的对象,构造器对象的定义是具有 [[constructor]]
私有字段的对象。
JavaScript 用对象模拟函数的设计代替了一般编程语言中的函数,它们可以像其他语言中的函数一样被调用、传参。任何宿主只要提供了“具有 [[call]]
私有字段的对象”,就可以被 JavaScript 函数调用语法支持,也就是说任何对象只需要实现 [[call]]
,那么它就是一个函数对象,可以作为函数被调用,而如果它能实现[[constructor]]
,那么它就是一个构造器对象,可以作为构造器被调用。
大部分对象例如浏览器环境提供的宿主对象和 JavaScript 提供的内置对象它们既可以作为函数被调用,也可以作为构造器被调用,但是它们实现 [[call]]
和 [[constructor]]
不总是一致的,例如 Date 对象作为构造器被调用时产生一个新对象,作为函数被调用时产生字符串:
console.log(typeof new Date) // Object
console.log(typeof Date()) // String
需要注意使用 function 关键字创建的函数必定同时是函数与构造器,但是ES6中 =>
语法创建的函数仅仅是函数,它们无法被当作构造器使用,示例:
new (a => 0) // error
对于使用 function 语法或者 Function 构造器创建的对象来说,[[call]]
和 [[constructor]]
的行为总是类似的,它们执行同一段代码,我们看如下示例:
function f() {
return 1;
}
var value = f() // 作为函数调用
var object = new f // 作为构造器调用
因此构造函数对象创建实例,即 [[constructor]]
的执行过程如下:
- 以
Object.prototype
为原型创建一个新对象 - 以新对象为 this,执行函数的
[[call]]
- 如果
[[call]]
的返回值是一个对象,那么,返回这个对象,否则,返回第一步创建的新对象
这样的规则造成了一个有趣的对象,如果我们的构造器返回一个新的对象,那么 new 创建的新对象就变成一个构造函数以外完全无法访问的对象,这一定程度上实现了“私有”。示例:
function cls() {
this.a = 100;
return {
getValue: () => this.a;
}
}
var o = new cls();
o.getValue(); // 100
// a在外面永远无法访问
注:《JavaScript高级程序设计 第三版》中关于此的介绍为:构造函数在不返回值的情况下,默认会返回新对象实例。而通过在构造函数末尾添加一个return语句,可以重写构造函数返回的值。
三、原型对象
JavaScript 中定义的每个函数对象(Function.prototype
除外,它是函数对象,但它很特殊,没有prototype属性)都有一个 prototype 属性,这个属性指向生成对象实例的原型对象,换句话说,函数对象的 prototype 属性就是通过调用构造函数而创建的那个对象实例的原型对象,使用原型对象即可以让所有对象实例共享它所包含的属性和方法,可类比于基于类面向对象中父子类之间的继承。
举个🌰:
function Person(name) {
this.name=name;
}
Person.prototype.eat = function () {
console.log(this.name+"吃东西");
};
Person.prototype.sleep=function () {
console.log(this.name+"睡觉");
}
// 所有通过调用构造函数Person()创建的对象共享原型对象上的属性和方法
var p1=new Person("小明");
p1.eat(); //小明吃东西
p1.sleep(); //小明睡觉
var p2=new Person("小红");
p2.eat(); //小红吃东西
p2.sleep(); //小红睡觉
// 原型对象的 constructor 属性指向构造函数本身
consloe.log(Person.prototype.constructor); // Preson(name) { this.name = name }
注意创建新函数时,其 prototype 属性指向的原型对象默认会自动获得一个 constructor(构造函数)属性,该属性指向包含 prototype 的函数,也就是构造函数,至于其他方法,则都是从 Object 继承而来的。这也解释了我们在前文中说到实例对象的 constructor(构造函数)属性指向构造函数的原因。
四、内部属性[[prototype]]
当调用构造函数创建一个新实例时,该实例的内部将包含一个指针(内部属性),ECMA-262 第 5 版中管这个指针叫[[prototype]]
,用于指向创建它的构造函数的原型对象。这个属性可以通过 Object.getPrototypeOf(obj)
或obj.__proto__
来访问,实际上,在 ES6 之前虽然大部分浏览器都支持通过 __proto__
属性来访问 [[prototype]]
属性,但它并不是规范的一部分,直到 ES6 才被加入到规范中,ES6之 前在其他实现中,这个属性对脚本是完全不可见的,虽然在所有实现中都无法访问到[[prototype]]
,但可以通过 isPrototypeOf()
方法来确定对象之间是否存在这个关系。
示例:
console.log(Person.prototype.isPrototypeOf(proson1)); // true
console.log(Person.prototype.isPrototypeOf(proson2)); // true
这里,我们用原型对象的 isPrototypeOf()
方法测试了 person1 和 person2,因为它们内部都有一个指向 Person.prototype
的指针,因此都返回了 true。
五、原型链
至此,简单回顾一下构造函数、原型与实例之间的关系:
每个构造函数都有一个原型对象,原型对象都包含一个指向构造函数的指针 constructor,而实例都包含一个指向原型对象的内部指针 [[prototype]]
,那么我们让原型对象等于另一个对象的实例,此时的原型对象将包含一个指向另一个原型的指针 [[prototype]]
,相应地,另一个原型中也包含着指向另一个构造函数的指针 constructor,假如另一个原型又是另一个类型的实例,那么上述关系依然成立,如此层层递进,就构成了实例与原型的链条,这就是原型链的基本概念。
通过实现原型链,本质上扩展了对象的原型搜索机制,对于 JavaScript 中的对象而言,当以读取模式访问一个属性时,首先会在实例上搜索该属性,如果没有找到该属性,则会继续搜索实例的原型,但是如果实现了原型链那么这个搜索过程会沿着原型链继续往上。
从上面的讲解我们不难看出原型链由对象的内部属性 [[prototype]]
和原型对象的 constructor 属性连接而成的两条链构成,下面我们对这两条链分别进行分析,为了表述方便下文将 [[prototype]]
属性用 _proto_
表述,由其连接起来的关系链也称为原型链。
由于__proto__
是任何对象都有的属性,因此 __proto__
是用来实现向上查找的一个引用,对象可以通过 __proto__
来寻找它构造函数的原型对象,__proto__
将对象连接起来组成了原型链。注意 Object.prototype
的 __proto__
是 null,也即是原型链的终点。
举个🌰:
function Cat(){}
var cat = new Cat();
console.log(cat.__proto__ === Cat.prototype); // true
console.log(Cat.prototype.__proto__ === Object.prototype) //true
console.log(Object.prototype.__proto__) //null
不过,要明确的真正重要的一点就是,__proto__
属性形成的对象之间的连接存在于实例与构造函数的原型对象之间,而不是存在于实例与构造函数之间。
前面我们用于继承的原型链,它链接的是原型对象。而对象是通过构造函数生成的,也就是说,普通对象、原型对象、函数对象都将有它们的构造函数,这将为我们引出另一条链:
在 JavaScript 中,谁是谁的构造函数,是通过 constructor 来标识的。正常来讲,普通对象(如图中的 cat 和 { name: 'Lin' }
对象)是没有 constructor 属性的,它是从原型上继承而来;而图中粉红色的部分即是函数对象(如 Cat Animal Object 等),它们的原型对象是 Function.prototype,这没毛病。关键是,它们是函数对象,对象就有构造函数,那么函数的构造函数是啥呢?是 Function。那么问题又来了,Function 也是函数,它的构造函数是谁呢?是它自己:Function.constructor === Function。由此,Function 即是构造函数链的终结。
前面我们讲了两条链:
- 原型链。它用来实现原型继承,最上层是 Object.prototype,终结于 null,没有循环
- 构造函数链。它用来表明构造关系,最上层循环终结于 Function
把这两条链结合到一起我们得到下图:
- 首先看构造函数链。所有的普通对象,constructor 都会指向它们的构造函数;而构造函数也是对象,它们最终会一级一级上溯到Function 这个构造函数。Function 的构造函数是它自己,也即此链的终结;
- Function 的 prototype 是 Function.prototype,它是个普通的原型对象;
- 其次看原型链。所有的普通对象,
_proto_
都会指向其构造函数的原型对象[Class].prototype
;而所有原型对象,包括构造函数链的终点Function.prototype
,都会最终上溯到Object.prototype
,终结于 null。
也即是说,构造函数链的终点 Function,其原型又融入到了原型链中:Function.prototype
-> Object.prototype
-> null,最终抵达原型链的终点 null。至此这两条契合到了一起。
至此关于 JavaScript 中的原型与原型链就解释完毕了,下面梳理一下 JavaScript 中与原型相关的其他知识点:
六、补充
1、Object.create()
Object.create()
方法是 ES6 提供的创建一个新对象的另一种方式,使用现有的对象来提供新创建的对象的__proto__
。
- 语法:
Object.create(proto[, propertiesObject])
- 参数:
- proto:新创建对象的原型对象,可以为null,即用来创建空对象。
- propertiesObject:可选。如果没有指定为 undefined,则是要添加到新创建对象的可枚举属性(即其自身定义的属性,而不是其原型链上的枚举属性)对象的属性描述符以及相应的属性名称。这些属性对应
Object.defineProperties()
的第二个参数,注意创建非空对象的属性描述符默认都为 false 的。
注意:如果 propertiesObject 参数是 null 或非原始包装对象,则抛出一个 TypeError 异常。
使用 Object.create()
可以实现模仿基于类面向对象中的类式继承,示例:
// Shape - 父类(superclass)
function Shape() {
this.x = 0;
this.y = 0;
}
// 父类的方法
Shape.prototype.move = function(x, y) {
this.x += x;
this.y += y;
console.info('Shape moved.');
};
// Rectangle - 子类(subclass)
function Rectangle() {
Shape.call(this); // call super constructor.
}
// 子类续承父类
Rectangle.prototype = Object.create(Shape.prototype);
Rectangle.prototype.constructor = Rectangle;
var rect = new Rectangle();
console.log(rect instanceof Rectangle); // true
console.log(rect instanceof Shape); // true
rect.move(1, 1); // Outputs, 'Shape moved.'
如果你希望能继承到多个对象,则可以使用混入的方式:
function MyClass() {
SuperClass.call(this);
OtherSuperClass.call(this);
}
// 继承一个类
MyClass.prototype = Object.create(SuperClass.prototype);
// 混合其它
Object.assign(MyClass.prototype, OtherSuperClass.prototype);
// 重新指定constructor
MyClass.prototype.constructor = MyClass;
MyClass.prototype.myMethod = function() {
// do a thing
};
2、Object.setPrototypeOf()
Object.setPrototypeOf()
方法的作用与直接设置 __proto__
相同,用来设置一个对象的 prototype 对象,返回参数对象本身,它是 ES6 正式推荐的设置原型对象的方法。
- 语法:
Object.setPrototypeOf(object, prototype)
示例:
var proto = {
y: 20,
z: 40
};
var obj = { x: 10 };
Object.setPrototypeOf(obj, proto);
console.log(obj);
3、Object.getPrototypeOf()
Object.getPrototypeOf()
用于读取一个对象的原型对象;
- 语法:
Object.getPrototypeOf(obj);
示例:
Object.getPrototypeOf('foo') === String.prototype // true
Object.getPrototypeOf(true) === Boolean.prototype // true
4、new
在前面讲解到构造器函数的时候其实我们已经在示例中使用了 new 操作符,new 运算虽然在 JavaScript 中主要是针对于构造器对象而不像其他基于类的编程语言一样针对于类,但是 new 仍然是 JavaScript 面向对象的重要一部分,new 运算接受一个构造器和一组调用参数,实际上做了几件事:
- 以构造器的 prototype 属性为原型创建新对象;
- 将 this 和调用参数传给构造器,执行;
- 如果构造器返回的是对象,则返回,否则返回第一步创建的对象。
new 这样的行为,试图让函数对象在语法上跟类变得相似,但是,它客观上提供了两种方式,一是在构造器中添加属性,二是在构造器的 prototype 属性上添加属性,下面示例展示了用构造器模拟类的两种方法:
function c1() {
this.p1 = 1;
this.p2 = function() {
console.log(this.p1)
}
}
var o1 = new c1;
o1.p2();
function c2() {}
c2.prototype.p1 = 1;
c2.prototype.p2 = function() {
console.log(this.p1);
}
var o2 = new c2;
o2.p2();
在没有 Object.create()、Object.setPrototypeOf()
的早期版本中,new 运算是唯一一个可以指定对象 [[prototype]]
属性的方法(__proto__
多数环境不支持),所以当时已经有人试图用它来代替后来的Object.create()
,我们甚至可以用它来实现一个不完整的 polyfill:
Object.create = function(prototype) {
var cls = function(){}
cls.prototype = prototype;
return new cls;
}
这段代码创建了一个空函数作为类,并把传入的原型挂在了它的prototype,最后创建了一个它的实例,根据new的行为,这将产生一个已传入的第一个参数为原型的对象。但是这个函数无法做到与原生的Object.create()一致,一个是不支持第二个参数,另一个是不支持null作为原型。
5、重写原型对象
大家也注意到了,前面例子中每添加一个属性和方法就要敲一遍 Person.prototype
。为减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原型对象,示例:
function Person() {}
Person.prototype = {
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
};
在上面的代码中,我们将 Person.prototype 设置为等于一个以对象字面量形式创建的新对象。最终结果相同,但有一个例外:constructor 属性不再指向 Person 了。前面我们介绍每创建一个函数,就会同时创建它的 prototype 对象,这个对象也会自动获得 constructor 属性。而我们在这里使用的语法,本质上完全重写了默认的 prototype 对象,因此 constructor 属性也就成了新对象的 constructor 属性(指向 Object 构造函数),不再指向 Person 函数。此时,尽管 instanceof 操作符还能返回正确的值,但用过 constructor 属性已经无法确定对象的类型了,示例:
var friend = new Person();
alert(friend instanceof Object) // true
alert(friend instanceof Person) // true
alert(friend.constructor === Person) // false
alert(friend.constructor === Object) // true
为了解决这个问题我们可以像下面这样特意将它设置回适当的值。
function Person(){}
Person.prototype = {
constructor: Person,
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
}
但是以这种方法重设 constructor 属性会导致 [[Enumerable]]
特性被设置为 true。但是默认情况下原生的 constructor 属性是不可枚举的,因此最好使用 Object.defineProperty()
。示例:
function Person() {}
Person.prototype = {
name: "xiaozhang",
age: 23,
job: "soft engineer",
sayName: function() {
alert(this.name);
}
};
// 重设构造函数,只适用于ECMAScript5兼容的浏览器
Object.defineProperty(Person.prototype, "constructor", {
enumerable: false,
value: Person
})
参考资料:
《重学前端》专栏
《JavaScript高级程序设计》
转载自:https://juejin.cn/post/7159214711517659166