# 数据类型
JS 中分为八种内置类型,八种内置类型又分为两大类型:基本类型和对象(Object)。
# 基本类型
基本类型有七种: null
, undefined
, boolean
, number
, string
, symbol
, bigint
。
其中 JS 的数字类型是浮点类型的,没有整型。并且浮点类型基于 IEEE754 标准实现,在使用中会遇到某些 Bug。NaN
也属于 number
类型,并且 NaN
不等于自身。
对于基本类型来说,如果使用字面量的方式,那么这个变量只是个字面量,只有在必要的时候才会转换为对应的类型:
let a = 111; // 这只是字面量,不是 number 类型
a.toString(); // 使用时候才会转换为对象类型
# 引用类型
对象(Object)是引用类型,包括 array
, object
, function
, date
, regexp
等,在使用过程中会遇到浅拷贝和深拷贝的问题:
let a = { name: 'FE' };
let b = a;
b.name = 'EF';
console.log(a.name) // EF
# 类型判断
# typeof
typeof
对于基本类型,除了 null
都可以显示正确的类型:
typeof 1 // 'number'
typeof '1' // 'string'
typeof undefined // 'undefined'
typeof true // 'boolean'
typeof Symbol() // 'symbol'
typeof b // b 没有声明,但是还会显示 undefined
typeof
对于对象,除了函数都会显示 object
:
typeof [] // 'object'
typeof {} // 'object'
typeof console.log // 'function'
对于 null
来说,虽然它是基本类型,但是会显示 object
,这是一个存在很久了的 Bug:
typeof null // 'object'
PS:为什么会出现这种情况呢?因为在 JS 的最初版本中,使用的是 32 位系统,为了性能考虑使用低位存储了变量的类型信息,000
开头代表是对象,然而 null
表示为全零,所以将它错误的判断为 object
。虽然现在的内部类型判断代码已经改变了,但是对于这个 Bug 却是一直流传下来。
# instanceof
instanceof
可以正确判断是否为对象类型,因为内部机制是通过判断对象的原型链中是不是能找到类型的 prototype
:
function Car(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}
const auto = new Car('Honda', 'Accord', 1998);
console.log(auto instanceof Car); // expected output: true
console.log(auto instanceof Object); // expected output: true
# Object.prototype.toString.call(x)
如果我们想获得一个变量的正确类型,可以通过 Object.prototype.toString.call(x)
。这样我们就可以获得类似 [object Type]
的字符串:
Object.prototype.toString.call([]); // [object Array]
Object.prototype.toString.call(/\.abc/); // [object RegExp]
# 其他
let a;
// 我们也可以这样判断 undefined
a === undefined;
// 但是 undefined 不是保留字,能够在低版本浏览器被赋值
let undefined = 1;
// 这样判断就会出错
// 所以可以用下面的方式来判断,并且代码量更少
// 因为 void 后面随便跟上一个组成表达式
// 返回就是 undefined
a === void 0;
# 类型转换
# 转 Boolean
在条件判断时,除了 undefined
, null
, false
, NaN
, ''
, 0
, -0
, 其他所有值都转为 true
,包括所有对象。
# 对象转基本类型
对象在转换基本类型时,首先会调用 valueOf
然后调用 toString
。并且这两个方法你是可以重写的。
let a = {
valueOf() {
return 0
}
}
当然你也可以重写 Symbol.toPrimitive
,该方法在转基本类型时调用优先级最高。
let a = {
valueOf() {
return 0;
},
toString() {
return '1';
},
[Symbol.toPrimitive]() {
return 2;
}
}
1 + a // => 3
'1' + a // => '12'
# 四则运算符
- 加法运算
- 其中一方是
string
类型,则会把另一个也转为string
类型 - 会先后触发 3 种类型转换:将值转换为原始值 (
[Symbol.toPrimitive]
),转换为数字 (valueOf
),转换为字符串 (toString
)
- 其中一方是
- 其他运算:只要其中一方是
number
,那么另一方就转为number
1 + '1' // '11'
2 * '2' // 4
[1, 2] + [2, 1] // '1,22,1'
// [1, 2].toString() -> '1,2'
// [2, 1].toString() -> '2,1'
// '1,2' + '2,1' = '1,22,1'
对于加号需要注意这个表达式 'a' + + 'b'
'a' + + 'b' // -> "aNaN"
// 因为 + 'b' -> NaN
// 你也许在一些代码中看到过 + '1' -> 1
# ==
操作符
比较运算 x == y
,其中 x
和 y
是值,产生 ture
或者 false
。这样的比较按如下方式进行:
- 若 Type(x) 与 Type(y) 相同,则:
- 若 Type(x) 为
undefined
,返回true
- 若 Type(x) 为
null
,返回true
- 若 Type(x) 为
number
,则:- 若 x 为
NaN
,返回false
- 若 y 为
NaN
,返回false
- 若 x 与 y 为相等数值,返回
true
- 若 x 为
+0
,y 为-0
,返回true
- 若 x 为
-0
,y 为+0
,返回true
- 其他返回
false
- 若 x 为
- 若 Type(x) 为
string
,则当 x 和 y 为完全相同的字符序列(长度相等且相同字符在相同位置)时返回true
。否则返回false
- 若 Type(x) 为
boolean
,当 x 和 y 同为true
或同为false
时返回true
。否则,返回false
- 当 x 和 y 为引用同一对象时返回
true
。否则,返回false
- 若 Type(x) 为
- 若 x 为
null
且 y 为undefined
,返回true
- 若 x 为
undefined
且 y 为null
,返回true
- 若 Type(x) 为
number
且 Type(y) 为string
,返回比较x == ToNumber(y)
的结果 - 若 Type(x) 为
string
且 Type(y) 为number
,返回比较ToNumber(x) == y
的结果 - 若 Type(x) 为
boolean
,返回比较ToNumber(x) == y
的结果 - 若 Type(y) 为
boolean
,返回比较x == ToNumber(y)
的结果 - 若 Type(x) 为
string
或number
,且 Type(y) 为object
,返回比较x == ToPrimitive(y)
的结果 - 若 Type(x) 为
object
,且 Type(y) 为string
或number
,返回比较ToPrimitive(x) == y
的结果 - 返回
true
以上 toPrimitive
就是对象转基本类型。
这里来解析一道题目 [] == ![] // -> true
,下面是这个表达式为何为 true
的步骤:
// [] 转成 true,然后取反变成 false
[] == false
// 根据第 7 条得出
[] == ToNumber(false)
[] == 0
// 根据第 10 条得出
ToPrimitive([]) == 0
// [].toString() -> ''
'' == 0
// 根据第 6 条得出
0 == 0 // -> true
# 比较运算符
- 如果是对象,就通过
toPrimitive
转换对象 - 如果是字符串,就通过
unicode
字符索引来比较
# this 关键字
在 Javascript 里,函数被调用的时候,除了接受声明时定义的形式参数,每一个函数还接受两个附加的参数:this
和 arguments
。随着函数使用场合的不同,this
的值会发生变化。但是有一个总的原则,那就是 this
指的是,调用函数的那个对象。
# 对象方法调用模式
window.name = 'window';
var object = {
name: 'object',
run: function() {
console.log(this.name);
}
};
object.run(); // 'object'
当一个函数被保存为对象的一个属性时,我们称它为一个方法。当方法被调用时(通过 .
表达式或 object[fun]
下标表达式),this
绑定到该对象。
# 函数调用模式
window.name = 'window';
var object = {
name: 'object',
run: function() {
innerFun();
function innerFun() {
console.log(this.name);
}
return function () {
console.log(this.name);
}
}
};
object.run()();
// =>
// 'window'
// 'window'
当一个函数并非一个对象的属性时,那么它就是被当做一个函数来调用的,以此模式调用函数时,this
被绑定到全局对象。
# 构造函数调用模式
function C() {
this.a = 37;
}
var o = new C();
console.log(o.a); // 37
function C2() {
this.a = 37;
return { a: 38 };
}
o = new C2();
console.log(o.a); // 38
当一个函数被作为一个构造函数来使用(使用 new 关键字),它的 this 与即将被创建的新对象绑定。
# call, apply, bind 调用
call
, apply
方法的用途都是在特定的作用域中调用函数:
apply
方法接收两个参数:一个是在其中运行函数的作用域,另一个是参数数组。其中,第二个参数可以是 Array 的实例,也可以是arguments
对象call
方法与apply
方法的作用相同,它们的区别仅在于接收参数的方式不同。对于call
方法而言,第一个参数是this
值没有变化,变化的是其余参数都直接传递给函数
var a = 1;
function test() {
console.log(this.a);
}
var o = {};
o.a = 2;
o.fun = test;
o.fun.apply(o); // 1
o.fun.call(o); // 1
ECMAScript5 引入了 Function.prototype.bind
。调用 f.bind(someObject)
会创建一个与 f
具有相同函数体和作用域的函数,但是在这个新函数中,this
将永久地被绑定到了 bind
的第一个参数,无论这个函数是如何被调用的。
function f() {
return this.a;
}
var g = f.bind({ a: 'azerty' });
console.log(g()); // azerty
var o = { a: 37, f: f, g: g };
console.log(o.f(), o.g()); // 37, azerty
模拟实现 call:
/**
* 思路:利用对象的方法在调用时,内部的 this 指向该对象
*/
Function.prototype.myCall = function (ctx) {
let context = ctx || window;
// 给 context 添加一个属性
context.fn = this;
// 将 context 后的参数取出
let args = [...arguments].slice(1);
let result = context.fn(...args);
// 执行后删除 fn
delete context.fn;
return result;
}
// Demo
let a = {
value: 1
}
function getValue(name, age) {
console.log(name);
console.log(age);
console.log(this.value);
}
getValue.myCall(a, 'jimco', 18);
// =>
// 'jimco'
// 18
// 1
模拟实现 apply:
/**
* 思路:原理与模拟 call 的实现相同,区别在于传参方式
*/
Function.prototype.myCall = function (ctx) {
let context = ctx || window;
// 给 context 添加一个属性
context.fn = this;
let result;
// 需要判断是否存在第二个参数
// 如果存在,就将第二个参数展开
if (arguments[1]) {
result = context.fn(...arguments[1]);
} else {
result = context.fn();
}
// 执行后删除 fn
delete context.fn;
return result;
}
// Demo
let a = {
value: 1
}
function getValue(name, age) {
console.log(name);
console.log(age);
console.log(this.value);
}
getValue.myCall(a, ['jimco', 18]);
// =>
// 'jimco'
// 18
// 1
模拟实现 bind:
/**
* 思路:bind 返回的是一个函数,可以直接调用,也可作为构造函数被调用
*/
Function.prototype.myBind = function (context) {
if (typeof this !== 'function') {
throw new TypeError('Error');
}
var _this = this;
var args = [...arguments].slice(1);
// 返回一个函数
return function F() {
// 因为返回了一个函数,可以 new F(),作为构造函数被调用
// 当作为构造函数被 new 构造调用时,函数内部的 this 指向此实例
if (this instanceof F) {
return new _this(...args, ...arguments);
}
return _this.apply(context, args.concat(...arguments));
}
}
# 箭头函数
以上几种情况明白了,大多代码中的 this
应该就没什么问题了,下面让我们看看箭头函数中的 this
:
window.name = 'window';
var object = {
name: 'object',
run: function() {
return () => {
console.log(this.name);
}
}
};
var o = { name: 'test_o' };
object.run()(); // 'object'
object.run().apply(o); // 'object'
如果这么写:
window.name = 'window';
var object = {
name: 'object',
run: () => {
console.log(this.name);
}
};
var o = { name: 'test_o' };
object.run(); // 'window'
object.run.apply(o); // 'window'
箭头函数和普通函数之间有一个重要的差别 - 箭头函数没有自己的 this
值,其 this
值是继承外域的 this
值,默认指向定义它时所处的对象(宿主对象),而不是执行时的对象。所以箭头函数不仅仅是从外观上简化了函数的写法,更解决了普通函数中 this
的 hack 问题。
箭头函数和普通函数的区别:
- 关于
this
- 箭头函数没有自己的
this
值,其this
值指向外围作用域 - 箭头函数的
this
是声明时确定,不可变;普通函数是运行时确定并且可改变(call
,apply
,bind
) - 箭头函数不可以当做构造函数,也就是说不可以使用
new
命令
- 箭头函数没有自己的
- 关于
arguments
:箭头函数中没有arguments
对象,若要使用,可用 rest 参数代替 - 关于
yield
:箭头函数不可以使用yield
命令,因此箭头函数不能用作 Generator 函数 - 关于
prototype
:箭头函数没有prototype
属性
# 参考资料
# 执行上下文
执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。
# 类型
- 全局执行上下文。这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的
window
对象(浏览器的情况下),并且设置this
的值等于这个全局对象。一个程序中只会有一个全局执行上下文。 - 函数执行上下文。每当一个函数被调用时,都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
- eval 执行上下文。执行在
eval
函数内部的代码也会有它属于自己的执行上下文,但由于我们并不经常使用eval
,所以在这里我们不讨论它。
# 属性
每个执行上下文中都有三个重要的属性:
- 变量对象 (variable object, VO):每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。
在函数上下文中,使用活动对象 (activation object, AO) 来表示变量对象。活动对象和变量对象其实是一个东西,只有当进入一个执行环境时,这个执行上下文的变量对象才会被激活,此时称为活动对象(AO),只有活动对象上的属性才能被访问。
- 作用域链 (scope chain):当代码在一个环境中执行时,会创建变量对象的一个作用域链。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。
this
。
# 如何创建
# 1. 创建阶段
创建阶段主要负责 3 件事,确定 this 指向、创建 LexicalEnvironment(词法环境) 组件、创建 VariableEnvironment(变量环境) 组件。以伪代码表示:
ExecutionContext = {
ThisBinding = <this value>, // 确定this
LexicalEnvironment = { ... }, // 词法环境
VariableEnvironment = { ... }, // 变量环境
}
1.1 确定 this 指向
这部分可分为多种情况,具体的可以查看 Javascript this 解析 (opens new window)。
1.2 创建 LexicalEnvironment(词法环境) 组件
词法环境有两个组成部分:
- 环境记录:存储变量和函数声明的实际位置
- 对外部环境的引用:可以访问其外部词法环境
词法环境有两种类型:
- 全局环境:是一个没有外部环境的词法环境,其外部环境引用为
null
。拥有一个全局对象(window
对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this
的值指向这个全局对象。 - 函数环境:用户在函数中定义的变量被存储在环境记录中,包含了
arguments
对象。对外部环境的引用可以是全局环境,也可以是包含内部函数的外部函数环境。
以伪代码表示:
// 全局环境
GlobalExectionContext = {
// 全局词法环境
LexicalEnvironment: {
// 环境记录
EnvironmentRecord: {
Type: "Object", //类型为对象环境记录
// 标识符绑定在这里
},
outer: < null >
}
};
// 函数环境
FunctionExectionContext = {
// 函数词法环境
LexicalEnvironment: {
// 环境纪录
EnvironmentRecord: {
Type: "Declarative", //类型为声明性环境记录
// 标识符绑定在这里
},
outer: < Global or outerfunction environment reference >
}
};
1.3 创建 VariableEnvironment(变量环境) 组件
变量环境也是一个词法环境,因此它具有上面定义的词法环境的所有属性。
在 ES6 中,词法环境和变量环境的区别在于前者用于存储函数声明和变量(let 和 const)绑定,而后者仅用于存储变量(var)绑定。
举个例子:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
执行上下文如下所示:
//全局执行上下文
GlobalExectionContext = {
// this绑定为全局对象
ThisBinding: <Global Object>,
// 词法环境
LexicalEnvironment: {
// 环境记录
EnvironmentRecord: {
Type: "Object", // 对象环境记录
// 标识符绑定在这里 let const 创建的变量 a b 在这
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
// 全局环境外部环境引入为 null
outer: <null>
},
// 变量环境
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object", // 对象环境记录
// 标识符绑定在这里 var创建的c在这
c: undefined,
}
// 全局环境外部环境引入为null
outer: <null>
}
}
// 函数执行上下文
FunctionExectionContext = {
// 由于函数是默认调用 this 绑定同样是全局对象
ThisBinding: <Global Object>,
// 词法环境
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative", // 声明性环境记录
// 标识符绑定在这里 arguments 对象在这
Arguments: { 0: 20, 1: 30, length: 2 },
},
// 外部环境引入记录为 </Global>
outer: <GlobalEnvironment>
},
// 变量环境
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative", // 声明性环境记录
// 标识符绑定在这里 var 创建的 g 在这
g: undefined
},
// 外部环境引入记录为 </Global>
outer: <GlobalEnvironment>
}
}
变量提升:在创建阶段,函数声明存储在环境中,而变量会被设置为 undefined
(在 var
的情况下)或保持未初始化(在 let
和 const
的情况下)。所以这就是为什么可以在声明之前访问 var
定义的变量(尽管是 undefined
),但如果在声明之前访问 let
和 const
定义的变量就会提示引用错误的原因。这就是所谓的变量提升。
# 2. 执行阶段
代码执行时根据之前的环境记录对应赋值,比如早期 var
在创建阶段为 undefined
,如果有值就对应赋值,像 let
, const
值为未初始化,如果有值就赋值,无值则赋予 undefined
。
# 执行栈
执行栈,也就是在其它编程语言中所说的「调用栈」,是一种拥有 LIFO(后进先出) 数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。
引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。
让我们通过下面的代码示例来理解:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
上图为上述代码的执行上下文栈。
当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first()
函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。
当从 first()
函数内部调用 second()
函数时,JavaScript 引擎为 second()
函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second()
函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first()
函数的执行上下文。
当 first()
执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。
# 参考资料
- JavaScript 深入之执行上下文 (opens new window)
- 理解 JavaScript 中的执行上下文和执行栈 (opens new window)
- 一篇文章看懂 JS 执行上下文 (opens new window)
- JavaScript中执行上下文和执行栈是什么? (opens new window)
# 闭包
# 定义
函数内部嵌套了一个新的函数,嵌套的函数对外部的函数造成了引用就形成了闭包。
function A() {
let a = 1;
function B() {
console.log(a);
}
return B;
}
# 作用
- 使用闭包可以在 JavaScript 中模拟块级作用域;
- 闭包可以用于在对象中创建私有变量。
# 实际应用
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i); // 5,5,5,5,5,5
}, 1000);
}
上面这段代码中,for
循环中的 i
变量被内部循环的函数使用,只能获得该变量最后保留的值。也就是说,闭包中所记录的自由变量,只是对这个变量的一个引用,而非变量的值,当这个变量被改变了,闭包里获取到的变量值,也会被改变。
解决的方法之一,是让内部函数在循环创建的时候立即执行,并且捕捉当前的索引值,然后记录在自己的一个本地变量里,然后利用返回函数的方法,重写内部函数,让下一次调用的时候,返回本地变量的值。
如果我们约定,用箭头 ->
表示其前后的两次输出之间有 1 秒的时间间隔,而逗号 ,
表示其前后的两次输出之间的时间间隔可以忽略,代码运行结果 5 -> 0,1,2,3,4
有如下解法:
// 解法一
// 注意:IE9 及更早的 IE 浏览器不支持向 setTimeout 回调函数传递额外参数
for (var i = 0; i < 5; i++) {
setTimeout(function(j) {
console.log(new Date, j); // 0,1,2,3,4
}, 1000, i);
}
// 解法二
for (var i = 0; i < 5; i++) {
(function(j) { // j = i
setTimeout(function() {
console.log(new Date, j); // 0,1,2,3,4
}, 1000);
})(i);
}
console.log(new Date, i); // 5
// 解法三
var output = function (i) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
};
for (var i = 0; i < 5; i++) {
output(i); // 这里传过去的 i 值被复制了
}
console.log(new Date, i);
// 解法四 (利用 ES6 let 块级作用域)
for (let i = 0; i < 5; i++) {
setTimeout(function() {
console.log(new Date, i);
}, 1000);
}
console.log(new Date, i);
如果期望代码的输出变成 0 -> 1 -> 2 -> 3 -> 4 -> 5
,该怎么改造代码?
// 解法一
for (var i = 0; i < 5; i++) {
(function(j) {
setTimeout(function() {
console.log(new Date, j);
}, 1000 * j)); // 这里修改 0~4 的定时器时间
})(i);
}
setTimeout(function() { // 这里增加定时器,超时设置为 5 秒
console.log(new Date, i);
}, 1000 * i);
// 解法二
const tasks = []; // 这里存放异步操作的 Promise
const output = (i) => new Promise((resolve) => {
setTimeout(() => {
console.log(new Date, i);
resolve();
}, 1000 * i);
});
// 生成全部的异步操作
for (var i = 0; i < 5; i++) {
tasks.push(output(i));
}
// 异步操作完成之后,输出最后的 i
Promise.all(tasks).then(() => {
setTimeout(() => {
console.log(new Date, i);
}, 1000);
});
还可以利用 ES7 中的 async
/await
特性来实现:
// 模拟其他语言中的 sleep,实际上可以是任何异步操作
const sleep = (timeountMS) => new Promise((resolve) => {
setTimeout(resolve, timeountMS);
});
(async () => { // 声明即执行的 async 函数表达式
for (var i = 0; i < 5; i++) {
await sleep(1000);
console.log(new Date, i);
}
await sleep(1000);
console.log(new Date, i);
})();
# 原型
# 原型链
- 每个函数都有
prototype
属性,除了Function.prototype.bind()
,该属性指向原型 - 每个对象都有
__proto__
属性,指向了创建该对象的构造函数的原型。其实这个属性指向了[[prototype]]
,但是[[prototype]]
是内部属性,我们并不能访问到,所以使用__proto__
来访问 - 对象可以通过
__proto__
来寻找不属于该对象的属性,__proto__
将对象连接起来组成了原型链
构造函数、原型和实例的关系:
- 对象是通过函数创建的,函数是对象
Object.__proto__ === Function.prototype
,Function.__proto__ === Function.prototype
- 每个函数都有
prototype
属性,除了用这个方法创建的函数let fun = Function.prototype.bind()
- 每个对象都有一个隐藏属性
__proto__
,这个属性指向了创建这个对象的构造函数的原型(这是一个非标准属性,不可以用于编程,是供浏览器自己使用的) prototype
属性里面默认有一个constructor
属性,该属性指向原型对象所属的构造函数
prototype
与 __proto__
的关系:
prototype
是构造函数的属性__proto__
是实例对象的属性
这两者都指向同一个对象
属性搜索:
- 在访问对象的某个成员的时候会先在对象中查找是否存在
- 如果当前对象中没有就在构造函数的原型对象中找
- 如果构造函数的原型对象中没有找到就到原型对象的原型上找
- 直到 Object 的原型对象的原型是
null
为止
# new 的过程
- 创建一个空的简单 JavaScript 对象(即
{}
); - 为新创建的对象添加属性
__proto__
,将该属性链接至构造函数的原型对象; - 将新创建的对象作为
this
的上下文; - 如果该函数没有返回对象,则返回
this
。
在调用 new
的过程中会发生以上四件事情,我们也可以试着来自己实现一个 new
:
function xnew() {
// 创建一个空的对象
let obj = new Object();
// 获得构造函数
let Con = [].shift.call(arguments);
// 链接到原型
obj.__proto__ = Con.prototype;
// 绑定 this,执行构造函数
let result = Con.apply(obj, arguments);
// 确保 new 出来的是个对象
return typeof result === 'object' ? result : obj;
}
对于实例对象来说,都是通过 new
产生的,无论是 function Foo()
还是 let a = { b : 1 }
对于创建一个对象来说,更推荐使用字面量的方式创建对象。因为你使用 new Object()
的方式创建对象需要通过作用域链一层层找到 Object
,但是你使用字面量的方式就没这个问题。
function Foo() {}
// function 就是个语法糖
// 内部等同于 new Function()
let a = { b: 1 }
// 这个字面量内部也是使用了 new Object()
# instanceof
instanceof
运算符用于检测构造函数的 prototype
属性是否出现在某个实例对象的原型链上。
function Car(make, model, year) {
this.make = make;
this.model = model;
this.year = year;
}
const auto = new Car('Honda', 'Accord', 1998);
console.log(auto instanceof Car); // expected output: true
console.log(auto instanceof Object); // expected output: true
我们也可以试着实现一下 instanceof
:
function instanceof(left, right) {
// 获得类型的原型
let prototype = right.prototype;
// 获得对象的原型
left = left.__proto__;
// 判断对象的类型是否等于类型的原型
while (true) {
if (left === null) return false;
if (prototype === left) return true;
left = left.__proto__;
}
}
# 参考资料
# 继承
基本思想:利用原型让一个引用类型继承另一个引用类型的属性和方法。
大部分面向对象的编程语言,都是通过「类」(class
)实现对象的继承。但 JavaScript 语言的继承不通过 class
(ES6 引入了 class
语法),而是通过「原型对象」(prototype
)实现。即便是在 ES2015/ES6 中引入了 class
关键字,但那也只是语法糖,JavaScript 仍然是基于原型的。
JS 中常见的继承方式:
- 原型链继承
- 构造函数继承
- 组合继承
- 原型式继承
- 寄生式继承
- 寄生组合式继承
- ES6 继承
以上,在此不做逐个解析,可查看 Javascript OO 实现 (opens new window) 了解各方案的实现。下面只列出 ES5 下继承的最理想范式 (寄生组合式继承) 及 ES6 Class 继承。
# 寄生组合式继承
- 原理:通过调用父类构造函数,继承父类的属性并保留传参的优点;然后通过将父类原型的拷贝作为子类原型,实现函数复用
- 优点:最理想的继承范式
- 缺点:-
function Super(name, age) {
this.name = name;
this.age = age;
}
Super.prototype.sayHello = function () {
console.log(`Hello ${this.name}!`);
}
function Sub(name, age, height) {
Super.call(this, name, age); // 继承父类属性
this.height = `${height}cm`;
}
Sub.prototype = Object.create(Super.prototype); // 继承父类方法
Sub.prototype.constructor = Sub; // 修复 Sub 子类的 constructor
const father = new Super('Jimco', 28);
const child = new Sub('Tom', 3, 60);
father.sayHello();
child.sayHello();
# ES6 Class 继承
class Super {
constructor(name, age) {
this.name = name;
this.age = age;
}
sayHello() {
console.log(`Hello ${this.name}!`);
}
}
class Sub extends Super {
constructor(name, age, heiget) {
super(name, age);
this.height = `${height}cm`;
}
}
几个注意点:
- 如果子类没有定义
constructor()
方法,这个方法会默认添加,并且里面会调用super()
- 子类若定义了
constructor()
方法,则该方法中必须调用super()
,否则就会报错 - 子类的
constructor()
方法没有调用super()
之前,就使用this
关键字,结果报错。这是因为子类实例的构建,必须先完成父类的继承,只有super()
方法才能让子类实例继承父类 - 除了私有属性,父类的所有属性和方法,都会被子类继承,包括静态方法
# 异步
# Promise
Promise
是 ES6 新增的语法,解决了回调地狱的问题。
可以把 Promise
看成一个状态机。初始是 pending
状态,可以通过函数 resolve
和 reject
,将状态转变为 resolved
或者 rejected
,状态一旦改变就不能再次变化。
then
函数会返回一个 Promise
实例,并且该返回值是一个新的实例而不是之前的实例。因为 Promise
规范规定除了 pending
状态,其他状态是不可以改变的,如果返回的是一个相同实例的话,多个 then
调用就失去意义了。
符合 Promise/A+ (opens new window) 规范的实现:
// Promise/A+ 规定的三种状态
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
// 构造方法接收一个回调
constructor(executor) {
this._status = PENDING; // Promise 状态
this._value = undefined; // 储存 then 回调 return 的值
this._resolveQueue = []; // 成功队列,resolve 时触发
this._rejectQueue = []; // 失败队列,reject 时触发
// 由于 resolve/reject 是在 executor 内部被调用,
// 因此需要使用箭头函数固定 this 指向,否则找不到 this._resolveQueue
let _resolve = (val) => {
// 把 resolve 执行回调的操作封装成一个函数,
// 放进 setTimeout 里,以兼容 executor 是同步代码的情况
const run = () => {
// 对应规范中的「状态只能由 pending 到 fulfilled 或 rejected」
if (this._status !== PENDING) return;
this._status = FULFILLED; // 变更状态
this._value = val; // 储存当前 value
// 这里之所以使用一个队列来储存回调,
// 是为了实现规范要求的「then 方法可以被同一个 promise 调用多次」,
// 如果使用一个变量而非队列来储存回调,那么即使多次 p1.then() 也只会执行一次回调
while (this._resolveQueue.length) {
const callback = this._resolveQueue.shift();
callback(val);
}
}
setTimeout(run);
}
// 实现同 resolve
let _reject = (val) => {
const run = () => {
// 对应规范中的「状态只能由 pending 到 fulfilled 或 rejected」
if (this._status !== PENDING) return;
this._status = REJECTED; // 变更状态
this._value = val; // 储存当前 value
while (this._rejectQueue.length) {
const callback = this._rejectQueue.shift();
callback(val);
}
}
setTimeout(run);
}
// new Promise() 时立即执行 executor,并传入 resolve 和 reject
executor(_resolve, _reject);
}
// then 方法,接收一个成功的回调和一个失败的回调
then(resolveFn, rejectFn) {
// 根据规范,如果 then 的参数不是 function,则我们需要忽略它,让链式调用继续往下执行
typeof resolveFn !== 'function' ? resolveFn = value => value : null;
typeof rejectFn !== 'function' ? rejectFn = reason => {
throw new Error(reason instanceof Error ? reason.message : reason);
} : null;
// return 一个新的 promise
return new MyPromise((resolve, reject) => {
// 把 resolveFn 重新包装一下,再 push 进 resolve 执行队列,这是为了能够获取回调的返回值进行分类讨论
const fulfilledFn = value => {
try {
// 执行第一个 (当前的) Promise 的成功回调,并获取返回值
let x = resolveFn(value);
// 分类讨论返回值,如果是 Promise,那么等待 Promise 状态变更,否则直接 resolve
x instanceof MyPromise ? x.then(resolve, reject) : resolve(x);
} catch (error) {
reject(error);
}
}
// reject 同理
const rejectedFn = error => {
try {
let x = rejectFn(error);
x instanceof MyPromise ? x.then(resolve, reject) : resolve(x);
} catch (error) {
reject(error);
}
}
switch (this._status) {
// 当状态为 pending 时,把 then 回调 push 进 resolve/reject 执行队列,等待执行
case PENDING:
this._resolveQueue.push(fulfilledFn);
this._rejectQueue.push(rejectedFn);
break;
// 当状态已经变为 resolve/reject 时,直接执行 then 回调
case FULFILLED:
fulfilledFn(this._value); // this._value 是上一个 then 回调 return 的值 (见完整版代码)
break;
case REJECTED:
rejectedFn(this._value);
break;
}
});
}
// catch 方法其实就是执行一下 then 的第二个回调
catch (rejectFn) {
return this.then(undefined, rejectFn);
}
// finally 方法
finally(callback) {
return this.then(
// 执行回调,并 return value 传递给后面的 then
value => MyPromise.resolve(callback()).then(() => value),
reason => MyPromise.resolve(callback()).then(() => {
throw reason
}) // reject 同理
);
}
// 静态的 resolve 方法
static resolve(value) {
// 根据规范,如果参数是 Promise 实例,直接 return 这个实例
if (value instanceof MyPromise) return value;
return new MyPromise(resolve => resolve(value));
}
// 静态的 reject 方法
static reject(reason) {
return new MyPromise((resolve, reject) => reject(reason));
}
// 静态的 all 方法
static all(promiseArr) {
let index = 0;
let result = [];
return new MyPromise((resolve, reject) => {
promiseArr.forEach((p, i) => {
// Promise.resolve(p) 用于处理传入值不为 Promise 的情况
MyPromise.resolve(p).then(
val => {
index++;
result[i] = val;
if (index === promiseArr.length) {
resolve(result);
}
},
err => {
reject(err);
}
);
});
});
}
// 静态的 race 方法
static race(promiseArr) {
return new MyPromise((resolve, reject) => {
// 同时执行 Promise,如果有一个 Promise 的状态发生改变,就变更新 MyPromise 的状态
for (let p of promiseArr) {
MyPromise.resolve(p).then( // Promise.resolve(p) 用于处理传入值不为 Promise 的情况
value => {
resolve(value); // 注意这个 resolve 是上边 new MyPromise 的
},
err => {
reject(err);
}
);
}
});
}
}
# Generator
Generator
是 ES6 中新增的语法,和 Promise
一样,都可以用来异步编程。
// 使用 * 表示这是一个 Generator 函数
// 内部可以通过 yield 暂停代码
// 通过调用 next 恢复执行
function* test() {
let a = 1 + 2;
yield 2;
yield 3;
}
let b = test();
console.log(b.next()); // > { value: 2, done: false }
console.log(b.next()); // > { value: 3, done: false }
console.log(b.next()); // > { value: undefined, done: true }
从以上代码可以发现,加上 *
的函数执行后拥有了 next
函数,也就是说函数执行后返回了一个对象。每次调用 next
函数可以继续执行被暂停的代码。以下是 Generator
函数的简单实现:
// 生成器函数根据 yield 语句将代码分割为 switch-case 块,
// 后续通过切换 _context.prev 和 _context.next 来分别执行各个 case
function gen$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return 'result1';
case 2:
_context.next = 4;
return 'result2';
case 4:
_context.next = 6;
return 'result3';
case 6:
case 'end':
return _context.stop();
}
}
}
// 低配版 context
var context = {
next: 0,
prev: 0,
done: false,
stop: function stop() {
this.done = true;
}
}
// 低配版 invoke
let gen = function() {
return {
next: function() {
value = context.done ? undefined: gen$(context);
done = context.done;
return {
value,
done
}
}
}
}
// 测试使用
var g = gen();
g.next(); // {value: "result1", done: false}
g.next(); // {value: "result2", done: false}
g.next(); // {value: "result3", done: false}
g.next(); // {value: undefined, done: true}
# async/await
async/await
其实是 generator
和 promise
的语法糖。一个函数如果加上 async
,那么该函数就会返回一个 Promise
:
async function test() {
return '1';
}
console.log(test()); // -> Promise {<resolved>: "1"}
可以把 async
看成将函数返回值使用 Promise.resolve()
包裹了下。
await
只能在 async
函数中使用:
function sleep() {
return new Promise(resolve => {
setTimeout(() => {
console.log('finish')
resolve('sleep');
}, 2000);
});
}
async function test() {
let value = await sleep();
console.log('object');
}
test();
上面代码会先打印 finish
然后再打印 object
。因为 await
会等待 sleep
函数 resolve
,所以即使后面是同步代码,也不会先去执行同步代码再来执行异步代码。
async
和 await
相比直接使用 Promise
来说,优势在于处理 then
的调用链,能够更清晰准确的写出代码。缺点在于滥用 await
可能会导致性能问题,因为 await
会阻塞代码,也许之后的异步代码并不依赖于前者,但仍然需要等待前者完成,导致代码失去了并发性。
下面来看一个使用 await
的代码:
var a = 0
var b = async () => {
a = a + await 10;
console.log('2', a);
a = (await 10) + a;
console.log('3', a);
}
b();
a++;
console.log('1', a);
/**
* =>
* '1' 1
* '2' 10
* '3' 20
* /
对于以上代码你可能会有疑惑,这里说明下原理
- 首先函数
b
先执行,在执行到await 10
之前变量a
还是0
,因为在await
内部实现了generators``,generators
会保留堆栈中东西,所以这时候a = 0
被保存了下来 - 因为
await
是异步操作,遇到await
就会立即返回一个pending
状态的Promise
对象,暂时返回执行代码的控制权,使得函数外的代码得以继续执行,所以会先执行console.log('1', a)
- 这时候同步代码执行完毕,开始执行异步代码,将保存下来的值拿出来使用,这时候
a = 10
- 然后后面就是常规执行代码了
async/await
模拟实现:
function asyncToGenerator(generatorFunc) {
// 返回的是一个新的函数
return function () {
// 先调用 generator 函数 生成迭代器
// 对应 var gen = testG()
const gen = generatorFunc.apply(this, arguments);
// 返回一个 promise 因为外部是用 .then 的方式 或者 await 的方式去使用这个函数的返回值的
// var test = asyncToGenerator(testG)
// test().then(res => console.log(res))
return new Promise((resolve, reject) => {
// 内部定义一个 step 函数 用来一步一步的跨过 yield 的阻碍
// key 有 next 和 throw 两种取值,分别对应了 gen 的 next 和 throw 方法
// arg 参数则是用来把 promise resolve 出来的值交给下一个 yield
function step(key, arg) {
let generatorResult;
// 这个方法需要包裹在 try catch 中
// 如果报错了 就把 promise 给 reject 掉 外部通过.catch 可以获取到错误
try {
generatorResult = gen[key](arg);
} catch (error) {
return reject(error);
}
// gen.next() 得到的结果是一个 { value, done } 的结构
const { value, done } = generatorResult;
if (done) {
// 如果已经完成了 就直接 resolve 这个 promise
// 这个 done 是在最后一次调用 next 后才会为 true
// 以本文的例子来说 此时的结果是 { done: true, value: 'success' }
// 这个 value 也就是 generator 函数最后的返回值
return resolve(value);
} else {
// 除了最后结束的时候外,每次调用 gen.next()
// 其实是返回 { value: Promise, done: false } 的结构,
// 这里要注意的是 Promise.resolve 可以接受一个 promise 为参数
// 并且这个 promise 参数被 resolve 的时候,这个 then 才会被调用
return Promise.resolve(
// 这个 value 对应的是 yield 后面的 promise
value
).then(
// value 这个 promise 被 resove 的时候,就会执行 next
// 并且只要 done 不是 true 的时候 就会递归的往下解开 promise
// 对应 gen.next().value.then(value => {
// gen.next(value).value.then(value2 => {
// gen.next()
//
// // 此时 done 为 true 了 整个 promise 被 resolve 了
// // 最外部的 test().then(res => console.log(res)) 的 then 就开始执行了
// })
// })
function onResolve(val) {
step('next', val);
},
// 如果 promise 被 reject 了 就再次进入 step 函数
// 不同的是,这次的 try catch 中调用的是 gen.throw(err)
// 那么自然就被 catch 到 然后把 promise 给 reject 掉啦
function onReject(err) {
step('throw', err);
},
);
}
}
step('next');
});
}
}
// 使用示例
const getData = () => new Promise(resolve => setTimeout(() => resolve('data'), 1000));
var test = asyncToGenerator(
function* testG() {
const data = yield getData();
console.log('data: ', data);
const data2 = yield getData();
console.log('data2: ', data2);
return 'success';
}
);
// 这样的一个函数,应该再 1 秒后打印 data 再过一秒打印 data2 最后打印 success
test().then(res => console.log(res));
async/await
和 Promise
在性能上有什么差异?
async/await
相比较 Promise
优化了堆栈处理,使用 async/await
不仅可以提高代码的可读性,同时也可以优化 JavaScript 引擎的执行方式。
# 参考资料
- Promise/async/Generator 实现原理解析 (opens new window)
- 从 Generator 入手读懂 co 模块源码 (opens new window)
- JS 高级之手写一个 Promise, Generator, async/await (opens new window)
- 手写 async/await 的最简实现 (opens new window)
# 模块规范
# AMD
- 特点:
- 异步加载
- 管理模块之间的依赖性,便于代码的编写和维护
- 环境:浏览器环境
- 应用:RequireJS 是参照 AMD 规范实现
- 语法:
- 导入:
require(['./a', './b'], function (a, b) { ... })
- 导出:
define(function () { ... })
- 导入:
// a.js
define(function () {
return {
a: 'hello world'
}
});
// b.js
require(['./a.js'], function (moduleA) {
console.log(moduleA.a); // hello world
});
# CMD
- 特点:CMD 是在 AMD 基础上改进的一种规范,和 AMD 不同在于对依赖模块的执行时机处理不同,CMD 是就近依赖,而 AMD 是前置依赖
- 环境:浏览器环境
- 应用:SeaJS 是参照 CMD 规范实现
- 语法:
- 导入:
define(function (require, exports, module) { ... })
- 导出:
define(function () { ... })
- 导入:
// a.js
define(function (require, exports, module) {
exports.a = 'hello world';
});
// b.js
define(function (require, exports, module) {
var moduleA = require('./a.js');
console.log(moduleA.a); // hello world
});
# UMD
- 特点:兼容 AMD 和 CommonJS 规范的同时,还兼容全局引用的方式
- 环境:浏览器环境 & 服务器环境
- 应用:-
- 语法:各模块规范的兼容性写法,无导入导出规范
(function(root, factory) {
if (typeof module === 'object' && typeof module.exports === 'object') {
// CommonJS 规范
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// AMD 规范
define(factory);
} else if (typeof define === 'function' && define.cmd) {
// CMD 规范
define(function(require, exports, module) {
module.exports = factory()
});
} else {
// 浏览器全局变量 (root 即 window)
root.umdModule = factory();
}
}(this, function() {
return {
name: '我是一个 UMD 模块',
// 暴露公共方法
myFunc,
}
}));
# CommonJS
- 特点:
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存;
- 模块加载会阻塞接下来代码的执行,需要等到模块加载完成才能继续执行 - 同步加载。
- 环境:服务器环境
- 应用:Nodejs 的模块规范是参照 CommonJS 实现
- 语法:
- 导入:
require('path')
- 导出:
module.exports
,exports
- 导入:
注意:
module.exports
和exports
的区别是exports
只是对module.exports
的一个引用,相当于 Node 为每个模块提供一个exports
变量,指向module.exports
。这等同在每个模块头部,有一行var exports = module.exports;
这样的命令。
// a.js
module.exports = {
a: 1
}
// or
exports.a = 1
// b.js
var moduleA = require('./a.js');
console.log(moduleA.a); // -> log 1
再来说说 module.exports
和 exports
,用法其实是相似的,但是不能对 exports
直接赋值,不会有任何效果。
# ES6 Module
- 特点:
- 按需加载(编译时加载)
import
和export
命令只能在模块的顶层,不能在代码块之中(如if
语句中),import()
语句可以在代码块中实现异步动态按需加载
- 环境:浏览器环境 & 服务器环境(node >= 13.2.0)
- 应用:ES6 的最新语法支持规范
- 语法:
- 导入:
import { a, b} from './path'
- 导出:
export
,export default
- 异步:
import('./path').then()
- 导入:
/* 错误的写法 */
// 写法一
export 1;
// 写法二
var m = 1;
export m;
// 写法三
if (x === 2) {
import MyModual from './myModual';
}
/* 正确的三种写法 */
// 写法一
export var m = 1;
// 写法二
var m = 1;
export { m };
// 写法三
var n = 1;
export { n as m };
// 写法四
var n = 1;
export default n;
// 写法五
if (true) {
import('./myModule.js')
.then(({ export1, export2 }) => {
// ...·
});
}
// 写法六
Promise.all([
import('./module1.js'),
import('./module2.js'),
import('./module3.js'),
])
.then(([module1, module2, module3]) => {
// ...
});
# CommonJS 和 ESModule 的区别
- 语法差异:导入导出语法不同,CommonJS 是
module.exports
,exports
导出,require
导入;ES6 Module 则是export
导出,import
导入 - 执行差异:
- ES6 Module 在编译期间会将所有
import
提升到顶部,CommonJS 不会提升require
- CommonJS 是运行时加载模块,ES6 Module 是在静态编译期间就确定模块的依赖(ES6 Module 的静态编译特性决定了他可以很容易实现 Tree Shaking 和 Code Splitting)
- ES6 Module 在编译期间会将所有
- 导出差异:
- CommonJS 导出的是一个值拷贝,会对加载结果进行缓存,一旦内部再修改这个值,则不会同步到外部;ES6 Module 是导出的一个引用,内部修改可以同步到外部
- CommonJs 是单个值导出,ES6 Module 可以导出多个
this
差异:CommonJS 中顶层的this
指向这个模块本身;ES6 Module 中顶层this
指向undefined
如何让 CommonJS 导出的模块也能改变其内部变量?
在 CommonJS 中,输入的是被输出的值的拷贝,比如:
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// main.js
var mod = require('./lib');
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
上面代码说明,lib.js
模块加载以后,它的内部变化就影响不到输出的 mod.counter
了。这是因为 mod.counter
是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值:
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
get counter() {
return counter
},
incCounter: incCounter,
};
// main.js
var mod = require('./lib');
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 4
# 参考资料
- ES6 Module (opens new window)
- AMD 和 CMD 的区别有哪些? (opens new window)
- 深入浅出 Commonjs 和 Es Module (opens new window)
- 高级前端理解的 CommonJS 模块和 ESM 模块 (opens new window)
# 浮点精度
ECMAScript 中的 Number 类型使用 IEEE754 标准来表示整数和浮点数值。所谓 IEEE754 标准,全称 IEEE 二进制浮点数算术标准,这个标准定义了表示浮点数的格式等内容。
在 IEEE754 中,规定了四种表示浮点数值的方式:单精确度(32 位)、双精确度(64 位)、延伸单精确度、与延伸双精确度。像 ECMAScript 采用的就是双精确度,也就是说,会用 64 位来储存一个浮点数。
精度问题解决办法:
parseFloat((0.1 + 0.2).toFixed(10));
# 参考资料
# 垃圾回收
在 V8 引擎逐行执行 JavaScript 代码的过程中,当遇到函数的情况时,会为其创建一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的作用域(handleScope)中包含了该函数中声明的所有变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的作用域会随之销毁,其包含的所有变量也会统一释放并被自动回收。试想如果在这个作用域被销毁的过程中,其中的变量不被回收,即持久占用内存,那么必然会导致内存暴增,从而引发内存泄漏导致程序的性能直线下降甚至崩溃,因此内存在使用完毕之后理当归还给操作系统以保证内存的重复利用。
V8 引擎帮助我们实现了自动的垃圾回收管理,回收策略是基于分代式垃圾回收机制,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
# 新生代算法
在 V8 引擎的内存结构中,新生代主要用于存放存活时间较短的对象。使用 Scavenge 算法。
在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 From 空间和 To 空间互换,这样 GC 就结束了。
# 老生代算法
老生代中的对象一般存活时间较长且数量也多,使用了两个算法,分别是标记清除算法和标记压缩算法。
在讲算法前,先来说下什么情况下对象会出现在老生代空间中:
- 新生代中的对象是否已经经历过一次 Scavenge 算法,如果经历过的话,会将对象从新生代空间移到老生代空间中。
- To 空间的对象占比大小超过 25 %。在这种情况下,为了不影响到内存分配,会将对象从新生代空间移到老生代空间中。
老生代中的空间很复杂,有如下几个空间:
enum AllocationSpace {
// TODO(v8:7464): Actually map this space's memory as read-only.
RO_SPACE, // 不变的对象空间
NEW_SPACE, // 新生代用于 GC 复制算法的空间
OLD_SPACE, // 老生代常驻对象空间
CODE_SPACE, // 老生代代码对象空间
MAP_SPACE, // 老生代 map 对象
LO_SPACE, // 老生代大空间对象
NEW_LO_SPACE, // 新生代大空间对象
FIRST_SPACE = RO_SPACE,
LAST_SPACE = NEW_LO_SPACE,
FIRST_GROWABLE_PAGED_SPACE = OLD_SPACE,
LAST_GROWABLE_PAGED_SPACE = MAP_SPACE
};
在老生代中,以下情况会先启动标记清除算法:
- 某一个空间没有分块的时候
- 空间中被对象超过一定限制
- 空间不能保证新生代中的对象移动到老生代中
在这个阶段中,会遍历堆中所有的对象,然后标记活的对象,在标记完成后,销毁所有没有被标记的对象。在标记大型对内存时,可能需要几百毫秒才能完成一次标记。这就会导致一些性能上的问题。为了解决这个问题,2011 年,V8 从 stop-the-world 标记切换到增量标志。在增量标记期间,GC 将标记工作分解为更小的模块,可以让 JS 应用逻辑在模块间隙执行一会,从而不至于让应用出现停顿情况。但在 2018 年,GC 技术又有了一个重大突破,这项技术名为并发标记。该技术可以让 GC 扫描和标记对象时,同时允许 JS 运行,你可以点击 该博客 (opens new window) 详细阅读。
清除对象后会造成堆内存出现碎片的情况,当碎片超过一定限制后会启动压缩算法。在压缩过程中,将活的对象像一端移动,直到所有对象都移动完成然后清理掉不需要的内存。
# 标记清除和标记整理
Mark-Sweep(标记清除) 分为标记和清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep 算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在 JavaScript 中,
window
全局对象可以看成一个根节点 - 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾
- 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统
以下几种情况都可以作为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其他函数的变量和参数
但是 Mark-Sweep 算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,因为我们所清理的对象的内存地址可能不是连续的,所以就会出现内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题,Mark-Compact(标记整理) 算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程中将死亡对象清除后,在整理的过程中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的全部内存。
# 内存泄露
注意常见的内存泄露场景:
- 意外的全局变量:由于使用未声明的变量,意外的创建了一个全局变量,而使这个变量一直留在内存中无法被回收
- 被遗忘的计时器或回调函数:设置了
setInterval
定时器忘记清除,若循环函数有对外部变量的引用,那么这个变量会被一直留在内存中,而无法被回收 - 脱离 DOM 的引用:获取一个 DOM 元素的引用,而后面这个元素被删除,由于一直保留了对这个元素的引用,所以它也无法被回收
- 闭包:不合理的使用闭包,从而导致某些变量一直被留在内存当中
# 参考资料
# 严格模式
ECMAScript5 (opens new window) 的严格模式是采用具有限制性 JavaScript 变体的一种方式,从而使代码隐式地脱离「马虎模式/稀松模式/懒散模式」(sloppy)模式。
严格模式对正常的 JavaScript 语义做了一些更改:
- 严格模式通过抛出错误来消除了一些原有静默错误
- 严格模式修复了一些导致 JavaScript 引擎难以执行优化的缺陷:有时候,相同的代码,严格模式可以比非严格模式下运行得更快
- 严格模式禁用了在 ECMAScript 的未来版本中可能会定义的一些语法
# 如何启用
为脚本开启严格模式:
// 整个脚本都开启严格模式的语法
'use strict';
var v = 'Hi! I\'m a strict mode script!';
为函数开启严格模式:
function strict() {
// 函数级别严格模式语法
'use strict';
function nested() {
return "And so am I!";
}
return "Hi! I'm a strict mode function! " + nested();
}
function notStrict() {
return "I'm not strict.";
}
# 参考资料
# ES 新增能力
# Proxy
Proxy 是 ES6 中新增的功能,可以用来自定义对象中的操作:
let p = new Proxy(target, handler);
// `target` 代表需要添加代理的对象
// `handler` 用来自定义对象中的操作
可以很方便的使用 Proxy 来实现一个数据绑定和监听:
let onWatch = (obj, setBind, getLogger) => {
let handler = {
// receiver: 表示调用对应属性或方法的主体对象
get(target, property, receiver) {
getLogger(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver) {
setBind(value);
return Reflect.set(target, property, value, receiver);
}
};
return new Proxy(obj, handler);
};
let obj = { a: 1 }
let value
let p = onWatch(obj, (v) => {
value = v
}, (target, property) => {
console.log(`Get '${property}' = ${target[property]}`);
});
p.a = 2; // bind `value` to `2`
p.a; // -> Get 'a' = 2
关于 receiver
参数:
receiver
是接受者的意思,表示调用对应属性或方法的主体对象,通常情况下,receiver
参数是无需使用的,但是如果发生了继承,为了明确调用主体,receiver
参数就需要出马了。比如:
let miaoMiao = {
_name: '疫苗',
get name () {
return this._name;
}
}
let miaoXy = new Proxy(miaoMiao, {
get (target, prop, receiver) {
return target[prop];
}
});
let kexingMiao = {
__proto__: miaoXy,
_name: '科兴疫苗'
};
console.log(kexingMiao.name); // 疫苗
实际上,这里预期显示应该是科兴疫苗
,而不是疫苗
。这个时候,就需要使用 receiver
参数了:
let miaoMiao = {
_name: '疫苗',
get name () {
return this._name;
}
}
let miaoXy = new Proxy(miaoMiao, {
get (target, prop, receiver) {
return Reflect.get(target, prop, receiver);
// 也可以简写为 Reflect.get(...arguments)
}
});
let kexingMiao = {
__proto__: miaoXy,
_name: '科兴疫苗'
};
console.log(kexingMiao.name); // 科兴疫苗
这就是 receiver
参数的作用,可以把调用对象当作 target
参数,而不是原始 Proxy
构造的对象。
为什么使用 Reflect 进行 set/get?
Proxy
中接受的 receiver
形参表示代理对象本身或者继承于代理对象的对象;Reflect
中传递的 receiver
实参表示修改执行原始操作时的 this
指向。
- 使用
Reflect.set
进行属性赋值,返回值为true
/false
,能够知道赋值是否执行成功 Reflect
不会因为报错而中断正常的代码逻辑执行this
指向为传入的receiver
对象,保证正确的上下文指向
参考资料:
- Proxy - MDN (opens new window)
- Reflect - MDN (opens new window)
- Proxy 是代理,Reflect 是干嘛用的? (opens new window)
- 你可能不知道的 Proxy (opens new window)
- 为什么 Proxy 一定要配合 Reflect 使用? (opens new window)
# Symbol
Symbol
是由 ES6 规范引入的基础数据类型,功能类似于一种标识唯一性的 ID。通过 Symbol()
函数返回 symbol 类型的值,任何一个 symbol 值都是唯一的。
const s1 = Symbol();
const s2 = Symbol('foo');
const s3 = Symbol('foo');
console.log(typeof s1); // symbol
console.log(s2 === s3); // false
console.log(s3.toString()); // Symbol(foo)
应用场景:
- 使用
Symbol
来作为对象属性名 (key) - 使用
Symbol
来替代常量 - 使用
Symbol
定义类的私有属性/方法
参考资料:
# Set & Map
# Set, WeakSet
Set
是 ES6 中新增的一种数据结构,与数组不同的是其成员无序且不重复。
// 例一
const s = new Set();
[2, 3, 5, 4, 5, 2, 2].forEach(x => s.add(x));
for (let i of s) {
console.log(i); // 2 3 5 4
}
// 例二
console.log(...new Set([2, 3, 5, 4, 5, 2, 2])); // 2 3 5 4
Set
常用属性及增删改查方法:size
, add(value)
, delete(value)
, has(value)
, clear()
Set
集合遍历方法:
keys()
: 返回包含集合中所有键名的迭代器values()
: 返回包含集合中所有数值的迭代器entries()
: 返回包含集合中所有键值对的迭代器forEach(callbackFn, thisArg)
: 用于对集合成员执行callbackFn
操作,如果提供了thisArg
参数,回调中的this
会是这个参数,没有返回值
let set = new Set(['red', 'green', 'blue']);
for (let x of set) {
console.log(x);
}
// red
// green
// blue
for (let item of set.keys()) {
console.log(item);
}
// red
// green
// blue
for (let item of set.values()) {
console.log(item);
}
// red
// green
// blue
for (let item of set.entries()) {
console.log(item);
}
// ["red", "red"]
// ["green", "green"]
// ["blue", "blue"]
set.forEach((value, key) => console.log(key + ' : ' + value));
// red : red
// green : green
// blue : blue
WeakSet
与 Set
的区别:
WeakSet
只能存放对象引用,而Set
可以存放任何类型的值WeakSet
中存储的对象值都是被弱引用的(即垃圾回收机制回收某对象时不会考虑该对象还存在WeakSet
中)- 其
clear
方法不可用 WeakSet
是无法被遍历的,也没有办法拿到它所包含的所有元素
const ws = new WeakSet();
ws.add(1);
// TypeError: Invalid value used in weak set
ws.add(Symbol());
// TypeError: invalid value used in weak set
# Map, WeakMap
JavaScript 的对象(Object),本质上是键值对的集合(Hash 结构),但是传统上只能用字符串当作键。这给它的使用带来了很大的限制。
为了解决这个问题,ES6 提供了 Map
数据结构。它类似于对象,也是键值对的集合,但是「键」的范围不限于字符串,各种类型的值(包括对象)都可以当作键。也就是说,Object 结构提供了「字符串—值」的对应,Map 结构提供了「值—值」的对应,是一种更完善的 Hash 结构实现。如果你需要「键值对」的数据结构,Map 比 Object 更合适。
const m = new Map();
const o = { p: 'Hello World' };
m.set(o, 'content');
m.get(o); // "content"
m.has(o); // true
m.delete(o); // true
m.has(o); // false
Map
常用属性及增删改查方法:size
, set(key, value)
, get(key)
, has(key)
, delete(key)
, clear()
const map = new Map();
// 如果对同一个键多次赋值,后面的值将覆盖前面的值
map
.set(1, 'aaa')
.set(1, 'bbb');
map.get(1); // "bbb"
// 只有对同一个对象的引用,Map 结构才将其视为同一个键
map.set(['a'], 555);
map.get(['a']); // undefined
// Map 的键是一个简单类型的值(数字、字符串、布尔值),则只要两个值严格相等,Map 将其视为一个键
map.set(-0, 123);
map.get(+0); // 123
// 布尔值 true 和字符串 true 是两个不同的键
map.set(true, 1);
map.set('true', 2);
map.get(true); // 1
// undefined 和 null 也是两个不同的键
map.set(undefined, 3);
map.set(null, 4);
map.get(undefined); // 3
// 虽然 NaN 不严格相等于自身,但 Map 将其视为同一个键
map.set(NaN, 123);
map.get(NaN); // 123
Map
常用遍历方法:
keys()
: 返回包含字典中所有键名的迭代器values()
: 返回包含字典中所有数值的迭代器entries()
: 返回包含字典中所有键值对的迭代器forEach()
: 遍历字典的所有成员
需要特别注意的是,Map 的遍历顺序就是插入顺序:
const map = new Map([
['F', 'no'],
['T', 'yes'],
]);
for (let key of map.keys()) {
console.log(key);
}
// "F"
// "T"
for (let value of map.values()) {
console.log(value);
}
// "no"
// "yes"
for (let item of map.entries()) {
console.log(item[0], item[1]);
}
// "F" "no"
// "T" "yes"
// 或者
for (let [key, value] of map.entries()) {
console.log(key, value);
}
// "F" "no"
// "T" "yes"
// 等同于使用 map.entries()
for (let [key, value] of map) {
console.log(key, value);
}
// "F" "no"
// "T" "yes"
Map
结构转为数组结构,比较快速的方法是使用扩展运算符(...
):
let xMap = new Map();
xMap.set('name', 'jimco');
xMap.set('age', 18);
xMap.set('sex', 'man');
console.log(...xMap.keys()); // 'name' 'age' 'sex'
console.log(...xMap.values()); // 'jimco' 18 'man'
console.log(...xMap); // ['name', 'jimco'] ['age', 18] ['sex', 'man']
Map
与 Set
的区别:
- 同:
Map
与Set
都是存储不重复的值 - 异:
Set
中是以[value, value]
存储的,Map
是以[key, value]
存储
WeakMap
与 Map
的区别:
WeakMap
只接受对象作为键名(null
除外),不接受其他类型的值作为键名WeakMap
中存储的对象值都是被弱引用的(即垃圾回收机制回收某对象时不会考虑该对象还存在WeakMap
中)WeakMap
无法清空,即不支持clear
方法WeakMap
是无法被遍历的,也没有办法拿到它所包含的所有元素
WeakMap
中,每个键对自己所引用对象的引用都是弱引用,在没有其他引用和该键引用同一对象,这个对象将会被垃圾回收(相应的 key 则变成无效的),所以 WeakMap
的 key
是不可枚举的。
WeakMap
常用方法:has(key)
, get(key)
, set(key)
, delete(key)
const wm = new WeakMap();
const element = document.getElementById('example');
wm.set(element, 'some information');
wm.get(element); // "some information"
# Object 的 keys 是无序的吗?
Object.keys() 返回一个数组,其元素是字符串,对应于直接在对象上找到的可枚举的字符串键属性名。这与使用 for...in 循环迭代相同,只是 for...in 循环还会枚举原型链中的属性。Object.keys() 返回的数组顺序和与 for...in 循环提供的顺序相同。 —— MDN (opens new window)
在一些现代的浏览器中,keys 输出顺序是可以预测的!
Key 都为自然数:
注意这里的自然数是指正整数或 0,如果是其他类的 Number(浮点数或者负数),都会走到下一组类型里,像 NaN
或者 Infinity
这种也自然归到下一个类型里,但是像科学记数法这个会稍微特殊一点,感兴趣的同学可以自己试一下。
总结来说,就是当前的 key 如果是自然数就按照自然数的大小进行升序排序。
const objWithIndices = {
23: 23,
'1': 1,
1000: 1000,
};
console.log(Reflect.ownKeys(objWithIndices)); // ["1", "23", "1000"]
console.log(Object.keys(objWithIndices)); // ["1", "23", "1000"]
console.log(Object.getOwnPropertyNames(objWithIndices)); // ["1", "23", "1000"]
包括在 for-in
循环的遍历中,keys 也是按照这个顺序执行的。
Key 都为 String: 如果 key 是不为自然数的 String(Number 也会转为 String)处理,则按照加入的时间顺序进行排序。
const objWithStrings = {
'002': '002',
c: 'c',
b: 'b',
'001': '001',
};
console.log(Reflect.ownKeys(objWithStrings)); // ["002", "c", "b", "001"]
console.log(Object.keys(objWithStrings)); // ["002", "c", "b", "001"]
console.log(Object.getOwnPropertyNames(objWithStrings)); // ["002", "c", "b", "001"]
Key 都为 symbol: 如果 Key 都为 Symbol,顺序和 String 一样,也是按照添加的顺序进行排序。
const objWithSymbols = {
[Symbol('first')]: 'first',
[Symbol('second')]: 'second',
[Symbol('last')]: 'last',
};
console.log(Reflect.ownKeys(objWithSymbols)); // [Symbol(first), Symbol(second), Symbol(last)]
console.log(Object.getOwnPropertySymbols(objWithSymbols)); // [Symbol(first), Symbol(second), Symbol(last)]
如果是以上类型的组合:
const objWithStrings = {
'002': '002',
[Symbol('first')]: 'first',
c: 'c',
b: 'b',
'100': '100',
'001': '001',
[Symbol('second')]: 'second',
};
console.log(Reflect.ownKeys(objWithStrings));
// ["100", "002", "c", "b", "001", Symbol(first), Symbol(second)]
结果是先按照自然数升序进行排序,然后按照非数字的 String 的加入时间排序,然后按照 Symbol 的时间顺序进行排序,也就是说他们会先按照上述的分类进行拆分,先按照自然数、非自然数、Symbol 的顺序进行排序,然后根据上述三种类型下内部的顺序进行排序。
总结:
- 在 ES6 之前 Object 的键值对是无序的;
- 在 ES6 之后 Object 的键值对按照自然数、非自然数和 Symbol 进行排序,自然数是按照大小升序进行排序,其他两种都是按照插入的时间顺序进行排序。
参考资料:
- Object.keys() - MDN (opens new window)
- Property order is predictable in JavaScript objects since ES2015 (opens new window)
- The traversal order of object properties in ES6 (opens new window)
# 理解弱引用
# 强引用
在讲弱引用之前,我们先来说下强引用。强引用就是将对象保留在内存中的引用。例如:
let cat = { name: 'Kitty' };
const pets = [cat];
cat = null;
console.log(pets); // [{ name: 'Kitty' }]
通过将变量 cat
创建为对象,并把这个对象放入一个数组 pets
中,然后通过将它的值设置为 null
来删除其对原始对象的引用。
尽管我们再也无法访问 cat
变量,但由于在 pets
数组和这个对象之间存在强引用关系,因此这个对象其实仍保留在内存中,并且可以通过 pets[0] 访问到它。换句话说,强引用可以防止垃圾回收从内存中删除对象。
# 弱引用
简单地说,弱引用是对对象的引用,如果它还是对内存中对象的唯一引用,就能顺利地进行垃圾回收。相反,一般强引用都会防止垃圾回收。可能还有些不太理解,让我们先来看个例子,我们将试着把刚才的代码中的强引用转换为弱引用:
let pets = new WeakMap();
let cat = { name: 'Kitty' };
pets.set(cat, 'Kitty');
console.log(pets); // WeakMap {{…} => 'Kitty'}
cat = null;
// 等待垃圾回收后
console.log(pets); // WeakMap{}
通过利用 WeakMap
及其附带的弱引用,我们可以看到两种类型的引用之间的差异。当对原始 cat
对象的强引用仍然存在时,cat
对象也仍然存在于 WeakMap
中,我们可以毫无问题地访问它。
但是,当我们通过将 cat
变量重新赋值 null
来覆盖对原始 cat
对象的引用时,由于内存中对原始对象的唯一引用是来自我们创建的 WeakMap
的弱引用,所以它不会阻止垃圾回收的发生。这意味着当 JavaScript 引擎再次运行垃圾回收过程时,cat
对象将从内存和我们分配给它的 WeakMap
中删除。
因此这里的关键区就别在于,强引用可以防止对象进行垃圾回收,而弱引用则不会。
默认情况下,JavaScript 对其所有引用使用强引用,使用弱引用的唯一方法是使用 WeakMap
或 WeakSet
。