JavaScript 操作符

位操作符

ECMAScript 中的所有数值都以 IEEE 754 64 位格式存储,但位操作并不直接应用到 64 位表示,而是先把值转换为 32 位整数,再进行位操作,之后再把结果转换为 64 位。对开发者而言,就好像只有 32 位整数一样,因 为 64 位整数存储格式是不可见的。这让二进制操作变得与其他语言中类似。但这个转换也导致了一个奇特的副作用,即特殊值 NaNInfinity 在位操作中都会被当成 0 处理。

有符号整数使用 32 位的前 31 位表示整数值。第 32 位表示数值的符号,如 0 表示正,1 表示负。这 一位称为符号位(sign bit),它的值决定了数值其余部分的格式。正值以真正的二进制格式存储,即 31 位中的每一位都代表 2 的幂。第一位(称为第 0 位)表示 20 ,第二位表示 21 ,依此类推。如果一个位是空的,则以 0填充,相当于忽略不计。

负值以一种称为二补数(或补码)的二进制编码存储。一个数值的二补数通过如下 3 个步骤计算 得到:

  1. 确定绝对值的二进制表示

  2. 找到数值的一补数(或反码),换句话说,就是每个 0 都变成 1,每个 1 都变成 0

  3. 给结果加 1

默认情况下,ECMAScript 中的所有整数都表示为有符号数。不过,确实存在无符号整数。对无符号整数来说,第 32 位不表示符号,因为只有正值。无符号整数比有符号整数的范围更大,因为符号位被用来表示数值了。

1
2
3
4
5
6
console.log(1 << 10); // 1024
console.log(-1 << 10); // -1024
console.log(1024 >> 10); // 1
console.log(-1024 >> 10); // -1
console.log(1024 >>> 10); // 1
console.log(-1024 >>> 10); // 4194303

相等操作符

全等和不全等操作符与相等和不相等操作符类似,只不过它们在比较相等时不转换操作数。全等操 作符由 3 个等于号(===)表示,只有两个操作数在不转换的前提下相等才返回 true

JavaScript 数据类型

ECMAScript 有 6 种简单数据类型(也称为原始类型):UndefinedNullBooleanNumberStringSymbol。还有一种复杂数据类型叫 Object(对 象)。Object 是一种无序名值对的集合。

UndefinedNull

Undefined 类型只有一个值 undefined,当使用 letvar 声明变量单并没有初始化时,就相当于给变量赋予了 undefined 值。

一般来说不建议显式的给变量赋值 undefined,字面值 undefined 主要是用来比较。

1
2
3
4
5
let v1;
var v2;
console.log(v1); // undefined
console.log(v2); // undefined
console.log(v1 === v2); // true

增加这个特殊值的目的就是为了正式明确空对象指针 null 和未初始化变量的区别。

undefined 值是由 null 值派生而来的,因此 ECMA-262 将它们定义为表面上相等,如下所示:

1
2
console.log(null === undefined); // false
console.log(null == undefined); // true

Null 类型同样只有一个值,即特殊值 null。逻辑上讲,null 值表示一个空对象指针,这也是给 typeof 传一个 null 会返回 “object” 的原因。

1
2
let v3 = null;
console.log(typeof v3); // "object"

在定义将来要保存对象值的变量时,建议使用 null 来初始化,不要使用其他值。这样,只要检查这个变量的值是不是 null 就可以知道这个变量是否在后来被重新赋予了一个对象的引用。

undefinednull 都是假值,如果只是为了检测假值,可以方便的检测它们;如果是明确的想要检测 undefinednull,则需要显式的对其进行判断。

1
2
3
4
5
6
let v4;
let o1 = null;
console.log(!v4); // true
console.log(v4 === undefined); // true
console.log(!o1); // true
console.log(o1 === null); // true

Boolean

Boolean 类型是使用非常频繁的数据类型,它有两个值 truefalse (布尔值字面量是区分大小写的)。

这两个布尔值并不等同于数值:

1
2
3
4
let t1 = true;
let f1 = false;
console.log(t1 === 1); // false
console.log(f1 === 0); // false

虽然布尔值只有两个,但所有其他 ECMAScript 类型的值都有相应布尔值的等价形式。要将一个其他类型的值转换为布尔值,可以调用特定的 Boolean() 转型函数。

1
2
3
4
5
6
7
8
9
10
11
console.log(Boolean(true)); // true
console.log(Boolean(false)); // false
console.log(Boolean('true')); // true
console.log(Boolean('false')); // true
console.log(Boolean(0)); // false
console.log(Boolean(-0)); // false
console.log(Boolean(NaN)); // false
console.log(Boolean(1)); // true
console.log(Boolean(-1)); // true
console.log(Boolean('')); // false
console.log(Boolean(' ')); // true

Number

