你不知道的 JavaScript(上卷)
第 1 章 作用域是什么
这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。
1.1 编译原理
尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。这个事实对你来说可能显而易见,也可能你闻所未闻,取决于你接触过多少编程语言,具有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。
1.2 理解作用域
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。
当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。
1.3 作用域嵌套
这个建筑代表程序中的嵌套作用域链。第一层楼代表当前的执行作用域,也就是你所处的位置。建筑的顶层代表全局作用域。
1.4 异常
相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”下。
ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。
1.5 小结
作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
2.1 词法阶段
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
2.2 欺骗词法
欺骗词法作用域会导致性能下降。
2.3 小结
词法作用域意味着作用域是由书写代码时函数声明的位置来决定的。编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。
3.1 函数中的作用域
JavaScript 具有基于函数的作用域,意味着每声明一个函数都会为其自身创建一个气泡,而其他结构都不会创建作用域气泡。但事实上这并不完全正确,
3.2 隐藏内部实现
功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会依此进行实现。
3.3 函数作用域
如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。 函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
3.4 块作用域
for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
第 4 章 提升
任何声明在某个作用域内的变量,都将附属于这个作用域
4.2 编译器再度来袭
包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。
其中第一部分是编译,而第二部分是执行。
可以看到,函数声明会被提升,但是函数表达式却不会被提升。
4.3 函数优先
函数会首先被提升,然后才是变量。
4.4 小结
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。
声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。
5.2 实质问题
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
词法作用域的查找规则,而这些规则只是闭包的一部分。
而闭包的“神奇”之处正是可以阻止这件事情的发生。
bar()依然持有对该作用域的引用,而这个引用就叫作闭包。
闭包使得函数可以继续访问定义时的词法作用域
无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。
5.3 现在我懂了
将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..)作用域的闭包,因此还保有对变量 message 的引用。
词法作用域在这个过程中保持完整。 这就是闭包。
本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
5.4 循环和闭包
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。
它需要有自己的变量,用来在每个迭代中储存 i 的值
很酷是吧?块作用域和闭包联手便可天下无敌。
快乐的 JavaScript 程序员。
5.5 模块
5.5 模块 还有其他的代码模式利用闭包的强大威力,但从表面上看,它们似乎与回调无关。下面一起来研究其中最强大的一个:模块。
可以将这个对象类型的返回值看作本质上是模块的公共 API。
模块模式需要具备两个必要条件。1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。module 会将整个模块的
API 导入并绑定到一个变量上(在我们的例子里是 foo 和 bar)。export 会将当前模块的一个标识符(变量、函数)导出为公共 API。
5.6 小结
当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。
如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在循环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。
附录 A 动态作用域
词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用 eval()或 with)。
,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。但是 this 机制某种程度上很像动态作用域。
主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
附录 B 块作用域的替代方案
let 作用域或 let 声明
附录 C this 词法
self 只是一个可以通过词法作用域和闭包进行引用的标识符,不关心 this 绑定的过程中发生了什么。
箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。
这样除了可以少写一些代码,我认为箭头函数将程序员们经常犯的一个错误给标准化了,也就是混淆了 this 绑定规则和词法作用域规则。
1.1 为什么要用 this
this 提供了一种更优雅的方式来隐式“传递”一个对象引用,因此可以将 API 设计得更加简洁并且易于复用
1.2 误解
this 在任何情况下都不指向函数的词法作用域。
每当你想要把 this 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。
1.3 this 到底是什么
this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
1.4 小结
this 实际上是在函数被调用时发生的绑定,它指向什么完全取决于函数在哪里被调用。
2.2 绑定规则
/ 硬绑定的 bar 不可能再修改它的 this
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。 1.创建(或者说构造)一个全新的对象。 2.这个新对象会被执行[[Prototype]]连接。 3.这个新对象会绑定到函数调用的 this。 4.如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
2.6 小结
ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法
作用域来决定 this,
3.2 类型
null 和 undefined 没有对应的构造形式,它们只有文字形式。相反,Date 只有构造,没有文字形式。
3.3 内容
所以当心不要搞混对象和数组中数字的用法:
虽然添加了命名属性(无论是通过.语法还是[]语法),数组的 length 值并未发生变化。
复制对象
configurable: true, enumerable: true } ); myObject.a = 3; // TypeError TypeError 错误表示我们无法修改一个不可写的属性。 [插图] 之后我们会介绍 getter 和 setter,不过简单来说,你可以把 writable:false 看作是属性不可改变,相当于你定义了一个空操作 setter。严格来说,如果要和 writable:false 一致的话,你的 setter 被调用时应当抛出一个 TypeError 错误。 2.Configurable 只要属性是可配置的,就可以使用 defineProperty(..)方法来修改属性描述符: var myObject = { a:2 };
之后
[[Get]]操作
注意,这种方法和访问变量时是不一样的。如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回 undefined,而是会抛出一个 ReferenceError 异常:
如果你引用了一个当前词法作用域中不存在的变量,并不会像对象属性一样返回 undefined,而是会抛出一个 ReferenceError 异常:
如果对象中不存在这个属性,[[Put]]操作会更加复杂
怎么理解
而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是没有意义的。
in 和 hasOwnProperty(..)
3.4 遍历
for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的 next()方法来遍历所有返回值。
3.5 小结
属性不一定包含值——它们可能是具备 getter/setter 的“访问描述符”。
4.1 类理论
好的设计就是把数据以及和它相关的行为打包(或者说封装)起来。这在正式的计算机科学中有时被称为数据结构。
举例来说,用来表示一个单词或者短语的一串字符通常被称为字符串。字符就是数据。但是你关心的往往不是数据是什么,而是可以对数据做什么,所以可以应用在这种数据上的行为(计算长度、添加数据、搜索,等等)都被设计成 String 类的方法。
所有字符串都是 String 类的一个实例,也就是说它是一个包裹,包含字符数据和我们可以应用在数据上的函数。
有些语言(比如 Java)并不会给你选择的机会,类并不是可选的——万物皆是类。
JavaScript 中实际上有类呢?简单来说:不是。由于类是一种设计模式,所以你可以用一些方法(本章之后会介绍)近似实现类的功能
4.2 类的机制
一个类就是一张蓝图。为了获得真正可以交互的对象,我们必须按照类来建造(也可以说实例化)一个东西,这个东西通常被称为实例
类通过复制操作被实例化为对象形式:
4.3 类的继承
如果受过编程教育。我敢保证,一定被这样指导过 🌝
不同类型的交通工具。这是一个非常典型(并且经常被抱怨)的讲解继承的例子。
多态并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。
4.4 混入
从技术角度来说,函数实际上没有被复制,复制的是函数引用。
4.5 小结
类意味着复制。
此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也是对象)只能复制引用
5.1 [[Prototype]]
如果包含 Proxy 的话,我们这里对[[Get]]和[[Put]]的讨论就不适用。
1.如果在[[Prototype]]链上层存在名为 foo 的普通数据访问属性(参见第 3 章)并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。2.如果在[[Prototype]]链上层存在 foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。3.如果在[[Prototype]]链上层存在 foo 并且它是一个 setter(参见第 3 章),那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter。
第二种情况可能是最令人意外的,只读属性会阻止[[Prototype]]链下层隐式创建(屏蔽)同名属性。这样做主要是为了模拟类属性的继承。
尽管 myObject.a++看起来应该(通过委托)查找并增加 anotherObject.a 属性,但是别忘了++操作相当于 myObject.a = myObject.a + 1。因此++操作首先会通过[[Prototype]]查找属性 a 并从 anotherObject.a 获取当前属性值 2,然后给这个值加 1,接着用[[Put]]将值 3 赋给 myObject 中新建的屏蔽属性 a,天呐!
5.2 “类”
在 JavaScript 中,类无法描述对象的行为,(因为根本就不存在类!)对象直接定义自己的行为。再说一遍,JavaScript 中只有对象。
调用 new Foo()时会创建 a(具体的 4 个步骤参见第 2 章),其中一步就是将 a 内部的[[Prototype]]链接到 Foo.prototype 所指向的对象。
继承意味着复制操作,JavaScript(默认)并不会复制对象属性。相反,JavaScript 会在两个对象之间创建一个关联,这样一个对象就可以通过委托访问另一个对象的属性和函数。
a 本身并没有.constructor 属性。
函数不是构造函数,但是当且仅当使用 new 时,函数调用会变成“构造函数调用”。
实际上,.constructor 引用同样被委托给了 Foo.prototype,而 Foo.prototype.constructor 默认指向 Foo
ructor 的错误理解很容易对你自己产生误导。举例来说,Foo.prototype 的.constructor 属性只是 Foo 函数在声明时的默认属性。如果你创建了一个新对象并替换了函数默认的.prototype 对象引用,那么新对象并不会自动获得.constructor 属性。
最好的办法是记住这一点“constructor 并不表示被构造
a1.constructor 是一个非常不可靠并且不安全的引用。通常来说要尽量避免使用这些引用。
5.3 (原型)继承
设置.proto属性来实现,但是这个方法并不是标准并且无法兼容所有浏览器。
instanceof 操作符的左操作数是一个普通的对象,右操作数是一个函数。instanceof 回答的问题是:在 a 的整条[[Prototype]]链中是否有指向 Foo.prototype 的对象?
.proto的实现大
dunder
5.4 对象关联
通常来说,这个链接的作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为“原型链”。
并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了
Object.create()的 polyfill 代码
内部委托比起直接委托可以让 API 接口设计更加清晰
5.5 小结
虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的[[Prototype]]链关联的。
6.1 面向委托的设计
通常来说,JavaScript 规范并不会控制浏览器中开发者工具对于特定值或者结构的表示方式,
Gotcha {}啊哈!抓到你了(
我们只是把对象关联起来,并不需要那些既复杂又令人困惑的模仿类的行为
对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对象之间的关联关系。
6.2 类与对象
创建 UI 控件
ES6 的 class 语法及其实现细节
对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
6.4 更好的语法
在 ES6 中,你可以使用对象的字面形式(这样就可以使用简洁方法定义)来改写之前繁琐的属性赋值语法
附录 A ES6 中的 Class
类是一种可选(而不是必须)的设计模式,而且在 JavaScript 这样的[[Prototype]]语言中实现类是很别扭的。
class 基本上只是现有[[Prototype]](委托!)机制的一种语法糖。
class 并不会像传统面向类的语言一样在声明时静态复制所有行为。如果你(有意或无意)修改或者替换了父“类”中的一个方法,那子“类”和所有实例都会受到影响,因为它们在定义时并没有进行复制,只是使用基于[[Prototype]]的实时委托
class 语法无法定义类成员属性(只能定义方法),如果为了跟踪实例之间共享状态必须要这么做,那你只能使用丑陋的.prototype 语法,像这样:
出于性能考虑,super 并不像 this 一样是晚绑定(late bound,或者说动态绑定)的,它在[[HomeObject]].[[Prototype]]上,[[HomeObject]]会在创建时静态绑定。
结论:如果 ES6 的 class 让[[Prototype]]变得更加难用而且隐藏了 JavaScript 对象最重要的机制——对象之
间的实时委托关联,我们难道不应该认为 class 产生的问题比解决的多吗?难道不应该抵制这种设计模式吗?