现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(七)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。
“一切”都是对象,那对象有什么,又能做什么?
有些话题总是经久不衰,比如:原型链、this、深/浅拷贝,这些都跟对象有关,而对象的故事远不止于此,我们就来适当聊一聊。
对象是什么
它是一组属性和方法的集合,属性——“有什么”,方法——“能干什么”。
使用场景大致分为两种:
语言层次,JavaScript的运行依赖对象系统,其中的每一种数据类型同时是一类对象,有着自己的属性和方法,供编写程序时使用。
1 | let str = "hello world"; |
业务层次:根据业务类型不同,我们可以从具体需求中抽象出多个角色,每种角色都可以封装成一个对象,有自己的属性和方法。
1 | let teacher = { |
创建对象
对象需要创建才能用,创建对象的方式有多种,接下来我们逐一介绍,讲一下前世今生。
- 本尊
聊类型的时候,我们就见过对象的本尊——Object。使用new运算符就可以创建一个对象,创建之后可以为其添加属性和方法:
1 | let person = new Object(); |
有了对象,就能在需要的时候使用它们,比如,前端请求后端传递过来的数据通常是对象形式,就可以取到它的属性值放到页面上展示,当然,前端可以创建对象来存储某种意义上的个体,进行传递或者其他操作。
但这种创建形式不够简洁,写了很多重复的对象名称,一种更加直观也更常用的方法,叫”对象字面量“:
1 | let person = { |
书写简洁对工程师的开发体验也是重要的,所以,这种写法在开发中很常见。
单这么看的话,对象就是个容器,不复杂,但是,这两种方法都有一个缺点:创建具有同样接口的多个对象需要重复编写很多代码。换句话说,只能一个个地生创建,没有实现封装、复用、继承。
更好的创建与继承
- 工厂模式
谈到封装和复用,我们可以十分自然地想到函数,如果能够用一个函数来封装整个创建对象的过程,类似一个小的对象“加工厂”,就解决了上面说的问题,这就是“工厂模式”的由来。
1 | function createPerson(name,age,work){ |
这个方案解决了类似对象创建的重复书写问题,是个不错的方案,但它有点不完整的地方,即“仅仅”封装了行为,创建出的对象和来源并没有什么关系,即person1、person2和createPerson是没有关系的。
很多时候建立它们之间的联系都是重要的,比如继承属性和方法,所以可以继续改良。
- 构造函数
什么是构造函数——用于构造某种对象实例的函数。
ECMAScript中的构造函数是用于创建特定类型对象的,就像我们可以用 new String()创建字符串,new Number()创建数字。
String()和Number()是内置函数,这里我们讨论的是自定义的构造函数。
它的写法和工厂模式相似,却有着本质的不同,譬如上面的对象我们可以这样写:
1 | function Person(name,age,work){ |
它有如下特点:
- 首字母大写,更易与普通函数作区分
- 没有显式创建对象,属性和方法直接赋值给this
- 没有return
那么本质不同是什么?这就要了解构造的过程,具体如下:
(1)在内存中创建一个新对象。
(2)这个新对象内部的[[Prototype]]特性被赋值为构造函数的prototype属性。
(3)构造函数内部的this被赋值为这个新对象。
(4)执行内部代码,给新对象添加属性。
(5)如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
可以看出,它不仅看起来和工厂模式像,背后做的事情也像,创建了一个新对象,添加属性和方法,再把这个对象返回。一点不同是在第2步,新对象的[[Prototype]]特性被赋值为构造函数的prototype属性,这就为实例和构造函数建立了联系。
1 | idea.constructor == Person //true |
所以,使用构造函数模式创建对象实例,既起到封装、复用的效果,又能为实例和构造函数之间建立联系。
但是,并不代表构造函数是完美的,我们发现构造函数里有方法,方法本身是函数,函数也是对象,这样就等同于,每次创建一个实例的时候,都新建了一个Function实例。
1 | let lisi = new Person("李四",18,"产品经理") |
两个实例是相互独立的,但其实它们做的是同一件事,这就有些浪费资源。
怎么办?有一种方法是,把函数定义放在构造函数的外部,你是new的时候发生的事情,我把你放在new操作的外部,不参与不就行了?
从效果上是可行的,但有几点弊端:
- 污染全局作用域
- 如果需要多个方法,就要在全局定义多个方法,变得较混乱
- 这些方法只是跟单一对象相关,跟其他都无关,归属不清晰
综上,这个方案是得不偿失的,不建议采用,那有没有更好的方式?
不急,我们先来看另外一种。
原型模式
“每个函数都会创建一个prototype属性”。知道这一点,是理解原型模式的前提。
prototype是个对象,对象中包含用特定引用类型创建的实例所共享的属性和方法。
实际上,这个对象就是通过调用构造函数创建的对象的原型。所以,可以在它上面定义的属性和方法,从而被对象实例共享。看代码:
1 | function Person(){} |
这样以来,当我们想创建对象的时候,可以:
1 | let person1 = new Person(); |
方法run得到共享。
我们来稍微探讨一下这个效果是怎么产生的。
前面说过,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype属性。默认情况下,所有原型对象自动获得一个名为constructor的属性,指回与之关联的构造函数。对前面的例子而言,Person.prototype.constructor指向Person。然后,可能会给原型对象添加其他属性和方法。
每次创建新实例,实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。需要指出的是,并没有访问[[Prototype]]特性的标准方式,但多数浏览器会在每个对象上暴露proto属性,通过这个属性可以访问对象的原型。实例便是通过proto链接到原型对象,同一个构造函数的不同实例,通过proto共享原型对象,而Person.prototype指向原型对象,所以,定义在原型对象上的属性和方法就得到了共享。
在这个基础之上,Object类型还有一个setPrototypeOf()方法,可以向实例的[[Prototype]]写入新值。这样就可以重写一个对象的原型继承关系.
1 | let person = { |
这个方法接受一个目标对象和一个原型对象,将二者建立联系,使得目标对象继承原型对象的属性。
但是,修改继承关系是个风险稍高(或者比较损耗性能)的操作,毕竟你不知道会有多少对象与之关联,如果的确需要在创建对象实例的时候为其指定原型这样的操作,有另一种方法——Object.create()。
Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的proto。
1 | let person = { |
由此,还可以了解一个叫“干净”的空对象的东西。我们建立空对象可用这些方法:
1 | let obj = {} |
如果你不是对对象完全陌生,应该会知道,通过前面两种方式创建的对象,虽然看起来没有添加属性和方法,但它们有继承自Object的属性和方法,而第三种,它没有属性,也没有方法,所以称作“干净”的空对象。
以上这些是我们讨论“原型模式”延伸出的小知识点,重点仍在模式本身。
“原型模式”看似很棒,也不是没有问题。首先,它弱化了向构造函数传参的能力,所有实例默认都是相同的属性值,这显然造成不便,但这不是原型的最大问题。最大问题源自它的共享特性,所谓”成也萧何,败也萧何“,共享对函数来说比较合适,对原始值属性也能接受,如果涉及引用值,问题就暴露了。
1 | function Person(){} |
前面讲引用类型的时候说过,它存的是地址,而不是值本身,这里person1和person2的背包指向的是同一个地址,改一方,另一方也跟着改变,但这里是需要个性化处理的,毕竟,不是每个人都能用上mac…
由于这个弊端的存在,原型模式通常不会单独使用,就有了下面这种模式。
组合继承模式
通过上面的讲述,我们知道,构造函数的成员都是私有的,原型模式的成员都是共享的,既然有些成员需要共享,有些成员需要私有,将二者组合起来不就可以了?
我们可以把上面的代码改成这样:
1 | function Person(name,age,work,bag){ |
这种方法规避了构造函数和原型模式的不足,同时利用了各自的优点,曾经成为很流行的方式。
但ES6之后,有了更好用的替代方法。
Class
在正统面向对象语言中都有类(class)的概念,类是一个模板,它描述一类对象的行为和状态。即使用类可以创建很多同类对象。
ES6之前的JavaScript中是没有类的,只能通过其他方法来模拟,比如前面介绍的构造函数,或者组合继承。ES6之后推出了Class这种创建类的新工具。先看类的定义:
1 | class Person{} |
可以直接定义,也可以使用赋值表达式,这看起来是一种全新的方式,但大家应该在很多地方看到过,说class只是一种语法糖。什么是语法糖?语法层面的不同,用起来更舒服、更自然。它背后仍是构造函数与原型的组合模式。
类可以包含构造函数方法、实例方法、getter函数、setter函数和静态类方法,但都不是必需的。正像上面的示例那样,空的类定义照样有效。默认情况下,类中的代码都在严格模式下执行。
下面就定义一个完整的类来认识它。
1 | class Person{ |
然后可以像这样定义一个实例:
1 | let person1 = new Person("idea",18,"程序员") |
通过定义类,可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。
实例成员
每次通过new调用类时,都会执行类构造函数。在函数内部,可以为新创建的实例添加“自有”属性。即上面Person类的constructor做的事。
每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。
原型方法与访问器
为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。比如person中的run方法。
类定义也支持获取和设置访问器。语法与行为跟普通对象一样。就像上面的getName和setAge。它们分别会在访问实例属性或者修改实例属性时被调用。
静态类
使用static关键字作为前缀。在静态成员中,this引用类自身。通常用于执行不特定于实例的操作,也不要求存在类的实例。
比如以上代码的sayName方法:
1 | person1.sayName() //Uncaught TypeError: person1.sayName is not a function |
使用实例无法调用静态方法,而类本身可以,且返回的this是类本身,name为Person。
类的继承
继承很常用,可以省很多重复的定义,比如,上面我们定义了人,那么可以在人的基础上,再定义一个男人,这样,男人是可以继承人的属性和方法的。直接看代码:
1 | class Man extends Person{ |
可以看出,Man所创建的实例,继承了Person中的属性,且Person存在于man1的原型链上。
当然,类还有其他一些细节,这里不再赘述,它已经成为现在创建对象和实例最常用的方式,且在流行框架React当中应用广泛。
总结
此文标题是”对象“之前,因为对象是个很大的话题,篇幅所限,一篇或者两篇文章可能都是说不完的,所以分开说,以减轻大家的阅读负担。
本文仅介绍了从对象/类不存在到存在的这个环节,曾有过什么方法,现在主要使用什么方法,以及它们的特点,还有一些细节和对象API我们留到”之后“再详细介绍。
祝学习进步,一起加油。