Number 类型使用 IEEE 754 格式表示整 数和浮点值(在某些语言中也叫双精度值)。不同的数值类型相应地也有不同的数值字面量格式。

image-20201223142823585

整数可以用八进制、十进制和十六进制字面量表示,但是使用八进制和十六进制格式创建的数值在所有数学操作中都被视为十进制数值。

1
2
3
4
5
6
let octalNum = 077;
let hexNum = 0xff;
console.log(octalNum); // 63
console.log(hexNum); // 255
let sum = octalNum + hexNum;
console.log(sum); // 318

浮点值

要定义浮点值,数值中必须包含小数点,而且小数点后面必须至少有一个数字。虽然小数点前面不是必须有整数,但推荐加上。

1
2
let float1 = 1.01;
let float2 = .01;

因为存储浮点值使用的内存空间是存储整数值的两倍,所以 ECMAScript 总是想方设法把值转换为 整数。在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小 数点后面跟着 0(如 1.0),那它也会被转换为整数。

对于非常大或非常小的数值,浮点值可以用科学记数法来表示。科学记数法用于表示一个应该乘以 10 的给定次幂的数值。ECMAScript 中科学记数法的格式要求是一个数值(整数或浮点数)后跟一个大 写或小写的字母 e,再加上一个要乘的 10 的多少次幂

1
2
3
4
let float3 = 2.23e9;
console.log(float3); //2230000000
let float4 = 1.01e-5;
console.log(float4); // 0.0000101

浮点值的精确度最高可达 17 位小数,但在算术计算中远不如整数精确。例如,0.1 加 0.2 得到的不 是 0.3,而是 0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。

1
2
3
let float5 = 0.1;
let float6 = 0.2;
console.log(float5 + float6); // 0.30000000000000004

之所以存在这种舍入错误,是因为使用了 IEEE 754 数值,这种错误并非 ECMAScript 所独有。其他使用相同格式的语言也有这个问题。

因此永远不要测试某个特定的浮点值。

使用最小精度值来比较浮点数:

1
console.log( Math.abs(0.1 + 0.2 - 0.3) <= Number.EPSILON);

值的范围

ECMAScript 可以表示的最小 数值保存在 Number.MIN_VALUE 中,这个值在多数浏览器中是 5e-324;可以表示的最大数值保存在 Number.MAX_VALUE 中,这个值在多数浏览器中是 1.7976931348623157e+308。如果某个计算得到的数值结果超出了 JavaScript 可以表示的范围,那么这个数值会被自动转换为一个特殊的 Infinity(无 穷)值。任何无法表示的负数以 -Infinity(负无穷大)表示,任何无法表示的正数以 Infinity(正 无穷大)表示。

1
2
console.log(Number.MIN_VALUE); // 5e-234
console.log(Number.MAX_VALUE); // 1.7976931348623157e+308

如果计算返回正 Infinity 或负 Infinity , 则该值将不能再进一步用于任何计算。 这是因为 Infinity 没有可用于计算的数值表示形式。要确定一个值是不是有限大(即介于 JavaScript 能表示的最小值和最大值之间),可以使用 isFinite() 函数。

1
2
3
4
let num1 = 6e-325;
let num2 = 1.8e308;
console.log(Number.isFinite(num1)); // false
console.log(Number.isFinite(num2)); // false

Number.NEGATIVE_INFINITYNumber.POSITIVE_INFINITY 也可以获取正、负 Infinity。没错,这两个属性包含的值分别就是 -InfinityInfinity

NaN

有一个特殊的数值叫 NaN,意思是 “不是数值”(Not a Number),用于表示本来要返回数值的操作 失败了(而不是抛出错误)。比如,用 0 除任意数值在其他语言中通常都会导致错误,从而中止代码执 行。但在 ECMAScript 中,0、+0 或-0 相除会返回 NaN

1
2
3
4
console.log(Number.parseFloat('f1.f')); // NaN
console.log(1/0); // Infinity
console.log(0/1); // 0
console.log(0/0); // NaN

NaN 有几个独特的属性。首先,任何涉及 NaN 的操作始终返回 NaN(如 NaN/10),在连续多步计算 时这可能是个问题。其次,NaN 不等于包括 NaN 在内的任何值。

1
2
console.log(0/0 === NaN); // false
console.log(NaN === NaN); // false

要判断一个值是不是 NaN, 可以使用 isNaN() 函数。

1
2
console.log(Number.isNaN(0/0)); // true
console.log(Number.isNaN(true)); // false

数值转换

JavaScript 中提供了 Number()parseInt()parseFloat() 三个方法用来将非数值转换为数值。

Number
1
2
3
4
5
6
7
8
console.log(Number(true)); // 1
console.log(Number(false)); // 0
console.log(Number(1000)); // 1000
console.log(Number(null)); // 0
console.log(Number(undefined)); // NaN
console.log(Number('12')); // 12
console.log(Number(' 12')); // 12
console.log(Number('12-')); // NaN
parseInt()

