You Don’t Know JS Study Notes
ES
JavaScript标准的官方名称是ECMAScript,简称ES。
最早版本ES1和ES2,实现很少,不怎么为人所知。第一个流行起来的版本是ES3,它成为浏览器IE6-8和早前的旧版Android 2.x移动浏览器的JavaScript标准。出于某些政治原因,倒霉的ES4从来没有成形。
2009年,ES5正式发布(然后是2011年的ES5.1),在当代浏览器(包括Firefox、Chrome、Opera、Safari以及许多其他类型)的进化和爆发中成为JavaScript广泛使用的标准。
ES6,发布日期从2013年拖到2014年,然后又到2015年。
在ES6之前的JavaScript标准通常被称为ES5(严格说是ES5.1)。
1. 作用域
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。
有两种主要工作模型: 词法作用域和动态作用域。
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。是一套关于引擎如何寻找变量及会在何处找到变量的规则。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从从何处调用。
两者之间主要区别:
- 词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。
- 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
编译执行
1 | var a = 2; |
编译阶段
遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作 用域的集合中声明一个新的变量,并命名为a
。执行阶段
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a
的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。
1 | 'use strict'; // ES5引入 |
上述代码对于a = 2
,如果引擎最终在所有可访问的词法作用域中找到了a
变量,就会将2
赋值给它。否则引擎就会抛出一个异常。
1 | function foo(a) { |
当获取某个变量的值,在所有嵌套作用域中找不到所需变量,引擎就会抛出ReferenceError,即作用域判别失败。而TypeError则是作用域判别成功了,但是对结果的操作是非法或不合理的。
常见作用域单元
函数声明
1
2
3
4
5
6
7
8
9var a = 4;
function foo() {
var a = 3;
console.log(a); // 3
}
foo();
console.log(a); // 4匿名函数表达式
1
2
3
4
5
6
7
8var a = 4;
setTimeout(function() {
var a = 3;
console.log(a); // 3
}, 1000);
console.log(a); // 4具名函数表达式
1
2
3setTimeout(function hadnler() {
// ...
}, 1000);立即执行函数表达式
1
2
3
4
5
6
7
8var a = 4;
(function() {
var a = 3;
console.log(a); // 3
})();
console.log(a); // 4
社区规定术语:IIFE(Immediately Invoked Function Expression)。最佳实践:1
2
3(function IIFE() {
// ...
})();
- 块
1
2
3
4
5for (var i = 0; i < 10; i++) {
console.log(i);
}
console.log(i); // 10
1 | var t = true; |
变量的声明应该距离使用的地方越近越好。但是,上述代码中变量i
和a
,会被绑定在外部作用域(函数或全局)(提升),它们只是为了风格而伪装出的形式上的块作用域。为了防止在作用域内被提升,ES6引入了let关键字。
提升
1 | a = 2; |
1 | console.log(a); |
变量和函数的所有声明都会在任何代码被执行前首先被处理。就好像它们在代码中出现的位置被“移动”到了最上面,这个过程就叫提升。
So,以上两段代码会以如下形式进行处理:1
2
3var a;
a = 2;
console.log(a);
1 | var a; |
- 只有声明本身会被提升,赋值或其他运行逻辑会留在原地,等待被执行。如果提改变了代码的执行顺序,会造成非常严重的破坏
- 每个作用域都会进行提升操作
- 函数会优先被提升
2. 闭包
识别闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
1 | function foo() { |
在foo()
执行后,通常会期待foo()
的整个内部作用域都被销毁,因为引擎有垃回收器用来释放不再使用的内存空间。由于看上去foo()
的内容不会再被使用,所以很自然地会考虑对其进行回收。
闭包的存在使得foo()
内部作用域没有被回收。bar()
本身在使用内部作用域,它依然持有对该作用域的引用,这个引用就叫“闭包”。
无论使用哪种形式对函数类型的值进行传递,当函数在别处被调用时,就会产生闭包。
1 | function foo() { |
1 | var fn; |
1 | function wait(msg) { |
循环和闭包
- 共享全局作用域
1
2
3
4
5for (var i = 1; i <= 5; i++) {
setTimeout(function timer() { // timer会在循环结束时才执行
console.log(i);
}, i*1000);
}
理想情况是循环中的每个迭代在运行时都会给自己“捕获”一个i
的副本。但是根据作用域的工作原理,尽管五个函数是在各个迭代中分别定义的,但其实它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i
。
每个迭代创建一个闭包作用域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17for (var i = 1; i <= 5; i++) {
(function() {
var j = i;
setTimeout(function timer() {
console.log(j);
}, j*1000);
})();
}
// 改进版
for (var i = 1; i <= 5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j*1000);
})(i);
}使用块作用域
1
2
3
4
5for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i*1000);
}
模块
1 | function CoolModule() { |
以上模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
模块模式需要具备两个必要条件:
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
上一段代码中有一个叫作CoolModule()
的独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20var foo = (function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join(" ! "));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
现代模块机制
模块加载器/管理器:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i = 0; i < deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply(impl, deps); // 核心
}
function get(name) {
return modules[name];
}
return {
define: define,
get: get
};
})();
定义模块: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
MyModules.define( "bar", [], function() {
function hello(who) {
return "Let me introduce: " + who;
}
return {
hello: hello
};
});
MyModules.define( "foo", ["bar"], function(bar) {
var hungry = "hippo";
function awesome() {
console.log(bar.hello( hungry ).toUpperCase());
}
return {
awesome: awesome
};
});
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(bar.hello( "hippo" )); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO
未来模块机制
bar.js
1
2
3
4
5function hello(who) {
return "Let me introduce: " + who;
}
export hello;foo.js
1
2
3
4
5
6
7
8
9
10// 仅从 "bar" 模块导入 hello()
import hello from "bar";
var hungry = "hippo";
function awesome() {
console.log(hello(hungry).toUpperCase());
}
export awesome;baz.js
1
2
3
4
5
6module foo from "foo";
module bar from "bar";
console.log(bar.hello("rhino")); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
3. this
this
可以优雅的隐式“传递”一个对象引用,让API设计的更加简洁并且易于使用。如果显示传递上下文对象会让代码变的越来越混乱。
this
是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this
的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this
就是记录的 其中一个属性,会在函数执行的过程中用到。
误解
指向函数自身
记录函数foo
的被调用次数:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17function foo(num) {
console.log("foo: " + num);
// 记录 foo 被调用的次数
this.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
if (i > 5) { foo(i); }
}
console.log(foo.count); // 0
console.log(count); // NaN
1 | function foo(num) { |
指向函数的作用域
在某种情况下是正确的,但是在其他情况下却是错误的。
this在任何情况下都不指向函数的词法作用域。
1 | function foo() { |
调用位置
调用位置,即函数被调用的位置,可以分析调用栈得到。真正的的调用位置,决定了this
绑定。
1 | function baz() { |
绑定规则
默认绑定
最常用的函数调用类型:独立函数调用。
1 | function foo() { |
1 | function foo() { |
1 | function foo() { |
通常不会在代码中混合使用strict
模式和非strict
模式。
隐式绑定
在一个对象内部包含一个指向函数的属性,通过这个属性间接引用函数,从而把this
隐式绑定到这个对象上。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this
绑定到这个上下文对象。
1 | function foo() { |
对象属性引用链中只有上一层(最后一层)在调用位置中起作用。e.g.:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); //42
隐式丢失
丢失绑定对象,应用默认绑定,从而绑定到全局对象或undefined
。
函数引用传递
1
2
3
4
5
6
7
8
9
10
11
12
13
14function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名
var a = "oops, global";
bar(); // oops, global传入回调
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn引用的是foo
fn(); // <-- 调用位置
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global";
doFoo(obj.foo); // oops, global
显式绑定
在某个对象上强制调用函数,可以使用call(..)
和apply(..)
。
1 | function foo() { |
如果传入一个原始值(字符串类型/布尔类型/数字类型)来当作this
的绑定对象,这个原始值会被转换成相应的对象形式(new String()/new Boolean()/new Number()
)。即装箱。
显式绑定仍然会出现丢失绑定的问题。
1 | function foo() { |
使用显式绑定的一个变种可以解决这个问题,称为硬绑定。
1 | function foo() { |
由于硬绑定是一种非常常用的模式,所以在ES5中提供了内置的方法Function.prototype.bind
,它的用法如下:
1 | function foo(something) { |
bind(..)
会返回一个硬编码的新函数,它会把你指定的参数设置为this
的上下文并调用原始函数。
new绑定
在传统的面向类的语言中,“构造函数”是类中的一些特殊方法,使用new
初始化类时会被调用。通常形式如下:
1 | User user = new User(); |
在JavaScript中,new
的机制和面向类的语言完全不同。构造函数只是一些使用new
操作符时被调用的普通函数。它们并不会属于某个类,也不会实例化一个类。
使用new
来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 1.创建(或者说构造)一个全新的对象。
- 2.这个新对象会被执行[[Prototype]]连接。
- 3.这个新对象会绑定到函数调用的this。
- 4.如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
1 | function foo(a) { |
优先级
关于this
的四条绑定规则,只需要找到函数的调用位置,基本就可以判断该应用哪条规则。但是,有些调用位置可以应用多条规则,这时就必须根据优先级来进行判断了。
毫无疑问,默认绑定的优先级是最低的。
- 显式绑定 vs 隐式绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19function foo() {
console.log(this.a);
}
var obj1 = {
a: 2,
foo: foo
};
var obj2 = {
a: 3,
foo: foo
};
obj1.foo(); // 2
obj2.foo(); // 3
obj1.foo.call(obj2); // 3
obj2.foo.call(obj1); // 2
显式绑定优先级高于隐式绑定。
- new绑定 vs 隐式绑定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19function foo(something) {
this.a = something;
}
var obj1 = {
foo: foo
};
var obj2 = {};
obj1.foo(2);
console.log(obj1.a); // 2
obj1.foo.call(obj2, 3);
console.log(obj2.a); // 3
var bar = new obj1.foo(4);
console.log(obj1.a); // 2
console.log(bar.a); // 4
new绑定优先级高于隐式绑定。
- new绑定 vs 显式绑定
1
2
3
4
5
6
7
8
9
10
11
12
13function foo(something) {
this.a = something;
}
var obj1 = {};
var bar = foo.bind(obj1);
bar(2);
console.log(obj1.a); // 2
var baz = new bar(3);
console.log(obj1.a); // 2
console.log(baz.a); // 3
bar
被硬绑定到obj1
上,但是new bar(3)
并没有把obj1.a
修改为3
。相反,new
修改了硬绑定调用bar(..)
中的this
。使用new
绑定,得到一个名为baz
的新对象,并且baz.a
的值是3
。
判断this
1.函数是否在
new
中调用(new绑定)?如果是的话this绑定的是新创建的对象。1
var bar = new foo();
2.函数是否通过
call
、apply
(显式绑定)或者硬绑定调用?如果是的话,this绑定的是指定的对象。1
var bar = foo.call(obj2);
3.函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,
this
绑定的是那个上下文对象。1
var bar = obj1.foo();
4.如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到
undefined
,否则绑定到全局对象。1
var bar = foo();
4. 原型
5. 异步
回调是JavaScript中最基础的异步模式。
以下代码被称为回调地狱(callback hell),有时也被称为毁灭金字塔(pyramid of doom):1
2
3
4
5
6
7
8
9
10
11listen("click", function handler(evt) {
setTimeout(function request() {
ajax("http://some.url.1", function response(text) {
if (text == "hello") {
handler(); }
else if (text == "world") {
request();
}
});
}, 500) ;
});
为了更优雅的处理错误,有些API设计提供了分离回调:1
2
3
4
5
6
7
8
9function success(data) {
console.log(data);
}
function failure(err) {
console.error(err);
}
ajax("http://some.url.1", success, failure);
ES6特性
语法特性
1. 块作用域
let
let
可以将变量绑定到所在的任意作用域中(通常是{..}内部)。换句话说,let
为其声明的变量隐式的劫持了所在的块作用域。
1 | for (let i = 0; i < 10; i++) { |
1 | var t = true; |
1 | var a = 2; |
let
声明不会在块作用域中进行提升:1
2
3
4
5
6
7{
console.log(a); // undefined
console.log(b); // ReferenceError: c is not defined
var a;
let b;
}
垃圾收集:
1 | function process(data) { |
click
函数的点击回调并不需要someReallyBigData
变量。理论上这意味着当process(..)
执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于click
函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存someReallyBigData
了:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15function process(data) {
// ...
}
// 在这个块中定义的内容可以销毁了!
{
let someReallyBigData = { .. };
process( someReallyBigData );
}
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
console.log("button clicked");
}, /*capturingPhase=*/false );
注意:
- 尽量把
let
声明放在块的最前面 - 声明多个变量,建议使用一个
let
1
let a, b, c;
const
1 | const a = 2; |
- 用于创建常量
- 常量不是对这个值本身的限制,而是对赋值的那个变量的限制
- 大写字母+下划线
1
const MAX_VALUE = 10;
块作用域函数
ES6之前condition
无论值为什么,bar
声明都会被提升,最后一个胜出。
1 | if (condition) { |
2. spread/rest
新运算符...
1 | // spread: 把变量展开为各个独立的值 |
arguments
,并不是真正的数组,而是类似数组的对象。已被弃用,不推荐使用。
1 | // 按照新的ES6的行为方式实现 |
3. 默认参数值
简单默认值
ES5中默认值的实现,作用很大,也很危险。
1 | function foo(x, y) { |
- 被认为是假的值
1 | foo(0, 42) |
- 省略第一个参数
1 | foo(, 6); |
ES6缺失参数赋值1
2
3
4
5
6
7
8
9
10
11
12function foo(x = 11, y = 31) {
console.log( x + y );
}
foo();
foo( 5, 6 );
foo( 0, 42 );
foo( 5 );
foo( 5, undefined );
foo( 5, null );
foo( undefined, 6 );
foo( null, 6 );
表达式默认值
函数默认值除了是简单值,可以是任意合法表达式,甚至是函数调用。
1 | function bar(val) { |
默认值表达式中的标识符
引用首先匹配到形式参数作用域'(...)'
,然后才会搜索外层作用域。
1 | // z已经声明,但未初始化 |
默认回调函数
1 | function dialog(msg, cb = function() {}) { |
4. 对象字面量扩展
简洁属性
1 | var x = 2, y = 3; |
对象属性名与词法标识符(变量)
同名,则可如下简写:
1 | var x = 2, y = 3; |
简洁方法
1 | var o = { |
1 | var o = { |
计算属性名
1 | var prefix = "user_"; |
1 | var prefix = "user_"; |
此外还有[[Prototype]]
的设定,super
对象。
5. 解构
解构(destructuring)可以看作是一个结构化赋值(structured assignment)方法。
曾经的手动赋值:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23// 数组
function foo() {
return [1, 2, 3];
}
// 对象
function bar() {
return {
x: 4,
y: 5,
z: 6
};
}
var tmp = foo(), // 临时变量
a = tmp[0], b = tmp[1], c = tmp[2];
console.log(a, b, c); // 1 2 3
var obj = bar(), // 临时变量
x = obj.x, y = obj.y, z = obj.z;
console.log(x, y, z); // 4 5 6
ES6新增了一个专门语法,专用于数组解构和对象解构。这个语法消除了前面代码对临时变量的需求,使代码更加简洁。
1 | // 数组解构 |
对象属性赋值模式
如果属性名和要赋值的变量名相同,可以使用简洁属性使得语法更加简短。
1 | var { x: x, y: y, z: z } = bar(); |
对于{ x, .. }
,缩写语法省略了x:
部分。
简短的形式使得代码更简洁,但是更长的的形式支持把属性赋给非同名变量:1
2
3
4var { x: bam, y: baz, z: bap } = bar();
console.log(bam, baz, bap); // 4 5 6
console.log(x, y, z); // ReferenceError
语法模式比较:
- 对象字面值:
target: source
(target <-- source
) - 对象解构赋值:
source: target
(source --> target
)
1 | var aa = 10, bb = 20; |
不只是声明
解构是一个通用的赋值操作,不仅仅是声明。
1 | var a, b, c, x, y, z; |
任何合法的赋值表达式都可以用解构赋值。
1 | var o = {}; |
使用计算属性:1
2
3
4
5
6var which = 'x',
o = {};
( { [which]: o[which] } = bar() );
console.log(o);
不用临时变量交换两个变量:1
2
3
4
5var x = 10, y = 20;
[y, x] = [x, y];
console.log(x, y); // 20 10
重复赋值
1 | var { a: X, a: Y } = { a: 1 }; |
解构子对象/数组属性:
1 | var { a: { x: X, x: Y }, a } = { a: { x: 1 } }; |
6. 模板字面量
模板字面量更应该被称为插入字符串字面量。
ES6之前的字符串连接方式:1
2
3
4
5
6var name = "Kyle";
var greeting = "Hello " + name + "!";
console.log(greeting); // Hello Kyle!
console.log(typeof greeting); // string
ES6中的方式:1
2
3
4
5
6var name = "Kyle";
var greeting = `Hello ${name}!`;
console.log(greeting); // Hello Kyle!
console.log(typeof greeting); // string
插入字符串字面量的一个优点是它们可以分散在多行:1
2
3
4
5
6var text =
`Now is the time for all good men
to come to the aid of their
country!`;
console.log( text );
插入表达式
在插入字符串字面量的${..}
内可以出现任何合法的表达式,包括函数调用、在线函数表
达式调用,甚至其他插入字符串字面量!
1 | function upper(s) { |
标签模板字面量
1 | function foo(strings, ...values) { |
7. 箭头函数
普通函数和箭头函数对比:1
2
3
4
5
6
7function foo(x, y) {
return x + y;
}
// 对比
var foo = (x, y) => x + y;
不同形式的箭头函数:1
2
3
4
5
6
7
8var f1 = () => 12;
var f2 = x => x * 2;
var f3 = (x, y) => {
var z = x * 2 + y;
y++;
x *= 3;
return (x + y + z) / 2;
};
箭头函数特点:
- 标识
=>
前面是参数,后面是函数体 - 零个或多个参数,需要用
( .. )
括起来 - 函数体的表达式多余1个,或者函数体包含非表达式语句的时候需要用
{ .. }
括起来 - 如果只有一个表达式,并且省略了
{ .. }
,则意味着表达式前面有一个隐含的return
箭头函数不仅仅是更短的语法,而是this
。在箭头函数内部,this
绑定不是动态的,而是词法的。
1 | function foo() { |
箭头函数最常用于回调函数中,例如事件处理器或定时器:1
2
3
4
5
6
7
8
9
10
11function foo() {
setTimeout(() => {
console.log(this.a);
});
}
var obj = {
a: 2
};
foo.call(obj); // 2
曾经使用的一种几乎和箭头函数完全一样的模式:1
2
3
4
5
6
7
8
9
10
11
12
13function foo() {
var self = this;
setTimeout(function() {
console.log(self.a);
});
}
var obj = {
a: 2
};
foo.call(obj); // 2
8. for..of循环
在for
和for..in
循环组合起来的基础上,ES6又新增了一个for..of
循环,在迭代器产生的一系列值上循环。
for..of
循环的值必须是一个iterable
,或者可以转换/封箱到一个iterable
对象的值。
1 | var a = ["a", "b", "c", "d", "e"]; |
JavaScript中默认为iterable
的标准内建值包括:
- Arrays
- Strings
- Generators
- Collections / TypedArrays
在原生字符串的字符上迭代:
1 | for (var c of "hello") { |
原生字符串值”hello”被强制类型转换/封箱到邓建的String
封装对象中。
9. 正则表达式
10. 数字字面量
11. Unicode
12. Symbol
代码组织
1. 迭代器
2. 生成器
3. 模块
4. 类
异步流控制
1. Promise
1 | function ajax(url, cb) { |
1 | function ajax(url) { |