为什么说,JavaScript才是真正意义上的“面向对象”语言?

2,398 阅读9分钟

前言

传统的面向对象(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中的对象系统有所帮助,本文一些观点其实很有争议,希望大家踊跃讨论,提出不同意见。但需文明交流,禁止无脑喷。。。