JavaScript的所有数据都可以看成对象
JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。
原型是指当我们想要创建xiaoming这个具体的学生时,我们并没有一个Student类型可用。那怎么办?恰好有这么一个现成的对象:
1 | const student = { |
注意最后一行代码把xiaoming的原型指向了对象Student,看上去xiaoming仿佛是从Student继承下来的.
在编写JavaScript代码时,不要直接用obj.__proto__去改变一个对象的原型
Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有,因此,我们可以编写一个函数来创建xiaoming:
1 | // 原型对象: |
对象的扩展
属性的简介表示法
ES6 允许直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。
1 | const foo = 'bar'; |
除了属性简写,方法也可以简写。
1 | const o = { |
属性名表达式
JavaScript 定义对象的属性,有两种方法。
1 | // 方法一 |
Object.is()
ES5 比较两个值是否相等,只有两个运算符:相等运算符(==)和严格相等运算符(===)。它们都有缺点,前者会自动转换数据类型,后者的NaN不等于自身,以及+0等于-0。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。
ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。
1 | Object.is('foo', 'foo') |
不同之处只有两个:一是+0不等于-0,二是NaN等于自身。
1 | +0 === -0 //true |
Object.assign()
Object.assign方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。
1 | const target = { a: 1 }; |
Object.assign方法的第一个参数是目标对象,后面的参数都是源对象。
注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。
注意点,Object.assign方法实行的是浅拷贝,而不是深拷贝。
对于这种嵌套的对象,一旦遇到同名属性,Object.assign的处理方法是替换,而不是添加。
1 | const target = { a: { b: 'c', d: 'e' } } |
对象的扩展运算符
1 | const data = {title: 'origin'}; |
创建对象
JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。
当我们用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined。
其原型链是:
1 | arr ----> Array.prototype ----> Object.prototype ----> null |
Array.prototype定义了indexOf()、shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。
当我们创建一个函数时:
1 | function foo() { |
函数也是一个对象,它的原型链是:
1 | foo ----> Function.prototype ----> Object.prototype ----> null |
由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。
很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。
构造函数
除了直接用{ ... }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:
1 | function Student(name) { |
在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:
1 | var xiaoming = new Student('小明'); |
注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this,也就是说,不需要在最后写return this;。
新创建的xiaoming的原型链是:
1 | xiaoming ----> Student.prototype ----> Object.prototype ----> null |
也就是说,xiaoming的原型指向函数Student的原型。如果你又创建了xiaohong、xiaojun,那么这些对象的原型与xiaoming是一样的:
1 | xiaoming ↘ |
用new Student()创建的对象还从原型上获得了一个constructor属性,它指向函数Student本身:
1 | xiaoming.constructor === Student.prototype.constructor; // true |
原型继承
在传统的基于Class的语言如Java、C++中,继承的本质是扩展一个已有的Class,并生成新的Subclass。
由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript由于采用原型继承,我们无法直接扩展一个Class,因为根本不存在Class这种类型。
但是办法还是有的。我们先回顾Student构造函数:
1 | function Student(props) { |
以及Student的原型链:

现在,我们要基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent:
1 | function PrimaryStudent(props) { |
但是,调用了Student构造函数不等于继承了Student,PrimaryStudent创建的对象的原型是:
1 | new PrimaryStudent() ----> PrimaryStudent.prototype ----> Object.prototype ----> null |
必须想办法把原型链修改为:
1 | new PrimaryStudent() ----> PrimaryStudent.prototype ----> Student.prototype ----> Object.prototype ----> null |
我们必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,中间对象可以用一个空函数F来实现:
1 | // PrimaryStudent构造函数: |
用一张图来表示新的原型链:

注意,函数F仅用于桥接,我们仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。
Class继承
JavaScript的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。
新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。
我们先回顾用函数实现Student的方法:
1 | function Student(name) { |
如果用新的class关键字来编写Student:
1 | class Student { |
比较一下就可以发现,class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}这样分散的代码。
最后,创建一个Student对象代码和前面完全一样:
1 | var xiaoming = new Student('小明'); |
取值函数(getter)和设值函数(setter)
与 ES5 一样,在“类”的内部可以使用get和set关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
1 | class Person{ |
注意不能这么写:
1 | class Person{ |
原因是,set 跟 get就是对成员属性的存取,当你调用 this.name的时候如果后面跟了赋予的值,则会调用对应的set方法,如果后面没有跟值,就是获取,则会调用对应的get方法,上面是因为,在给name赋值的时候,this.name = value,然后它一直调用set name 方法,没有尽头,这样当然会报错。
静态方法
类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。
1 | class Foo { |
class继承
用class定义对象的另一个巨大的好处是继承更方便了。想一想我们从Student派生一个PrimaryStudent需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过extends来实现:
1 | class PrimaryStudent extends Student { |
注意PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要name和grade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。
PrimaryStudent已经自动获得了父类Student的hello方法,我们又在子类中定义了新的myGrade方法。
ES6引入的class和原有的JavaScript原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让JavaScript引擎去实现原来需要我们自己编写的原型链代码。简而言之,用class的好处就是极大地简化了原型链代码。
模块化Module
模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。
export
一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。
1 | // profile.js |
上面代码是profile.js文件,保存了用户信息。ES6 将其视为一个模块,里面用export命令对外部输出了三个变量。
export的写法,除了像上面这样,还有另外一种。
1 | // profile.js |
上面代码在export命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。
export命令除了输出变量,还可以输出函数或类(class)。
1 | export function multiply(x, y) { |
通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。
1 | function v1() { ... } |
上面代码使用as关键字,重命名了函数v1和v2的对外接口。重命名后,v2可以用不同的名字输出两次。
需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。
1 | // 报错 |
上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m,还是直接输出 1。1只是一个值,不是接口。
同样的,function和class的输出,也必须遵守这样的写法。
1 | // 报错 |
import
使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。
1 | // main.js |
上面代码的import命令,用于加载profile.js文件,并从中输入变量。import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。
如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。
import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。
1 | import {a} from './xxx.js' |
上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。
1 | import {a} from './xxx.js' |
a的属性可以成功改写,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。
import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。
1 | import {myMethod} from 'util'; |
上面代码中,util是模块文件名,由于不带有路径,必须通过配置,告诉引擎怎么取到这个模块。
注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。
1 | foo(); |
上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。
由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。
1 | // 报错 |
上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。
如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。
1 | import 'lodash'; |
上面代码加载了两次lodash,但是只会执行一次。
模块的整体加载
除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。
下面是一个circle.js文件,它输出两个方法area和circumference。
1 | // circle.js |
现在,加载这个模块。
1 | // main.js |
上面写法是逐一指定要加载的方法,整体加载的写法如下。
1 | import * as circle from './circle'; |
注意,模块整体加载所在的那个对象(上例是circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。
1 | import * as circle from './circle'; |
export default命令
使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。
为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。
1 | // export-default.js |
上面代码是一个模块文件export-default.js,它的默认输出是一个函数。
其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。
1 | // import-default.js |
上面代码的import命令,可以用任意名称指向export-default.js输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号。
export default命令用在非匿名函数前,也是可以的。
1 | // export-default.js |
上面代码中,foo函数的函数名foo,在模块外部是无效的。加载的时候,视同匿名函数加载。
export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能唯一对应export default命令。
本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。
正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。
1 | // 正确 |
上面代码中,export default a的含义是将变量a的值赋给变量default。所以,最后一种写法会报错。