parseInt() 函数更专注于字符串是否包含数值模式。字符串最前面的空格会被忽略,从第一个非空格字符开始转换。如果第一个字符不是数值字符、加号或减号,parseInt() 立即 返回 NaN。这意味着空字符串也会返回 NaN(这一点跟 Number() 不一样,它返回 0)。如果第一个字符 是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾,或碰到非数值字符。

1
2
3
4
5
6
console.log(parseInt(100)); // 100
console.log(parseInt('1000')); // 1000
console.log(parseInt('1000lll')); // 1000
console.log(parseInt(' 1000')); // 1000
console.log(parseInt('001000')); // 1000
console.log(parseInt('a 1000')); // NaN

parseInt() 也接收第二个参数,用于指定底数(进制数)。

1
2
console.log(parseInt('077', 8)); // 63
console.log(parseInt('0xff', 16)); // 255

不传底数参数相当于让 parseInt() 自己决定如何解析,所以为避免解析出错,建议始终传给 它第二个参数。

parseFloat()

parseFloat() 函数的工作方式跟 parseInt() 函数类似,都是从位置 0 开始检测每个字符。同样, 它也是解析到字符串末尾或者解析到一个无效的浮点数值字符为止。这意味着第一次出现的小数点是有效的,但第二次出现的小数点就无效了,此时字符串的剩余字符都会被忽略。

1
2
3
4
5
6
7
console.log(parseFloat('-1.01')); // -1.01
console.log(parseFloat('1.01')); // 1.01
console.log(parseFloat('1.01.01')); // 1.01
console.log(parseFloat(' 1.01')); // 1.01
console.log(parseFloat('a1.01')); // NaN
console.log(parseFloat('1.01a')); // 1.01
console.log(parseFloat('00001.01a')); // 1.01

String

String(字符串)数据类型表示零或多个 16 位 Unicode 字符序列。

image-20201223160958920

ECMAScript 中的字符串是不可变的(immutable),意思是一旦创建,它们的值就不能变了。要修改某个变量中的字符串值,必须先销毁原始的字符串,然后将包含新值的另一个字符串保存到该变量。

把值转换为字符串

使用 toString() 方法

nullundefined 没有 toString() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
let val1 = 12;
let val2= '12';
let val3 = [1,2];
let val4 = new Date();
let val5 = {};

// 12 12 1,2 Fri Dec 25 2020 13:48:14 GMT+0800 (China Standard Time) [object Object]
console.log(val1.toString(), val2.toString(), val3.toString(), val4.toString(), val5.toString());

null.toString(); // Uncaught TypeError: Cannot read property 'toString' of undefined

undefined.toString(); // Uncaught TypeError: Cannot read property 'toString' of null

在对数字类型值调用 toString() 方法时,可传递一个参数指定以什么底数输出数值的字符串表示。

1
2
3
4
5
6
let numVal = 123;

console.log(numVal.toString(2)); // 1111011
console.log(numVal.toString(8)); // 173
console.log(numVal.toString(10)); // 123
console.log(numVal.toString(16)); // 7b
使用 String()

在使用 String() 将值转换为字符串时,如果值有 toString() 方法,则直接调用 toString() 方法并返回结果。对于没有 toString() 方法的 nullundefined ,则会分别返回 “null” 和 “undefiend”。

1
2
console.log(String(null));
console.log(String(undefined));

Symbol

Symbol(符号)是 ECMAScript 6 新增的数据类型。符号是原始值,且符号实例是唯一不可变的。 符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。

尽管听起来跟私有属性有点类似,但符号并不是为了提供私有属性的行为才增加的。相反,符号就是用来创建唯一记号,进而用作非字符串形式的对象属性。

1
2
3
4
let s1 = Symbol();
let s2 = Symbol('s2');
console.log(s1);
console.log(s2);

Object

ECMAScript 中的对象其实就是一组数据和功能的集合。对象通过 new 操作符后跟对象类型的名称 来创建。

JavaScript 变量作用域

变量作用域

变量能够被访问的范围被称为变量作用域。

变量声明

