原型
[[Prototype]]
JavaScript中的对象有一个特殊的 [[Prototype]]
内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]]
属性都会被赋予一个非空的值。
var myObj = {
a: 2
};
myObj.a; // 2
如果 a
不在 myObj
中,就需要使用对象的 [[Prototype]]
链了。对于默认的 [[Get]]
操作来说,如果无法在对象本身找到需要的属性,就会继续访问对象的 [[Prototype]]
链:
var anotherObj = {
a: 2
};
var myObj = Object.create(anotherObj);
myObj.a; // 2
如果 anotherObject
中也找不到 a
并且 [[Prototype]]
链不为空的话,就会继续查找下去。这个过程会持续到找到匹配的属性名或者查找完整条 [[Prototype]]
链。如果是后者的话,[[Get]]
操作的返回值是 undefined
。
使用 for..in
遍历对象时原理和查找 [[Prototype]]
链类似,任何可以通过原型链访问到(并且是 enumerable
)的属性都会被枚举。使用 in
操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举 ):
var anotherObj = {
a: 2
};
var myObj = Object.create(anotherObj);
for (var k in myObj) {
console.log(i);
}
// "a"
('a' in myObj); // true
当你通过各种语法进行属性查找时都会查找 [[Prototype]]
链,直到找到属性或者查找完整条原型链。
Object.prototype
所有普通 的 [[Prototype]]
链最终都会指向内置的 Object.prototype
。由于所有的“普通”(内置,不是特定主机的扩展)对象都“源于”(或者说把 [[Prototype]]
链的顶端设置为)这个 Object.prototype
对象,所以它包含 JavaScript 中许多通用的功能。
属性设置和屏蔽
myObj.foo = 'bar';
如果属性名 foo
既出现在 myObject
中也出现在 myObject
的 [[Prototype]]
链上层,那么就会发生屏蔽。myObject
中包含的 foo
属性会屏蔽原型链上层的所有 foo
属性,因为 myObject.foo
总是会选择原型链中最底层的 foo
属性。
如果 foo
不直接存在于 myObject
中而是存在于原型链上层时 myObject.foo ="bar"
会出现的三种情况:
- 如果在
[[Prototype]]
链上层存在名为foo
的普通数据访问属性并且没有被标记为只读(writable:false
),那就会直接在myObject
中添加一个名为foo
的新属性,它是屏蔽属性 。 - 如果在
[[Prototype]]
链上层存在foo
,但是它被标记为只读(writable:false
),那么无法修改已有属性或者在myObject
上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。 - 如果在
[[Prototype]]
链上层存在foo
并且它是一个setter
,那就一定会调用这个setter
。foo
不会被添加到(或者说屏蔽于)myObject
,也不会重新定义foo
这个setter
。
“类”
JavaScript 和面向类的语言不同,它并没有类来作为对象的抽象模式或者说蓝图。JavaScript 中只有 对象。
在 JavaScript 中,类无法描述对象的行为,(因为根本就不存在类!)对象直接定义自己的行为。
“类” 函数
多年以来,JavaScript 中有一种奇怪的行为一直在被无耻地滥用,那就是模仿 类。“类似类”的行为利用了函数的一种特殊特性:所有的函数默认都会拥有一个名为 prototype
的公有并且不可枚举的属性,它会指向另一个对象:
function Foo () {
// ...
}
Foo.prototype; // { constructor: Foo }
这个对象通常被称为 Foo 的原型 ,因为我们通过名为 Foo.prototype
的属性引用来访问它。
这个对象到底是什么?这个对象是在调用 new Foo()
时创建的,最后会被(有点武断地)关联到这个“Foo.prototype
” 对象上。
function Foo() {
// ...
}
var foo = new Foo();
Object.getPrototypeOf(foo) === Foo.prototype; // true
调用 new Foo()
时会创建 foo
,其中的一步就是给 foo
一个内部的 [[Prototype]]
链接,关联到 Foo.prototype
指向的那个对象。
在 JavaScript 中,没有类似的复制机制。你不能创建一个类的多个实例,只能创建多个对象,它们 [[Prototype]]
关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
new Foo()
会生成一个新对象(foo
),这个新对象的内部链接 [[Prototype]]
关联的是 Foo.prototype
对象。
最后得到了两个对象,它们之间互相关联,就是这样。并没有初始化一个类,实际上并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
:::tip
实际上,绝大多数 JavaScript 开发者不知道的秘密是,new Foo()
这个函数调用实际上并没有直接 创建关联,这个关联只是一个意外的副作用。new Foo()
只是间接完成了我们的目标:一个关联到其他对象的新对象。
:::
在 JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们关联起来。从视觉角度来说,[[Prototype]]
机制如下图所示,箭头从右到左,从下到上:
这个机制通常被称为原型继承,它常常被视为动态语言版本的 类继承。
:::tip
在“继承”前面加上“原型”对于事实的曲解就好像一只手拿橘子一只手拿苹果然后把苹果叫作“红橘子”一样。无论添加什么标签都无法改变事实 :一种水果是苹果,另一种是橘子。更好的方法是直接把苹果叫作苹果——使用更加准确并且直接的术语。
:::
继承 意味着复制操作,JavaScript (默认)并不会复制对象属性。相反,JavaScript 会在两个对象之间创建一个关联,这样一个对象就可以通过委托 访问另一个对象的属性和函数。委托 这个术语可以更加准确地描述 JavaScript 中对象的关联机制。
“构造函数”
function Foo() {
// ...
}
var foo = new Foo();
到底是什么让我们认为 Foo
是一个“类”呢?其中一个原因是我们看到了关键字 new
,在面向类的语言中构造类实例时也会用到它。另一个原因是,看起来我们执行了类的构造函数方法,Foo()
的调用方式很像初始化类时类构造函数的调用方式。
除了令人迷惑的“构造函数”语义外,Foo.prototype
还有另一个绝招。
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
Foo.prototype
默认有一个公有并且不可枚举的属性 constructor
,这个属性引用的是对象关联的函数(本例中是 Foo
)。此外,我们可以看到通过“构造函数”调用 new Foo()
创建的对象也有一个 constructor
属性,指向“创建这个对象的函数”。
- 构造函数还是调用
实际上,Foo
和程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当你在普通的函数调用前面加上 new
关键字之后,就会把这个函数调用变成一个“构造函数调用”。实际上,new
会劫持所有普通函数并用构造对象的形式来调用它。
function NothingSpecial () {
console.log(`Don't mind me!`);
}
var a = new NothingSpecial(); // 输出 "Don't mind me!"
NothingSpecial
只是一个普通的函数,但是使用 new
调用时,它就会构造 一个对象并赋值给 a
,这看起来像是 new
的一个副作用(无论如何都会构造一个对象)。这个调用是一个构造函数调用,但是 NothingSpecial
本身并不是一个构造函数。
换句话说,在 JavaScript 中对于“构造函数”最准确的解释是,所有带 new
的函数调用。函数不是构造函数,但是当且仅当使用 new
时,函数调用会变成“构造函数调用”。
(原型)继承
原型继承机制,a
可以“继承” Foo.prototype
并访问 Foo.prototype
的 myName()
函数。但是之前我们只把继承看作是类和类之间的关系,并没有把它看作是类和实例之间的关系:
上图,它不仅展示出对象(实例)a1
到 Foo.prototype
的委托关系,还展示出 Bar.prototype
到 Foo.prototype
的委托关系,而后者和类继承很相似 ,只有箭头的方向不同。图中由下到上的箭头表明这是委托关联,不是复制操作。
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
};
function Bar(name, label) {
Foo.call(this, name);
this.label = label;
}
// 创建一个新的 Bar.prototype 对象 关联到 Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.myLabel = function() {
return this.label;
};
var a = new Bar("a1", "obj a");
a.myName(); // "a1"
a.myLabel(); // "obj a"
声明 function Bar() { .. }
时,和其他函数一样,Bar
会有一个 .prototype
关联到默认的对象,但是这个 对象并不是我们想要的 Foo.prototype
。因此我们创建了一个新 对象并把它关联到我们希望的对象上,直接把原始的关联对象抛弃掉。
::: warning
两种方式是常见的错误做法,实际上它们都存在一些问题:
// 实现机制不一样
// Bar.prototype = Foo.prototype 并不会创建一个关联到Bar.prototype 的新对象
// 它只是让Bar.prototype 直接引用Foo.prototype 对象
// 因此当执行类似Bar.prototype.myLabel = ... 的赋值语句时会直接修改Foo.prototype 对象本身
Bar.prototype = Foo.prototype;
// 基本满足要求,但会有一些副作用
Bar.prototype = new Foo();
:::
检查 “类” 关系
a instanceof Foo; // true
instanceof
回答的问题是:在 a
的整条 [[Prototype]]
链中是否有指向 Foo.prototype
的对象?
这个方法只能处理对象(a
)和函数(带 .prototype
引用的 Foo
)之间的关系。如果你想判断两个对象 (比如 a
和 b
)之间是否通过 [[Prototype]]
链关联,只用 instanceof
无法实现。
Foo.prototype.isPrototypeOf(a); // true
isPrototypeOf(..)
回答的问题是:在 a
的整条 [[Prototype]]
链中是否出现过 Foo.prototype
?
同样的问题,同样的答案,但是在第二种方法中并不需要间接引用函数(Foo
),它的 .prototype
属性会被自动访问。
也可以直接获取一个对象的 [[Prototype]]
链。在 ES5 中,标准的方法是:
Object.getPrototypeOf(a); // Foo.prototype
绝大多数(不是所有!)浏览器也支持一种非标准的方法来访问内部 [[Prototype]]
属性:
a.__proto__ === Foo.prototype; // true
和 .constructor
一样,.__proto__
实际上并不存在于你正在使用的对象中(本例中是 a
)。实际上,它和其他的常用函数(.toString()
、.isPrototypeOf()
,等等)一样,存在于内置的 Object.prototype
中。
.__proto__
看起来很像一个属性,但是实际上它更像一个 getter/setter。
对象关联
[[Prototype]]
机制就是存在于对象中的一个内部链接,它会引用其他对象。
通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]]
关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]]
,以此类推。这一系列对象的链接被称为“原型链”。
创建关联
var foo = {
something: function () {
console.log('something');
}
};
var bar = Object.create(foo);
bar.something(); // "something"
Object.create()
会创建一个新对象(bar
)并把它关联到我们指定的对象(foo
),这样我们就可以充分发挥 [[Prototype]]
机制的威力(委托)并且避免不必要的麻烦(比如使用 new
的构造函数调用会生成 .prototype
和 .constructor
引用)。
:::tipObject.create(null)
会创建一个拥有空(或者说 null
)[[Prototype]]
链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof
操作符(之前解释过)无法进行判断,因此总是会返回 false
。这些特殊的空 [[Prototype]]
对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。
:::
我们并不需要 类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而 Object.create()
不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。
行为委托
[[Prototype]]
机制就是指对象中的一个内部链接引用另一个对象。
如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在 [[Prototype]]
关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的 [[Prototype]]
,以此类推。这一系列对象的链接被称为“原型链”。
JavaScript 中这个机制的本质就是对象之间的关联关系 。
面向委托的设计
[[Prototype]]
代表的是一种不同于类的设计模式。试着把思路从类和继承的设计模式转换到委托行为的设计模式。
类理论
假设我们需要在软件中建模一些类似的任务(“XYZ”、“ABC”等)。
如果使用类,那设计方法可能是这样的:定义一个通用父(基)类,可以将其命名为 Task ,在 Task 类中定义所有任务都有的行为。接着定义子类 XYZ 和 ABC ,它们都继承自 Task 并且会添加一些特殊的行为来处理对应的任务。
类设计模式鼓励你在继承时使用方法重写(和多态),比如说在 XYZ 任务中重写 Task 中定义的一些通用方法,甚至在添加新行为时通过 super 调用这个方法的原始版本。你会发现许多行为可以先“抽象”到父类然后再用子类进行特殊化(重写)。
// 伪代码
class Task {
id;
// 构造函数
Task (ID) {
id = ID;
}
outputTask () {
output(id);
}
}
class XYZ inherits Task {
label;
// 构造函数
XYZ (ID, LABEL) {
super(ID);
label = LABEL;
}
outputTask () {
super.outputTask();
output(label);
}
}
class ABC inherits Task {
// ...
}
现在你可以实例化子类 XYZ 的一些副本然后使用这些实例来执行任务 “XYZ”。这些实例会复制 Task 定义的通用行为以及 XYZ 定义的特殊行为。同理,ABC 类的实例也会复制 Task 的行为和 ABC 的行为。在构造完成后,你通常只需要操作这些实例(而不是类),因为每个实例都有你需要完成任务的所有行为。
委托理论
使用委托行为 而不是类来思考同样的问题。
首先你会定义一个名为 Task 的对象(和许多 JavaScript 开发者告诉你的不同,它既不是类也不是函数),它会包含所有任务都可以使用(写作使用,读作委托)的具体行为。接着,对于每个任务(“XYZ”、“ABC”)你都会定义一个对象来存储对应的数据和行为。你会把特定的任务对象都关联到 Task 功能对象上,让它们在需要的时候可以进行委托。
基本上你可以想象成,执行任务“XYZ”需要两个兄弟对象(XYZ 和 Task)协作完成。但是我们并不需要把这些行为放在一起,通过类的复制,我们可以把它们分别放在各自独立的对象中,需要时可以允许 XYZ 对象委托给 Task 。
Task = {
setID: function(ID) {
this.id = ID;
},
outputID: function() {
console.log(this.id);
}
}
// 让 XYZ 委托 Task
XYZ = Object.create(Task);
XYZ.prepareTask = function(ID, LABEL) {
this.setID(ID);
this.label = LABEL;
};
XYZ.outputTaskDetails = function() {
this.outputID();
console.log(this.label);
};
ABC = Object.create(Task);
// ...
Task 和 XYZ 并不是类(或者函数),它们是对象。XYZ 通过 Object.create()
创建,它的 [[Prototype]]
委托了 Task 对象。
相比于面向类(或者说面向对象),可以把这种编码风格称为“对象关联”(OLOO,objects linked to other objects)。我们真正关心的只是 XYZ 对象(和 ABC 对象)委托了 Task 对象。
在 JavaScript 中,[[Prototype]]
机制会把对象关联到其他对象。
委托行为 意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
比较
面向对象风格:
function Foo (who) {
this.me = who;
}
Foo.prototype.identify = function () {
return "I am " + this.me;
};
function Bar (who) {
Foo.call(this, who);
}
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.speak = function () {
alert("Hello, " + this.identify() + ".");
};
var b1 = new Bar("b1");
var b2 = new Bar("b2");
b1.speak(); // "Hello, I am b1."
b2.speak(); // "Hello, I am b2."
对象关联风格:
Foo = {
init: function(who) {
this.me = who;
},
identify: function() {
return "I am " + this.me;
}
};
Bar = Object.create(Foo);
Bar.speak = function () {
alert("Hello, " + this.identify() + ".");
};
var b1 = Object.create(Bar);
b1.init("b1");
var b2 = Object.create(Bar);
b2.init("b2");
b1.speak(); // "Hello, I am b1."
b2.speak(); // "Hello, I am b2."
通过比较可以看出,对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系 。
类与对象
// 父类
function Widget (width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
Widget.prototype.render = function ($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
};
// 子类
function Button (width, height, label) {
Widget.call(this, width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
// 子类继承父类
Button.prototype = Object.create(Widget.prototype);
// 重写
Button.prototype.render = function ($where) {
// super 调用
Widget.prototype.render.call(this, $where);
this.$elem.click(this.onClick.bind(this));
};
Button.prototype.onClick = function (evt) {
console.log("Button '" + this.label + "' clicked!");
};
$(document).ready(function () {
var $body = $(document.body);
var btn1 = new Button(125, 30, "Hello");
var btn2 = new Button(150, 40, "World");
btn1.render($body);
btn2.render($body);
});
ES6 的 class 语法糖
class Widget {
constructor(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
}
}
class Button extends Widget {
constructor(width, height, label) {
super(width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
render($where) {
super.render($where);
this.$elem.click(this.onClick.bind(this));
}
onClick(evt) {
console.log("Button '" + this.label + "' clicked!");
}
}
$(document).ready(function () {
var $body = $(document.body);
var btn1 = new Button(125, 30, "Hello");
var btn2 = new Button(150, 40, "World");
btn1.render($body);
btn2.render($body);
});