JavaScript 学习笔记(六)

JavaScript的所有数据都可以看成对象

JavaScript不区分类和实例的概念,而是通过原型(prototype)来实现面向对象编程。

原型是指当我们想要创建xiaoming这个具体的学生时,我们并没有一个Student类型可用。那怎么办?恰好有这么一个现成的对象:

1
2
3
4
5
6
7
8
9
10
11
12
const student = {
name:"Student",
height:1.2,
run(){
console.log(this.name + " is running...");
}
};
const xiaoming = {
name: "小明"
};
xiaoming.__proto__ = student;
xiaoming.run(); // 小明 is running...

注意最后一行代码把xiaoming的原型指向了对象Student,看上去xiaoming仿佛是从Student继承下来的.

在编写JavaScript代码时,不要直接用obj.__proto__去改变一个对象的原型

Object.create()方法可以传入一个原型对象,并创建一个基于该原型的新对象,但是新对象什么属性都没有,因此,我们可以编写一个函数来创建xiaoming

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 原型对象:
var Student = {
name: 'Robot',
height: 1.2,
run: function () {
console.log(this.name + ' is running...');
}
};

function createStudent(name) {
// 基于Student原型创建一个新对象:
var s = Object.create(Student);
// 初始化新对象:
s.name = name;
return s;
}

var xiaoming = createStudent('小明');
xiaoming.run(); // 小明 is running...
xiaoming.__proto__ === Student; // true

对象的扩展

属性的简介表示法

ES6 允许直接写入变量和函数,作为对象的属性和方法。这样的书写更加简洁。

1
2
3
4
5
6
const foo = 'bar';
const baz = {foo};
baz // {foo: "bar"}

// 等同于
const baz = {foo: foo};

除了属性简写,方法也可以简写。

1
2
3
4
5
6
7
8
9
10
11
12
13
const o = {
method() {
return "Hello!";
}
};

// 等同于

const o = {
method: function() {
return "Hello!";
}
};

属性名表达式

JavaScript 定义对象的属性,有两种方法。

1
2
3
4
5
// 方法一
obj.foo = true;

// 方法二
obj['a' + 'bc'] = 123;

Object.is()

ES5 比较两个值是否相等,只有两个运算符:相等运算符(==)和严格相等运算符(===)。它们都有缺点,前者会自动转换数据类型,后者的NaN不等于自身,以及+0等于-0。JavaScript 缺乏一种运算,在所有环境中,只要两个值是一样的,它们就应该相等。

ES6 提出“Same-value equality”(同值相等)算法,用来解决这个问题。Object.is就是部署这个算法的新方法。它用来比较两个值是否严格相等,与严格比较运算符(===)的行为基本一致。

1
2
3
4
Object.is('foo', 'foo')
// true
Object.is({}, {})
// false

不同之处只有两个:一是+0不等于-0,二是NaN等于自身。

1
2
3
4
5
+0 === -0 //true
NaN === NaN // false

Object.is(+0, -0) // false
Object.is(NaN, NaN) // true

Object.assign()

Object.assign方法用于对象的合并,将源对象(source)的所有可枚举属性,复制到目标对象(target)。

1
2
3
4
5
6
7
const target = { a: 1 };

const source1 = { b: 2 };
const source2 = { c: 3 };

Object.assign(target, source1, source2);
target // {a:1, b:2, c:3}

Object.assign方法的第一个参数是目标对象,后面的参数都是源对象。

注意,如果目标对象与源对象有同名属性,或多个源对象有同名属性,则后面的属性会覆盖前面的属性。

注意点,Object.assign方法实行的是浅拷贝,而不是深拷贝。

对于这种嵌套的对象,一旦遇到同名属性,Object.assign的处理方法是替换,而不是添加。

1
2
3
4
const target = { a: { b: 'c', d: 'e' } }
const source = { a: { b: 'hello' } }
Object.assign(target, source)
// { a: { b: 'hello' } }

对象的扩展运算符

