前言
传统的面向对象(OOP)语言,比如java,大量采用类的抽象设计来实现面向对象的三大特征:封装、继承、多态,随着这类语言的流行,这已然成为一种非常经典的编程范式。 但在js中并没有真正的类,或者换句更准确的说法,js中的类与java中的类差别很大。
这让很多从别的语言转过来的程序员困惑不已,他们因此认为js并不是一门真正的面向对象的语言,因为他们不能理解js中有new,有对象,但没有类,如果将函数类比为java中类,那么它甚至在es6出来之前连最基本的继承语法都没有。他们甚至为了专门解释这种现象,发明了类似于“基于对象”而非“面向”对象,这样的新名词来形容js中的对象系统。
但是,笔者认为,思维不应该僵化,至少不能完全照搬硬套之前的经验来学习一门新的语言,是不是真正的面向对象与是不是用类来实现,并不能画等号,换句话说,类只是面向对象的其中一种实现方式,很明显,js的设计者选择了另外一种:基于原型的方式。
正文
在JS中模拟类的实现
在java中的子类继承一个父类,是需要开辟一块新的空间,将父类中的方法属性重新复制一份给子类,也就是说,这些可继承的方法属性,在子类创建成功后,就没有任何关系了,之后父类的某个方法变化,也绝不会影响到子类,实例也更是如此,你可以简单的理解为执行的是一次性的物理复制。但在js中,不是复制,而是共享。
在js中模拟java类继承的实现:
// 用一个名字叫Parent的函数模拟父类
function Parent(name){
this.name = name
}
// 给父类增加一个原型上的sayName方法
Parent.prototype.sayName = function(){
console.log(this.name)
}
// 用一个名字叫Child的函数模拟子类
function Child(age,...p){
Parent.call(this,...p) //这类似于java中的super,是个指向父类的指针,调用,可以传参
this.age = age;
}
// 建立Child.prototype与Parent.prototype这两对象的关联
Child.prototype = Object.create(Parent.prototype)
// 给子类增加一个原型上的sayAge方法
Child.prototype.sayAge = function(){
console.log(this.age)
}
const obj = new Child(18,'lili')
console.log(obj)
// 实例既有父类的sayName,也有子类的sayAge,实现了“继承”
obj.sayName()
obj.sayAge()
接下来,当你尝试改变一个“父类”的方法时,会发生什么。
...
//在上方代码下边加两行,并执行
Parent.prototype.sayName = null
obj.sayName() // TypeError obj.sayName is not a function
报错了,这证实了,在重写了父类的sayName方法后,会实时影响下级实例上方法的调用表现,所以js中“类”方法与实例方法的关系,不是简单的拷贝复制,而是共享同一个方法引用。 大家可以看到,上边的代码复杂且长,模拟的表现也很牵强,所以在js中模拟其他语言类的实现,并不容易。
es6新增的class,是不是就和java中的类一模一样了呢,上代码:
class Parent{
constructor(name){
this.name = name
}
sayName(){
console.log(this.name)
}
}
class Child extends Parent{
constructor(age,name){
super(name)
this.age = age;
}
sayAge(){
console.log(this.age)
}
}
const obj = new Child(18,'lili');
obj.sayName() // 'lili'
Parent.prototype.sayName = null
obj.sayName() // TypeError obj.sayName is not a function
并没有改变共享的本质,所以,es6中的class只是es5原型继承的语法糖。
基于原型的委托模式
你也许并不需要类
回到js的应用场景,绝大多数情况下,前端并不需要承载过多的业务逻辑,所以类的抽象用处并不是很大,大家不妨回想一下,你在js代码中自认为写的很漂亮的类,都真正的new过几回呢?就拿使用频率很高的框架如vue举例,绝大多数应用也只会在index.js里new Vue(...)一次,直接用一个叫vue的对象也一样可以实现功能,稍微有点说服力的优势是,Vue这种构造函数的形式,比直接的对象形式,可以使代码的组织更加聚合,全局变量也会少一些。
// 构造函数形式
class Vue{
constuctor(){
//这里写初始化的一些功能代码
}
$set(..){ ...}
...这里写一些共享功能
}
// 对象形式
function createVue(..){
return {
$set(..){...}
}
}
function initHandlers(vue){
//这里写初始化的一些功能代码
}
initHandlers(createVue(..))
对象的本质
对象,本质上并没有那么神秘,它只是一个包含属性数据的数据包而已。而且,在js中创建对象,使用new关键字也不是必选项,用字面量创建即可。
const obj = {
age:1,
name:'lili'
}
那属性方法复用,该如何实现呢
const obj = {
sayName(){
console.log(this.name)
}
}
const obj1 = Object.create(obj)
obj1.name = 'lili';
obj1.sayName() // 'lili'
下边看起来更直观
const obj = {
sayName(){
console.log(this.name)
}
}
const obj1 = {
name:'lili'
}
Object.setPrototypeOf(obj1,obj);//相当于 obj1.__proto__ = obj;
obj1.sayName() // 'lili'
es5的Object.create或者es6的 Object.setPrototypeOf功能上是一样的,都是用来建立两个对象之间的关联,方便属性及方法的共享,区别是前者会帮你自动创建新对象,后者需要你自己来创建。
构造函数的意义
大家知道js中大部分的能力,如果不是通过window上的全局方法来提供,那么大概率是由各种内置对象来提供的,内置对象的创建依赖一个个的内置构造函数,比如Array``Date``RegExp等,所以首先构造函数本身就是js提供api的一种形式。
使用构造函数建立两个对象的关联
function Contructor(name){
this.name = name;
}
Contructor.prototype.sayName = function(){
console.log(this.name)
}
const instance = new Contructor('lili');
instance.sayName() // lili
Contructor只是一个普通的函数,创建这个函数时,js会自动帮你创建一个Contructor.prototype的对象,你直接就可以在这个对象上新增一些属性和方法,所以这也可以做为js中创建对象的第N种方式。并且当你用new关键词调用Contructor时,它就是一个构造函数的角色,既可以传参数定制对象内容,又可以自动帮你把创建的新对象直接“return”出来,所以是比下方的纯手动创建原型方便很多的。
// 手动创建原型
const prototype = {
sayName(){
console.log(this.name)
}
}
// 创建对象的工厂函数
function createObj(name){
return {
name
}
}
// 创建实例
const instance = createInstance('lili')
instance.__proto__ = prototype;// 建立两个对象的关联 也可以使用 Object.setPrototypeOf(instance,prototype)
instance.sayName()
class语法的意义
总结以上的需求:
- 批量的通过传参个性化创建对象
- 将这些对象链接到一个公共的原型对象,实现方法共享
其实这样快捷创建对象的
Factory的需求还是很普遍的,甚至是编码中的强需求,那么ES官方为了简化上述语法,新增了class语法,这和别的语言保持了一致,extends表示继承。
class Child extends Parent{
constructor(age,name){
super(name)
this.age = age;
}
sayAge(){
console.log(this.age)
}
}
const obj = new Child(18,'lili');
但请记住,这依然和java中的类差别很大,不要混为一谈,只是语法上更像了一些,但是却离真相越来越远了。因为js中的面向对象,才是真正的面向“对象”编程,而非面向“类”编程,创建对象根本也并不需要类的协助(简单的字面量就可以),但是为了更方便的实现上述需求,对es5里复杂的构造函数形式进行语法层面的简化是对开发很有帮助得,这就是es官方发布class语法的出发点,而不是满足或者迎合某一些人对“js版本的类要与其他语言的类保持一致”的期待。
尽管 ECMAScript 对象本质上不是基于类的,但基于构造函数、原型对象和方法的通用模式来定义类类抽象通常很方便。ECMAScript 内置对象本身遵循这样的类模式。从 ECMAScript 2015 开始,ECMAScript 语言包括语法类定义,允许程序员简明地定义符合内置对象使用的相同类抽象模式的对象。—— 摘自ES6语言规范文档里对class的解释 原文
原型链就是个单向链表
根据关注点分离的开发原则,你一般会将逻辑分散在不同的对象中去组织。比如,你有个工具对象utils,还有一个负责页面渲染数据的data对象,点击按钮,会弹出this.text文案。
const utils = {
tip(){
alert(this.text)
}
}
const data = {
text:'111',
}
// 建立 utils与data 两个对象之间的委托关联
Object.setPrototypeOf(data,utils)
Btn.onClick = ()=>{
data.text = '222';
data.tip() // 弹出 222
}
这只是个示例,真实项目里不会这么傻的去写,`utils.tip.call(data)`一句其实就搞定了。
在使用Object.setPrototypeOf(data,utils)建立两个对象的关联后,就组成了一个原型链:data => utils。这么看的话,原型链其实就是一个链接各个对象的单向链表,next指针就是__proto__属性,自身找不到的方法属性就会去这个链上遍历去找。例子中就是,在data身上调用tip方法,找不到,去原型链上下一个对象的util对象上去找,找到了之后,因为tip方法中的this指向自身,所以可以弹出自己的text数据。
原型委托相比类继承有什么优势
-
性能。 大家知道js一开始就是针对一种只在浏览器运行的语言而设计的,在node诞生之前,一直属于一种客户端语言,客户端语言天然的需要更低的内寸空间占用,原型委托的方式,会比java的类的物理复制,更节省内存开销。
-
灵活。 大家知道js是一门动态语言,最大的特点之一就是使用起来比较灵活,对象系统设计的也足够动态,保持了语言风格的一致,当然动态也不一定都是好处,使用不当也会成为劣势,比如上边例子中就有直接修改原型,导致对象方法调用报错的危险例子。
当然,js之所以做出这样的选择,也不排除设计者个人的喜好,其作者Brendan Eich也小范围的公开表示,自己特别不喜欢java的类模式,所以设计之初就选用了更灵活的原型方式。
结尾
希望以上会对大家理解js中的对象系统有所帮助,本文一些观点其实很有争议,希望大家踊跃讨论,提出不同意见。但需文明交流,禁止无脑喷。。。