《You don't know JS》 上(入门&作用域&对象)
原作:You-Dont-Know-JS
本文的99.9%的内容都来自《You dont know JS》的电子中文版
入门与进阶
值和类型
JavaScript只有带类型的值,没有带类型的变量。大家都知道JS的基本类型共6类:
- undefined
- null
- boolean
- number
- string
- object
但是在ES6之后,需要新增一类symbol
。另外,对null使用typeof
将得到“object”的结果。
JavaScript中对“falsy”的定义包括:
- “”
- 0, -0, NaN
- null, undefined
- false
除此之外的值都是truthy。
关于JavaScript中的==
和===
,作者的看法是在必要的时候==
会很好地改善程序。然而==
的判断规则比较复杂,可以总结出一些情况便于我们选择是否使用==
:
- 如果一个比较的两个值之一可能是
true
或false
,避免==而使用===。 - 如果一个比较的两个值之一可能是
0
/""
/[]
,避免==而使用===。 - 其他情况下,放心使用
==
。不仅安全,在许多情况下它可以简化你的代码并改善可读性。
变量
一个变量标识符必须以a-z,A-Z,$,或_
开头。它可以包含任意这些字符外加数字0-9,但不能使用保留字。
变量作用域提升(var定义)和嵌套就不多说了。
Strict模式
让代码更严谨,同样可以选择用在全局或是函数中。
函数作为值
IIFE(立即执行函数)和闭包是JS中值得玩味的特性。除了使用()
包裹,还可以用void
打头开始一个IIFE。
闭包经常用来包装模块。
this指代和prototype
略
新的特性
填充(polyfill)和转译(transpile)
作用域与闭包
作用域
作用域与LHS,RHS。在非Strict模式下,如果到全局作用域还找不到变量,会在作用域内创建一个新的同名变量。在Strict模式下,不允许这种行为(意外地创建全局变量),此时会抛出ReferenceError,即找不到变量。如果找到了值,但是并不能对它做一些事情,就会抛出TypeError。
词法作用域
JavaScript使用词法作用域,即变量和作用域在编写代码阶段已经确定。JS引擎也可以在这个阶段针对作用域和变量对代码进行优化,但是eval()
和with()
会在代码中动态改变作用域,从而使得引擎无法进行优化,使代码运行得更慢。在strict模式下,eval()
的不安全用法(修改作用域)以及with()
都是不允许使用的。
词法作用域是编写时的,而动态作用域(和this)是运行时的。词法作用域关心的是函数在何处被声明,但是动态作用域关心的是函数从何处被调用。
this
在JS中始终是运行时的,即根据运行时的调用情况有不同的值。在箭头函数中则是词法this的,即声明时决定。
块作用域
封装、匿名函数、IIFE。
for循环、if、while、switch等流程控制语句的{}
,都是假的块作用域,其中的内容都依附于外部的函数作用域。with(不建议使用),try catch,let,const可以形成新的块作用域。
在ES6到ES5的转译时,具有块作用域的代码,会采用try catch
来实现:
1 | // ES6 |
提升
- 在代码被执行前,所有的声明,变量和函数,都会首先被处理。处理的只有“声明”,而没有“赋值”。
- 函数提升优先于变量的提升
- 后续的提升会覆盖此前的同名提升
闭包
闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。
循环加闭包会出现面试中的经典问题:
1 | for (var i=1; i<=5; i++) { |
上面的代码为啥不好用?
从之前关于作用域的讨论来看,每次setTimeout只是完成了函数声明,并丢进队列里而已。当定时器函数在其词法作用域外执行时,因为闭包的特点会保留有父级的作用域。而这5个函数都定义在同一个父级函数作用域内,对变量i的引用自然是同一个了。
1 | for (var i=1; i<=5; i++) { |
有IIFE的加持,父级作用域现在变成了每个IIFE而非for循环所在的作用域。即每个变量i来自不同的独立作用域,自然就可以得到理想的效果了。
1 |
|
不就是想要个块作用域嘛,使用let
关键字后变量将不是只为循环声明一次,而是为每次迭代声明一次。每次都能得到一个新的块作用域,自然得到和IIFE一样的效果。
this
与对象
this
是什么
也许JS已经入门的前端程序员们早就对this在不同环境下的不同值烂熟在心。但可能没有想过这种情况的本质:上一部分提到的JS中的this是运行时的,和作用域完全不一样。
对比一下按照传统OOP理解下的JS代码,从不同的角度看,能进一步得到对this的认识:
1 | function foo() { |
1 | function foo(num) { |
虽然看上去很愚蠢,但是从词法作用域的角度去理解,是不是能更清楚看到JS中this
的特殊之处。
this
豁然开朗
根据上面的描述,this
是根据调用点确定含义的。下面的4个规则,在准备JS面试的时候肯定都见过:
- 默认绑定,独立函数调用。可以认为这种this规则是在没有其他规则适用时的默认规则。此时
this
指向全局对象,在strict mode下,this
指向undefined。 - 隐含绑定,调用点有一个环境对象,即作为函数方法,但是下面的情况下会回退到默认绑定,因为调用点实际位于独立函数内
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
30
31
32
33
34function foo() {
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
var bar = obj.foo; // 函数引用!
var a = "oops, global"; // `a`也是一个全局对象的属性
bar(); // "oops, global"
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" - 显式绑定,
call
与apply
可以显式attach context到函数上,使用bind
可以避免前面那种this
丢失的情况。 - new绑定,函数作为构造函数调用时,
this
指向即将返回的新对象。
从优先级上看,new > 硬绑定 > 隐含绑定 > 默认绑定。其中“new > 硬绑定”有趣的一点是,使用bind
在第一个后的参数实际上会作为函数的默认入参(类似于函数柯里化),如下:
1 | function foo(p1,p2) { |
除了上面的规则,还有一些特例:
传递null
或undefined
给call
,apply
或bind
时,那么这些值会被忽略掉,取而代之的是默认绑定规则将适用于这个调用。单纯使用apply数组化输入参数(现在已经可以用[...foo]
了)和bind柯里化函数时常用到。
不过,这么做还是有风险,建议用Object.create(null)
创建的对象替代null
,既能表示无意义的值,又能避免默认绑定的行为。
作者给出了软绑定的工具方法,提高了硬绑定的灵活性,又避免了默认绑定的问题。逻辑是在绑定时检查this
是否是全局对象,如果是才使用输入的this
。
1 | if (!Function.prototype.softBind) { |
另外,前面提到的箭头函数具有词法this
,等同在调用前声明self = this
,再把self
传入的效果。
对象
内建对象中,只有Date()
是必须要使用new
创建的。
对象的属性有两种访问方法:.
操作符或[ ]
操作符。不同的是.
操作符后只能使用标识符兼容的属性名,[...]
操作符后可以使用任何合理的UTF-8的字符串。另外,对象的属性名总是字符串,如果使用了其他类型值,会进行强制转换:
1 | var myObject = { }; |
计算型属性名
ES6中新增了计算型属性名,允许使用表达式作为一个键名称,表达式用[ ]
括起来。像下面这样:
1 | var prefix = "foo"; |
深、浅复制与对象属性描述符(description),writable
和configurable
,getOwnPropertyDescriptor()
与defineProperty()
。在JS中,delete
仅用于直接从目标对象移除该对象的(可以被移除的)属性,与释放内存并无直接关系。
Immutability
注意:所有这些方法创建的都是浅不可变性。也就是,它们仅影响对象和它的直属属性的性质。如果对象拥有对其他对象(数组、对象、函数等)的引用,那个对象的内容不会受影响,任然保持可变。
属性描述符里的writable
和configuratable
限制了对属性和属性值的修改。preventExtensions()
方法可以防止对象被添加新属性。
seal()
=configuratable: false
+preventExtensions()
freeze()
=seal()
+writable: false
getter与setter
除了使用defineProperty
外,可以直接用字面量的形式,通过get prop1()
或set prop1(val)
的形式设置getter和setter。
for in
和for of
。
混合(淆)“类”的对象
有些语言(比如Java)不给你选择,所以这根本没什么 选择性 —— 一切都是类。其他语言如C/C++或PHP同时给你过程式和面向类的语法,在使用哪种风格合适或混合风格上,留给开发者更多选择。
类意味着拷贝。
当一个传统的类被实例化时,就发生了类的行为向实例中拷贝。当类被继承时,也发生父类的行为向子类的拷贝。多态也是拷贝行为的结果。
但是:
- 第一,JavaScript并不会自动地 (像类那样)在对象间创建拷贝;
- 第二,显式mixin只能复制函数或对象的引用,而不是自身。
正如我们在第四章讲解的,在 JavaScript 中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。
实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而 JavaScript 就是其中之一。
在 JavaScript 中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象。
总之,JavaScript中面向对象的部分和大多数OOP语言不完全一样。这使得在JS中模拟类编程将既累又会埋下很多坑。
原型
使用[[get]]
查询属性时,也会在[[prototype]]
链上寻找,因此修改对象属性的时候,应该注意属性遮蔽(即在[[prototype]]
中找到)的情况。它会增加代码的复杂度和可读性,如下:
1 | var anotherObject = { |
在for in
循环中,同样注意用hasOwnProperty()
排除[[prototype]]
上的属性。
所有用constructor
构建的对象都有所指向的prototype,而在prototype中的.constructor
又会指回constructor
。但是这个关系是可以被覆盖的。
原型继承
作者认为,JS中的对象是通过链接组织起来的。说是原型继承,实际上就是在两个原型间建立了[[prototype]]
的关系。这个关系的建立方法很多,各有优劣。最简单的还是用ES5提供的Object.create()
方法,对__proto__
和constructor
等视而不见。它的polyfill像下面这样:
1 | if (!Object.create) { |
另外,Object.create()
第一个后面的参数可以用来声明对象属性描述符,不过用得不多。
总结
虽然这些JavaScript机制看起来和传统面向类语言的“初始化类”和“类继承”类似,而在JavaScript中的关键区别是,没有拷贝发生。取而代之的是对象最终通过
[[Prototype]]
链链接在一起。相反,“委托”是一个更确切的术语,因为这些关系不是拷贝而是委托链接。
从这个角度去看new Foo()
过程中发生的事,除了返回一个新的对象外,Foo()
还会将这个对象和Foo.prototype
链接起来(通过指定[[prototype]]
),Foo.prototype
和别的对象并没有本质区别。
行为委托
在上面一章提到,[[prototype]]
是存在于对象内部的引用另一个对象的内部连接。当一个属性/方法引用在一个对象上发生,而这样的属性/方法又不存在时,这个链接就会被使用。在这种情况下,[[Prototype]]
链接告诉引擎去那个被链接的对象上寻找该属性/方法。接下来,如果那个对象也不能满足查询,就沿着它的[[Prototype]]
查询,如此继续。这种对象间的一系列链接构成了所谓的“原形链”。
其重要的实质全部在于被连接到其他对象的对象。
下面是一段OLOO(链接到其他对象的对象)风格的例子:
1 | var Task = { |
它的特点在于:
- 状态保留在委托者上
- 避免
[[prototype]]
链上的重复命名 - 行为委托用在内部实现,避免暴露在API的设计上
思维的转变
放弃传统OO思路在JS中的蹩脚实现(像下面这样),抓住[[prototype]]
链接对象以及“原型链”的特殊性,可以让思路更加自然且符合JS的特点(像下面的下面那样)。
1 | function Foo(who) { |
1 | var Foo = { |
在这种委托的思路下,不存在严格的父子关系,甚至不存在继承和类的说法。全程通过Object.create()
建立起对象和对象的联系,连new
也是不建议使用的。
但是这种思路也有个明显的问题,“子类”没法定义“父类”的同名方法,因为整个程序建立在[[prototype]]
联系的基础上,重复命名将会隔断连接。
作者并不推荐匿名函数的使用,认为:1,追踪调试栈困难;2,难以自引用;3,代码变得不好理解。这点上我是保留意见的。
类型自省
类型自省即instanceof
,而这个操作符是依赖于[[prototype]]
中的constructor属性的,这个属性除了不可枚举外,相较其他属性并没有特别之处。重写或者[[prototype]]
的改变就可以改变它。因此,instanceof
在很多情况下可能并不会那么可靠。
使用鸭子类型的类型自省就更加不可靠了。
在作者提出的OLOO范式中,采取isPrototypeOf()
和Object.getPrototypeOf()
进行类型自省。
新的class
关键字
为了便于“类”思维编程者,class
可以说是一大福音。
- 不再有
.prototype
的困扰 extends
一键式继承super
对多态的支持- 语法上使用更加贴近OOP语言
但实际上,这里的class
只是语法糖,它还是没实现从类(“蓝图”)到实例(“建筑”)以及从父类到子类的复制,还建立在[[prototype]]
的基础上。原文给出了很多例子说明这点。
在传统面向类的语言中,你从不会在晚些时候调整类的定义,所以类设计模式不提供这样的能力。但是JS的一个最强大的部分就是它是动态的,而且任何对象的定义都是(除非你将它设定为不可变)不固定的可变的东西。
换句话说,class 好像在告诉你:“动态太坏了,所以这可能不是一个好主意。这里有看似静态语法,把你的东西静态编码。”
关于 JavaScript 的评论是多么悲伤啊:动态太难了,让我们假装成(但实际上不是!)静态吧。