问:类继承和原型继承不是同一回事儿吗,只是风格选择而已?
答:不是!
类继承和原型继承不论从本质上还是从语法上来说,都是两个截然不同的概念。
二者之间有着区分彼此的本质性特征。要完全看懂本文,你必须牢牢记住以下几点:
类继承中,实例继承自模版(类),并且创建子类关系。换言之,你不能像使用实例一样使用类。实例由类创建出来,并且能调用类的方法,但是你不能直接在类上调用本身的方法。你必须创建一个实例,然后在实例上应用那些方法。
原型继承中,实例继承自其他的实例。它们使用的是原型委托(将实例的原型对象指向一个模板对象),这种方式被Kyle Simpson(你不知道的JS系列作者)称为对象关联(OLOO, Objects Linking to Other Objects)。使用这种关联继承,你只是将模板对象的属性拷贝到新的实例中而已。
理解上述区别至关重要。类继承的机制在创建子类的同时,也不小心创建了类的层级。
原型继承却可以避免创建类似的层级。建议原型链越短越好,其实很容易将很多原型扁平化为一个单委托原型。
总结:
- 类是一个抽象的模版。
- 原型是一个具体的对象实例。
问:JavaScript中类不是创建对象的正确方式吗?
答:不是!
JavaScript中创建对象有几种方式。最常见的一种是对象字面量方式。看个例子,用ES6语法写的对象:
// ES6 或称 ES2015, 因为发布于2015.
let mouse = {
furColor: 'brown',
legs: 4,
tail: 'long, skinny',
describe () {
return `A mouse with ${this.furColor} fur,
${this.legs}${this.tail};
}
};
当然,对象字面量方式比ES6出来早多了,但之前的写法缺少对象中函数方法的简写方式,以及定义变量时你只能用var而用不了let。对了,describe()方法中的模板字符串在ES5中也是不能用的。
我们可以利用ES5中的Object.create()附上对象的委托原型:
let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType}, with ${this.furColor} fur,
${this.legs}${this.tail};
}
};
let mouse = Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny'
});
让我们仔细分析这个例子。animal是委托原型,mouse是实例。当你尝试获取mouse对象上没有的属性时,JavaScript将会在animal(委托对象)上寻找这个属性。
Object.assign()是ES6的新特性,由Rick Waldron提出。其实它早已在一些知名的库中被实现,比如jQuery中的$.extend(),Underscore中的_.extend(),还有Lodash中的assign()。该方法传入一个目标对象,以及任何多个用逗号隔开的源对象,它将会从最后一个源对象开始拷贝所有的可枚举属性到目标对象。若存在属性名冲突,前者会被后者覆盖。
本文不涉及到构造函数,因为我非常不推荐这种方式。太多滥用构造函数的例子,以及太多由此引起的麻烦。值得一提的是,很多聪明人并不同意我的看法。没关系,聪明人想怎么做就怎么做。
总有明智的人会听取Douglas Crockford 的意见:
如果某个特性有时会不靠谱,而且存在一个更好的选择,那么还是选择那个更好的方式。
问:难道不需要构造函数来定义实例的行为,以及进行实例化吗?
答:不需要!
任何函数均可创建并返回对象。当该函数不是用作构造函数来创建时,它被称为工厂函数(factory function)。
更佳选择
let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType}${this.furColor} fur,
${this.legs}${this.tail};
}
};
let mouseFactory = function mouseFactory () {
return Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny'
});
};
let mickey = mouseFactory();
通常我不会将函数命名为factory,那只是一个形象的比喻。一般我就简单称之为mouse()。
问:不需要用构造函数来创造私有变量或者属性吗?
答:不需要
JavaScript中,当你返回一个函数,该函数可以访问外部函数的变量。当你使用这个函数的时候,JS引擎创建了一个闭包。闭包是JavaSript中非常常见的模式,它通常用来创建私有变量。
闭包并不是构造函数独有的。任何函数均可创建闭包:
let animal = {
animalType: 'animal',
describe () {
return `An ${this.animalType}${this.furColor} fur,
${this.legs}${this.tail};
}
};
let mouseFactory = function mouseFactory () {
let secret = 'secret agent';
return Object.assign(Object.create(animal), {
animalType: 'mouse',
furColor: 'brown',
legs: 4,
tail: 'long, skinny',
profession () {
return secret;
}
});
};
let james = mouseFactory();
问:使用new关键词是否意味着类继承?
答:不是!
new关键词的作用是调用构造函数,具体做了以下几件事:
- 创建一个新实例
- 将this绑定于该实例
- 将该实例的委托[[Prototype]]指向构造函数的prototype属性所指的对象
- 以构造函数来命名对象属性,通常在debug阶段你会注意到获得实例对象的属性时,你会得到[Object Foo]而不是[Object object]。
- 允许instanceof判断该实例的原型和构造函数的prototype属性是否指向同一个对象。
不靠谱的instanceof
是时候重新思考instanceof了,或许你开始质疑它的作用。
注意:instanceof并不是我们所预期的像强类型语言那样进行类型检查。它只是检查对象的原型属性,而且很容易忽悠别人或者被忽悠。比如,它不能在不同执行环境下起作用(比如iframe中),这也是出bug的常见原因之一。
另外,利用instanceof很可能得到错误的结果。因为它仅仅是对目标对象的.prototype属性的身份检查,所以可能会出现以下奇怪的现象:
function foo(){}
var bar = { a: ‘a’};
foo.prototype = bar; // Object {a: “a”}
baz = Object.create(bar); // Object {a: “a”}
baz instanceof foo // true. oops.
最后一行的结果完全符合JavaScript对instanceof的定义。没有什么不对,仅仅是因为instanceof并不能保证结果的正确性罢了。它很容易得到错误的结果。
除此之外,强制代码强类型化,会让函数远离更有用的高复用度的类。
总而言之,instanceof限制了代码的可用性,也给程序带来了潜在的 bug。
奇怪的new
什么?!new会返回一些奇怪的东西。如果你尝试返回一个基本数据类型,new做不到。倘若想返回其他任意对象,new可以做到,但这也意味着this被抛弃了,也就切断了所有能链接到this的引用(包括.call()和.apply()),同时返回东西和构造函数的prototype属性也没有了联系。
问:类继承和原型继承性能差别大吗?
答:差别不大。
你可能听说过hidden classes,认为用构造函数来创建实例会比Object.create()快很多。其实,有点夸大其词。
项目运行中只有很微少的时间是用来运行脚本的,然后花在获取对象属性上的时间更是微乎其微。事实上,当今最慢的计算机每秒也可访问上百万个属性。
所以,这并不是项目性能优化的瓶颈。你需要做的是仔细分析项目,去发现真正的性能瓶颈。我相信在你思考这些非常微小的优化之前,有数不尽的地方值得你去优化。
不相信?若想该微优化明显提升性能,你必须成千上万次地循环涉及的操作,而且微优化中你唯一需要关心的地方是那些跟数量级相关的代码。
经验之谈:仔细分析你的项目,尽量减少网络加载,文件读写,渲染等可能的瓶颈。然后你才应该开始考虑微优化的问题。
你能区别.0000000001秒和.000000001秒吗?不能吧?我也不能,但我能区别加载10个小图标和加载一个字体的时间长短。
如果你真的分析了你的项目,并且发现瓶颈真的出在创建对象上,最快的解决方式不是用new或者类继承,而是使用对象字面量。如果因为性能你觉得值得放弃原型面向对象,那也值得同时放弃原型链和继承转而直接使用字面量对象。
可谷歌说使用类更快。。。
什么?!我没听错吧!谷歌做的是JavaScript引擎,而你做的是实际项目。显然你们二者关心的不是同一件事情。就让谷歌那小子去处理微优化的摊子。你就担心担心你自己应用真正的瓶颈。我敢说,你担心什么都比担心原型继承带来的性能问题好。
问:类继承和原型继承内存消耗区别大吗?
答:不大!
两者均可使用委托原型使实例共享方法,同时,它们也可使用或者避免将一堆状态变量封装到闭包里。
实际上,如果你用的是工厂函数,你能更容易操作这些对象,因为你会更加谨慎地处理内存问题,也能避免时不时被垃圾回收器阻碍。想了解更多有关构造函数的尴尬,请看“使用new关键词是否意味着类继承”问答中的最后一段。
简而言之,如果你想更随心地进行内存管理,请使用工厂函数,而非构造器或者类继承。
问:原始API使用构造函数,难道不是因为它们比工厂模式更常用吗?
答:不是!
JavaScript中工厂模式是极其常用的。比如,一直以来最流行的jQuery库,使用的也是工厂模式。John Resig选择工厂和原型扩展,而不是类。因为他可不想每次开发者进行DOM选择的时候,都要用new来初始化。简直不忍直视!
/**
以类为设计核心的jQuery - 非常糟糕,可能jQuery就从此被埋没!
**/
// 看起来有点蠢,我们是在创造一个id为"foo"的DOM元素吗?错,我们是在选择这个现有的元素。
var $foo = new $('#foo');
// 重复冗余的输入
var $bar = new $('.bar');
var $baz = new $('.baz');
// 看下面这坨是什么鬼?
var $bif = new $('.foo').on('click', function () {
var $this = new $(this);
$this.html('clicked!');
});
jQuery用工厂模式成功地避免了类似代码。
那还有哪些使用工厂模式的例子?
- React中的React.createClass()是工厂函数;
- Angular使用了类和工厂;
- Ember中的Ember.Application.create() ;
- Node中核心服务,如http.createServer(),net.createServer();
- Express也是一个工厂。
如上所见,几乎所有最流行的库和框架都使用了工厂函数。而JavaScript唯一比工厂还常见的对象实例化模式是对象字面量。
JavaScript内联函数使用的是构造函数,因为Brendan Eich设计语言的时候想让JavaScript设计得更像Java一点。考虑到自我连贯性,JavaScript就一直使用着构造函数的方式。现在想把所有东西都变成工厂,废弃构造函数就显得有点尴尬了。
但这并不意味着你的代码就要很糟糕。
结论是,工厂函数是个不错的选择。(译者话)
问:难道类继承不比原型继承更常用吗?
答:不是!
问:难道选择类继承还是原型继承不是决定于实际情况吗?
答:不是!
Eric Elliott is the author of “Programming JavaScript Applications” (O’Reilly), & host of the documentary film-in-production, “Programming Literacy”. He has contributed to software experiences for Adobe Systems, Zumba Fitness, The Wall Street Journal, ESPN, BBC, and top recording artists including Usher, Frank Ocean, Metallica, and many more.
He spends most of his time in the San Francisco Bay Area with the most beautiful woman in the world.