ES6 之前所有的变量都使用 var 关键字进行声明,ES6 中新增了 letconst 两种变量声明的关键字。

  • 使用 var 声明变量

    使用 var 声明的变量会被自动添加到最靠近的上下文中,如果变量未经声明就初始化了,会被添加到全局上下文中。

    var 声明的变量会被拿到函数或全局作用域的顶部,这种现象被称为变量提升。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提 升也会导致合法却奇怪的现象,即在变量声明之前使用变量。

    单需要注意的是:变量的声明会提升,但变量的赋值不会。

  • 使用 let 的块级作用域声明

    ES6 新增的 let 关键字跟 var 很相似,但它的作用域是块级的,这也是 JavaScript 中的新概念。块级作用域由最近的一对包含花括号 {} 界定。换句话说,if 块、while 块、function 块,甚至连单独 的块也是 let 声明变量的作用域。

    letvar 的另一个不同之处是在同一作用域内不能声明两次。

    重复的 var 声明会被忽略,而重 复的 let 声明会抛出 SyntaxError

  • 使用 const 的常量声明

    ES6 同时还增加了 const 关键字。使用 const 声明的变量必须同时初始化为某个值。 一经声明,在其生命周期的任何时候都不能再重新赋予新值。

    由于 const 声明暗示变量的值是单一类型且不可修改,JavaScript 运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找。谷歌的 V8 引擎就执行这种优化。

JavaScript delete 操作符

delete 操作符用于删除对象的某个属性,如果没有指向这个属性的引用,它最终将会被释放。

1
delete obj['property'];

对于所有结果都返回 true,除非属性是一个不可配置属性,这种情况,在非严格模式下返回 false,在严格模式下则抛出 TypeError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const originO = {
name: 'Mike',
age: 29,
speak() {
console.log(`I am ${this.name}`);
}
}

Object.defineProperty(originO, 'sex', {
value: '女'
configurable: false
});

console.log(delete originO.sex); // false

'use strict'
console.log(delete originO.sex); // TypeError: Cannot delete property 'sex' of #<Object>
  • 如果要删除的属性不存在,delete 不会有任何作用,但仍返回 true

  • delete 只能删除对象本身上的属性,不会删除原型链上的同名属性(如果有的话)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    function originO() {
    this.name = 'originO';
    }

    originO.prototype.name = 'name in prototype';

    let obj = new originO();

    delete obj.name;

    console.log(obj.name); // name in prototype
  • 任何使用 var 声明的属性不能从全局作用域或函数的作用域中删除

    1
    2
    3
    4
    5
    var globalVar = 'XYZ';

    const descriptor = Object.getOwnPropertyDescriptor(window, 'globalVar');

    console.log(descriptor); // { ..., configurable: false, ... }

    可以看到 var 声明的属性不可配置

  • 任何使用 letconst 声明的属性不能从它声明的作用域中被删除

  • delete 删除数组元素

    1
    2
    3
    4
    5
    6
    7
    const arr = [ 1, 2, 3, 4 ];

    delete arr[3];

    console.log(arr); // [ 1, 2, 3, <1 empty item> ]
    console.log(arr.length); // 4
    console.log(arr[3]); // undefined

JavaScript 原型和继承

很多面向对象语言都支持两种继承实现方式:接口继承和实现继承 - 接口继承继承方法签名,实现继承则继承方法的实现。但由于 JavaScript 中没有方法签名,因此只支持实现继承者一种方式,而原型链正是实现它的方式。

原型链

JavaScript 中每个对象都有一个被称为原型的持有其它对象引用的私有属性,而原型对象也有其自己的原型,以此类推,这种链条持续到某个原型对象的原型为 null 。根据定义,null 是没有对象的,被作为原型链的最后一环。

当试图访问一个对象的属性时,不仅会搜索对象本身 - 如果对象本身未找到对应的属性,则会沿着对象的原型链层层向上搜索,直到找到匹配的属性或达到原型链的末尾。

每个构造函数都有一个原型对象 ,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。

1
2
3
4
5
6
7
8
9
10
11
function Point(x, y) {
this.x = x;
this.y = y;
}

console.log(Point.__proto__ === Function.prototype); // true
console.log(Point === Point.prototype.constructor); // true

const somePoint = new Point(0, 0);
console.log(somePoint.__proto__ === Point.prototype); // true

如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函 数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function SuperType() {
this.superName = 'Super Type';
}

SuperType.prototype.getSuperTypeName = function() { return this.superName; };

function SubType() {
this.subName = 'Sub Type';
}

SubType.prototype = new SuperType();
SubType.prototype.getSubTypeName = function() { return this.subName; };

const aType = new SubType();

console.log(aType.__proto__ === SubType.prototype); // true
console.log(SubType.prototype.__proto__ === SuperType.prototype); // true
console.log(SuperType.prototype.constructor === SuperType); // true

下图展示了子类的实例与两个构造函数及其对应的原型之间的关系:

image-20201202145300965

实际上,原型链中还有一环。默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。 任何函数的默认原型都是一个 Object 的实例, 这意味着这个实例有一个内部指针指向 Object.prototype。这也是为什么自定义类型能够继承包括 toString()valueOf() 在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系:

image-20201202145812496

继承

原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SuperType() {
this.colors = ["red", "blue", "green"];
}

function SubType() {}

// 继承 SuperType SubType.prototype = new SuperType();

let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"

let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green,black"

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

