第一章 作用域是什么
第一章关键词:
词法分析、语法分析、AST
引擎、编译器、作用域
LHS,RHS
传统编译语言的流程中,程序中的源代码在执行前会经历三个步骤,统称为编译。
分词/词法分析
将字符串分解成有意义的代码块,这些代码块称为词法单元(token)。如var a = 2
; 这段程序会被分解成var
, a
, =
, 2
等
解析/语法分析
将词法单元流转换成一个由元素逐层嵌套的代表程序语法结构的树。称为抽象语法树(AST)
代码生成
将AST转为可执行代码的过程称为代码生成,也就是将var a = 2的AST转为一组机器指令,用来创建一个名a的变量,为它分配内存,并将一个值存在其中。
JS引擎会在语法分析和代码生成阶段
对运行性能进行优化。
引擎,编译器,作用域
- 引擎:从头到尾负责整个js程序的编译及执行过程
- 编译器:负责语法分析,代码生成等。
- 作用域:负责
收集并维护
由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则
,确定当前执行的代码对这些标识符的访问权限
。
他们的工作过程:
当声明了var = 2
,很可能认为这是一句声明,但我们的引擎却不这么看。引擎认为这里有两个完全不同的声明,一个由编译器
在编译
时处理,一个则由引擎
在运行时
处理。
当编译器开始进行代码生成时,他对这段程序的处理方式会和预期的有所不同。让我们用伪代码进行概括。
“为一个变量分配内存,将其命名为a,然后把2保存进这个变量”
这并不完全正确。
事实上编译器会如下处理。
1.遇到var a,编译器
会询问作用域是否已经有一个变量a存在于当前作用域的集合中。如果有,编译器会忽略该声明,继续进行编译,否则会要求作用域在当前作用域的集合中声明一个新的变量并命名为a。
2.接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会询问作用域,当前作用域集合是否存在一个叫做a的变量。如果有,引擎会使用这个变量;如果否,引擎会继续查找该变量。
如果引擎最后找到了a,就讲a赋值给它,否则引擎会抛出异常。
编译器中的LHS和RHS查询
例子:
-
console.log(a)
//这是一个RHS引用,因为a并没有赋予任何值 -
a = 2
// 这是一个LHS引用,因为我们并不关心当前的值是什么,只是要为 = 2 这个赋值操作找到一个目标。
function foo(a){ // 这里有一个a=2的LHS引用,对形参进行赋值
console.log(a);
}
foo(2) // RHS引用,去找foo
这里有个容易忽略的细节,代码中隐式的a=2容易被忽略。2被分配给参数a,为了给参数a(隐式地)分配值,需要进行一次LHS查询。
*函数声明属于LHS吗?
你可能倾向于将函数声明function foo(){}概念化为普通的变量声明和赋值,比如var foo、foo = function (){},如果这么理解则函数声明需要进行LHS查询,
然而编译器可以在代码生成的同时处理声明和值的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值分配给foo,因此函数声明并不符合LHS查询和赋值。
作用域嵌套
作用域是根据名称查找变量的一套规则。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,引擎会从当前的执行作用域开始查找, 在当前作用域中无法找到,引擎会在外层嵌套的作用域中继续查找。直到找到该变量,或抵达最外层的作用域为止。
为什么区分LHS和RHS是一件重要的事
因为如果RHS查询在嵌套的作用域中遍寻不到所需变量,引擎会抛出ReferenceError异常。
相较之下,当引擎执行LHS查询时,如果无法找到,全局作用域就会创建一个该名称的变量,前提是在非严格模式下。
ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功,但是对结果操作是非法的,如
null.slice(0) // Uncaught TypeError
第二章 词法作用域
2.1 词法阶段
词法作用域是由你写代码时讲变量和块作用域写在哪里决定的。
作用域查找会在找到第一个匹配的标识符(变量)时停止,无论函数在哪里被调用,也无论函数如何被调用,词法作用域都只由函数声明时所处的位置决定。
词法作用域只会查找一级标识符,如果代买引用了foo.bar.baz
,词法作用域查找只会试图查找foo标识符,找到这个变量后,再去查找bar和baz的属性。
2.2欺骗词法
eval()
:接受字符串为参数,将参数视为书写时就存在于这个位置的代码,如
function foo(str){
var b = 'bbb';
eval(str)
console.log(b)
}
foo('var b = 3;') // 3
可以看到,原先foo函数中的变量b,值为bbb,eval后被修改为了3。
其他的类似eval的方法
setTimeout方法第一个参数可以是字符串,字符串内容可以被解释为一段动态生成的函数代码。
new Function()最后一个参数可以接受代码字符串。
with
不推荐使用eval和with
js引擎在编一阶段进行性能优化,有些优化依赖于根据代码词法进行分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
第三章 函数作用域和块作用域
第三章关键词:
最小授权原则
作用域改进,IIFE
let垃圾回收机制
设计API的时候,应该最小限度地暴露必要内容,这叫做最小授权原则
例如
function doSth(a){
b = a + doSthElse(a*2)
console.log(b*3)
}
function doSthElse(a){
return a-1
}
var b;
doSth(2) // 15
这个例子中,变量b
和doSthElse()
应该是doSth
的私有内容,不需要给外部作用域访问权限,因此我们可以将这段代码改为
function doSth(a){
var b = a + doSthElse(a*2)
console.log(b*3)
function doSthElse(a){
return a-1
}
return b
}
doSth(2) // 15
这样改进以后,b
和doSthElse
无法从外部访问,也不容易和全局变量产生冲突。
避免命名冲突的办法
1.声明一个对象,将要暴露的方法或变量当做对象的属性,如
var mathTool = {
name: 'tool',
sum: function(...args){
return args.reduce( ( sum, cur) => sum + cur, 0 )
},
multiple: function(...args){
return args.reduce( ( sum, cur) => sum * cur, 1 )
}
}
2.模块管理(ES6,commonJS…)
函数作用域的改进
// 函数声明
function foo(){
...
}
这样的写法依然会在全局中声明一个具名函数,使用了变量foo,
如果我们不需要函数名,我们可以使用立即执行函数
// 函数表达式
(function foo(){
...
})()
foo() // Uncaught ReferenceError: foo is not defined
这么写的情况下,函数会被当做一个函数表达式而不是函数声明,这时候全局是无法调用foo方法的,也就避免了命名冲突。
(区别函数声明和表达式的方法是看function关键字出现在声明中的位置,如果function是声明中的第一个词,那么就是一个函数声明,否则是函数表达式)
(function foo(){ … })作为函数表达式意味着foo只能在…所代表的的位置中访问,外部作用域则不行。
匿名函数的缺点
1.在栈追踪时不会显示出有意义的函数名,不利于调试
2.没有函数名,只能使用arguments.callee来进行递归
3.省略了对代码可读性很重要的函数名。
IIFE的传参
(function IIFE(global, doc){
let el = doc.getElementById('xxx');
el.classList += 'xxx'
})(window, document)
我们可以把外部的变量传进来。
许多UMD项目(如jQuery),会将要运行的函数放在第二位,在IIFE执行后当做参数传进来,如
(function IIFE(def){
def(window, document)
})( function def(global, doc){
let el = doc.getElementById('xxx')
el.classList += 'xxx'
})
块作用域(有坑)
坑1:循环
for(var i=0; i<3; i++){
console.log(i)
}
console.log(i) // 3
当我们在全局作用域中使用for循环,我们声明了变量i
作为循环条件判断的变量,但当循环结束时,我们并不希望i
继续存在。
这时候我们可以将var i = 0
改为let i = 0
,就不会让变量i
跑到全局作用域中了。
坑2:提升
var foo = false
// var bar; 被提升到了这里
if(foo){
var bar = 123
cosnole.log(bar)
}
console.log(bar) // undefined
这段代码中,我们将foo设置为了false,所以理应没有执行 if(){…} 中的代码块,然而var bar却被提升了,因此当我们在全局中console.log(bar),打印出的undefined,而不是一个ReferenceError(RHS错误)
其他的块作用域
- with
- try/catch 中的catch,可以拿到error,并且只在catch内生效
- let
let
let可以将变量绑定到所在的任意作用域中,通常是{…}内部。
let不会进行变量提升,声明的代码在被运行之前,声明并不"存在"
{
console.log(bar) // ReferenceError!
let bar = 2;
}
let的垃圾收集
另一个块作用域非常有用的原因和闭包及回收内存垃圾的机制有关。
function process(data) {
}
var someReallyBigData = { ... };
process(someReallyBigData)
var btn = docuemnt.getElementById('myBtn')
btn.addEventListener('click', function click(e){
console.log('btn click...')
})
click函数的点击回调并不需要someReallyBigData
变量。理论上这意味着当process(...)
执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。
但是,由于click函数形成了一个覆盖整个作用域的闭包,JS引擎极可能保存着这个结构。(取决于具体实现)
块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存someReallyBigData
了。
function process(data) {
}
// 在这个块中定义的内容完事可以完全销毁!
{
let someReallyBigData = { ... }
process( someReallyBigData )
}
var btn = document.getElementById('myBtn')
btn.addEventListener('click', function(){
console.log('click')
})
let循环
for循环头部的let不仅将i绑定到了for循环的块中,事实上它将其重新绑定到了循环中的每一次迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。
let声明附属于一个新的作用域而不是当前的函数作用域(也不是全局作用域)
第四章 提升
这里省略定义,相关文章很多。
引擎
会在解释js代码前先对其进行编译,编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域把他们关联起来。
编译(查找所有声明) => 让引擎解析代码
当看到var a = 2时,可能会认为这是一个声明,但其实js会将其看做两个声明,var a;和a = 2,第一个声明实在编译阶段进行的,第二个赋值声明会被留在原地等待执行。
例子:
a = 2;
var a;
console.log(a)
会解释为
var a;
a = 2;
console.log(a)
除了变量以外,函数声明也会提升,如function foo(){…},而函数表达式则不会提升(var foo = function(){…})
一个例子
foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
// ...
}
可以解释一个为什么第一行是TypeError,第二行是ReferenceError
函数提升
函数声明和变量声明都会被提升,但是优先级是函数先被提升。
例子:
foo()
var foo;
function foo(){
console.log(1)
}
foo = function() {
console.log(2)
}
这里输出结果为1
,而不是2
引擎会将这段代码理解为
function foo(){
console.log(1)
}
foo()
foo = function() {
console.log(2)
}
var foo
跑到哪里去了?
尽管var foo
出现在function foo()...
的声明之前,但他是重复的声明,因此被忽略了,因为函数声明会被提升到普通变量之前。例如
function foo(){
console.log(123)
}
var foo; // 这一行会被忽略
foo() // 123
虽然var foo
会被忽略,但是如果是var foo = function(){...}
,这是一个函数声明,是不会被忽略的,并且会覆盖前面的function,例
function foo(){
console.log(123)
}
// 这时候就不会被忽略了
var foo=function(){
console.log(321
};
foo() // 321
判断语句中的函数声明
一个普通块内部的函数声明通常会被提升到所在作用域的顶部,这个过程不会像下面的代
码暗示的那样可以被条件判断所控制:
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log("a"); }
}
else {
function foo() { console.log("b"); }
}
但是需要注意这个行为并不可靠,在 JavaScript 未来的版本中有可能发生改变,因此应该
尽可能避免在块内部声明函数。
第五章 闭包
当函数可以记住并访问所在的词法作用域,即使在当前作用域以外的地方调用,就产生了闭包。
function foo() {
var a = 2;
function bar() {
console.log(a); // 由于作用域嵌套,内部函数可以获取包裹着它的函数的变量
}
return bar
}
var baz = foo();
baz() // 2
在这个例子中,我们可以看到,bar在自身被定义的词法作用域以外被执行,并且将foo作用域中的变量a传递给了全局。
在foo()执行后,通常期待foo()的整个内部作用域都被销毁,因为引擎有垃圾回收器来释放不再使用的内存空间。
但是由于产生了闭包,因此内部作用域并没有被回收。bar()函数在使用这个内部作用域,
拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一
直存活,以供 bar() 在之后任何时间进行引用。
bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。
本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。
在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包!
循环和闭包(较难理解)
for(var i = 1; i<=5; i++){
setTimeout(function timer(){
console.log(i);
},i * 1000)
}
在这段代码中,我们期望的是每隔一秒将这次循环的i打印出来,结果是1,2,3,4,5在一秒间隔中依次打印出来。
然而,实际运行结果却是在一秒的间隔中依次打印出6。
这是因为,setTimeout函数的回调是延迟的,会在所有循环结束时候才运行,而for(…)循环的结束条件是i<=5,因此i是5+1 = 6
我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。
要解决问题,需要让setTimeout中的匿名函数,记住 i 的值,而不能让它一直是变动的。用闭包的思路是每个闭包都创建了一个新的作用域,每个作用域的i是独立不影响的,依靠闭包的特性把i保存下来,也就避免了共用 i,而 i 到最后都是5的问题。代码如下。
for(var i = 1; i<=5; i++){
(function(){
setTimeout( function timer() {
console.log(i)
}, i * 1000)
})()
}
我们显然拥有更多的词法作用域了,的确每个延迟函数会将IIFE的每次迭代中创建的作用域封闭起来。但仅仅这么做是不行的,这么运行结果还是和刚才一样。
这是因为,如果作用域是空的,那么仅仅将他们进行封闭是不够的。我们的IIFE只是一个什么都没有的空作用域,它现在继承的i仍然是for循环结束后的i,我们需要利用闭包的特性,声明一个变量将每次循环的i保存起来。
for(var i = 1; i <=5; i++){
(function(){
var j = i; // 这时候立即执行函数里作用域中就多了一个j的变量,而不是空作用域了
setTimeout( function timer(){
console.log(j)
}, j*1000 )
})();
}
这么做,就可以正常运行了。
我们还可以将i传进去
for(var i=1; i<=5; i++) {
(function(j){
setTimeout(function timer(){
console.log(j)
}, j * 1000)
})(i)
}
在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。
使用let来做
for(let i = 1; i <= 5; i++) {
setTimeout(function timer(){
console.log(i)
},i*1000)
}
for循环头部的let声明会有一个特殊行为,这个行为指出变量i在循环中不止被声明一次,每次迭代都会声明。随后每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
模块
function MathModule(){
var version = '1.0'
function addSum(...args){
return args.map((sum, cur) => sum+cur, 0)
}
function mulSum(...args){
return args.map((sum, cur) => sum*cur, 0)
}
return {
addSum,
mulSum
}
}
var foo = MathModule();
foo.addSum(1,2,3,4) // 10
在这段代码中,MathModule()只是一个函数,必须通过调用它来创建一个模块实例,如果不执行外部函数,内部作用域和闭包都无法被创建。
这个模式在js中被称为模块,我们可以将开放给外界使用的函数return出去,其他的变量(如version)仅供内部函数使用。
模块模式需要具备两个必要条件
- 必须有外部的封闭函数,该函数至少被调用一次
- 封闭函数必须返回至少一个内部函数,这样内部函数才会在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
上面示例代码中,MathModule可以被调用多次,每次调用都会创建一个新的模块实例,如果我们只需要一个MathModule实例,可以对模式进行简单的改进来实现单例模式。
var foo = (function MathModule(){
var version = '1.0'
function addSum(...args){
return args.reduce((sum, cur) => sum+cur, 0)
}
function mulSum(...args){
return args.reduce((sum, cur) => sum*cur, 0)
}
return {
addSum,
mulSum
}
})()
foo.addSum(1,2,3,4) // 10
如果我们想要修改内部的属性,可以需要在模块中创建一个方法来私有属性修改,而不是直接在外部修改
例子:
var foo = (function MathModule(){
var version = '1.0'
function addSum(...args){
return args.reduce((sum, cur) => sum+cur, 0)
}
function getV(){
return version
}
return {
addSum,
getV
}
})()
foo.getV() // 1.0
foo.version // undefined
foo.version = 123 // 不会修改实例的version
foo.getV() // 1.0
这里我们可以看到,我们在外部去修改foo.version,并不会真正修改module中的version,如果我们有修改的需求,可以通过新增方法来进行修改
var foo = (function MathModule(){
var version = '1.0'
function addSum(...args){
return args.reduce((sum, cur) => sum+cur, 0)
}
function getV(){
return version
}
function setV(){ // 修改静态变量的方法
version = '2.0'
}
return {
addSum,
getV,
setV
}
})()
foo.getV() // 1.0
foo.setV()
foo.getV() = 2.0 // 修改成功!
可以看到这时候我们就成功修改了静态变量。
模块化机制(es6之前)(有难度)
大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 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
};
})();
这段代码的核心是modules[name] = impl.apply(impl, deps),为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值储存在一个根据名字来管理的模块列表中。
下面展示了如何使用他们来定义模块:
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
未来的模块机制
基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们的 API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块的 API(参考前面关于公共 API 的讨论)。
相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。由于编辑器知道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成员的引用是否真实存在。如果 API 引用并不存在,编译器会在运行时抛出一个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。