我们现在深入发掘一下javaScript中对象的[[Prototype]]机制到底是什么。首先回顾一下一个结论:[[Prototype]]机制就是指对象中的一个内部链接引用另一个对象。
如果在第一个对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找,同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被成为“原型链”。
换句话说,javaScript中这个机制的本质就是对象之间的关联关系。
这个观点对于理解内容来说是非常基础并且非常重要的。
面向委托的设计
为了更好的学习如何更直观的使用[[Prototype]],我们必须认识到它代表的是一种不同于类的设计模式。
面向类的设计中有些原则依然有效,因此不要把所有知识都跑掉。举例来说,封装是非常有用的,它同样可以应用在委托中。(虽然不太常见)
我们需要试着把思路从类和继承的设计模式转换到委托行为的设计模式。如果你在学习或者工作的过程中几乎一直在使用类,那转换思路可能不太自然并且不太舒服。你可能需要多重复几次才能熟悉这种思维模式。首先我会先进行一些理论训练,然后再传授一些能够应用在代码中的具体实例。
类理论
假设我们需要在软件中建模一些类似的任务(”XYZ”、“ABC”),如果使用类,那设计方法可能是这样的:定义一个通过的父(基)类,可以将其命名为Task,在Task类中定义所有任务都有的行为。接着定义子类XYZ和ABC,他们都继承自Task并且会添加一些特殊的行为来处理对应的任务。
非常重要的是,类设计模式鼓励你在继承时使用方法重写(和多态),比如说在XYZ任务中重写Task中定义的一些通用方法,甚至在添加行为时通过super调用这个方法的原始版本,你会发现许多行为可以先“抽象”到父类然后再用子类进行特殊化(重写),下面是对应的代码:
```
class Task {
id;
//构造函数Task()
outputTask(){ output(id);}
}
class XYZ inherits Task {
label;
//构造函数XYZ()
XYX(ID, Label) {super(ID); label = Label;}
outputTask() {super(); 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
//ABC... = ...
在这段代码中,Task和XYZ并不是类(或者函数),他们是对象。XYZ通过Object.create(…)创建,它的[[Prototype]]委托了Task对象。
相比于面向类(或者说面向对象),我会把这种编码风格成为“对象关联”(OLOO,object linked to other objects)。我们真正关心的是XYZ对象(和ABC对象),委托了Task对象。
在javaScript中,[[Prototype]]机制会把对象关联到其他对象。无论你多么努力地说服自己,javaScript中就是没有类似“类”的抽象机制。这有点像逆流而上:你确实可以这么做,但是如果你选择对抗事实,那要达到目的就显然会更加困难。
对象关联风格的代码还有一些不同之处:
1.在上面的代码中,id和label数据成员都是直接存储在XYZ上(而不是Task)。通常来说,在[[Prototype]]委托中最好把状态保存在委托者(XYZ、ABC),而不是委托目标(Task)上。
2.在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有outputTask方法,这样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在[[Prototype]]链中不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法来消除引用歧义。
这个设计模式要球尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法名,尤其是要写清楚响应对象行为的类型。这样做实际上可以创建出更容易理解和维护的代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。
3.this.setID(ID),XYZ中的方法首先会寻找xyz自身是否有setID(…),但是xyz中并没有这个方法名,因此会通过[[Prototype]],委托关联到Task继续寻找,这时就可以找到setID()方法,由于调用位置触发了this的隐式绑定规则,这时就可以找到setID(…),方法。 此外,由于调用位置触发了this的隐式绑定规则,因此虽然setID(…)方法在Task中,运行时this仍然会绑定到xyz,这正是我们想要的,在之后的代码中我们还会看到this.outputID(),原理相同。
换句话说,我们和xyz进行交互时可以使用Task中的通用方法,因为xyz委托了Task。
这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中对象不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。
在api接口的设计中,委托最好在内部实现,不要直接暴露出去,在之前的例子中我们并没有让开发者通过api直接调用XYZ.setID(),(当然,可以这么做!)相反,我们把委托隐藏在了api的内部,xyz.prepareTask(..)会委托Task.setID()
1.互相委托(禁止)
你无法在两个或两个以上互相(双向)委托的对象之间创建循环委托。如果你把B关联到A然后试着把A关联到B,就会出错。很遗憾(并不是非常出乎意料,但是有点烦人)这种方法是被禁止的。如果你引用了一个两边都不存在的属性或者方法,那就会在[[Prototype]]链上产生一个无限递归的循环。但是所有的以你用都被严格限制的话,B是可以委托A的,反之亦然。因此,互相委托理论上是可以正常工作的,在某些情况下这是非常有用的。
之所以要禁止互相委托,是因为引擎的开发者们发现在设置时检查(并禁止!)一次无限循环引用要更加高效,否则每次从对象中查找属性时都需要进行检查。
2.调试
我们来简单介绍一个容易让开发者感到迷惑的细节。通常来说,javaScript规范并不会控制浏览器中开发者工具对于对于特定值或者结构的表示方式,浏览器和引擎可以自己选择合适的方式来进行解析,因此浏览器和工具的解析结果并不一定相同,比如,下面这段代码的结果只能在chrome的开发者工具中才能看到。
这类传统的“类构造函数”javaScript代码在chrome开发者工具的控制台中结果如下所示:
function Foo(){}
var a1 = new Foo();
a1;//Foo{}
我们看代码的最后一行:表达式a1的输出是Foo{},如果你在firefox中运行同样的代码会得到Object()为什么会这样呢?这些输出是什么意思呢?
Chrome实际上是想说”{}”是一个空对象,由名为Foo的函数构造,Firfox想说的是“{}”是一个空对象,有Object构造,之所以有这种细微的差别,是因为chrome会动态跟踪并把实际执行构造函数名当作一个内置属性,但是其他浏览器并不会跟踪这些额外信息。
看起来可以用javaScript的机制来解释chrome的跟踪原理
function Foo() {}
var a1 = new Foo();
a1.constructor;//Foo(){}
a1.constructor.name;//"Foo"
chrome是不是直接输出了对象的.constructor.name呢?令人迷惑的是,答案是“既是又不是”。
思考下面的代码:
function Foo () {
}
var a1 = new Foo();
Foo.prototype.constructor = function Gotcha () {
}
console.log(a1.constructor);//Gotcha()
console.log(a1.constructor.name) //"Gotcha"
a1;//Foo{}
即使我们把a1.constructor.name修改为另一个合理的值(Gotcha),chrome控制台仍然会输出Foo
看起来之前那个问题(是否使用.constructor.name?)的答案是“不是”,Chrome在内部肯定是通过另一种方式进行跟踪。
别着急!我们先看看下面这段代码:
var Foo = {};
var a1 = Object.create(Foo);
console.log(a1);
Object.defineProperty(Foo, "constructor", {
enumerable: false,
value: function Gotcha () {
}
})
a1;//Gotcha{}
本例中chrome的控制台确实使用了.constructor.name 实际上,这个行为被认定是chrome的一个bug,现在看到的是Object{}
如果你并不是使用“构造函数”来生成对象,比如使用本章介绍的对象关联风格来编写代码,那chrome就无法跟踪对象内部“构造函数名称”,这样的对象输出是Object{}意思是Object{}意思是Object()构造出的对象。
当然,当然这并不是对象关联风格代码的缺点。当你使用对象关联风格来编写代码并使用行为委托设计模式时,并不需要关注是谁“构造了”对象(就是使用new调用的那个函数)。只有使用了类风格来编写代码时chrome内部的“构造函数名称”跟踪才有意义,使用对象关联时这个功能不起任何作用。
比较思维模型
现在你已经明白了“类”和“委托”这两种设计模式的理论区别,接下来我们看看它们在思维模型方面的区别。
我们会通过一些示例(Foo、Bar)代码来比较一下两种设计模式(面向对象和对象关联)具体的实现方法。下面是典型的(‘原型’)面向对象风格:
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();
b2.speak();
子类Bar继承了父类Foo,然后生成了b1和b2两个实例,b1委托了Bar.prototype,后者委托了Foo.prototype,这种风格很常见,你应该很熟悉了。
下面我们看看如何使用对象关联风格来编写功能完全相同的代码:
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();
b2.speak();
这段代码中我们同样利用[[Prototype]]把b1委托给Bar并把Bar委托给Foo,和上一段代码一模一样,我们仍然实现了三个对象之间的关联。
但是非常重要的一点是,这段代码简洁了许多,我们只是把对象关联起来,并不需要那些既复杂又令人困惑的模范类的行为(构造函数、原型以及new)。
问问你自己:如果对象关联风格的代码能够实现类风格代码的所有功能并且更加简洁易懂,那它是不是比类风格更好?
下面我们看看两段代码对应的思维模型:
首先,类风格代码的思维模型强调实体以及实体间的关系
javaScript机制有很强的内部连贯性。
举例来说,javaScript中的函数之所以可以访问call(…)、apply(…)和bind(..),就是因为函数本身是对象。而函数对象同样有[[Prototype]]属性并且关联到Function.prototype对象,因此所有函数对象都可以通过委托调用这些默认方法、javaScript能做到这一点,你也可以。
类与对象
我们已经看到了“类”和“行为委托”在理论和思维模型方面的区别,现在看看在真实场景中如何应用这些方法。
首先看看web开发中非常典型的一种前端场景:创建ui控件(按钮,下拉列表,等等)。
控件类
你可能已经习惯了面向对象设计模式,所以很快会想到一个包含所有空间行为的父类(可能叫做widget)和继承父类的特殊控件子类(比如button)
这里将使用jquery来操作DOM和css,因为这些操作和我们现在讨论的内容没有关系。这些代码并不关注你是否使用,或使用哪种javaScript框架(jquery,Dojo,YUI等等)来解决问题。
下面这段代码展示的是如何在不使用任何“类”辅助库或者语法的情况下,使用纯javaScript实现类风格的代码:
//父类
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) {
//调用“super”构造函数
Widget.call(this, width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
//让button继承widget
Button.prototype = Object.create(Widget.prototype);
//重写render(。。。)
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);
})
在面向对象设计模式中我们需要先在父类中定义基础的render(),然后在子类中重写它,子类并不会替换基础的render(…),只是添加一些按钮特有的行为。
可以看到代码中出现了丑陋的显示伪多态,即通过Widget.call和Widget.prototype.render.call从子类中引用“父类”中的基础方法。呸!
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 extend Widget {
constructor(width, height, label) {
super(width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
render($where) {
super($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);
})
毫无疑问,使用es6的class之后,上一段代码中许多丑陋的语法都不见了,super(…)函数棒极了,(尽管深入探究就会发现并不是那么完美)
尽管语法上得到了改进,但实际上这里并没有真正的类,class仍然是通过[[Prototype]]机制实现的,因此我们仍会面临思维模式不匹配的问题。
无论你使用的是传统的原型语法还是es6中的新语法糖,你仍然需要用“类”的概念来对问题(UI控件)进行建模,不过这种做饭会为你带来新的麻烦。
委托控件对象
下面的例子使用对象关联风格委托来更简单的实现Widget/Button
var Widget = {
init: function (width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
},
insert: function ($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
}
}
var Button = Object.create(Widget);
Button.setup = function (width, height, label) {
//委托调用
this.init(width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
Button.build = function ($where) {
//委托调用
this.insert($where);
this.$elem.click(this.onClick.bind(this));
}
Button.onClick = function (evt) {
console.log("Button"+this.label+"clicked");
}
$(document).ready(function () {
var $body = $(document.body);
var btn1 = Object.create(Button);
btn1.setup(125, 30, "Hello");
var btn2 = Object.create(Button);
btn2.setup(150, 40, "World");
btn1.build($body);
btn2.build($body);
})
使用对象关联风格来编写代码时不需要把Widget和Button当作父类和子类,相反,Widget只是一个对象,包含一组通用的函数,任何类型的控件都可以委托,Button同样知识一个对(当然,他会通过委托关联到Widget)。
从设计模式的角度来说,我们并没有像类一样在两个对象中都定义相同的方法名render(…),相反,我们定义了两个更具描述性的方法名(insert(…)和build(…)).同理,初始化方法分别叫作init(…)和this.insert(…)
在委托设计模式中,除了建议使用不相同的并且更具描述性的方法名之外,还要通过对象关联避免丑陋的显示伪多态调用(Widget.call和Widget.prototype.render.call),代之以简单的相对委托调用this.init(…)和this.insert(…)
从语法角度来说,我们同样没有使用任何构造函数,.prototype或new实际上也没有必要使用他们。
如果你仔细观察就会发现,之前的一次调用(var btn1 = new Button(…)),现在变成了两次(var btn1 = Object.create(Button)和btn1.setUp(…)).咋一看这似乎是一个缺点(需要更多代码)
但是这一点其实也是对象关联风格代码相比传统原型风格代码有优势的地方,为什么呢?
使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构造和初始化,然而,在许多情况下把这两步分开(就像关联代码一样)更灵活。
举例来说,假如你在程序启动时创建了一个实例池,然后一直等到实例被取出并使用时才执行特定的初始化过程,这个过程中两个函数调用是挨着的,但是完全可以根据需要让它们出现在不同的位置。
对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
更简洁的设计
对象关联除了能让代码看起来更简洁(并且更具扩展性)外可以通过行为委托模式简化代码结构,我们来看最后一个例子,他展示了对象关联如何简化整体设计。
在这个场景中我们有两个控制器对象,一个用来操作网页中的登录表单,另一个用来与服务器进行验证(通信)。
我们需要一个辅助函数来创建ajax通信,我们使用的是jquery他不仅可以处理ajax并且会返回一个类promise的结果,因此我们可以使用.then(..)来监听响应。
在传统的类设计模式中,我们会把基础的函数定义在名为controller的类中,然后派生两个子类LoginController和AurhController,他们都继承自Controller并且重写了一些基础行为。
//父类
function Controller () {
this.errors = [];
}
Controller.prototype.showDialog(title, msg) {
//给用户显示标题和消息
}
Controller.prototype.success = function (msg) {
this.showDialog("success", msg);
}
Controller.prototype.failure = function (err) {
this.errors.push(err);
this.showDialog("Error", err);
}
//子类
function LoginController () {
Controller.call(this);
}
//把子类关联到父类
LoginController.prototype = Object.create(Controller.prototype);
LoginController.prototype.getUser = function () {
return document.getElementById("Login_username").value;
}
LoginController.prototype.getPassword = function () {
return document.getElementById("login_password").value;
}
LoginController.prototype.validateEntry = function (user, pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure("please enter a username & password!");
}
else if (user.length < 5) {
return this.failure("password must be 5+ characters!");
}
//如果执行到这里说明通过验证
return true;
}
//重写基础的failure()
LoginController.prototype.failure = function (err) {
//super调用
Controller.prototype.failure.call(this, "Login invalid"+err);
}
//子类
function AuthController (login) {
Controller.call(this);
//合成
this.login = login;
}
//把子类关联到父类
AuthController.prototype = Object.create(Controller.prototype);
AuthController.prototype.server = function (url, date) {
return $.ajax({
url: url,
data: data
})
}
AuthController.prototype.checkAuth = function () {
var user = this.login.getUser();
var pw = this.login.getPassword();
if(this.login.validateEntry(user, pw)){
this.server("/check-auth", {
user: user,
pw: pw
})
.then(this.success.bind(this));
.fail(this.failure.bind(this));
}
}
//重写基础的success()
AuthController.prototype.success = function () {
//Super调用
Controller.prototype.success.call(this, "Authenticated");
}
//重写基础的failure()
Authenticater.prototype.failure = function (err) {
//super调用
Controller.prototype.failure.call(this, "Auth failed:"+err);
}
var auth = new AuthController();
auth.checkAuth(
//除了继承,我们还需要合成
new LoginController();
)
所有控制器共享的基础行为是success(…).failure(..)和showDialog(…),子类LoginController和AuthController通过重写failure(..)和success(..)来扩展默认基础类行为。此外,注意AuthController需要一个LoginController的实例来和登录表单进行交互,因此实例变成了一个数据属性。
你可能想让AuthContruller继承LoginController或者相反,这样我们就通过继承链实现了真正的合成,但是这就是类继承在问题领域建模时会产生的问题,因为AnthContruller和LoginController都不具备对方的基础行为,所以这种继承关系是不恰当的,我们的解决办法是进行一些简单的合成从而让他们既不必互相继承又可以互相合作。
如果你熟悉面向类的设计,你一定会觉得以上内容非常亲切自然。
反类
但是,我们真的需要一个Controller父类,两个子类加上合成来对这个问题进行建模吗?能不能使用对象关联风格的行为委托来实现更简单的设计呢?当然可以!
var LoginController = {
errors: [],
getUser: function () {
return document.getElementById("login-username").value;
},
getPassword: function () {
return document.getElementById("login_password").value;
},
validateEntry: function (user, pw) {
user = user || this.getUser();
pw = pw || this.getPassword();
if (!(user && pw)) {
return this.failure("please enter a username & password");
}
else if (user.length < 5) {
return this.failure("password must be 5+ characters");
}
//如果执行到这里说明通过验证
return true;
},
showDialog: function (title, msg) {
//给用户显示标题和消息
},
failure: function (err) {
this.errors.push(err);
this.showDialog("error", "Login invalid "+err);
}
}
//让AnthController委托LoginController
var AuthController = Object.create(LoginController);
AuthController.errors = [];
AnthController.checkAuth = function () {
var user = this.getUser();
var pw = this.getPassword();
if (this.validateEntry(user, pw)) {
this.server("/check-auth",{user: user, pw: pw})
.then(this.accepted.bind(this))
.fail(this.rejected.bind(this));
}
};
AuthController.server = function (url, data) {
return $.ajax({
url: url,
data: data
});
};
AuthController.accepted = function () {
this.shwoDialog("success", "Authenticated");
}
AnthController.rejected = function (err) {
this.failure("Auth failed"+err);
}
由于AuthController只是一个对象(loginController也一样),因此我们不需要实例化(比如new AuthController()),只需要一行代码就行:
AuthController.checkAuth();
借助对象关联,你可以简单向委托链上条件一个或多个对象,而且同样不需要实例化:
var controller1 = Object.create(AuthController);
var controller2 = Object.create(AuthController);
在行为委托模式中,AuthController和LoginController只是对象,他们之间是兄弟关系,并不是父类和子类的关系,代码中AuthController委托了LoginController,反向委托也完全没有问题。这种模式的重点在于只需要两个实体(LoginController和AuthController),而之前的模式需要三个。
我们不需要Controller基类来“共享”两个实体之间的行为,因为委托足以满足我们需要的功能。同样,前面提到过,我们也不需要实例化类,因为他们根本就不是类,他们只是对象,此外,我们也不需要合成,因为两个对象可以通过委托进行合作。
最后,我么避免了面向对象类设计模式中的多态,我们在不同在对象中没有使用相同的函数名sucesss(..)和failure(..),这样就不需要使用丑陋的显示伪多态,相反,在AnthController中他们的名字是accepted(…)和rejected(…)-可以更好地描述他们的行为。
总结:我们用一种(极其)的设计实现了同样的功能,这就是对象关联风格代码和行为委托设计模式的力量。
更好的语法
ES6的class语法可以简洁地定义类方法,这个特性让class乍看起来更有吸引力,
class Foo {
methodName(){
}
}
我们终于可以抛弃定义中的关键字function了,对所有的javascript来说真是大快人心!
你可能注意到了,在之前推荐的对象关联语法中出现了许多function,看起来违背了对象关联的简洁性。但是实际上大可不必如此!
在ES6中我们可以再任意对象的字面形式中使用简洁方法声明,所以对象关联风格的对象可以这样声明
var LoginController = {
errors: [],
getUser() {
},
getPassword(){
}
}
唯一的区别是对象的字面形式仍然需要使用“,”,来分隔元素,而class语法不需要,这个区别对于整体的设计来说无关紧要。
此外,在ES6中,你可以使用对象的字面形式来改写之前繁琐的属性赋值语法,然后用Object.setPrototypeof(…)来修改它的[[Prototype]]:
//使用更好的对象字面形式语法和简洁方法
var AuthController = {
errors: [],
checkAuth(){
},
server(url, data){
}
}
//现在把AuthController关联到LoginController
Object.setPrototypeOf(AuthController, LoginController);
使用ES6的简洁语法可以让对象关联风格更加人性化,你完全不需要使用类就能享受整洁的对象语法。
反词法
简洁方法有一个非常小但是非常重要的缺点,思考下面的代码:
var Foo = {
bar() {},
baz: function baz() {}
}
去掉语法糖之后的代码如下所示:
var Foo = {
bar: function(){},
baz: function baz(){}
}
看到区别了吗?由于函数本身没有名称标识符,所以bar()的缩写形式(function baz()…)会额外给.baz属性添加一个词法名称标识符baz
然后呢? 匿名函数表达式的有三个缺点,
匿名函数没有name标识符,这会导致:
1.调试栈更难追踪
2.自我引用(递归,事件(解除)绑定,等等,更难。
3代码稍微更难理解
简洁方法没有1和3两个缺点
去掉语法糖的版本使用的是匿名函数表达式,通常来说并不会再追踪栈中添加name,但是简洁方法很特殊,会给对象的函数对象设置一个内部的name属性,这样可以再理论上可以用在追踪栈中。
很不幸,简洁方法无法避免第2个缺点,它们不具备可以自我引用的词法标识符,思考下面的代码
var Foo = {
bar: function(x){
if (x < 10){
return Foo.bar(x *2);
}
return x;
},
baz: function baz(x){
if (x < 10){
return baz(x * 2);
}
return x;
}
}
在本例中Foo.bar(x*2)就足够了,但是在许多情况下无法使用这种方法,比如多个对象通过代理共享函数,使用this绑定,等等。这种情况下最好的办法就是使用函数对象的name标识符来进行真正的自我引用。
使用简洁方法一定要小心这一点。如果你需要自我引用的话,那最好使用传统的具名函数表达式来定义对象的函数,不要使用简洁方法。
内省
如果你写过许多面向类的程序,那你可能很熟悉自省,自省就是检查实例的类型。类实例的自省主要目的是通过创建方式来判断对象的结构和功能。
下面的代码使用instanceof来推测对象a1的功能。
function Foo(){
}
Foo.prototype.something = function () {
}
var a1 = new Foo()
if(a1 instanceof Foo) {
a1.something();
}
因为Foo.prototype在a1的[[Prototype]]链上,所以instanceof操作告诉我们a1是Foo类的一个实例,知道了这一点后,我们就可以认为a1有Foo类描述的功能。
当然,Foo类并不存在,只有一个普通的函数Foo,它引用了a1委托的对象,从语法角度来说,instanceof似乎是检查a1和Foo的关系,但是实际上它想说的是a1和Foo.prototype(引用的对象)时互相关联的。
instanceof 语法会产生语义困惑而且非常不直观。如果你想检查对象a1和某个对象的关系,那必须使用另一个引用该对象的函数才行,你不能直接判断两个对象是否关联。
还记得本章之前介绍的抽象的Foo/Bar/b1例子吗,简单来说是这样的:
function Foo(){}
Foo.prototype...
function Bar() {}
Bar.prototype = Object.create(Foo.prototype)
var b1 = new Bar("b1")
如果要使用instanceof和.prototype语义来检查本例中实体的关系,那必须这样做:
//让Foo和Bar互相关联
Bar.prototype instanceof Foo; //true
Object.getPrototypeOf(Bar.prototype) === Foo.prototype //true
Foo.prototype.isPrototypeOf(Bar.prototype);//true
//让b1关联到Foo和Bar
b1 instanceof Foo ; //true
b1 instanceof Bar; //true
Object.getPrototypeOf(b1) === Bar.prototype; //true
Foo.prototype.isPrototypeOf(b1);//true
Bar.prototype.isPrototypeOf(b1);//true
显然这是一种非常糟糕的方法,举例来说,你最直观的想法可能是使用Bar instanceof Foo,但是在javascript中这是行不通的,你必须使用Bar.prototype instanceof Foo.
还有一种常见但是可能更加脆弱的内省模式,许多开发者认为它比instanceof 更好,这种模式被称为”鸭子类型“,这个术语源自这句格言”如果看起来像鸭子,叫起来像鸭子。那就一定是鸭子”
举例来说:
if (a1.something){
a1.something();
}
我们并没有检查a1和委托something()函数的对象之间的关系,而是假设如果a1通过了测试a1.something的话,那a1就一定能调用.something(),这个假设的风险其实并不算很高。
但是“鸭子类型”通常会在测试之外做出许多关于对象功能的假设,这当然会带来许多风险。
ES6的Promise就是典型的“鸭子类型”。
由于各种各样的原因,我们需要判断一个对象引用是否是Promise,但是判断的方法是检查对象是否有then()方法,换句话说,如果对象有then()方法,ES6的promise就会认为这个对象是”可持续的(thenable)”,因此会期望它具有Promise的所有标准行为。
如果一个不是Promise但是具有then()方法的对象,那你千万不要把它用在ES6的Promise机制中,否则会出错。
这个例子清楚的解释了“鸭子类型”的危害。你应该尽量避免使用这个方法,即使使用也要保证条件是可控的。
现在回到说的对象关联风格代码,起内省更加简洁。我们先来回顾一下之前的例子:
var Foo = {};
var Bar = Object.create(Foo);
Bar...
var b1 = Object.create(Bar);
使用对象关联时,所有的对象都是通过[[Prototype]]委托互相关联,下面是内省的方法,非常简单:
//让Foo和Bar互相关联
Foo.isPrototypeOf(Bar);// true
Object.getPrototypeOf(Bar) === Foo; //true
//让b1关联到Foo和Bar
Foo.isPrototypeOf(b1); //true
Bar.isPrototypeOf(b1); //true
Object.getPrototypeOf(b1) === Bar;//true
我们没有使用instanceof,因为他会产生一些和类有关的误解。现在我们想问的问题是“你是我的原型吗?”我们并不需要使用简洁的形式,比如Foo.prototype或者繁琐的Foo.isPrototypeOf(..)
我觉得和之前的方法比起来,这样方法显得更加简洁并且清晰,再说一次,我们认为javascript中对象关联比类风格的代码更加简洁。
小结
在软件架构中你可以选择是否使用类和继承设计模式,大所数开发者理所当然地认为类是唯一的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。javascript的[[Prototype]]机制本质上就是行为委托机制,也就是说,我们可以选择在javascript中努力实现类机制,也可以拥抱更自然的[[Prototype]]委托机制。
当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。
对象关联是一种编码风格,它倡导的是直接创建对象和关联对象,不把他们抽象成类,对象关联可以用基于[[Prototype]]的行为委托非常自然地实现。
class陷阱
在javascript中使用类设计模式仍然存在许多深层问题。
首先,你可能会认为Es6中的class语法是向javascript中引入了一种新的“类”机制,其实不是这样,class基本上是[[Prototype]]机制的一种语法糖。
也就是说,class并不会像传统面向类的语言一样,在声明时静态复制所有行为,如果你修改了或者替换了父类中的一个方法,那子类和所有的实例都会受到影响。因为它们在定义时没有进行复制,只是基于[[Prototype]]的实时委托。
class C {
constructor(){
this.num = Math.random();
}
rand() {
console.log("random"+this.num);
}
}
var c1 = new C();
c1.rand(); //0.43...
C.prototype.rand = function(){
console.log("random"+Math.round(this.name * 1000));
}
var c2 = new C();
c2.rand();//867
c1.rand();//432
如果你已经明白了委托的原理所以并不会期望得到类的副本的话,那这种行为才看起来比较合理所以你需要问自己:为什么药使用本质上不是类的class语法呢?