在子类构造函数中调用父类构造函数,因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()call() 方法以新创建的对象为上下文执行构造函数,这种实现继承的方法被称为盗用构造函数继承

这种方法的问题是必须在构造函数中定义方法,因此函数不能重用,而且子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。

使用原型链继承原型上的属性和方法,通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性,这种实现继承的方法被称为组合继承

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
function SuperType(name) { 
this.name = name;
this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() { console.log(this.name); };

function SubType(name, age){
// 继承属性
SuperType.call(this, name);
this.age = age;
}

// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() { console.log(this.age); };

let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29

let instance2 = new SubType("Greg", 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力。

其它补充

函数的 prototype

每个函数都有 prototype 属性,它默认包含了一个只有 constructor 属性的对象,这个 constructor 属性指向函数自身。

image-20201202160040769
1
2
3
function Foo() {}

console.log(Foo.prototype.constructor === Foo); // true

在使用 new 操作符创建对象时,构造函数的 prototype 被赋值给被创建的对象实例的 [[prototype]] 属性。

image-20201202160157525
1
2
3
4
5
6
7
8
function Foo() {}

const foo = new Foo();

console.log(foo.constructor === Foo); // true
console.log(foo.__proto__ === Foo.prototype); // true

const anotherFoo = new foo.constructor();

我们可以使用 constructor 属性来创建一个新对象,该对象使用与现有对象相同的构造器。

1
2
3
4
5
6
7
function Foo() {}

const foo = new Foo();

const anotherFoo = new foo.constructor();

console.log(anotherFoo.constructor === Foo); // true

JavaScript 迭代器和生成器

ECMAScript 6 规范新增的两个高级特性:生成器和迭代器能够使我们高效、清晰、方便的实现迭代。

ECMAScript 较早的版本中,执行迭代必须使用循环或其他辅助结构。随着代码量增加,代码会变得越发混乱。很多语言都通过原生语言结构解决了这个问题,开发者无须事先知道如何迭代就能实现迭代操作。这个解决方案就是迭代器模式。PythonJavaC++,还有其他很多语言都对这个模式提供了完备的支持。

生成器和生成器对象

生成器是 ECMAScript 6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。

需要注意的是:箭头函数不能用来定义生成器函数。(箭头函数的作用域根据当前环境决定)

通过生成器函数可以生成一个遵循可迭代协议(Iterable Protocol)和迭代器协议(Iterator Protocol)的生成器对象。

  • 可迭代协议是指:支持迭代的自我识别能力和创建实现 Iterator 接口的对象的能力。在 ECMAScript 中,这意味着必须暴露一个属性作为 “默认迭代器”,而且这个属性必须使用特殊的 [Symbol.iterator] 作为键。这个默认迭代器属性必须引用一个迭代器工厂函数,调用这个工厂函数必须返回一个新迭代器。
  • 迭代器协议是指:含有 next() 方法可以在可迭代对象中遍历数据,并返回 { done: Boolean, value: any } 形式的返回数据,done 用来标识迭代是否完成。

yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。生成器函数在遇到 yield 关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用 next() 方法来恢复执行:

1
2
3
4
5
6
7
8
9
10
11
12
function* generatorFun() {
yield 1;
yield 2;
yield 3;
}

const generatorObj = generatorFun(); // {}

console.log(generatorObj.next()); // { value: 1, done: false }
console.log(generatorObj.next()); // { value: 2, done: false }
console.log(generatorObj.next()); // { value: 3, done: false }
console.log(generatorObj.next()); // { value: undefined, done: true }

生成器对象遵循可迭代协议和迭代器协议,因此它可以通过 for...of 进行迭代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* generatorFun() {
yield 1;
yield 2;
yield 3;
}

const generatorObj = generatorFun();

for (const iterator of generatorObj) {
console.log(iterator);
}

// 1
// 2
// 3

也可以通过 spread 语法输出其值

1
2
3
4
5
6
7
8
9
function* generatorFun() {
yield 1;
yield 2;
yield 3;
}

const generatorObj = generatorFun();

console.log(...generatorObj); // 1 2 3

通过 yield* 可以将执行委托给另一个迭代器,是将一个生成器的流插入到另一个生成的流的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function* generateSquence(start, end) {
for (let i = start; i <= end; i++) yield i;
}

function* generatePwdChars() {
yield* generateSquence(48, 57);
yield* generateSquence(65, 90);
yield* generateSquence(97, 122);
}

let pwdChars = '';
for (const char of generatePwdChars()) {
pwdChars += String.fromCharCode(char);
}

console.log(pwdChars); // 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz

yield 不仅可以向外返回结果,还可以将外部的值传递到生成器内部(第一次调用 next() 方法传递的参数会被忽略)

1
2
3
4
5
6
7
8
9
10
11
12
function* gen() {
// 向外部代码传递一个问题并等待答案
let result = yield "2 + 2 = ?"; // (*)

alert(result);
}

let generator = gen();

let question = generator.next().value; // <-- yield 返回的 value

generator.next(4); // --> 将结果传递到 generator 中

可以通过 generator.throw() 向生成器内部传递一个错误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* gen() {
try {
let result = yield "2 + 2 = ?"; // (1)

console.log("The execution does not reach here, because the exception is thrown above");
} catch(e) {
console.log(e); // Error: The answer is not found in my database
}
}

let generator = gen();

let question = generator.next().value;

generator.throw(new Error("The answer is not found in my database")); // (2)

当迭代值是以异步的形式出现时,就需要使用异步迭代,通过为对象添加 [Symbol.asyncIterator] 方法返回异步迭代器对象,异步迭代器对象通过 for await...of 循环来访问迭代值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let range = {
from: 1,
to: 5,

[Symbol.asyncIterator]: async function* () {
for (let start = this.from; start <= this.to; start++) {
value = awiat doSth();
yield value;
}
}
};

(async () => {
for await (let value of range) { // (4)
alert(value); // 1,2,3,4,5
}
})()

迭代器

JavaScript 中的可迭代对象是对数组的泛化,可以通过 for...of 对其进行迭代。

迭代器是一种一次性使用的对象,用于迭代和它关联的可迭代对象。

Symbol.iterator

可以通过为对象添加一个返回迭代器的 [Symbol.iterator] 方法来使得对象成为可迭代的 – 迭代器是指一个拥有 next() 方法的对象。

通过 for...of 对可迭代对象进行迭代时,for...of 希望获得下一个迭代值时就会调用迭代器对象的 next() 方法,next() 方法返回的对象必须是{ value: any: done: Boolean } 形式的值,当 donetrue 时表示迭代结束,否则表示还可以迭代下一个值。

一般来说,迭代器对象和要迭代的对象是不同的,这样可以避免迭代时造成要迭代对象的属性变化。

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
const range = {
from: 1,
to: 3,
[Symbol.iterator]: function () {
return {
current: this.from,
last: this.to,
next() {
if (this.current <= this.last) {
return { value: this.current++, done: false };
} else {
return { value: undefined, done: true };
}
}
};
}
};

// for...of 希望获得下一个迭代值时就会调用迭代器对象的 next() 方法
// next() 方法返回的对象必须是 { value: any: done: boolean } 形式的值
for (const iterator of range) {
console.log(iterator);
}
// 1
// 2
// 3

使用 for...of 循环对对象进行迭代时,它会寻找 [Symbol.iterator] 方法,如果没有找到则会报错。

1
2
3
4
5
6
7
8
const range = {
from: 1,
to: 3,
};

for (const iterator of range) { // TypeError: range is not iterable
console.log(iterator);
}

如果需要在迭代过程中做更多的事情,我们可以通过显示调用迭代器来获得比 for...of 跟多的控制权

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
const range = {
from: 1,
to: 3,
[Symbol.iterator]: function () {
return {
current: this.from,
last: this.to,
next() {
if (this.current <= this.last) {
return { value: this.current++, done: false };
} else {
return { value: undefined, done: true };
}
}
};
}
};

const generatorObj = range[Symbol.iterator]();

while(true) {
const res = generatorObj.next();
if (res.done) break;
console.log(res.value);
}

JavaScript 中的内建可迭代对象

JavaScript 中很多内建对象都遵循了可迭代协议:

  • 字符串

  • 数组

  • 映射(Map/WeakMap)

  • 集合(Set/WeakSet)

  • arguments 对象

  • NodeListDOM 集合类型

数组和字符串

数组和字符串是使用最广泛的内建可迭代对象。

1
2
3
4
5
6
7
8
9
const string = 'Best wishes for the beautiful world.'
for (let iterator of string) {
console.log(iterator);
}

const arr = [ 'Best', 'wishes', 'for', 'the', 'beautiful', 'world' ];
for (const iterator of arr) {
console.log(iterator);
}

类数组对象和可迭代对象

JavaScript 中的类数组对象是指拥有 length 属性和索引的对象,但它不一定是可迭代的。

1
2
3
4
5
6
7
const arrLikeObj = {
length: 2,
0: '1',
1: '2'
}

for (const iterator of arrLikeObj) {} // TypeError: arrLikeObj is not iterable

JavaScript 中的可迭代对象是指实现了 [Symbol.iterator] 方法的对象。

我们可以通过全局方法 Array.from 将类数组对象转变成真正的数组再进行迭代

1
2
3
4
5
6
7
8
9
const arrLikeObj = {
length: 2,
0: '1',
1: '2'
}

for (const iterator of Array.from(arrLikeObj)) {
console.log(iterator);
}

使用生成器进行迭代

根据上文对生成器的介绍,很容易想到可以使用生成器函数来返回可迭代对象实现对象的迭代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const range = {
from: 1,
to: 3,
[Symbol.iterator]: function* () {
for (let value = this.from; value <= this.to; value++) {
yield value;
}
}
};

for (const iterator of range) {
console.log(iterator);
}

// 1
// 2
// 3

提前终止迭代器

可选的 return() 方法用于指定在迭代器提前关闭时执行的逻辑。执行迭代的结构在想让迭代器知道它不想遍历到可迭代对象耗尽时,就可以 “关闭” 迭代器。可能的情况包括:

  • for...of 循环通过 breakcontinuereturnthrow 提前退出
  • 解构操作并未消费所有值

JavaScript 中的事件循环

JavaScript 有一个基于事件循环的并发模型,它负责执行代码、收集和处理事件以及执行队列中子任务,它被称为事件循环(Event Loop)。了解事件循环的工作方式和原理对于进行优化非常重要。

运行时概念

Stack,Heap,Queue

函数调用形成了一个有若干帧组成的栈。

栈是一种仅限在表为进行添加和删除的线性数据结构。

对象被分配在堆中。堆是一个用来表示一块非结构化内存区域的计算机术语。

堆是一种利用完全二叉树维护的线性数据结构。

队列

一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用来处理这个消息的回调函数。

在事件循环的合适时刻,JavaScript 运行时会从最早进入消息队列的消息开始处理消息。被处理的消息会被移出消息队列,并以自身为参数调用与之关联的函数。

函数的处理会一直到执行栈再次为空为止,然后事件循环会处理下一个消息。

事件循环

事件循环的概念非常简单,一个无限的循环:JavaScript 引擎等待任务、执行任务,然后进入休眠状态,等待更多任务。

1
2
3
while (queue.waitForMessage()) {
queue.processNextMessage();
}

queue.waitForMessage() 会同步地等待消息到达。

消息队列中的消息只有在当前消息完全的执行完成后,接下来的消息才会被执行。这个特性会带来一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。

这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。

零延迟

零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数,其等待的时间取决于队列里待处理的消息数量。延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。

运行时通信

一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。

浏览器下的事件循环实现

浏览器中所有任务都会被放到调用栈中等待主线程执行。

调用栈

调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。

宏任务和微任务

在每一个宏任务执行完成后,浏览器会立即执行所有来自微任务队列的微任务,这是比执行任何其它宏任务或者渲染更具有优先级的操作。

只有当微任务队列中的所有微任务都执行完之后,才会执行其它宏任务,这保证了在微任务执行时应用程序的环境不会改变。

如果想要在渲染或处理新的事件之前运行一个异步函数,可以通过 queueMicrotsk 方法来达成。

  • 宏任务 - scripts/setTimeOut/setInterval/I/O/UI Rendering
image-20201201110148857
  • 微任务 - Promise.then catch finally/MutationObserver
image-20201201110210529

同步任务和异步任务

Javascript 单线程任务被分为同步任务异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。

任务执行逻辑

  • -> 选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即null,则执行跳转到微任务(MicroTask)的执行步骤。

    -> 将事件循环中的任务设置为已选择任务。

    -> 执行任务。

    -> 将事件循环中当前运行任务设置为 null

    -> 将已经运行完成的任务从任务队列中删除。

    -> microtasks 步骤:进入microtask检查点。

    -> 更新界面渲染。

    -> 返回第一步。

  • -> 设置 microtask 检查点标志为 true

    -> 当事件循环 microtask 执行不为空时:选择一个最先进入的 microtask 队列的 microtask,将事件循环的 microtask 设置为已选择的 microtask,运行 microtask,将已经执行完成的 microtasknull,移出 microtask 中的 microtask

    -> 清理 IndexDB 事务

    -> 设置进入 microtask 检查点的标志为 false

img img

事件循环的一些应用

分割占用 CPU 较多的任务

假设我们有一个需要占用非常多 CPU 的任务,当浏览器在处理这个任务时,它就不能做其它 DOM 相关的操作,处理用户事件等,这将导致浏览器变的卡顿甚至崩溃,显然这是不可接受的。但我们可以通过将大任务分割成较小任务的方法来避免这个问题。

比如下面的代码,在执行时会导致浏览器挂起一段不短的时间,这期间用户事件都不会得到处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let i = 0;

let start = Date.now();

function count() {
// 大任务
for (let j = 0; j < 1e9; j++) {
j++;
}

console.log("Done in " + (Date.now() - start) + "ms");
}

count();

如果用 setTimeout 将其分割成较小的任务,如果在小任务执行时有其它任务要执行,会被放入任务队列,当目前的分割小任务完成后立即执行这个任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let i = 0;

let start = Date.now();

function count() {
do {
i++;
} while(i % 1e6 !== 0)

if (i === 1e9) {
console.log("Done in " + (Date.now() - start) + "ms");
} else {
setTimeout(count);
}
}

count();

永不阻塞

JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。

Node 中的事件循环、定时器和 process.nextTick()

JavaScript Map 和 Object

Map

Map 是一个保存’键值对’的对象,会记忆键的插入顺序,而且不管是对象还是原始值都能作为其键或值。

如果看过 Map 的实例方法,就会发现它在某些方面起到的作用极为相似,但也有很多的不同:

Map Object
意外的键 默认情况下不包含任何键,只包含显式插入的键。 如果 Object 有原型,原型链上的键名则可能和你在对象上设置的键名冲突
键的类型 可以是任意值,包括函数、对象和原始值 只能是 String 或 Symbol
键的顺序 键是有序的,顺序为插入顺序 键是无序的
Size 通过 size 属性获取 手动计算
迭代 可以直接迭代
性能 在频繁增删键的情况下性能更好

JavaScript 装饰器和转发

装饰器模式

装饰器是一个特殊的函数,它接受一个函数作为参数,并改变它的行为。

它非常重要和普遍的一种应用就是缓存函数返回结果,节省重复计算的时间。

1
2
3
4
5
6
7
8
9
10
11
12
function cacheDecorator(fun) {
const cache = new Map();
return function(x) {
// 如果命中缓存,则直接返回缓存中的计算好的值
if (cache.has(x)) return cache.get(x);
// 如果没有命中缓存,则使用传入的函数重新计算结果
const result = fun(x);
// 将新的结果进行缓存
cache.set(x, result);
return result;
}
}

callapply 都允许显式地设置调用函数执行时的上下文(即 this 值),在作用上它们几乎相同。

不同的是 call 以参数列表的形式接收参数,而 apply 以类数组的形式接收参数。

JavaScript 文件操作

在大部分浏览器上,你不能直接存储你创建的文件,因为这会导致大量的安全问题。不过你可以将你的文件通过下载链接的方式提供给用户。

在客户端生成文件

静态方法 window.URL.createObjectURL(object) 会创建一个表示参数中给出的对象 objectURLDOMString ,其生命周期和创建它的 document 生命周期绑定。

object 可以是 FileBlobMediaSource

每次调用 createObjectURL() 都会为指定的对象创建一个新的 URL 对象,而不管是否已经用这个指定对象创建过 URL 对象。因此在创建的 URL 对象不再使用时,应该在安全的时机调用 revokeObjectURL() 方法来释放掉它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const objArrInJson = JSON.stringify([
{
"name": "一般职业",
"value": "0"
},
{
"name": "农牧业",
"value": "1",
"childs": [
{
"name": "机关、团体、公司",
"value": "1"
}
]
},]);

function generateF(raw) {
const datasInBlob = new Blob([raw], { type: 'application/json' });
return window.URL.createObjectURL(datasInBlob);
}

关于 FileReader

在网页应用程序中,使用 FileReader 可以通过使用 BlobFile 指定要异步的读取的文件内容(或数据缓冲区内容)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head>

<body>
<input type="file" multiple accept="*" id="fileInput">

<script>
const fileInput = document.getElementById('fileInput');

// change 事件当用户选择了文件时被触发
fileInput.addEventListener('change', (event) => {
const fileI = event.target;
console.log(fileI.files);
})
</script>
</body>

</html>

文件加载完成后返回的 files 是一个 FileList,其中的 File 的就是选择的文件的相关信息。

File 对象包含提供文件有关的信息,使得网页中的代码能够访问文件内容。

1
2
3
4
5
6
7
8
{
lastModified: 00000000000, // 只读属性,以毫秒表示的的文件上次修改时间
lastModifiedDate: '', // 只读属性,文件上次修改时间
name: 'file.text', // 只读属性, File 对象的关联的文件的名称
size: 1024, // 只读属性,以字节表示的文件大小
type: 'text/plain', // 只读属性,文件的 MIME 类型,类型不确定是返回 ""
webkitRelativePath: '' // 文件相关的 `Path` 或 `URL`
}

拖拽文件

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
40
41
42
43
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
</head>

<body>
<div class="dropbox" id="dropbox"></div>

<script>
const dropbox = document.getElementById('dropbox');

dropbox.addEventListener('dragenter', (e) => {
e.stopPropagation();
e.preventDefault();
}, false);
dropbox.addEventListener('dragover', (e) => {
e.stopPropagation();
e.preventDefault();
console.log(e);
}, false);
dropbox.addEventListener('drop', (e) => {
e.stopPropagation();
e.preventDefault();

const dt = e.dataTransfer;
console.log(dt);
const files = dt.files;
}, false);
</script>
<style>
.dropbox {
width: 100vw;
height: 100vh;
background-color: aliceblue;
}
</style>
</body>

</html>