1
2
3
4
5
const data = {title: 'origin'};
const newData = {...data,other: 'none'};
// { title: 'origin', other: 'none'}
const newData2 = {...data, title: 'newTitle'};
// { title: 'newTitle' } 可以理解为 Object.assign(data,{title: 'newTitle'});

创建对象

JavaScript对每个创建的对象都会设置一个原型,指向它的原型对象。

当我们用obj.xxx访问一个对象的属性时,JavaScript引擎先在当前对象上查找该属性,如果没有找到,就到其原型对象上找,如果还没有找到,就一直上溯到Object.prototype对象,最后,如果还没有找到,就只能返回undefined

其原型链是:

1
arr ----> Array.prototype ----> Object.prototype ----> null

Array.prototype定义了indexOf()shift()等方法,因此你可以在所有的Array对象上直接调用这些方法。

当我们创建一个函数时:

1
2
3
function foo() {
return 0;
}

函数也是一个对象,它的原型链是:

1
foo ----> Function.prototype ----> Object.prototype ----> null

由于Function.prototype定义了apply()等方法,因此,所有函数都可以调用apply()方法。

很容易想到,如果原型链很长,那么访问一个对象的属性就会因为花更多的时间查找而变得更慢,因此要注意不要把原型链搞得太长。

构造函数

除了直接用{ ... }创建一个对象外,JavaScript还可以用一种构造函数的方法来创建对象。它的用法是,先定义一个构造函数:

1
2
3
4
5
6
function Student(name) {
this.name = name;
this.hello = function () {
alert('Hello, ' + this.name + '!');
}
}

在JavaScript中,可以用关键字new来调用这个函数,并返回一个对象:

1
2
3
var xiaoming = new Student('小明');
xiaoming.name; // '小明'
xiaoming.hello(); // Hello, 小明!

注意,如果不写new,这就是一个普通函数,它返回undefined。但是,如果写了new,它就变成了一个构造函数,它绑定的this指向新创建的对象,并默认返回this,也就是说,不需要在最后写return this;

新创建的xiaoming的原型链是:

1
xiaoming ----> Student.prototype ----> Object.prototype ----> null

也就是说,xiaoming的原型指向函数Student的原型。如果你又创建了xiaohongxiaojun,那么这些对象的原型与xiaoming是一样的:

1
2
3
xiaoming ↘
xiaohong -→ Student.prototype ----> Object.prototype ----> null
xiaojun ↗

new Student()创建的对象还从原型上获得了一个constructor属性,它指向函数Student本身:

1
2
3
4
5
6
xiaoming.constructor === Student.prototype.constructor; // true
Student.prototype.constructor === Student; // true

Object.getPrototypeOf(xiaoming) === Student.prototype; // true

xiaoming instanceof Student; // true

原型继承

在传统的基于Class的语言如Java、C++中,继承的本质是扩展一个已有的Class,并生成新的Subclass。

由于这类语言严格区分类和实例,继承实际上是类型的扩展。但是,JavaScript由于采用原型继承,我们无法直接扩展一个Class,因为根本不存在Class这种类型。

但是办法还是有的。我们先回顾Student构造函数:

