# 第1章 作用域是什么?
用来存储变量信息和变量值信息的一套运行规则。规则的定义是为了让程序能够存储、访问、修改变量和变量值信息。
几乎所有的编程语言最基本的功能之一,就是能够存储变量当中的值,并且能在之后对这个值进行访问或修改。事实上,正是这种存储和访问变量的值的能力将状态带给了程序。
这些变量住在哪里?换句话说,它们存储在哪里?最重要的是,程序需要时如何找到它们?
这些问题说明需要一套良好的规则来存储变量,并且之后可以方便的找到这些变量。这套规则被称为作用域。
# 1.1 编译原理
传统编译语言的流程为:
- 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如:考虑程序
var a = 2;
。这段程序通常会被分解成为下面这些词法单元:var、a、=、2、;
。空格是否被当做词法单元,取决于空格在这门语言中是否具有意义。
分词(Tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。 简单来说,如果词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。 - 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
var = 2;
的抽象语法树中可能会有一个叫做VariableDeclaration的顶级节点,接下来是一个叫做Identifier(它的值是a)的子级节点,以及一个叫做AssignmentExpression的子节点。AssignmentExpression节点有一个叫做NumericLiteral(它的值是2)的子节点 - 代码生成
将AST转换为可执行的过程叫做代码生成。这个过程与语言、 目标平台等息息相关。
抛开具体细节,简单来说就是有某种方法可以将var a = 2;
的AST转化为一组机器指令,用来创建一个叫做a的变量(包括分配内存等),并将一个值存储在a中。
关于引擎如何管理系统资源超出了我们的讨论范围,因此只需要简单的了解引擎可以根据需要创建并存储变量即可。
JavaScript的编译过程:
JavaScript的编译过程和传统编译语言非常相似
- JavaScript的编译过程不是发生在构建之前
- 大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。
- 在词法分析和代码生成阶段有特定的步骤来对运行性能来进行优化,包括对冗余元素进行优化等。
# 1.2 理解作用域
# 1.2.1 演员表
- 引擎
负责JavaScript程序的编译及执行过程
- 编译器
负责语法分析及代码生成
- 作用域
负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限
# 1.2.2 引擎的执行过程
var a = 2;
这段程序时,编译器的处理步骤:
- 首先编译器将这段程序分解成词法单元,然后将词法单元解析成一个树结构。
- 遇到var a,编译器询问作用域是否存在同名称a的变量。
- 如果存在,则忽略该声明
- 如果不存在,作用域会在当前的作用域集合中声明一个新的变量,并命名为a
- 编译器为引擎生成运行时所需的代码,用于处理
a = 2
这个赋值操作 - 引擎运行时会询问作用域,在当前的作用域中是否存在一个叫做a的变量。
- 如果存在,引擎会使用这个变量
- 如果不存在,引擎会继续查找该变量
- 最终引擎找到了a变量,就会将2赋值给它。如果没有找到,则会抛错。
总结:变量的赋值操作会执行两个动作
- 编译器会在当前作用域中声明一个变量(如果之前没有声明过)
- 在运行时,引擎会在作用域中查找该变量,如果能找到就会进行赋值操作。
# 1.2.3 引擎的查找方式
引擎查找变量的过程由作用域进行协助,但引擎执行怎样的查找,会影响最终的查找结果。
引擎的查找类型有两种:
- LHS(左查询)
- RHS(右引用或者右查询)
RHS(右引用)查询与简单的查找某个变量的值别无二致,而LHS(左)查询是试图找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS(右引用)并不是真正意义上的“赋值操作的右侧”,更准确的说是非左侧。
下列代码中存在哪些LHS(左查询)和RHS(右引用)?
var a = 2;
console.log(a);
2
a = 2
是LHS(左查询)操作console
是RHS(右引用)操作(a)
是RHS(右引用)操作
下列代码中存在哪些LHS(左查询)和RHS(右引用)
function foo(a) {
console.log(a);
}
foo(2);
2
3
4
LHS(左查询)操作:
foo
方法的形参a = 2
的操作 RHS(右引用查询)操作:- 查找
foo
方法 - 查找
console
对象 - 查找
console.log(a)
中的变量a
的值
你可能会倾向于将函数声明function foo(a) {...}
概念化为普通的变量申明和赋值,比如var foo
、foo = function(a) {...}
。如果这样理解的话,这个函数声明将需要进行LHS(左)查询。
然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值“分配给”foo。因此,将函数声明理解成前面讨论的LHS(左)查询和赋值的形式并不合适。
# 1.2.5 小测验
找出下面代码中的LHS(左查询)和RHS(右引用查询)
function foo(a) {
var b = a;
return a + b;
}
var c = foo(2);
2
3
4
5
LHS查询
c = foo(2)
是LHS查询- foo函数的形参
a = 2
是LHS查询 b = a
是LHS查询
RHS 查询
foo(2)
方法调用是RHS查询= a
是RHS查询a + b
是两处RHS查询
# 1.3 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域嵌套。
当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层作用域(全局作用域)。
当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
# 1.4 异常
如果RHS(右引用)查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常。ReferenceError异常是非常重要的异常。
如果是LHS(左)查询时,在所有嵌套的作用域中遍寻不到所需要的变量时(包括顶层的全局作用域),那么全局作用域中就会创建一个具有该名称的变量,并返回给引擎。这一切的前提是程序运行在非“严格模式”下。
严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中LHS(左)查询失败时,引擎会抛出ReferenceError异常。
在RHS查询到了一个变量,但是对这个变量的值进行不合理的操作,比如对一个非函数类型的值进行函数调用,或者引用null或undefined类型的值的属性。引擎会抛TypeError异常。
ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或者不合理的。
# 1.5 小结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用LHS查询;如果目的是获取变量的值,就会使用RHS查询。
LHS和RHS都会在当前执行的作用域中开始,如果在当前执行的作用域中没有找到,那么就会向上级作用域中继续查找,最后抵达全局作用域,无论是否找到都将会停止查询操作。
# 第2章 词法作用域
作用域有两种主要的工作模型。第一种是词法作用域,最为普通且被大多数编程语言所采用的(词法作用域)。另一种是动态作用域,比如:Bash脚本、Perl中的一些模式等。
JavaScript所采用的是词法作用域。
# 2.1 词法阶段
词法作用域是由你在写代码时将变量和块作用域写在哪里决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
每个函数都会创建一个新的作用域
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,知道遇见第一个匹配的标识符为止。
全局变量会自动的成为全局对象(比如浏览器的window对象)的属性。因此可以不直接通过全局对象的词法名称,而是间接的通过对全局对象属性的引用来进行访问。比如window.a
。这种方式可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法访问到。
词法作用域查找只会查找一级标识符,比如a、b
和c
。如果代码中引用了foo.bar.baz
,词法作用域查找只会试图查找foo标识符,找到这个变量后,对象属性访问规则会分别接管对bar
和baz
属性的访问。
# 2.2 词法欺骗
欺骗词法作用域会导致性能下降。
# 2.2.1 动态运行时
在执行eval(...)
之后的代码时,引擎并不“知道”或者“在意”前面的代码是以动态形式插入进来,并对词法作用域的环境进行修改的。引擎只会如往常地进行词法作用域查找。
一下代码:
function foo(str, a) {
eval(str);
console.log(a, b);
}
var b = 2;
foo('var b = 3;', 1);
2
3
4
5
6
这段代码实际上在foo(...)内部创建了一个变量b,并遮蔽了外部(全局)作用域中的同名变量。
默认情况下,如果eval(...)中所执行的代码包含至少有一个或多个声明(无论是变量还是函数),就会对eval(...)所处的词法作用域进行修改。通过一些技巧可以间接调用eval(...)来使其运行在全局作用域中,并对全局作用域进行修改。但无论何种情况,evel(...)都可以在运行期间修改书写期的词法作用域。
在严格模式的程序中,eval(...)在运行时尤其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
function foo(str) {
"use strict";
eval(str);
console.log(a); // ReferenceError: a is not define
}
foo('var a = 2;');
2
3
4
5
6
setTimeout(...)和setInterval(...)的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。这些功能已经过时并且不被提倡。不要使用它们!
new Function(...)函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成函数的形参)。这种构建函数的语法比eval(...)略微安全一些。但也要尽量避免使用。
在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法低效性能上的损失。
# 2.2.2 with
with可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
所以,在es6中的块级作用域中也会失效吗? 比如,用es6的引擎执行下列代码
const obj = {
a: 1
}
function foo() {
with (obj) {
b = 2
}
console.log(obj.b) // undefined
console.log('foo-', b); // 2
}
foo();
console.log(b) // 2
2
3
4
5
6
7
8
9
10
11
12
答案是不会报错,在全局作用域中确实存在变量b
尽管with块可以将一个对象处理为词法作用域,但是这个块内部正常的var声明并不会被限制在这个块的作用域中,而是被添加到with所处的函数作用域中。
eval(...)函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而with声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。
不推荐使用eval(...)和with的原因是会被严格模式所影响(限制)。with被完全禁止,而在保留核心功能的前提下,间接或非安全的使用eval(...)也被禁止了。
# 2.2.3 性能
使用动态运行时(eval、new Function、setTimeout等)和with会影响JavaScript的性能。因为JavaScript引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。 动态运行时和with的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用其中任何一个机制都将导致代码运行变慢。
# 第3章 函数作用域和块作用域
# 3.1 函数中的作用域
JavaScript具有基于函数的作用域,意味着每创建一个函数都会有一个作用域
函数内部创建的变量无法被外部访问,也就是说这些变量无法在全局作用域中访问
# 3.2 隐藏内部实现
在函数中创建的变量都将绑定在这个新创建的函数作用域中,而不是在函数之外的作用域中
最小特权原则
这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的API设计。 如果所有的变量或函数都在全局作用域中,会破坏最小特权原则。
规避冲突
将变量或函数放在函数作用域中,可以避免同名标识符的冲突。
例如:
function foo() {
function bar(a) {
i = 3; // 修改for循环所属作用域中的i
console.log(a + i);
}
for (var i = 0; i < 10; i++) {
bar(i * 2) // 无限循环了
}
}
foo();
2
3
4
5
6
7
8
9
10
全局命名空间
如果不妥善的处理某一个库中的私有方法,极容易导致和第三方库的方法冲突。所以,这些库通常只会声明一个足够独特的名称,通常是一个对象的名称。所需要暴露给外界的功能都将成为这个对象下的属性方法。
例如:
var MyReallyCoolLibrary = {
doSomething() {
// ...
},
noSomething() {
// ...
}
}
2
3
4
5
6
7
8
模块管理
模块管理的工具是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。
# 3.3 函数作用域
区分函数声明和表达式最简单的方法是看function关键字出现在声明的位置(不仅仅是一行代码,而是整个声明的位置)。如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
(function foo(){...})
作为函数表达式意味着foo只能在...
所代表的位置中被访问,外部作用域不行。foo变量名称被影藏在自身中意味着不会非必要的污染外部作用域。
# 3.3.1 匿名和具名
以下代码示例:
setTimeout(function (){
console.log('...')
}, 1000)
2
3
这叫匿名函数表达式,因为function()...
没有标识符。函数表达式可以是匿名的,而函数声明则不可以省略函数名 ———— 在JavaScript的语法中这是非法的。
匿名函数的几个缺点:
- 匿名函数在栈追踪中不会显示出有意义的函数名,这使得调试困难。
- 如果没有函数名,当函数需要引用自身时只能使用已过期的arguments.callee引用,比如递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
- 匿名函数省略了对代码可读性/可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。
# 3.3.2 立即执行函数表达式
var a = 2;
(function foo() {
var a = 3;
console.log(a); // 3
})();
console.log(a); // 2
2
3
4
5
6
第一个()
将函数变成表达式,第二个()
执行了这个函数。
这种模式有一个术语:IIFE,代表立即执行函数表达式(IIFE,代表立即执行函数表达式(Immediately Invoked Function Expression)
IIFE的另一种改进形式: (function(){}())
。这种形式与常见的IIFE形式的功能上是一致的。
IIFE另一种进阶用法是把它们当做函数调用并传递参数进去。 例如:
var a = 2;
(function(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
})(window)
console.log(a); // 2
2
3
4
5
6
7
这种方式解决的另一个应用场景是解决undefined标识符的默认值被错误覆盖导致的异常。例如:
undefined = true;
(function(undefined){
var a;
if (a === undefined) {
console.log(true);
}
})();
2
3
4
5
6
7
IIFE的另一种用途是倒置代码运行顺序,将需要运行的函数放在第二位,在IIFE执行之后当做参数传递进去。这种模式在UMD(Universal Module Definition)项目中被广泛使用。 例如:
var a = 2;
(function(def) {
def(window)
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});
2
3
4
5
6
7
8
# 3.4 块作用域
ES6标准之前不存在块作用域概念,ES6的标准中新增了块作用域的概念。
# 3.4.2 try/catch
在ES3规范中,try/catch的catch分句会创建一个块作用域,其中声明的变量仅在catch内部有效。 例如:
try {
undefined() // 执行一个非法操作来触发异常
} catch(err) {
console.log(err); // 能够正常执行
}
console.log(err); // ReferenceError
2
3
4
5
6
# 3.4.3 let
let关键字可以将变量绑定到所在的任意作用域中(通常是{ .. }内部)
var foo = true;
if (foo) {
let bar = foo * 2;
console.log(bar);
}
console.log(bar) // ReferenceError
2
3
4
5
6
7
使用let进行的声明不会在块作用域中提升,声明之前去使用,变量并不“存在”。
{
console.log(bar); // ReferenceError
let bar = 3;
}
2
3
4
# 3.4.3 垃圾收集
function process(data) {
// 做点事情
}
var someReallyBigData = { ... };
process(someReallyBigData);
var btn = document.getElementById("my button");
btn.addEventListener('click', function(evt) {
console.log('click button');
});
2
3
4
5
6
7
8
9
10
click函数的点击回调并不需要someReallyBigData变量。理论上这意味着当process(..)执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于click函数形成了一个覆盖整个作用域的闭包,JavaScript引擎极有可能依然保存着这个结构(取决于具体实现)。
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存someReallyBigData了:
function process(data) {
// 做点事情
}
{
var someReallyBigData = { ... };
process(someReallyBigData);
}
var btn = document.getElementById("my button");
btn.addEventListener('click', function(evt) {
console.log('click button');
});
2
3
4
5
6
7
8
9
10
11
# 3.3.4 const
const 同样可以创建块作用域,但其值是固定的(常量)。
# 第4章 变量提升和函数提升
# 4.1 先有鸡还是先有蛋
以下代码会输出什么?
a = 2;
var a;
console.log(a);
2
3
会输出2
以下代码会输出什么?
console.log(a);
var a = 2;
2
输出undefined
# 4.2 编译器再度来袭
编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。也正是词法作用域的核心内容。
正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
这个var a = 2;console.log(a);
代码片段会以如下方式执行:
var a; // 定义声明,在编译阶段进行
a = 2; // 赋值声明会留在执行阶段
console.log(a);
2
3
这个过程就叫作(变量)提升。换句话说,先有蛋(声明)后有鸡(赋值)。
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。如果提升改变了代码执行的顺序,会造成非常严重的破坏。
另外值得注意的是,每个作用域都会进行提升操作。例如下列代码:
foo();
function foo() {
console.log(a); // undefined
var a = 2;
}
2
3
4
5
6
可以看到上面的代码片段,函数声明会被提升,但是函数表达式却不会被提升。
foo(); // 不是ReferenceError,而是TypeError
var foo = function bar() {
// ...
}
2
3
4
5
这段程序中的变量标识符foo()被提升并分配给所在作用域(在这里是全局作用域),因此foo()不会导致ReferenceError。但是foo此时并没有赋值(如果它是一个函数声明而不是函数表达式,那么就会赋值)。foo()由于对undefined值进行函数调用而导致非法操作,因此抛出TypeError异常。
即使是具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用:
foo(); // ReferenceError
bar(); // TypeError
var foo = function bar() {
// ...
}
2
3
4
5
6
这段代码经过提升后,实际上会被理解为以下形式:
var foo;
foo(); // TypeError
bar(); // ReferenceError
foo = function() {
var bar = ...self...
// ...
}
2
3
4
5
6
7
8
# 4.3 函数优先
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
比如这个代码片段:
foo(); // 1
var foo;
function foo() {
console.log(1);
}
foo = function() {
console.log(2);
};
2
3
4
5
6
7
8
9
10
11
会输出1而不是2!这个代码片段会被引擎理解为如下形式:
function foo() {
console.log(1);
}
foo(); // 1
foo = function() {
console.log(2);
};
2
3
4
5
6
7
8
9
var foo尽管出现在function foo()...的声明之前,但它是重复的声明(因此被忽略了),因为函数声明会被提升到普通变量之前。
尽管重复的var声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。
foo(); // 3
function foo() {
console.log(1);
}
var foo = function() {
console.log(2);
};
function foo() {
console.log(3);
}
2
3
4
5
6
7
8
9
10
11
12
13
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代码暗示的那样可以被条件判断所控制:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
2
3
4
5
6
7
8
9
但是需要注意这个行为并不可靠,在JavaScript未来的版本中有可能发生改变,因此应该尽可能避免在块内部声明函数。
# 第5章 作用域闭包
# 5.1 启示
闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用它们而有意识地创建闭包。闭包的创建和使用在你的代码中随处可见。
# 5.2 实质问题
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
例如这段代码:
function foo() {
var a = 2;
function bar() {
console.log(a); // 2
}
bar();
}
foo();
2
3
4
5
6
7
8
9
10
11
这是闭包吗?
技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释bar()对a的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。(但却是非常重要的一部分!)
在上面的代码片段中,函数bar()具有一个涵盖foo()作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为bar()封闭了foo()的作用域中。为什么呢?原因简单明了,因为bar()嵌套在foo()内部。
这一段代码清晰的展示了闭包:
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2——朋友,这就是闭包的效果。
2
3
4
5
6
7
8
9
10
11
12
在这个例子中,它在自己定义的词法作用域以外的地方执行。
在foo()执行后,通常会期待foo()的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去foo()的内容不会再被使用,所以很自然地会考虑对其进行回收。
闭包可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。因为bar()本身在使用它。
bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。
function foo() {
var a = 2;
function baz() {
console.log(a); // 2
}
bar(baz);
}
function bar(fn) {
fn(); // 妈妈快看呀,这就是闭包!
}
2
3
4
5
6
7
8
9
10
11
12
13
把内部函数baz传递给bar,当调用这个内部函数时(现在叫作fn),它涵盖的foo()内部作用域的闭包就可以观察到了,因为它能够访问a。
传递函数当然也可以是间接的。
var fn;
function foo() {
var a = 2;
function baz() {
console.log(a);
}
fn = baz; // 将baz分配给全局变量
}
function bar() {
fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
# 5.4 循环和闭包
for (var i = 1; i <=5; i++) {
setTimeout(function timer(){
console.log(i);
}, i * 1000);
}
2
3
4
5
正常情况下,我们对这段代码行为的预期是分别输出数字1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次6。
这个循环的终止条件是不再<=5。条件成立时i的值是6。因此,输出显示的是循环结束时的最终值。
根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。
for (var i=1; i<=5; i++) {
(function() {
setTimeout(function timer() {
console.log(i);
}, i*1000 );
})();
}
2
3
4
5
6
7
这种方式也是不行的,因为还是共享同一个作用域中的变量i。
我们需要在每个函数作用域中去缓存下当前的for循环的的变量i。
for (var i=1; i<=5; i++) {
(function(j) {
setTimeout(function timer() {
console.log(j);
}, j*1000 );
})(i);
}
2
3
4
5
6
7
在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
# 重返块作用域
let可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
因此,下面这些代码就可以正常运行了:
for (var i=1; i<=5; i++) {
let j = i; // 是的,闭包的块作用域!
setTimeout( function timer() {
console.log(j);
}, j*1000 );
}
2
3
4
5
6
for循环头部的let声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
for (let i=1; i<=5; i++) {
setTimeout(function timer() {
console.log(i);
}, i*1000 );
}
2
3
4
5
# 5.5 模块
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
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
这个模式在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。
从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery就是一个很好的例子。jQuery和$标识符就是jQuery模块的公共API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)。
# 附录A 动态作用域
事实上JavaScript并不具有动态作用域。它只有词法作用域,简单明了。但是this机制某种程度上很像动态作用域。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
最后,this关注函数如何调用,这就表明了this机制和动态作用域之间的关系多么紧密。如果想了解更多关于this的详细内容,参见本书第二部分“this和对象原型”。
# 附录B 块作用域的替代方案
{
let a = 2;
console.log(a); // 2
}
console.log(a); // ReferenceError
2
3
4
5
6
上面的代码在ES6之前的环境中如何才能实现这个效果?
try{throw 2; }catch(a){
console.log(a); // 2
}
console.log(a); // ReferenceError
2
3
4
5
我们看见一个会强制抛出错误的try/catch,但是它抛出的错误就是一个值2,然后catch分句中的变量声明会接收到这个值。
没错,catch分句具有块作用域,因此它可以在ES6之前的环境中作为块作用域的替代方案。
# Traceur
Google维护着一个名为Traceur的项目,该项目正是用来将ES6代码转换成兼容ES6之前的环境(大部分是ES5,但不是全部)。TC39委员会依赖这个工具(也有其他工具)来测试他们指定的语义化相关的功能。
# 隐式和显式作用域
let作用域或let申明
let (a = 2) {
console.log(a); // 2
}
console.log(a); // ReferenceError
2
3
4
5
let声明有意将变量声明放在块的顶部,如果你并没有到处使用let定义,那么你的块作用域就很容易辨识和维护。
但是这里有一个小问题,let声明并不包含在ES6中。官方的Traceur编译器也不接受这种形式的代码。
我们有两个选择,使用合法的ES6语法并且在代码规范性上做一些妥协。
/*let*/
{ let a = 2;
console.log(a);
}
console.log(a); // ReferenceError
2
3
4
5
6
另一种方式是编写一个工具来解决这个问题。
IIFE和try/catch并不是完全等价的,因为如果将一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的this、return、break和contine都会发生变化。IIFE并不是一个普适的解决方案,它只适合在某些情况下进行手动操作。
# 第二部分 this和对象原型
# 第一章 关于this
this提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将API设计得更加简洁并且易于使用。
使用arguments.callee可以引用当前正在运行的函数对象。这是唯一一种可以从匿名函数对象内部引用自身的方法。但,更好的方式是避免使用匿名函数,至少在需要自引用时使用具名函数(表达式)。arguments.callee已经被弃用,不应该再使用它。
this在任何情况下都不指向函数的词法作用域。在JavaScript内部,作用域确实和对象类似,可见的标识符都是它的属性。但是作用域“对象”无法通过JavaScript代码访问,它存在于JavaScript引擎内部。
当你想要把this和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。
this是在运行时绑定的,并不是在编写时绑定的,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈)、函数的调用方式、传入的参数等信息。this就是这个记录的一个属性,会在函数执行的过程中用到。
this既不指向函数自身也不指向函数的词法作用域
# 第二章 this全面解析
调用位置就是函数在代码找那个被调用的位置(而不是声明的位置)
什么是调用栈和调用位置
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log('baz');
bar(); // <--- bar的调用位置
}
function bar() {
// 当前调用栈是 baz -> bar
// 因此,当前调用位置在baz中
console.log('bar');
foo() // <--- foo的调用位置
}
function foo() {
// 当前调用栈是baz -> bar -> foo
// 因此,当前调用位置在bar中
console.log("foo");
}
baz(); // <--- baz的调用位置
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 绑定规则
默认绑定:函数独立调用
function foo() {
console.log(this.a);
}
var a = 2;
foo(); // 2;
2
3
4
5
this.a被解析成了全局变量a。因为函数调用时应用了this的默认绑定,因此this指向全局对象。
如果使用严格模式(strict mode),则不能将全局对象用于默认绑定,因此this会绑定到undefined;
function foo() {
"use strict";
console.log(this.a);
}
var a = 2;
foo(); // TypeError: this is undefined
2
3
4
5
6
7
8
9
this的绑定规则完全取决于调用位置,但是只有foo()运行在非strict mode下时,默认绑定才能绑定到全局对象;在严格模式下调用foo则不影响默认绑定
function foo() {
console.log(this.a);
}
var a = 2;
(function(){
"use strict";
foo(); // 2
})();
2
3
4
5
6
7
8
9
10
11
隐式绑定:调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
2
3
4
5
6
7
8
9
10
无论是直接在obj中定义还是先定义再添加为引用属性,这个函数严格来说都不属于obj对象。
然而,调用位置会使用obj上下文来引用函数,因此你可以说函数被调用时obj对象“拥有”或者“包含”它。
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。以你为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。
对象属性引用链中只有上一层或者说最后一层在调用位置中起作用。
function foo() {
console.log(this.a);
}
var obj2 = {
a: 42,
foo: foo
};
var obj1 = {
a: 2,
obj2: obj2
};
obj1.obj2.foo(); // 42
2
3
4
5
6
7
8
9
10
11
12
13
14
15
隐式丢失
最常见的this绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,从而把this绑定到全局对象或者undefined上,取决于是否是严格模式。
function foo() {
console.log(this.a);
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a是全局对象的属性
bar(); // "oops, global"
2
3
4
5
6
7
8
9
10
11
12
13
14
虽然bar是obj.foo的一个引用,但是实际上,它引用的是foo函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值。所以,回调函数丢失this绑定是非常常见的。
function foo() {
console.log(this.a);
}
function doFoo(fn) {
// fn其实引用的是foo
fn(); // <-- 调用位置!
}
var obj = {
a: 2,
foo: foo
};
var a = "oops, global"; // a是全局对象的属性
doFoo(obj.foo); // "oops, global"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
显式绑定:在某个对象上强制调用函数
可以使用函数对象下的call和apply方法进行显式绑定。JavaScript提供的绝大多数函数以及你自己创建的所有函数都可以使用call()和apply()方法。它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。
function foo() {
console.log(this.a);
}
var obj = {
a: 2
}
foo.call(obj);
2
3
4
5
6
7
8
9
10
从this绑定的角度来说,call()和apply()是一样的,它们的区别体现在其他的函数上。
ES5提供了内置的方法Function.prototype.bind,它的用法如下:
function foo(something) {
console.log(this.a, something);
return this.a + something;
}
var obj = {
a:2
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5
2
3
4
5
6
7
8
9
10
11
12
13
bind()会返回一个硬编码的新函数,它会把你指定的参数设置为this的上下文并调用原始函数。
new 绑定
构造函数只是一些使用new操作符时被调用的函数。他们并不会属于某个类,也不会实例化一个类。实际上,他们甚至都不能说是一种特殊的函数类型,它们只被new操作符调用的普通函数而已。
something = new MyClass(..);
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
- 创建(或者说构造)一个全新的对象
- 这个新对象会被执行[[Prototype]]连接
- 这个新对象会绑定到函数调用的this
- 如果函数没有返回其他对象,那么new表达式中的函数调用会自动返回这个新对象。
使用new来调用foo()时,我们会构造一个新对象并把它绑定到foo()调用中的this上。
function foo(a) {
this.a = a;
}
var bar = new foo();
console.log(bar.a); // 2
2
3
4
5
# 绑定优先级
this的默认绑定优先级最低
显示绑定比隐式绑定的优先级高
function 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new 绑定比隐式绑定的优先级高
function 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new 绑定比显示绑定的优先级高,因为new创建了一个新的对象。
function 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
2
3
4
5
6
7
8
9
10
11
12
13
# 其他情况下的绑定
如果你把null或者undefined作为this的绑定对象传入call、apply、bind,这些值在调用时会被忽略,实际应用的是默认规则
function foo(a, b) {
console.log("a: " + a + ", b: " + b);
}
// 把数组“展开”成参数
foo.aapply(null, [2, 3]); // a:2, b:3
// 使用bind(...)进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2, b:3
2
3
4
5
6
7
8
9
更安全的this
Object.create(null)和{}很像,但是并不会创建Object.prototype这个委托,所以它比{}更空。
function foo(a, b) {
console.log("a: " + a + ", b:" + b);
}
// 我们的DMZ(非军事区,一个空的委托对象)对象
var ø = Object.create(null);
// 把数组“展开”成参数
foo.aapply(null, [2, 3]); // a:2, b:3
// 使用bind(...)进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2, b:3
2
3
4
5
6
7
8
9
10
11
12
13
间接引用最容易在赋值时发生:
function foo() {
console.log(this.a);
}
var a = 2;
var o = {a: 3, foo: foo};
var p = {a: 4};
o.foo(); //3;
(p.foo = o.foo)(); // 2;
2
3
4
5
6
7
8
9
10
11
赋值表达式p.foo = o.foo的返回值是目标函数的引用,因此调用位置是foo()而不是p.foo()或者o.foo()。所以,这里会应用默认绑定。
对于默认绑定来说,决定this绑定对象的并不是调用位置是否处于严格模式,而是函数体是否处于严格模式。如果函数体处于严格模式,this会被绑定到undefined,否则this会被绑定到全局对象。
通过应用一种软绑定的方法可以防止函数应用默认绑定规则,也可以具有硬绑定没有的灵活性(使用硬绑定之后就无法使用隐式绑定或者显示绑定来修改this)。
if (! Function.prototype.softBind) {
Function.prototype.softBind = function(obj) {
var fn = this;
// 捕获所有 curried 参数
var curried = [].slice.call(arguments, 1);
var bound = function() {
return fn.apply(
(!this || this === (window || global)) ? obj : this,
curried.concat.apply(curried, arguments)
);
};
bound.prototype = Object.create(fn.prototype);
return bound;
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
softBind会对指定的函数进行封装,手偶限检查调用时的this,如果this绑定到全局对象或者undefined,那就把指定的默认对象obj绑定大奥this,否则不会修改this。
function foo() {
console.log("name: " + this.name);
}
var obj = { name: "obj" },
obj2 = { name: "obj2" },
obj3 = { name: "obj3" };
var fooOBJ = foo.softBind(obj);
fooOBJ(); // name: obj
obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2 <---- 看!! !
fooOBJ.call(obj3); // name: obj3 <---- 看!
setTimeout(obj2.foo, 10);
// name: obj <---- 应用了软绑定
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
可以看到,软绑定版本的foo()可以手动将this绑定到obj2或者obj3上,但如果应用默认绑定,则会将this绑定到obj。
# this词法:箭头函数
ES6中介绍了一种无法使用这些规则的特殊函数类型:箭头函数。
箭头函数并不是使用function关键字定义,而是使用“=>” 操作符定义。箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。
function foo() {
// 返回一个箭头函数
return (a) => {
// this继承自foo函数
console.log(this.a);
}
}
var obj1 = {
a: 2
};
var obj2 = {
a: 3,
}
var bar = foo.call(obj1);
bar.call(obj2); // 2,不是3!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
箭头函数可以像bind()一样确保函数的this被绑定到指定对象,此外它的词法作用域取代了传统的this机制。在ES6之前我们就已经在使用一种和箭头函数类似的模式
function foo() {
var self = this; // lexical capture of this
setTimeout( function(){
console.log(self.a);
}, 100 );
}
var obj = {
a: 2
};
foo.call(obj); // 2
2
3
4
5
6
7
8
9
10
11
12
# 第三章 对象
对象定义的语法
声明(文字)形式
var myObj = {
key: 'value'
}
2
3
构造形式
var myObj = new Object();
myObject.key = 'value';
2
在文字声明中你可以添加多个键/值对,但是在构造形式中你必须逐个添加属性。
在JavaScript中一共有六种主要类型(语言类型):
- string
- number
- boolean
- null
- undefined
- object
简单基本类型:string、boolean、number、null、undefined本身并不是对象。null有时会被当做一种对象类型,这其实是语言本身的一个bug。typeof null === 'object'
JavaScript中对象子类型(内置对象):
- String
- Number
- Boolean
- Object
- Function
- Array
- Date
- RegExp
- Error
在JavaScript中它们实际上只是一些内置函数。这些内置函数可以当做构造函数来使用,从而构造一个对应的子类型对象:
var strPrimitive = 'I am a string';
typeof strPrimitive; // string
strPrimitive instanceof String; // false
var strObject = new String('I am a string');
typeof strObject; // "object"
strObject instanceof String; // true
// 检查sub-type对象
Object.prototype.toString.call(strObject); // [object String]
2
3
4
5
6
7
8
9
10
原始值"I am a string"并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要执行一些获取长度、访问其中某个字符串等操作时需要将其转换为String对象。
然而,JavaScript引擎会自动把字面量转换成String对象,所以可以在直接访问字面量下的属性和方法。
null和undefined没有对应的构造形式,它们只有文字形式。Date只有构造,没有文字形式。
对于Object、Array、Function、RegExp来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。
对象值的存储是多种多样的,一般并不会存在对象容器内部。存在对象容器内部的是这些属性的名称,它们就像指针一样,指向这些值真正的存储位置。
const myObject = {
a: 2
}
myObject.a; // 2; 属性访问
myObject['a']; // 2; 键访问
2
3
4
5
6
属性访问和键访问这两种语法的主要区别在于,.
操作符要求属性名满足标识符的命名规范,而['...']
语法可以接受任意UTF-8/Unicode字符串作为属性名。
在对象中,属性名永远都是字符串。如果使用string(字面量)以外的其他值作为属性名,那么也会被转换为一个字符串。即时是数字也不例外。
ES6增加了可计算属性名:
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
2
3
4
5
6
7
8
9
# 属性描述符
从ES5开始,所有的属性都具备了属性描述符
var myObject = {
a:2
};
// 这是创建一个普通属性是描述符使用的默认值
Object.getOwnPropertyDescriptor(myObject, "a");
{
value: 2,
writable: true, // 可写
enumerable: true, // 可枚举
configurable: true // 可配置
}
2
3
4
5
6
7
8
9
10
11
12
13
可以通过Object.defineProperty()来添加一个新属性或者修改一个已有属性,前提是configurable
为true
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: true,
enumerable: true
});
myObject.a; // 2
2
3
4
5
6
7
8
9
10
writable
决定是否可以修改属性的值。
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: false, // 不可写!
});
myObject.a = 3;
myObject.a; // 2
2
3
4
5
6
7
8
9
10
在严格模式下,这种方法会报错:
"use strict";
var myObject = {};
Object.defineProperty(myObject, "a", {
value: 2,
writable: false, // 不可写!
});
myObject.a = 3; // TypeError
2
3
4
5
6
7
8
9
10
configurable
表示只要是属性是可配置的,就可以使用defineProperty()
方法来需修改属性描述符。不管是不是处理严格模式下,如果configurable
为false
后,再使用definePrperty()
来修改属性描述符都将报错。
var myObject = {
a:2
};
myObject.a = 3;
myObject.a; // 3
Object.defineProperty(myObject, "a", {
value: 4,
configurable: false, // 不可配置!
});
myObject.a; // 4
myObject.a = 5;
myObject.a; // 5
Object.defineProperty(myObject, "a", {
value: 6,
writable: true,
configurable: true,
enumerable: true
}); // TypeError
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
除了无法修改,configurable: false
还会禁止删除这个属性
var myObject = {
a:2
};
myObject.a; // 2
delete myObject.a;
myObject.a; // undefined
Object.defineProperty(myObject, "a", {
value: 2,
writable: true,
configurable: false,
enumerable: true
});
myObject.a; // 2
delete myObject.a;
myObject.a; // 2
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
不要把delete看作一个释放内存的工具,它只是一个删除对象属性的操作,仅此而已。
enumerable
描述符控制属性是否会出现在对象的属性枚举中,比如for...in
循环。enumerable: false
则不会出现在枚举中,反之enumerate: true
则会出现在枚举中。用户定义的所有普通属性默认都是enumerate: true
。
# 对象属性的不变性
所有方法创建的都是浅不变性,它们只会影响目标对象和它的直接属性。如果目标对象引用了其他对象(数组、对象、函数等),其他对象的内容不受影响,仍然是可变的。
结合writable: false
和configurable: false
就可以创建一个真正的对象常量属性(不可修改、重定义或者删除):
var myObject = {};
Object.defineProperty(myObject, "FAVORITE NUMBER", {
value: 42,
writable: false,
configurable: false
});
2
3
4
5
6
7
如果想禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventEtensions()
。
var myObject = {
a:2
};
Object.preventExtensions(myObject);
myObject.b = 3;
myObject.b; // undefined
2
3
4
5
6
7
8
在严格模式下,将会抛出TypeError的错误。
Object.seal()
会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions()
并把所有现有属性标记为configuration: false
。
所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。
Object.freeze()
会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal()
并把所有“数据访问”属性标记为writable:false
,这样就无法修改它们的值。
这个对象下其他对象的引用是不受影响的。如果想要“深度冻结”一个对象,可以通过遍历对象下的属性,调用Object.freeze()
# [[Get]]和[[Put]]
对象默认的[[Put]]
和[[Get]]
操作分别可以设置属性值的设置和获取
var myObject = {
a: 2
};
myObject.a; // 2
2
3
4
5
在语言规范中,myObject.a
在myObject
上实际上是实现了[[Get]]
操作(有点像函数调用:[[Get]]()
)。对象默认的内置[[Get]]
操作首先在对象中查找是否有名称相同的属性,如果找到就返回这个属性的值。
如果没有找到名称相同的属性,按照[[Get]]
算法的定义会遍历可能存在的[[Prototype]]
链,也就是原型链。
无论如何都没有找到名称相同的属性,那么[[Get]]
操作会返回值undefined
var myObject = {
a:2
};
myObject.b; // undefined
2
3
4
5
下面代码中这两个引用没有区别——它们都返回了undefined
,实际上底层的[[Get]]
操作对myObject.b
进行了原型链查找:
var myObject = {
a: undefined
};
myObject.a; // undefined
myObject.b; // undefined
2
3
4
5
6
7
[[Put]]
被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。
如果存在这个属性,[[Put]]
算法大致会检查下面这些内容:
- 属性是否是访问描述符?如果是并且存在
setter
就调用setter
- 属性的数据描述符中
writable
是否为false
?如果是,在非严格模式下失败,在严格模式下抛出TypeError
异常。 - 如果都不是,将该值设置为属性的值
# Getter和Setter
在ES5中可以使用getter
和setter
部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。getter
是一个隐藏函数,会在获取属性值时调用。 setter
也是一个隐藏函数,会在设置属性值时调用。
对于访问描述符来说,JavaScript会忽略它们的 value
和 writable
特性,取而代之的是关心 set
和 get
(还有 configurable
和 enumerable
)特性。
var myObject = {
// 给a定义一个getter
get a() {
return 2;
}
};
Object.defineProperty(
myObject, // 目标对象
"b", // 属性名
{ // 描述符
// 给b设置一个getter
get: function(){ return this.a * 2 },
// 确保b会出现在对象的属性列表中
enumerable: true
}
);
myObject.a; // 2
myObject.b; // 4
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
不管是对象文字语法中的get a() {}
,还是defineProperty()
中的显示定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当做属性访问的返回值:
var myObject = {
// 给a定义一个getter
get a() {
return 2;
}
};
myObject.a = 3;
myObject.a; // 2
2
3
4
5
6
7
8
9
10
setter会覆盖单个属性默认的[[Put]](也被成为赋值)操作。通常来说getter和setter是成对出现的。
var myObject = {
// 给a定义一个getter
get a() {
return this. a ;
},
// 给a定义一个setter
set a(val) {
this. a = val * 2;
}
};
myObject.a = 2;
myObject.a; // 4
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 检查属性是否存在于对象中
在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // false
2
3
4
5
6
7
8
9
in
操作符会检查属性是否存在于myObject
对象中及其[[Prototype]]
原型链中。hasOwnProperty()
只会检查属性是否存在于myObject
对象中,不会检查[[Prototype]]
原型链。
普通对象可以通过Object.prototype
的委托来访问hasOwnProperty()
。
通过Object.create(null)
来创建的对象没有连接到Object.prototype
上(比如这个对象叫myObject
),这种情况下,myObject.hasOwnProperty()
就会失败。这时可以使用Object.prototype.hasOwnProperty.call(myObject, 'a')
来进行判断。它借用基础的hasOwnProperty()
方法并把它显式绑定到myObject上
。
看起来in
操作符可以检查容器内是否有某个值,但是它实际上检查的是某个属性是否存在。对于数组来说这个区别非常重要,4 in [2, 4, 6]
的结果并不是true
。这是因为[2,4,6]
这个数组中包含的属性名是0、1、2
。
# enumerable枚举
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 让a像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(
myObject,
"b",
// 让b不可枚举
{ enumerable: false, value: 3 }
);
myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty("b"); // true
// .......
for (var k in myObject) {
console.log(k, myObject[k]);
}
// "a" 2
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
在代码中,myObject.b
确实存在并且有访问值,但是却不会出现在for...in
循环中(尽管可以通过in
操作符来判读是否存在)。原因是,“可枚举”就相当于“可以出现在对象属性的遍历中”。
另一种方式来区分属性是否可枚举
var myObject = { };
Object.defineProperty(
myObject,
"a",
// 让a像普通属性一样可以枚举
{ enumerable: true, value: 2 }
);
Object.defineProperty(
myObject,
"b",
// 让b不可枚举
{ enumerable: false, value: 3 }
);
myObject.propertyIsEnumerable("a"); // true
myObject.propertyIsEnumerable("b"); // false
Object.keys(myObject); // ["a"]
Object.getOwnPropertyNames(myObject); // ["a", "b"]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
propertyIsEnumerable()
会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足enumerable: true
的条件。
Object.keys()
会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames()
会返回一个数组,包含所有属性,无论它们是否可枚举。
Object.keys()
和Object.getOwnPropertyNames()
都只会查找对象直接包含的属性。
# 对象的遍历
for...in
循环可以用来遍历对象的可枚举属性列表,包括[[Prototype]]
链
遍历数组下标时采用的是数字顺序(for
循环或者其他迭代器),但是遍历对象属性时的顺序是不确定的,在不同的JavaScript引擎中可能不一样。因此,在不同的环境中徐亚保证一致性时,一定不要相信任何观察到的顺序,它们是不可靠的。
ES6增加了一种用来遍历数组的for...of
循环语法,如果对象本身定义了迭代器的话也可以遍历对象:
var myArray = [1, 2, 3];
for(let v of myArray){
console.log(v);
}
// 1
// 2
// 3
2
3
4
5
6
7
8
数组有内置的@@iterator
,因此for...of
可以直接应用在数组上。使用内置的@@iterator
来手动遍历数组:
var myArray = [1, 2, 3];
var it = myArray[Symbol.iterator]();
it.next(); // { value: 1, done: false }
it.next(); // { value: 2, done: false }
it.next(); // { value: 3, done: false }
it.next(); // { done: true }
2
3
4
5
6
7
和值“3”一起返回的是done: false
,需要再调用一次next()
才能得到done: true
,从而完成遍历。这是因为和ES6中发生器函数的语义相关。
普通的对象没有内置的@@iterator
,所以无法自动完成for...of
遍历。这样做是为了避免影响未来的对象类型。
我们可以给想要遍历的对象定义@@iterator
var myObject = {
a: 2,
b: 3
}
Object.defineProperty(myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function () {
var o = this;
var idx = 0;
var ks = Object.keys(0);
return {
next: function() {
return {
value: o[ks[idx++]],
done: idx > ks.length,
}
}
}
}
})
// 手动遍历myObject
var it = myObject[Symbol.iterator]();
it.next() // { value: 2, done: false }
it.next() // { value: 3, done: false }
it.next() // { done: true }
// 用for...of遍历myObject
for(var v of myObject) {
console.log(v);
}
// 2
// 3
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
使用Object.defineProperty()
定义了myObject
对象自己的@@iterator
主要是为了让@@iterator
不可枚举。此外,也可以直接定义对象时进行声明,比如var myObject = {a: 2, b: 3, [Symbol.iterator]: function() {...}}
定义一个永远不会结束的迭代器
var randoms = {
[Symbol.iterator]: function () {
return {
next: function() {
return { value: Math.random() };
}
}
}
}
var randoms_pool = [];
for (var n of randoms){
randooms_pool.push(n);
// 防止无限运行,导致程序挂起
if(randoms_pool.length === 1000) break;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 第五章 原型
# [[Prototype]]
对于默认的[[Get]]
操作来说,如果无法在对象本身找到需要的属性,就会继承访问对象的[[Prototype]]
链
var anotherObject = {
a:2
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);
myObject.a; // 2
2
3
4
5
6
7
8
Object.create()
会创建一个对象并把这个对象的[[Prototype]]
关联到指定的对象。
使用for...in
遍历对象时原理和查找[[Prototype]]
链类似,任何可以通过原型链访问到并且是enumerable
的属性都会被枚举。使用in
操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链,无论属性是否可以枚举
var anotherObject = {
a:2
};
// 创建一个关联到anotherObject的对象
var myObject = Object.create(anotherObject);
for (var k in myObject) {
console.log("found: " + k);
}
// found: a
("a" in myObject); // true
2
3
4
5
6
7
8
9
10
11
12
当你通过各种语法进行属性查找时都会查找[[Prototype]]
链,知道找到属性或者查找完整条原型链。
所有普通的[[Prototype]]
链最终都会指向内置的Object.prototype
。
myObject.foo = 'bar';
如果myObject
对象中包含名为foo
的普通数据访问属性,这条赋值语句只会修改已有的属性值。
如果foo
不是直接存在于myObject
中,[[Prototype]]
链就会被遍历,类似[[Get]]
操作。如果原型链上找不到foo
,foo
会被直接添加到myObject
上。
如果属性名即出现在myObject
中也出现在myObject
的[[Prototype]]
链上,那么就会发生屏蔽。myObject
中包含的foo
属性会屏蔽原型链上的所有foo
属性,因为myObject.foo
总是会选择原型链中最底层的foo
属性。
如果foo
存在于原型链上,赋值语句myObject.foo = 'bar'
的行为会有以下逻辑:
- 如果在
[[Prototype]]
链上存在foo的
普通数据访问属性并且没有被标记为只读(writable: false
),会直接在myObject
中添加一个名为foo
的新属性,它是屏蔽属性。 - 如果在
[[Prototype]]
链上存在foo
,但是它被标记为只读(writable: false
),那么无法修改已有属性或者在myObject上
创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。 - 如果在
[[Prototype]]
链上存在foo
并且它是一个setter
,那就一定会调用这个setter
。foo
不会被添加到myObject上
,也不会重新定义foo
这个setter
。
如果希望在第二种和第三种情况下也屏蔽原型链上的foo
,那就不能使用=
操作符来赋值,而是使用Object.defineProperty()
来向myObject
添加foo
。
第二种情况中的只读属性会阻止[[Prototype]]
链下层的myObject
创建(屏蔽)同名属性。这样做主要是为了模拟类属性的继承。把原型链上层的foo
看作是父类中的属性,它会被myObject
继承,这样myObject
中的foo
属性也是只读,所以无法创建。但实际上并不会发生类似的继承复制。这个限制只存在于=
赋值中,使用Object.defineProperty()
并不会受影响。
有些情况会隐式产生屏蔽:
var anotherObject = {
a:2
};
var myObject = Object.create(anotherObject);
anotherObject.a; // 2
myObject.a; // 2
anotherObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("a"); // false
myObject.a++; // 隐式屏蔽!
anotherObject.a; // 2
myObject.a; // 3
myObject.hasOwnProperty("a"); // true
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
修改委托属性时一定要小心。如果想要让anotherObject.a的值增加,唯一的办法是anotherObject.a++。
# "类"
在JavaScript中只有对象,没有类的定义,因为对象可以直接定义自己的行为。
所有的函数默认都会拥有一个名为prototype
的公有并且不可枚举的属性,它会指向另一个对象
function Foo() {
// ...
}
Foo.prototype; // {}
2
3
4
5
这个对象通常被称为Foo
的原型,因为我们通过名为Foo.prototype
的属性引用来访问它。
这个对象是在调用new Foo()
时创建的,最后欧会被关联到这个Foo.prototype
对象上。
function Foo() {
// ...
}
var a = new Foo();
Object.getPrototypeOf(a) === Foo.prototype; // true
2
3
4
5
6
调用new Foo()
时会创建a,其中一步就是将a
内部的[[Prototype]]
链接到Foo.prototype
所指的对象。
在JavaScript中,并没有类似的复制机制。不能创建一个类的多个实例,只能创建多个对象,它们[[Prototype]]
关联的是同一个对象。但是在默认情况下并不会进行复制,因此这些对象之间并不会完全失去联系,它们是互相关联的。
JavaScript会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。
function Foo() {
// ...
}
Foo.prototype.constructor === Foo; // true
var a = new Foo();
a.constructor === Foo; // true
2
3
4
5
6
7
8
Foo.prototype
默认有一个公有并且不可枚举的属性.constructor
,这个属性引用的是对象关联的函数(本例中是Foo
)。此外,我们可以看到通过”构造函数“调用new Foo()
创建的对象也有一个.constructor
属性,指向”创建这个对象的函数“。
实际上a
本身并没有.constructor
属性。而且,虽然a.constructor
确实指向Foo
函数,但是这个属性并不是表示a
由Foo
构造
Foo和你程序中的其他函数没有任何区别。函数本身并不是构造函数,然而,当你在普通的函数调用前面加上new关键字之后,就会把这个函数调用变成一个”构造函数调用“。实际上,new会劫持所有普通函数并用构造对象的形式来调用它。
function NothingSpecial() {
console.log("Don't mind me! ");
}
var a = new NothingSpecial();
// "Don't mind me! "
a; // {}
2
3
4
5
6
7
8
在JavaScript中对于”构造函数“最准确的解释是,所有带new的函数调用。
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!
2
3
4
5
6
7
a1
并没有.constructor
属性,所以它会委托[[Prototype]]
链上的Foo.prototype
。但是Foo.prototype
这个对象也没有.constructor
属性,所以它会继续委托,直到委托给链顶端的Object.prototype
。Object.prototype
对象有.constructor
属性,指向内置的Object()
函数。
# (原型)继承
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
}
function Bar(name, label) {
Foo.call(this, name);
this.label = label;
}
// 创建一个新的Bar.prototype对象并关联到Foo.prototype
Bar.prototype = Object.create(Foo.prototype);
// 注意!现在没有Bar.prototype.constructor了
// 如果你需要这个属性的话可能需要手动修复一下它
Bar.prototype.myLabel = function () {
return this.label;
}
var a = new Bar('a', 'obj a');
a.myName(); // a
a.myLable(); // obj a
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
这段代码的核心部分是语句Bar.prototype = Object.create(Foo.prototype)
。调用Object.create()
会创建一个”新“对象并把新对象内部的[[Prototype]]
关联到你指定的对象(本例中是Foo.prototype
)。
// 和你想要的机制不一样!
Bar.prototype = Foo.prototype;
// 基本上满足你的新需求,但是可能会产生一些副作用:(
Bar.prototype = new Foo();
2
3
4
5
Bar.prototype = Foo.prototype
并不会创建一个关联到Bar.prototype
的新对象,它只是让Bar.prototype
直接引用了Foo.prototype
对象。因此当你执行类似Bar.prototype.myLable = ...
的赋值语句时会直接修改Foo.prototype
对象本身。
Bar.prototype = new Foo()
的确会创建一个关联到Bar.prototype
的新对象。但是它使用了Foo()
的"构造函数调用",如果函数Foo
有一些副作用(比如写日志、修改状态、注册到其他对象等等)的话,就会影响到Bar()
的”后代“,后果不堪设想。
ES6添加了辅助函数Object.setPrototypeOf(),可以用标准并且可靠的方法来修改关联。
// ES6之前需要抛弃默认的Bar.prototype
Bar.prototype = Object.create(Foo.prototype)
// ES6开始可以直接需修改现有的Bar.prototype
Object.setPrototypeOf(Bar.prototype, Foo.prtotype);
2
3
4
5
function Foo() {
// ...
}
Foo.prototype.blah = ...;
var a = new Foo();
2
3
4
5
6
通过 instanceof
找出a的祖先(委托关联)
a instanceof Foo; // true
通过isPrototypeOf()方法来判断
Foo.prototype.isPrototypeOf(a); // true
直接或缺一个对象的[[Prototype]]链,用于判断
Object.getPrototypeOf(a) === Foo.prototype;
部分浏览器也支持一种非标准的方法来访问内部[[Prototype]]属性
a.__proto__ === Foo.prototype;
.__proto__
看起来很像一个属性,但实际上它更像一个getter/setter
。.__proto__
的实现大致上是这样的
Object.defineProperty(Object.prototype, " __proto__", {
get: function() {
return Object.getPrototypeOf(this);
},
set: function(o) {
// ES6中的setPrototypeOf(..)
Object.setPrototypeOf(this, o);
return o;
}
} );
2
3
4
5
6
7
8
9
10
# 对象关联
Object.create(null)会创建一个拥有空(或者说null)[[Prototype]]链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以instanceof操作符无法进行判断,因此总是会返回false。这些特殊的空[[Prototype]]对象通常被称作”字典“,他们完全不会受到原型链的干扰,因此非常适合用来存储数据。
ES5之前实现Object.create方法:
if (! Object.create) {
Object.create = function(o) {
function F(){}
F.prototype = o;
return new F();
};
}
2
3
4
5
6
7
Object.create()的第二个参数指定了需要添加到新对象中的属性名以及这些属性的属性描述符
var anotherObject = {
a:2
};
var myObject = Object.create(anotherObject, {
b: {
enumerable: false,
writable: true,
configurable: false,
value: 3
},
c: {
enumerable: true,
writable: false,
configurable: false,
value: 4
}
});
myObject.hasOwnProperty("a"); // false
myObject.hasOwnProperty("b"); // true
myObject.hasOwnProperty("c"); // true
myObject.a; // 2
myObject.b; // 3
myObject.c; // 4
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