你不知道的JavaScript(上卷)
去年就想读这一套书,由于个人原因一直拖到现在,今天终于周末在家没事,看了5个小时才看了上卷的1/3(其实也就60页)。主要讲述了作用域和闭包的相关知识,可能是由于自己基础知识的匮乏,所以进度有些慢。总之,要提高阅读速度了。
作用域和闭包
作用域是什么
- JavaScript是一门编译语言,其引擎进行编译的步骤和传统的编译语言非常相似,包括下列三个编译步骤:
- 分析/词法分析(Tokenizing/Lexing)(词法化、单词化)
- 解析/语法分析(Parsing)
- 代码生成
- 作用域是一种规则,用于确定在何处以及如何查找该变量(标识符),即用来管理引擎如何在当前作用域以及嵌套的子作用域中根据标识符名称进行变量查找
- 引擎
- 编译器
- 作用域
- var a = 2;
- var a:编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。(定义声明在编译阶段进行)
- a = 2:编译器会为引擎生成运行时所需要的代码,这些代码被用来处理a=2这个赋值操作。首先会询问作用域,在当前作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;否则会向外层嵌套的作用域继续查找该变量。(赋值声明会被留在原地等待执行阶段被调用)
- 编译器在编译过程中,对变量有两种查询方式
- LHS查询:变量在左侧,目的为赋值操作
- RHS查询:变量在非左侧,目的为获取变量的值
- LHS和RHS都会再当前执行作用域中开始
- 不成功的RHS会导致抛出ReferenceError异常
- 不成功的LSH会导致自动隐式的创建一个全局变量(非严格模式下),该变量使用LHS查询的目标作为标识符,或者抛出ReferenceError异常(严格模式下)
- LHS和RHS的异常错误
- ReferenceError异常:同作用域判别失败相关
- TypeError异常:作用域判别成功,但对结果的操作是非法或不合理的
- 编译器可以再代码生成的同时处理声明和值的定义
- 在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层作用域(即全局作用域)为止
词法作用域
- 作用域的两种工作模型
- 词法作用域:在写代码或定义时确定的,作用域链基于作用域的嵌套,即更关注在何处声明。大多数编程语言都采用
- 动态作用域:在运行时确定的,作用域链是基于调用栈的,即更关注函数是从何处调用的。this的机制也是如此
- 词法作用域:是一套关于引擎如何寻找以及会在何处找到变量的规则
- JavaScript中有两个机制可以“欺骗”词法作用域。但在编译时引擎均无法对作用域查找进行优化,所以不要使用它们
- eval(…):生成代码并运行
- with:会产生内存泄漏
- 箭头函数:ES6添加了一个特殊的语法形式用于函数声明,将this同词法作用域联系起来
- 箭头函数在涉及this绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通this绑定的规则,取而代之的是用当前的词法作用域覆盖了this本来的值
函数作用域和块作用域
- 函数作用域:属于这个函数的全部变量都可以再整个函数的范围内使用及复用(事实上在嵌套的作用域内也可以使用)
- 基于作用域的隐藏方法:大豆是从最小特权原则(最小授权原则、最小暴露原则)中引申出来的,即应该最小限度的暴露必要内容
- 如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式
- 函数声明和函数表达式之间最重要的区别:他们的名称标识符将会绑定在何处
- 函数表达式的应用场景:
- 匿名函数表达式:
setTimeout(() => {}, 0)
- 立即执行函数表达式-IIFE(Immediately Invoked Function Expression):
(function foo(){...})()
或(function(){...}())
- 匿名函数表达式:
- 匿名函数表达式的缺点:(==养成始终给函数表达式命名的好习惯==)
- 在栈追踪中不会显示出有意义的函数名,使调试很困难
- 没有函数名,自身引用自身,只能使用过期的
arguments.callee
来引用 - 可读性差,可理解性差
- 除了JavaScript歪的很多编程语言都支持块作用域(表面上看没有,除非更加深入的研究)P30页
- 块作用域的应用场景:
- with:从对象中创建出的作用域仅在with声明中而非外部作用域中有效
- try/catch:ES3规范中规定catch分句会创建一个块作用域
- let:为其声明的变量隐式的创建了一个块作用域
- const:创建块作用域变量,但值是固定的常亮
- ???
- ==for循环的let i==:
- 将i重新绑定到循环的每一个迭代中,并确保使用上一个循环迭代结束时的值重新进行赋值
- i在循环过程中不止被声明一次,每次迭代都会被声明,随后的每次迭代都会使用上一个迭代结束时的值来初始化下一个i
- 为变量显式声明块作用域,并对变量进行本地绑定是非常有用的==工具==,P34页
提升
- 任何声明在某个作用域的变量,都将属于这个作用域
- 包括变量和函数在内的所有声明都会再任何代码被执行前首先被处理,可以将这个过程形象的想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端
- 先有声明,后有赋值
- 在提升中,函数声明会首先被提升,然后才是变量声明;但出现在后面的函数声明还是可以覆盖前面的
作用域闭包
- 闭包是基于词法作用域书写代码时所产生的自然结果
- 当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
- 无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包
- 闭包的应用场景:
- function调用
setTimeout(...)
- 定时器、事件监听器、Ajax请求、跨窗口通信、WebWorkers、任何异步或同步任务,只要使用了==回调函数==,实际上就是在使用闭包
- for循环
- 立即执行函数表达式IIFE:
(function(){...})())
(本身创建了闭包,但严格来说并不是闭包) - 模块模式:比如jQuery和$符就是jQuery模块的公共api
- function调用
- 延迟函数的回调会在循环结束时才执行,即使定时器是
setTimeout(..., 0)
- 在迭代内使用IIFE会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问
- let声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量;本质上这是将一个块转换成一个可以被关闭的作用域
- 最常见的实现模块模式的方法被称为模块暴露
- 模块模式需要具备的两个必要条件:
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有属性
- 模块的两个主要特征:
- 为创建内部作用域而调用了一个包装函数
- 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包
- 模块也是普通函数,也可以接受参数
- 模块模式另一个简单而强大的变化用法是,命名将要作为公共api返回的对象(大多数模块依赖加载器/管理器的本质都是将这种模块定义封装进一个友好的api)
- import:将一个模块中的一个或多个api导入到当前作用域,并可以分别绑定在不同的变量上
- module:将整个模块的api导入并绑定 到一个变量上
- export:将当前模块的一个标识符(变量或函数)导出为公共api
this和对象原型
任何足够先进的技术都和魔法无异。
在遇到问题时,许多开发者并不会深入思考为什么this的行为和预期的不一致,也不会试图回答那些很难解决但确实非常重要的问题,他们只会回避这个问题并使用其他方法来达到目的,这显然不是一种很好的解决办法。
关于this
this关键字是JavaScript中最复杂的机制之一
this指向的两大误区:
- 指向函数自身
- 指向函数的作用域(在任何情况下this都不会指向函数的词法作用域,因为作用域“对象”是存在于JavaScript的引擎内部的)
当你想把this和词法作用域的查找混合使用时,这是无法实现的!
不能使用this来引用一个词法作用域内部的属性
this实际上是在函数被调用时发生的绑定,this指向什么只取决于函数在哪被调用,和函数的生命位置毫无关系。
this全面解析
要判断一个运行中函数的this绑定,就需要找到这个函数的直接调用位置,最重要的就是分析调用栈(为了到达当前执行位置所调用的所有函数,即函数调用链)。
调用位置如何决定this的绑定对象?可顺序应用下面四条规则来判断
- 是否在new中调用(new绑定)?绑定到新创建的对象。
var bar = new foo()
- 是否通过call、apply(显示绑定)或者bind(硬绑定)调用?绑定到指定的对象。
var bar = foo.(obj2)
- 是否在某个上下文对象(隐式绑定)中调用?绑定到那个上下文对象。
var bar = obj1.foo()
- 如果都不是即独立函数调用(默认绑定),严格模式下绑定到undefined,非严格模式下绑定到全局对象。
var bar = foo()
- 是否在new中调用(new绑定)?绑定到新创建的对象。
默认绑定:直接使用不带任何修饰的函数引用进行调用
1
2
3
4
5
6
7
8
9
10
11
12function foo() {
console.log(this.a)
}
var obj = {
a: 2,
foo: foo
}
var bar = obj.foo // 函数别名
var a = 'oops, global' // a是全局对象的属性
bar() // >> 'oops, global'
// 虽然bar是obj.foo的一个引用,看似是隐式绑定,但实际上,它引用的是foo的函数本身,因此此时的bar()其实是一个不带任何修饰的函数调用,因此应用了默认绑定。隐式绑定:调用位置是否有上下文对象,或者说是否被某个对象拥有或包含。即我们必须在一个对象内部包含一个指向函数的属性,并通过这个属性间接引用函数,从而把this间接(隐式)绑定到这个对象上。
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// 参数传递就是一种隐式赋值
function foo() {
console.log(this.a)
}
function doFoo(fn) {
fn() // fn其实引用的是foo函数本身
}
var obj = {
a: 2,
foo: foo
}
var a = 'oops, global' // a是全局对象的属性
doFoo(obj.foo) // >> 'oops, global'
// 如果把函数传入语言内置的函数而不是自定义的函数,结果是一样的
function foo() {
console.log(this.a)
}
var obj = {
a: 2,
foo: foo
}
var a = 'oops, global' // a是全局对象的属性
setTimeout(obj.foo, 100) // >> 'oops, global'
// JavaScript环境中内置的setTimeout()函数实现和下面伪代码类似
function setTimeout(fn, delay) {
// 等待delay秒
fn() // 调用函数内容本身
}显示绑定:在对象内部不包含函数引用的情况下,在某个对象上强制调用函数,可以使用
call(...)
和apply(...)
方法,它们的第一个参数是一个对象,它们会吧这个对象绑定到this上,接着在调用函数时指定这this。1
2
3
4
5
6
7function foo() {
console.log(this.a)
}
var obj = {
a: 2
}
foo.call(obj) // >> 2new绑定:JavaScript中的new机制实际上和面向类的语言完全不同,JavaScript中的new构造函数,其实只是一些使用new操作符调用的普通函数。它们不会属于某个类,也不会实例化一个类。
有些调用可能在无意中使用默认绑定规则。为了保护全局对象,可以使用一个
DMZ对象(DemilitarizedZone,非军事区)
,即一个空的非委托的对象,比如const ∅ = Object.create(null)
,使用变量名∅
不仅让函数变得更加安全,而且可以提高代码的可读性,因为∅
标识“我希望this是空”这比null的含义更清楚。Object.create(null)
和{}
很像,但并不会创建Object.prototype
这个委托,所以它比{}“更空”。ES6中
箭头函数()=>{}
并不会使用这四条标准的绑定规则,而是根据当前的词法作用域
来决定this,具体来说,箭头函数会继承外层函数调用的this绑定(无论this绑定到什么)。这其实和ES6之前的self = this
机制一样。代码风格:
- 词法作用域风格:只使用
词法作用域
,并完全抛弃错误的this风格。 - this风格:完全采用this风格,必要时使用
bind(...)
,尽量避免使用self = this
和箭头函数()=>{}
。
- 词法作用域风格:只使用