1
2
3
4
5
6
7
function Student(props) {
this.name = props.name || 'Unnamed';
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

以及Student的原型链:

2021-02-03-01.png

现在,我们要基于Student扩展出PrimaryStudent,可以先定义出PrimaryStudent

1
2
3
4
5
function PrimaryStudent(props) {
// 调用Student构造函数,绑定this变量:
Student.call(this, props);
this.grade = props.grade || 1;
}

但是,调用了Student构造函数不等于继承了StudentPrimaryStudent创建的对象的原型是:

1
new PrimaryStudent() ----> PrimaryStudent.prototype ----> Object.prototype ----> null

必须想办法把原型链修改为:

1
new PrimaryStudent() ----> PrimaryStudent.prototype ----> Student.prototype ----> Object.prototype ----> null

我们必须借助一个中间对象来实现正确的原型链,这个中间对象的原型要指向Student.prototype。为了实现这一点,中间对象可以用一个空函数F来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// PrimaryStudent构造函数:
function PrimaryStudent(props) {
Student.call(this, props);
this.grade = props.grade || 1;
}

// 空函数F:
function F() {
}

// 把F的原型指向Student.prototype:
F.prototype = Student.prototype;

// 把PrimaryStudent的原型指向一个新的F对象,F对象的原型正好指向Student.prototype:
PrimaryStudent.prototype = new F();

// 把PrimaryStudent原型的构造函数修复为PrimaryStudent:
PrimaryStudent.prototype.constructor = PrimaryStudent;

// 继续在PrimaryStudent原型(就是new F()对象)上定义方法:
PrimaryStudent.prototype.getGrade = function () {
return this.grade;
};

// 创建xiaoming:
var xiaoming = new PrimaryStudent({
name: '小明',
grade: 2
});
xiaoming.name; // '小明'
xiaoming.grade; // 2

// 验证原型:
xiaoming.__proto__ === PrimaryStudent.prototype; // true
xiaoming.__proto__.__proto__ === Student.prototype; // true

// 验证继承关系:
xiaoming instanceof PrimaryStudent; // true
xiaoming instanceof Student; // true

用一张图来表示新的原型链:

2021-02-03-02.png

注意,函数F仅用于桥接,我们仅创建了一个new F()实例,而且,没有改变原有的Student定义的原型链。

Class继承

JavaScript的对象模型是基于原型实现的,特点是简单,缺点是理解起来比传统的类-实例模型要困难,最大的缺点是继承的实现需要编写大量代码,并且需要正确实现原型链。

新的关键字class从ES6开始正式被引入到JavaScript中。class的目的就是让定义类更简单。

我们先回顾用函数实现Student的方法:

1
2
3
4
5
6
7
function Student(name) {
this.name = name;
}

Student.prototype.hello = function () {
alert('Hello, ' + this.name + '!');
}

如果用新的class关键字来编写Student:

1
2
3
4
5
6
7
8
9
class Student {
constructor(name) {
this.name = name;
}

hello() {
alert('Hello, ' + this.name + '!');
}
}

比较一下就可以发现,class的定义包含了构造函数constructor和定义在原型对象上的函数hello()(注意没有function关键字),这样就避免了Student.prototype.hello = function () {...}这样分散的代码。

最后,创建一个Student对象代码和前面完全一样:

1
2
var xiaoming = new Student('小明');
xiaoming.hello();

取值函数(getter)和设值函数(setter)

与 ES5 一样,在“类”的内部可以使用getset关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person{
get getName(){
return `my name is ${this.name}`;
}
set setName(value){
this.name = value;
}
}

let example = new Person();
example.setName = "hello";
console.log(example.getName);
// my name is hello

注意不能这么写

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person{
get name(){
return `my name is ${this.name}`;
}
set name(value){
this.name = value;
}
}

let example = new Person();
example.name = "hello";
console.log(example.name);
// 这样直接报错 Maximum call stack size exceeded

原因是,set 跟 get就是对成员属性的存取,当你调用 this.name的时候如果后面跟了赋予的值,则会调用对应的set方法,如果后面没有跟值,就是获取,则会调用对应的get方法,上面是因为,在给name赋值的时候,this.name = value,然后它一直调用set name 方法,没有尽头,这样当然会报错。

静态方法

类相当于实例的原型,所有在类中定义的方法,都会被实例继承。如果在一个方法前,加上static关键字,就表示该方法不会被实例继承,而是直接通过类来调用,这就称为“静态方法”。

1
2
3
4
5
class Foo {
static classMethod() {
return 'hello';
}
}

class继承

class定义对象的另一个巨大的好处是继承更方便了。想一想我们从Student派生一个PrimaryStudent需要编写的代码量。现在,原型继承的中间对象,原型对象的构造函数等等都不需要考虑了,直接通过extends来实现:

1
2
3
4
5
6
7
8
9
10
class PrimaryStudent extends Student {
constructor(name, grade) {
super(name); // 记得用super调用父类的构造方法!
this.grade = grade;
}

myGrade() {
alert('I am at grade ' + this.grade);
}
}

注意PrimaryStudent的定义也是class关键字实现的,而extends则表示原型链对象来自Student。子类的构造函数可能会与父类不太相同,例如,PrimaryStudent需要namegrade两个参数,并且需要通过super(name)来调用父类的构造函数,否则父类的name属性无法正常初始化。

PrimaryStudent已经自动获得了父类Studenthello方法,我们又在子类中定义了新的myGrade方法。

ES6引入的class和原有的JavaScript原型继承有什么区别呢?实际上它们没有任何区别,class的作用就是让JavaScript引擎去实现原来需要我们自己编写的原型链代码。简而言之,用class的好处就是极大地简化了原型链代码。

模块化Module

模块功能主要由两个命令构成:exportimportexport命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

export

一个模块就是一个独立的文件。该文件内部的所有变量,外部无法获取。如果你希望外部能够读取模块内部的某个变量,就必须使用export关键字输出该变量。下面是一个 JS 文件,里面使用export命令输出变量。

1
2
3
4
// profile.js
export var firstName = 'Michael';
export var lastName = 'Jackson';
export var year = 1958;

上面代码是profile.js文件,保存了用户信息。ES6 将其视为一个模块,里面用export命令对外部输出了三个变量。

export的写法,除了像上面这样,还有另外一种。

1
2
3
4
5
6
// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;

export { firstName, lastName, year };

上面代码在export命令后面,使用大括号指定所要输出的一组变量。它与前一种写法(直接放置在var语句前)是等价的,但是应该优先考虑使用这种写法。因为这样就可以在脚本尾部,一眼看清楚输出了哪些变量。

export命令除了输出变量,还可以输出函数或类(class)。

1
2
3
export function multiply(x, y) {
return x * y;
};

通常情况下,export输出的变量就是本来的名字,但是可以使用as关键字重命名。

1
2
3
4
5
6
7
8
function v1() { ... }
function v2() { ... }

export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};

