你不知道的JavaScript阅读笔记
# 作用域是什么
JavaScript 编译过程
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编 译”。
- 分词/词法分析(Tokenizing/Lexing)
- 解析/语法分析(Parsing)
- 生成机器能执行的机器指令代码
编译过程中,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此, JavaScript 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且 通常马上就会执行它。
理解作用域
先介绍将要参与到对程序 var a = 2; 进行处理的过程中的角色
引擎
从头到尾负责整个 JavaScript 程序的编译及执行过程。
编译器
引擎的好朋友之一,负责语法分析及代码生成等脏活累活。
作用域引擎
的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查 询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
下面我们将 var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编 译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。
可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内 存,将其命名为 a,然后将值 2 保存进这个变量。”然而,这并不完全正确。
事实上编译器会进行如下处理。
遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的 集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作 用域的集合中声明一个新的变量,并命名为 a。
接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值 操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(向上查找)。
总结: 变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如 果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对 它赋值。
# LHS 与 RHS
“L”和“R”分别代表左侧和右侧。 是一个赋值操作的左侧和右侧。
换句话说,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图 找到变量的容器本身,从而可以对其赋值。
考虑下面的程序,其中既有 LHS 也有 RHS 引用:
function foo(a) { console.log( a ); // 2
}
foo( 2 );
2
3
最后一行 foo(..) 函数的调用需要对 foo 进行 RHS 引用,意味着“去找到 foo 的值,并把 它给我”。并且 (..) 意味着 foo 的值需要被执行,因此它最好真的是一个函数类型的值!
这里还有一个容易被忽略却非常重要的细节。
代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给 foo(..) 函数时,2 会被分配给参数 a。为了给参数 a(函数参数传递-隐式地)分配值,需要进行一次 LHS 查询。
这里还有对 a 进行的 RHS 引用,并且将得到的值传给了 console.log(..)。console. log(..) 本身也需要一个引用才能执行,因此会对 console 对象进行 RHS 查询,并且检查 得到的值中是否有一个叫作 log 的方法。
# 作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用 域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止。
函数是 JavaScript 中最常见的作用域单元。本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。
但函数不是唯一的作用域单元。块作用域指的是变量和函数不仅可以属于所处的作用域,也可以属于某个代码块(通常指 { .. } 内部)。
try/catch 结构在 catch 分句中具有块作用域。
# 函数提升与变量提升
考虑以下代码:
foo() // 1
var foo
function foo() {
console.log(1)
}
foo = function() {
console.log(2)
}
2
3
4
5
6
7
8
会输出 1 而不是 2 !这个代码片段会被引擎理解为如下形式:
function foo() {
console.log(1)
}
foo() // 1
foo = function() {
console.log(2)
}
2
3
4
5
6
7
函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。
要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!
# 闭包
function wait(message) {
setTimeout(function timer() {
console.log(message)
}, 1000)
}
wait('Hello, closure!')
2
3
4
5
6
将一个内部函数(名为 timer)传递给 setTimeout(..)。timer 具有涵盖 wait(..) 作用域的闭包,因此还保有对变量 message 的引用。
wait(..) 执行 1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有 wait(..)作用域的闭包。
深入到引擎的内部原理中,内置的工具函数 setTimeout(..) 持有对一个参数的引用,这个 参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是 内部的 timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包。
# 闭包实现现代的模块机制
大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。这里并不会研究某个具体的库,为了宏观了解我会简单地介绍一些核心概念:
var 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
}
})()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装 函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。
下面展示了如何使用它来定义模块:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"foo" 和 "bar" 模块都是通过一个返回公共 API 的函数来定义的。"foo" 甚至接受 "bar" 的 示例作为依赖参数,并能相应地使用它。
模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个 特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。
ES6 中为模块增加了一级语法支持。但通过模块系统进行加载时,ES6 会将文件当作独立 的模块来处理。每个模块都可以导入其他模块或特定的 API 成员,同样也可以导出自己的 API 成员。
模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭 包模块一样。
# this 到底是什么
function foo() {
var a = 2
this.bar()
}
function bar() {
console.log(this.a)
}
foo() // ReferenceError: a is not defined
2
3
4
5
6
7
8
每当你想要把 this 和词法作用域的查找混合使用时,一定要提醒自己,这是无法实现的。
之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包 含函数在哪里被调用(调用栈)、函数的调用方法、传入的参数等信息。this 就是记录的 其中一个属性,会在函数执行的过程中用到。
在理解 this 的绑定过程之前,首先要理解调用位置:调用位置就是函数在代码中被调用的 位置(而不是声明的位置)。只有仔细分析调用位置才能回答这个问题:这个 this 到底引 用的是什么?
通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单, 因为某些编程模式可能会隐藏真正的调用位置。
最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的 调用位置就在当前正在执行的函数的前一个调用中。
下面我们来看看到底什么是调用栈和调用位置:
function baz() {
// 当前调用栈是:baz
// 因此,当前调用位置是全局作用域
console.log('baz')
}
bar() // bar 的调用位置
function bar() {
82
// 当前调用栈是 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
回调函数当作参数传入:
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
参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一 个例子一样。
就像我们看到的那样,回调函数丢失 this 绑定是非常常见的
如果你经常编写 this 风格的代码,但是绝大部分时候都会使用 self = this 或者箭头函数 来否定 this 机制,那你或许应当:
- 只使用词法作用域并完全抛弃错误 this 风格的代码;
- 完全采用 this 风格,在必要时使用 bind(..),尽量避免使用 self = this 和箭头函数。
当然,包含这两种代码风格的程序可以正常运行,但是在同一个函数或者同一个程序中混 合使用这两种风格通常会使代码更难维护,并且可能也会更难编写。
# 对象
JS 一 共 有 六 种 主 要 类 型 ( 术 语 是 “ 语 言 类 型 ”) :
• string
• number
• boolean
• null
• undefined
• object
注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。 null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。1 实际上,null 本身是基本类型。
原始值 "I am a string" 并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其 转换为 String 对象。
有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。
实际上,JavaScript 中有许多特殊的对象子类型,我们可以称之为复杂基本类型。
函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript 中的函 数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作 其他对象一样操作函数(比如当作另一个函数的参数)。
数组也是对象的一种类型,具备一些额外的行为。数组中内容的组织方式比一般的对象要 稍微复杂一些。
# 内置对象
JavaScript 中还有一些对象子类型,通常被称为内置对象。
• String
• Number
• Boolean
• Object
• Function
• Array
• Date
• RegExp
• Error
这些内置对象从表现形式来说很像其他语言中的类型(type)或者类(class),比如 Java 中的 String 类。
幸好,在必要时语言会自动把字符串字面量转换成一个 String 对象,也就是说你并不需要 显式创建一个对象。JavaScript 社区中的大多数人都认为能使用文字形式时就不要使用构造形式。
思考下面的代码:
var strPrimitive = 'I am a string'
console.log(strPrimitive.length) // 13
console.log(strPrimitive.charAt(3)) // "m"
2
3
使用以上两种方法,我们都可以直接在字符串字面量上访问属性或者方法,之所以可以这 样做,是因为引擎自动把字面量转换成 String 对象,所以可以访问属性和方法。
# 属性名永远都是字符串
在对象中,属性名永远都是字符串。如果你使用 string(字面量)以外的其他值作为属性 名,那它首先会被转换为一个字符串。即使是数字也不例外,虽然在数组下标中使用的的 确是数字,但是在对象属性名中数字会被转换成字符串,所以当心不要搞混对象和数组中 数字的用法:
var myObject = {}
myObject[true] = 'foo'
myObject[3] = 'bar'
myObject[myObject] = 'baz'
myObject['true'] // "foo"
myObject['3'] // "bar"
myObject['[object Object]'] // "baz"
2
3
4
5
6
7
# 属性与方法
如果访问的对象属性是一个函数,有些开发者喜欢使用不一样的叫法以作区分。由于函数 很容易被认为是属于某个对象,在其他语言中,属于对象(也被称为“类”)的函数通常 被称为“方法”,因此把“属性访问”说成是“方法访问”也就不奇怪了。
从技术角度来说,函数永远不会“属于”一个对象,所以把对象内部引用的函数称为“方法”似乎有点不妥。
确实,有些函数具有 this 引用,有时候这些 this 确实会指向调用位置的对象引用。但是 这种用法从本质上来说并没有把一个函数变成一个“方法”,因为 this 是在运行时根据调 用位置动态绑定的,所以函数和对象的关系最多也只能说是间接关系。
举例来说:
function foo() {
console.log('foo')
}
var someFoo = foo // 对 foo 的变量引用
var myObject = { someFoo: foo }
foo // function foo(){..}
someFoo // function foo(){..}
myObject.someFoo // function foo(){..}
2
3
4
5
6
7
8
someFoo 和 myObject.someFoo 只是对于同一个函数的不同引用,并不能说明这个函数是特 别的或者“属于”某个对象。如果 foo() 定义时在内部有一个 this 引用,那这两个函数引
对象, 用的唯一区别就是 myObject.someFoo 中的 this 会被隐式绑定到一个对象。无论哪种引用 形式都不能称之为“方法”。
最保险的说法可能是,“函数”和“方法”在 JavaScript 中是可以互换的。
# 数组对象
数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性:
var myArray = ['foo', 42, 'bar']
myArray.baz = 'baz'
myArray.length // 3
myArray.baz // "baz"
2
3
4
可以看到虽然添加了命名属性(无论是通过 . 语法还是 [] 语法),数组的 length 值并未发 生变化。
注意:如果你试图向数组添加一个属性,但是属性名“看起来”像一个数字,那它会变成 一个数值下标(因此会修改数组的内容而不是添加一个属性):
var myArray = ['foo', 42, 'bar']
myArray['3'] = 'baz'
myArray.length // 4
myArray[3] // "baz"
2
3
4
# setter 和 getter
在 ES5 中可以使用 getter 和 setter 部分改写默认操作,但是只能应用在单个属性上,无法 应用在整个对象上。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
22
# 存在性
前面我们介绍过,如 myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能 是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined。那么如何区分 这两种情况呢?
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = { a: 2 }
'a' in myObject // true
'b' in myObject // false
myObject.hasOwnProperty('a') // true
myObject.hasOwnProperty('b') // false
2
3
4
5
in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链。相比之下, hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。
# 类
面向类的设计模式:实例化(instantiation)、继承(inheritance)和 (相对)多态(polymorphism)。
# 类理论
类 / 继承描述了一种代码的组织结构形式 —— 一种在软件中对真实世界中问题领域的建模方法。
用来表示一个单词或者短语的一串字符通常被称为字符串。字符就是数据。但 是你关心的往往不是数据是什么,而是可以对数据做什么,所以可以应用在这种数据上的 行为(计算长度、添加数据、搜索,等等)都被设计成 String 类的方法。
所有字符串都是 String 类的一个实例,也就是说它是一个包裹,包含字符数据和我们可以 应用在数据上的函数。我们还可以使用类对数据结构进行分类,可以把任意数据结构看作范围更广的定义的一种 特例。
我们来看一个常见的例子,“汽车”可以被看作“交通工具”的一种特例,后者是更广泛 的类。
我们可以在软件中定义一个 Vehicle 类和一个 Car 类来对这种关系进行建模。
Vehicle 的定义可能包含推进器(比如引擎)、载人能力等等,这些都是 Vehicle 的行为。我们在 Vehicle 中定义的是(几乎)所有类型的交通工具(飞机、火车和汽车)都包含的东西。
在我们的软件中,对不同的交通工具重复定义“载人能力”是没有意义的。相反,我们只 在 Vehicle 中定义一次,定义 Car 时,只要声明它继承(或者扩展)了 Vehicle 的这个基 础定义就行。Car 的定义就是对通用 Vehicle 定义的特殊化。
虽然 Vehicle 和 Car 会定义相同的方法,但是实例中的数据可能是不同的,比如每辆车独 一无二的 VIN(Vehicle Identification Number,车辆识别号码),等等。
这就是类、继承和实例化。
类的另一个核心概念是多态,这个概念是说父类的通用行为可以被子类用更特殊的行为重写。实际上,相对多态性允许我们从重写行为中引用基础行为。
类理论强烈建议父类和子类使用相同的方法名来表示特定的行为,从而让子类重写父类。 我们之后会看到,在 JavaScript 代码中这样做会降低代码的可读性和健壮性。
# JavaScript 中的“类”
由于类是一种设计模式,所以你可以用一些方法(本章之后会介绍)近似实现类的功能。 为了满足对于类设计模式的最普遍需求,JavaScript 提供了一些近似类的语法。
虽然有近似类的语法,但是 JavaScript 的机制似乎一直在阻止你使用类设计模式。在 近似类的表象之下,JavaScript 的机制其实和类完全不同。语法糖和(广泛使用的) JavaScript“类”库试图掩盖这个现实,但是你迟早会面对它:其他语言中的类和 JavaScript 中的“类”并不一样。
类是一种设计模式。许多语言提供了对于面向类软件设计的原生语法。JavaScript 也有类似的语法,但是和其他语言中的类完全不同。
类意味着复制。
传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类 中。
多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父 类,但是本质上引用的其实是复制的结果。
总地来说,在 JavaScript 中模拟类是得不偿失的,虽然能解决当前的问题,但是可能会埋 下更多的隐患。
# 原型
Object.prototype 但是到哪里是 [[Prototype]] 的“尽头”呢? 所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。由于所有的“普通” (内置,不是特定主机的扩展)对象都“源于”(或者说把 [[Prototype]] 链的顶端设置为)这个 Object.prototype 对象
所以它包含 JavaScript 中许多通用的功能。 有些功能你应该已经很熟悉了,比如说 .toString() 和 .valueOf() .hasOwnProperty(..) .isPrototypeOf(..)。
# “类”
现在你可能会很好奇:为什么一个对象需要关联到另一个对象?这样做有什么好处? 这个问题非常好,但是在回答之前我们首先要理解 [[Prototype]]“不是”什么。
前面我们说过,JavaScript 和面向类的语言不同,它并没有类来作为对象的抽象模式或者说蓝图。JavaScript 中只有对象。实际上,JavaScript 才是真正应该被称为“面向对象”的语言,因为它是少有的可以不通过类,直接创建对象的语言。
在 JavaScript 中,类无法描述对象的行,(因为根本就不存在类!)对象直接定义自己的行为。再说一遍,JavaScript 中只有对象。
在面向类的语言中,类可以被复制(或者说实例化)多次,就像用模具制作东西一样。之所以会这样是因为实例化(或者继承)一个类就意味着“把类的 行为复制到物理对象中”,对于每一个新实例来说都会重复这个过程。
但是在 JavaScript 中,并没有类似的复制机制。你不能创建一个类的多个实例,只能创建 多个对象,它们 [[Prototype]] 关联的是同一个对象。但是在默认情况下并不会进行复制, 因此这些对象之间并不会完全失去联系,它们是互相关联的。
new Foo() 会生成一个新对象(我们称之为 a),这个新对象的内部链接 [[Prototype]] 关联 的是 Foo.prototype 对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
# 关于名称
在 JavaScript 中,我们并不会将一个对象(“类”)复制到另一个对象(“实例”),只是将它们 关联起来。从视觉角度来说,[[Prototype]] 机制如下图所示,箭头从右到左,从下到上:
这个机制通常被称为原型继承,它常常被视为动态语言版本 的类继承。这个名称主要是为了对应面向类的世界中“继承”的意义,但是违背(写作违 背,读作推翻)了动态脚本中对应的语义。
这个容易混淆的组合术语“原型继承”(以及使用其他面向类的术语比如 “类”、“构造函数”、“实例”、“多态”,等等)严重影响了大家对于 JavaScript 机制真实原理的理解。
# “构造函数”
回到之前的代码:
function Foo() {
// ...
}
var a = new Foo()
2
3
4
到底是什么让我们认为 Foo 是一个“类”呢?
其中一个原因是我们看到了关键字 new,在面向类的语言中构造类实例时也会用到它。另 一个原因是,看起来我们执行了类的构造函数方法,Foo() 的调用方式很像初始化类时类 构造函数的调用方式。
除了令人迷惑的“构造函数”语义外,Foo.prototype 还有另一个绝招。思考下面的代码:
function Foo() {
// ...
}
Foo.prototype.constructor === Foo // true
var a = new Foo()
a.constructor === Foo // true
2
3
4
5
6
Foo.prototype 默认(在代码中第一行声明时!)有一个公有并且不可枚举的属性 .constructor,这个属性引用的是对象关联的函数(本例中是 Foo)。此外,我们
可以看到通过“构造函数”调用 new Foo() 创建的对象也有一个 .constructor 属性,指向 “创建这个对象的函数”。
# “构造函数”
看起来 a.constructor === Foo 为真意味着 a 确 实有一个指向 Foo 的 .constructor 属性,但是事实不是这样。
这是一个很不幸的误解。实际上,.constructor 引用同样被委托给了 Foo.prototype,而 Foo.prototype.constructor 默认指向 Foo。
把 .constructor 属性指向 Foo 看作是 a 对象由 Foo“构造”非常容易理解,但这只不过 是一种虚假的安全感。a.constructor 只是通过默认的 [[Prototype]] 委托指向 Foo,这和“构造”毫无关系。相反,对于 .constructor 的错误理解很容易对你自己产生误导。
# 创建关联 Object.create()
我们已经明白了为什么 JavaScript 的 [[Prototype]] 机制和类不一样,也明白了它如何建立
对象间的关联。
那 [[Prototype]] 机制的意义是什么呢?为什么 JavaScript 开发者费这么大的力气(模拟 类)在代码中创建这些关联呢?
还记得吗,本章前面曾经说过 Object.create(..) 是一个大英雄,现在是时候来弄明白为 什么了:
var foo = {
something: function() {
console.log('Tell me something good...')
}
}
var bar = Object.create(foo)
bar.something() // Tell me something good...
2
3
4
5
6
7
Object.create(..) 会创建一个新对象(bar)并把它关联到我们指定的对象(foo),这样 我们就可以充分发挥 [[Prototype]] 机制的威力(委托)并且避免不必要的麻烦(比如使 用 new 的构造函数调用会生成 .prototype 和 .constructor 引用)。
我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而 Object.create(..) 不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。
Object.create()的 polyfill 代码
Object.create(..) 是在 ES5 中新增的函数,所以在 ES5 之前的环境中(比如旧 IE)如 果要支持这个功能的话就需要使用一段简单的 polyfill 代码,它部分实现了 Object. create(..) 的功能:
if (!Object.create) {
Object.create = function(o) {
function F() {}
F.prototype = o
return new F()
}
}
2
3
4
5
6
7
这段 polyfill 代码使用了一个一次性函数 F,我们通过改写它的 .prototype 属性使其指向想 要关联的对象,然后再使用 new F() 来构造一个新对象进行关联。
小结
如果要访问对象中并不存在的一个属性,[[Get]] 操作就会查找对象内部[[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”,在查找属性时会对它进行遍历。
所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域),如 果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能 都存在于 Object.prototype 对象上,因此语言中所有的对象都可以使用它们。
关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的 4 个步骤中会创建一个关联其他对象的新对象。
使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用 通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。
虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无 法帮助你理解 JavaScript 的真实机制(不仅仅是限制我们的思维模式)。相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。
# 行为委托
# 委托理论
首先你会定义一个名为 Task 的对象(和许多 JavaScript 开发者告诉你的不同,它既不是类 也不是函数),它会包含所有任务都可以使用(写作使用,读作委托)的具体行为。
接着, 对于每个任务(“XYZ”、“ABC”)你都会定义一个对象来存储对应的数据和行为。你会把 特定的任务对象都关联到 Task 功能对象上,让它们在需要的时候可以进行委托。
基本上你可以想象成,执行任务“XYZ”需要两个兄弟对象(XYZ 和 Task)协作完成。但 是我们并不需要把这些行为放在一起,通过类的复制,我们可以把它们分别放在各自独立 的对象中,需要时可以允许 XYZ 对象委托给 Task。
下面是推荐的代码形式,非常简单:
Task = {
setID: function(ID) {
this.id = ID
},
outputID: function() {
console.log(this.id)
}
}
// 让XYZ委托Task
XYZ = Object.create(Task)
XYZ.prepareTask = function(ID, Label) {
this.setID(ID)
this.label = Label
}
XYZ.outputTaskDetails = function() {
this.outputID()
console.log(this.label)
}
// ABC = Object.create( Task ); // ABC ... = ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在这段代码中,Task 和 XYZ 并不是类(或者函数),它们是对象。XYZ 通过 Object. create(..) 创建,它的 [[Prototype]] 委托了 Task 对象
相比于面向类(或者说面向对象),我会把这种编码风格称为 “对象关联” (OLOO, objects linked to other objects)。我们真正关心的只是 XYZ 对象(和 ABC 对象)委托了 Task 对象。
对象关联风格的代码与类还有一些不同之处。
在上面的代码中,id 和 label 数据成员都是直接存储在 XYZ 上(而不是 Task)。通常 来说,在 [[Prototype]] 委托中最好把状态保存在委托者(XYZ、ABC)而不是委托目标(Task)上。
在类设计模式中,我们故意让父类(Task)和子类(XYZ)中都有 outputTask 方法,这 样就可以利用重写(多态)的优势。在委托行为中则恰好相反:我们会尽量避免在 [[Prototype]] 链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法 来消除引用歧义(参见第 4 章)。这个设计模式要求尽量少使用容易被重写的通用方法名,提倡使用更有描述性的方法 名,尤其是要写清相应对象行为的类型。这样做实际上可以创建出更容易理解和维护的代码,因为方法名(不仅在定义的位置,而是贯穿整个代码)更加清晰(自文档)。
this.setID(ID); XYZ 中的方法首先会寻找 XYZ 自身是否有 setID(..),但是 XYZ 中并没 有这个方法名,因此会通过 [[Prototype]] 委托关联到 Task 继续寻找,这时就可以找到 setID(..) 方法。此外,由于调用位置触发了 this 的隐式绑定规则,因此虽然 setID(..) 方法在 Task 中,运行时 this 仍然会绑定到 XYZ,这正是我们想要的。 在之后的代码中我们还会看到 this.outputID(),原理相同。
换句话说,我们和 XYZ 进行交互时可以使用 Task 中的通用方法,因为 XYZ 委托了 Task。委托行为意味着某些对象(XYZ)在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
这是一种极其强大的设计模式,和父类、子类、继承、多态等概念完全不同。在你的脑海中对象并不是按照父类到子类的关系垂直组织的,而是通过任意方向的委托关联并排组织的。
# 比较思维模型
现在你已经明白了“类”和“委托”这两种设计模式的理论区别,接下来我们看看它们在思维模型方面的区别。
我们会通过一些示例(Foo、Bar)代码来比较一下两种设计模式(面向对象和对象关联) 具体的实现方法。下面是典型的(“原型”)面向对象风格:
function Foo(who) {
this.me = who
}
Foo.prototype.identify = function() {
return 'I am ' + this.me
}
function Bar(who) {
Foo.call(this, who)
}
Bar.prototype = Object.create(Foo.prototype)
Bar.prototype.speak = function() {
alert('Hello, ' + this.identify() + '.')
}
var b1 = new Bar('b1')
var b2 = new Bar('b2')
b1.speak()
b2.speak()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
子类 Bar 继承了父类 Foo,然后生成了 b1 和 b2 两个实例。b1 委托了 Bar.prototype,后者委托了 Foo.prototype。这种风格很常见,你应该很熟悉了。
下面我们看看如何使用对象关联风格来编写功能完全相同的代码:
Foo = {
init: function(who) {
this.me = who
},
identify: function() {
return 'I am ' + this.me
}
}
Bar = Object.create(Foo)
Bar.speak = function() {
alert('Hello, ' + this.identify() + '.')
}
var b1 = Object.create(Bar)
b1.init('b1')
var b2 = Object.create(Bar)
b2.init('b2')
b1.speak()
b2.speak()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这段代码中我们同样利用 [[Prototype]] 把 b1 委托给 Bar 并把 Bar 委托给 Foo,和上一段 代码一模一样。我们仍然实现了三个对象之间的关联。
但是非常重要的一点是,这段代码简洁了许多,我们只是把对象关联起来,并不需要那些 既复杂又令人困惑的模仿类的行为(构造函数、原型以及 new)。
下面我们看看两段代码对应的思维模型。
首先,类风格代码的思维模型强调实体以及实体间的关系:
现在我们看看对象关联风格代码的思维模型:
通过比较可以看出,对象关联风格的代码显然更加简洁,因为这种代码只关注一件事:对 象之间的关联关系。
其他的“类”技巧都是非常复杂并且令人困惑的。去掉它们之后,事情会变得简单许多 (同时保留所有功能)。
# 小结
在软件架构中你可以选择是否使用类和继承设计模式。大多数开发者理所当然地认为类是 唯一(合适)的代码组织方式,但是本章中我们看到了另一种更少见但是更强大的设计模式:行为委托。
行为委托认为对象之间是兄弟关系,互相委托,而不是父类和子类的关系。JavaScript 的 [[Prototype]] 机制本质上就是行为委托机制。也就是说,我们可以选择在 JavaScript 中努 力实现类机制,也可以拥抱更自然的 [[Prototype]] 委托机制。
当你只用对象来设计代码时,不仅可以让语法更加简洁,而且可以让代码结构更加清晰。 对象关联(对象之前互相关联)是一种编码风格,它倡导的是直接创建和关联对象,不把
它们抽象成类。对象关联可以用基于 [[Prototype]] 的行为委托非常自然地实现。
# 你不知道的 JavaScript(中)
# 类型和语法
值和类型
JavaScript 中的变量是没有类型的,只有值才有。变量可以随时持有任何类型的值。
换个角度来理解就是,JavaScript 不做“类型强制”;也就是说,语言引擎不要求变量总是 持有与其初始值同类型的值。一个变量可以现在被赋值为字符串类型值,随后又被赋值为 数字类型值。
在对变量执行 typeof 操作时,得到的结果并不是该变量的类型,而是该变量持有的值的类 型,因为 JavaScript 中的变量没有类型。
var a = 42
typeof a // "number"
a = true
typeof a // "boolean"
2
3
4
typeof 运算符总是会返回一个字符串:
typeof typeof 42 // "string"
typeof 42 首先返回字符串 "number",然后 typeof "number" 返回 "string"。
undefined 和 undeclared
变量在未持有值的时候为 undefined。此时 typeof 返回 "undefined":
var a
typeof a // "undefined"
var b = 42
var c
b = c
typeof b // "undefined"
typeof c // "undefined"
2
3
4
5
6
7
8
大多数开发者倾向于将 undefined 等同于 undeclared(未声明),但在 JavaScript 中它们完全 是两回事。
已在作用域中声明但还没有赋值的变量,是 undefined 的。相反,还没有在作用域中声明 过的变量,是 undeclared 的。
例如:
var a
a // undefined
b // ReferenceError: b is not defined
2
3
数字
JavaScript 只有一种数值类型:number(数字),包括“整数”和带小数的十进制数。此处 “整数”之所以加引号是因为和其他语言不同,JavaScript 没有真正意义上的整数,这也是
它一直以来为人诟病的地方。这种情况在将来或许会有所改观,但目前只有数字类型。
JavaScript 中的“整数”就是没有小数的十进制数。所以 42.0 即等同于“整数”42。
与大部分现代编程语言(包括几乎所有的脚本语言)一样,JavaScript 中的数字类型是基 于 IEEE 754 标准来实现的,该标准通常也被称为“浮点数”。JavaScript 使用的是“双精 度”格式(即 64 位二进制)。
整数的安全范围 数字的呈现方式决定了“整数”的安全值范围远远小于 Number.MAX_VALUE。能够被“安全”呈现的最大整数是 2^53 - 1,即9007199254740991,在 ES6 中被定义为 Number.MAX_SAFE_INTEGER。 最 小 整 数 是 -9007199254740991, 在 ES6 中 被 定 义 为 Number. MIN_SAFE_INTEGER。
有时 JavaScript 程序需要处理一些比较大的数字,如数据库中的 64 位 ID 等。由于 JavaScript 的数字类型无法精确呈现 64 位数值,所以必须将它们保存(转换)为字符串。
好在大数值操作并不常见(它们的比较操作可以通过字符串来实现)。如果确实需要对大 数值进行数学运算,目前还是需要借助相关的工具库。
不是值的值
undefined 类型只有一个值,即 undefined。null 类型也只有一个值,即 null。它们的名称既是类型也是值。
undefined 和 null 常被用来表示“空的”值或“不是值”的值。二者之间有一些细微的差 别。例如:
- null 指空值(empty value)
- undefined 指没有值(missing value)
或者:
- undefined 指从未赋值
- null 指曾赋过值,但是目前没有值
null 是一个特殊关键字,不是标识符,我们不能将其当作变量来使用和赋值。然而 undefined 却是一个标识符,可以被当作变量来使用和赋值。
ToString
ToString 负责处理非字符串到字符串的强制类型转换。
基本类型值的字符串化规则为:null 转换为 "null",undefined 转换为 "undefined",true 转换为 "true"。数字的字符串化则遵循通用规则,不过那些极小和极大的 数字使用指数形式:
// 1.07 连续乘以七个 1000
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000
// 七个1000一共21位数字
a.toString() // "1.07e21"
2
3
4
对普通对象来说,除非自行定义,否则 toString()(Object.prototype.toString())返回 内部属性 [[Class]] 的值,如 "[object Object]"。
然而前面我们介绍过,如果对象有自己的 toString() 方法,字符串化时就会调用该方法并 使用其返回值。
数组的默认 toString() 方法经过了重新定义,将所有单元字符串化以后再用 "," 连接起 来:
var a = [1, 2, 3]
a.toString() // "1,2,3"
2
toString() 可以被显式调用,或者在需要字符串化时自动调用。
工具函数 JSON.stringify(..) 在将 JSON 对象序列化为字符串时也用到了 ToString。
请注意,JSON 字符串化并非严格意义上的强制类型转换,因为其中也涉及 ToString 的相关规则,所以这里顺带介绍一下。
对大多数简单值来说,JSON 字符串化和 toString() 的效果基本相同,只不过序列化的结 果总是字符串:
JSON.stringify(42) // "42"
JSON.stringify('42') // ""42""(含有双引号的字符串) JSON.stringify( null ); // "null"
JSON.stringify(true) // "true"
2
3
所有安全的 JSON 值(JSON-safe)都可以使用 JSON.stringify(..) 字符串化。安全的 JSON 值是指能够呈现为有效 JSON 格式的值。
为了简单起见,我们来看看什么是不安全的 JSON 值。undefined、function、symbol (ES6+)和包含循环引用(对象之间相互引用,形成一个无限循环)的对象都不符合 JSON
结构标准,支持 JSON 的语言无法处理它们。
JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。
对包含循环引用的对象执行 JSON.stringify(..) 会出错。
如果对象中定义了 **toJSON()**方法,JSON 字符串化时会首先调用该方法,然后用它的返回 值来进行序列化。
如果要对含有非法 JSON 值的对象做字符串化,或者对象中的某些值无法被序列化时,就 需要定义 toJSON() 方法来返回一个安全的 JSON 值。
请记住,JSON.stringify(..) 并不是强制类型转换。在这里介绍是因为它涉及 ToString 强制类型转换,具体表现在以下两点。
(1) 字符串、数字、布尔值和 null 的 JSON.stringify(..) 规则与 ToString 基本相同。
(2) 如果传递给 JSON.stringify(..) 的对象中定义了 toJSON() 方法,那么该方法会在字符串化前调用,以便将对象转换为安全的 JSON 值。
# 异步
使用像 JavaScript 这样的语言编程时,很重要但常常被误解的一点是,如何表达和控制持 续一段时间的程序行为。
事实上,程序中现在运行的部分和将来运行的部分之间的关系就是异步编程的核心。
下面这种情景不是很常见,但也可能发生,从中(不是从代码本身而是从外部)可以观察 到这种情况:
var a = {
index: 1
}
// 然后
console.log(a) // ??
// 再然后
a.index++
2
3
4
5
6
7
我们通常认为恰好在执行到 console.log(..) 语句的时候会看到 a 对象的快照,打印出类 似于{ index: 1 }这样的内容,然后在下一条语句 a.index++执行时将其修改,这句的执 行会严格在 a 的输出之后。
多数情况下,前述代码在开发者工具的控制台中输出的对象表示与期望是一致的。但是, 这段代码运行的时候,浏览器可能会认为需要把控制台 I/O 延迟到后台,在这种情况下, 等到浏览器控制台输出对象内容时,a.index++ 可能已经执行,因此会显示 { index: 2 }。
到底什么时候控制台 I/O 会延迟,甚至是否能够被观察到,这都是游移不定的。如果在调 试的过程中遇到对象在 console.log(..) 语句之后被修改,可你却看到了意料之外的结果, 要意识到这可能是这种 I/O 的异步化造成的。
如果遇到这种少见的情况,最好的选择是在 JavaScript 调试器中使用断点, 而不要依赖控制台输出。次优的方案是把对象序列化到一个字符串中,以强 制执行一次“快照”,比如通过 JSON.stringify(..)。
程序执行顺序
doA(function() {
doB()
doC(function() {
doD()
})
doE()
})
doF()
2
3
4
5
6
7
8
尽管有经验的你能够正确确定实际的运行顺序,但我敢打赌,这比第一眼看上去要复杂一 些,需要费一番脑筋才能想清楚。实际运行顺序是这样的:
- doA()
- doF()
- doB()
- doC()
- doE()
- doD()