上面代码使用as关键字,重命名了函数v1v2的对外接口。重命名后,v2可以用不同的名字输出两次。

需要特别注意的是,export命令规定的是对外的接口,必须与模块内部的变量建立一一对应关系。

1
2
3
4
5
6
// 报错
export 1;

// 报错
var m = 1;
export m;

上面两种写法都会报错,因为没有提供对外的接口。第一种写法直接输出 1,第二种写法通过变量m,还是直接输出 1。1只是一个值,不是接口。

同样的,functionclass的输出,也必须遵守这样的写法。

1
2
3
4
5
6
7
8
9
10
// 报错
function f() {}
export f;

// 正确
export function f() {};

// 正确
function f() {}
export {f};

import

使用export命令定义了模块的对外接口以后,其他 JS 文件就可以通过import命令加载这个模块。

1
2
3
4
5
6
// main.js
import { firstName, lastName, year } from './profile.js';

function setName(element) {
element.textContent = firstName + ' ' + lastName;
}

上面代码的import命令,用于加载profile.js文件,并从中输入变量。import命令接受一对大括号,里面指定要从其他模块导入的变量名。大括号里面的变量名,必须与被导入模块(profile.js)对外接口的名称相同。

如果想为输入的变量重新取一个名字,import命令要使用as关键字,将输入的变量重命名。

import命令输入的变量都是只读的,因为它的本质是输入接口。也就是说,不允许在加载模块的脚本里面,改写接口。

1
2
3
import {a} from './xxx.js'

a = {}; // Syntax Error : 'a' is read-only;

上面代码中,脚本加载了变量a,对其重新赋值就会报错,因为a是一个只读的接口。但是,如果a是一个对象,改写a的属性是允许的。

1
2
3
import {a} from './xxx.js'

a.foo = 'hello'; // 合法操作

a的属性可以成功改写,并且其他模块也可以读到改写后的值。不过,这种写法很难查错,建议凡是输入的变量,都当作完全只读,不要轻易改变它的属性。

import后面的from指定模块文件的位置,可以是相对路径,也可以是绝对路径,.js后缀可以省略。如果只是模块名,不带有路径,那么必须有配置文件,告诉 JavaScript 引擎该模块的位置。

1
import {myMethod} from 'util';

上面代码中,util是模块文件名,由于不带有路径,必须通过配置,告诉引擎怎么取到这个模块。

注意,import命令具有提升效果,会提升到整个模块的头部,首先执行。

1
2
3
foo();

import { foo } from 'my_module';

上面的代码不会报错,因为import的执行早于foo的调用。这种行为的本质是,import命令是编译阶段执行的,在代码运行之前。

由于import是静态执行,所以不能使用表达式和变量,这些只有在运行时才能得到结果的语法结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 报错
import { 'f' + 'oo' } from 'my_module';

// 报错
let module = 'my_module';
import { foo } from module;

// 报错
if (x === 1) {
import { foo } from 'module1';
} else {
import { foo } from 'module2';
}

上面三种写法都会报错,因为它们用到了表达式、变量和if结构。在静态分析阶段,这些语法都是没法得到值的。

如果多次重复执行同一句import语句,那么只会执行一次,而不会执行多次。

1
2
import 'lodash';
import 'lodash';

上面代码加载了两次lodash,但是只会执行一次。

模块的整体加载

除了指定加载某个输出值,还可以使用整体加载,即用星号(*)指定一个对象,所有输出值都加载在这个对象上面。

下面是一个circle.js文件,它输出两个方法areacircumference

1
2
3
4
5
6
7
8
9
// circle.js

export function area(radius) {
return Math.PI * radius * radius;
}

export function circumference(radius) {
return 2 * Math.PI * radius;
}

现在,加载这个模块。

1
2
3
4
5
6
// main.js

import { area, circumference } from './circle';

console.log('圆面积:' + area(4));
console.log('圆周长:' + circumference(14));

上面写法是逐一指定要加载的方法,整体加载的写法如下。

1
2
3
4
import * as circle from './circle';

console.log('圆面积:' + circle.area(4));
console.log('圆周长:' + circle.circumference(14));

注意,模块整体加载所在的那个对象(上例是circle),应该是可以静态分析的,所以不允许运行时改变。下面的写法都是不允许的。

1
2
3
4
5
import * as circle from './circle';

// 下面两行都是不允许的
circle.foo = 'hello';
circle.area = function () {};

export default命令

使用import命令的时候,用户需要知道所要加载的变量名或函数名,否则无法加载。

为了给用户提供方便,让他们不用阅读文档就能加载模块,就要用到export default命令,为模块指定默认输出。

1
2
3
4
// export-default.js
export default function () {
console.log('foo');
}

上面代码是一个模块文件export-default.js,它的默认输出是一个函数。

其他模块加载该模块时,import命令可以为该匿名函数指定任意名字。

1
2
3
// import-default.js
import customName from './export-default';
customName(); // 'foo'

上面代码的import命令,可以用任意名称指向export-default.js输出的方法,这时就不需要知道原模块输出的函数名。需要注意的是,这时import命令后面,不使用大括号。

export default命令用在非匿名函数前,也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
// export-default.js
export default function foo() {
console.log('foo');
}

// 或者写成

function foo() {
console.log('foo');
}

export default foo;

上面代码中,foo函数的函数名foo,在模块外部是无效的。加载的时候,视同匿名函数加载。

export default命令用于指定模块的默认输出。显然,一个模块只能有一个默认输出,因此export default命令只能使用一次。所以,import命令后面才不用加大括号,因为只可能唯一对应export default命令。

本质上,export default就是输出一个叫做default的变量或方法,然后系统允许你为它取任意名字。

正是因为export default命令其实只是输出一个叫做default的变量,所以它后面不能跟变量声明语句。

1
2
3
4
5
6
7
8
9
// 正确
export var a = 1;

// 正确
var a = 1;
export default a;

// 错误
export default var a = 1;

上面代码中,export default a的含义是将变量a的值赋给变量default。所以,最后一种写法会报错。

文章作者: Jie
文章链接: https://blog.zhangjie.me/2021/02/03/JavaScript-Learning-Notes-six/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JieのBlog