作用域和闭包
作用域
问题:将变量引入程序中,他们储存在哪里,程序需要时如何找到
编译原理
-
分词/词法分析
将字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元(token)
例:
var a = 2;
会分解成var
、a
、=
、2
、;
,空格是否会被当成词法单元取决于空格在该门语言中是否有意义 -
解析/语法分析
将第一步生成的词法单元流(数组)转化成一个由元素逐级嵌套所组成的代表了程序语法结构的树,这棵树被称为抽象语法树(AST)
AST
例:将一个函数拆开
function add(a, b){ return a + b; }
这个语法块是一个FunctionDeclaration(函数定义)对象
将其拆开:
-
一个id,也就是名字add
id不能继续拆下去了,他是一个最基础的Identifier(标志)对象,作为函数的唯一标志
{ name: 'add' type: 'identifier' ... }
-
两个params,就是参数[a,b]
params继续拆,就是两个Identifier组成的数组
[ { name: 'a' type: 'identifier' ... }, { name: 'b' type: 'identifier' ... } ]
-
一块body,也就是函数体的主要内容
body是一个BlockStatement(块状域)对象,用来表示是
{return a+b}
BlockStatement里面还有一个ReturnStatement(return域)对象,用来表示
return a+b
ReturnStatement里面还有一个BinaryExpression(二项式)对象,用来表示
a+b
BinaryExpression拆出来,里面分了三部分:left , operator , right
- operator : +
- left : identifier对象a
- right : identifier对象b
查看一段代码的AST结构
npm i recast -S //安装recast工具
//解析代码 const recast = require('recast') const code = ` function add(a, b) { return a + b } ` const ast = recast.parse(code); console.log(ast.program.body[0]);
-
-
代码生成
将AST转化为可执行代码的过程
简单来说就是将AST转化为一组机器指令,用来创建一个叫做a的变量(包括内存分配),并将值存储在a中
但是js的编译过程要比普通编译器复杂的多,比如在语法分析和代码生成阶段来对运行性能进行优化,包括对冗余元素进行优化等
js引擎不会有大量的时间用来进行优化,因为js的编译过程不是发生在构建之前的
对于js来说,大部分编译都是发生在代码执行前的几微秒(甚至更短)的时间内,在作用域背后,js引擎用了各种方法来保证性能最佳
作用域
将程序var a = 2;
模拟成几个人物的对话
人物:
- 引擎:从头到尾负责JS程序的编译及执行过程
- 编译器:引擎的好朋友之一,负责语法分析及代码生成等
- 作用域:引擎的另外一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限
对话:
一般看到
var a = 2
这段程序,都会以为这只是一句声明,但是引擎不会这么认为引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,一个由引擎在运行时处理
详细剖析
var a = 2
:首先:编译器会将这段程序分解成词法单元,然后将词法单元解析成一个AST
其次:编译器会开始进行代码生成,但是对这段程序的处理方式可能会和预期不一样
预期:为一个变量分配内存,将其命名为a,然后将值2保存在这个变量中
实际:
- 编译器遇到
var a
,会询问作用域中是否已经有了这个名称的变量,有的话就忽略这个声明,没有就会要求作用域在当前作用域的集合之中声明一个新的变量,并命名为a- 编译器会为引擎生成运行时所需的代码,这些代码用来处理
a = 2
这个操作。引擎运行时会先询问作用域在当前作用域下是否存在一个叫做a的变量,如果是,引擎就会使用这个变量,否则引擎就会继续查找这个变量- 引擎如果找到了a,就会进行赋值,但是如果找不到,就会抛出一个异常
也就是说,变量的赋值操作会执行两个动作,首先编译器会在当前作用域下声明一个变量(如果之前没有声明过),然后在运行的时候引擎就会在作用域中查找该变量,能够找到就会进行赋值
编译器:
编译器编译过程的第二步生成代码,引擎执行时会通过查找变量a来判断是否已经声明过
在
var a = 2;
这个例子中,引擎会对a进行LHS查询。还有一种查找的类型是RHSLHS和RHS表示在赋值操作的左侧和右侧进行查找
RHS查询与简单的查找某个变量的值没啥区别
LHS查询则是试图找到变量的容器本身,从而可以对其赋值
在
console.log(a);
这个例子中,引擎则会进行RHS查询因为打印a并没有对a进行赋值,而是要查找a的值,然后传递给
console.log
所以RHS和LHS并不是单纯的在赋值操作的左侧和右侧查询,更加准确的理解应该是赋值操作的目标是谁(LHS)和谁是赋值操作的源头(RHS)
还有一个例子,引擎同时进行了LHS和RHS
function foo(a){ console.log(a); } foo(2);
在
foo(..)
调用时进行了RHS查询,并且(..)
意味着foo要被执行,所以foo得是一个函数类型的值代码中还有一个隐式的
a = 2
操作,这个操作发生在把2作为参数传递给foo(..)
函数时,2会分配给参数a,所以这里要进行赋值,所以进行了LHS查询代码中还有将a的值传递给
console.log
,所以这里进行了RHS查询
LHS和RHS的区别
若查找的目的是对变量进行赋值,则使用LHS查询
若查找的目的是获取变量的值,则使用RHS查询
作用域嵌套
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套
因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量, 或抵达最外层的作用域(也就是全局作用域)为止
异常
如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出ReferenceError异常
如果LHS查询在所有嵌套作用于下找不到目标变量时,全局作用域就会帮我们创建一个具有该名称的变量,并将其返回给引擎(前提要在非严格模式下)
在严格模式下,严格模式有一个行为就是禁止自动或隐式的创建全局变量,所以如果LHS查询失败,并不会创建一个该变量,而是引擎会抛出类似ReferenceError错误
对一个变量进行RHS查询之后,如果尝试对他进行不合理的操作,比如对一个非函数类型的值进行调用,就会抛出TypeError错误
总结:
-
ReferenceError 同作用域判别失败相关
-
TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的
词法作用域
作用域有两种主要的工作模式
- 词法作用域
- 动态作用域
词法阶段
词法作用域就是定义在词法阶段的作用域
也就是说,词法作用域是由在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)
查找
作用域查找会在找到第一个匹配的标识符时停止
遮蔽效应:在多层作用域中如果有多个同名的标识符,内部的标识符会遮蔽外部的标识符
全局变量会自动称为全局对象(比如浏览器中的window)的属性,因此可以不通过全局对象的词法名称,而间接通过对全局对象属性的引用来进行访问,也就是window.a
这样可以访问被同名变量遮蔽的全局变量,但非全局变量如果被遮蔽了,无论如何都无法访问到
欺骗词法
如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修改”(也可以说欺骗)词法作用域
有两种方式可以欺骗词法作用域,但是会导致性能下降
eval
eval
这个函数可以接收一个字符串作为参数,并将其中的内容作为在书写时就存在于程序中的那个位置的代码一样
在执行eval
之后的代码,引擎并不知道前面的代码是以动态形式插入进来的,并对词法环境进行修改
例:
function foo(str, a){
eval(str); //相当于直接把var b = 3放在此处,在foo内部创建了一个新的变量b,并遮蔽外部的同名变量b
console.log(a, b);
}
var b = 2;
foo("var b = 3", 1); //1, 3
在严格模式下,eval()
在运行时有自己的词法作用域,意味着其中的声明无法修改所在的作用域
function foo(str){
"use strict";
eval(str);
console.log(a); //ReferenceError:a is no defined
}
foo("var a = 2");
with
with
通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身
var obj = {
a: 1,
b: 2,
c: 3,
}
//普通调用
obj.a = 2;
obj.b = 3;
obj.c = 4;
//with调用
with(obj){
a = 3;
b = 4;
c = 5;
}
但是如果不只是进行访问对象属性操作
function foo(obj) {
with (obj) {
a = 2; //LHS引用
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
};
foo( o1 );
console.log( o1.a ); // 2
foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 ,a 被泄漏到全局作用域上了!
在o2处a = 2
创建了一个全局的变量a?
with可以将一个没有或者有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符
在严格模式下,with会被完全禁止
性能
eval()
和with
会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域
JS引擎在编译的过程中会进行数项性能优化,有些代码要依赖代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符
但是引擎要是遇到欺骗词法,他不能分析eval
会接收到什么代码,这些代码对作用域会有什么影响,也不能分析with
创建的新词法作用域的对象内容是什么,所以可能所有的优化都是无意义的,因此最简单的方法就是不做优化,所以代码中如果有大量的eval()
和with
语法,性能就会大幅下降
动态作用域
JS的作用域是词法作用域,但是其他语言有一些有动态作用域
词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则,最重要的特征就是他的定义过程发生在代码的书写阶段
动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心在何处调用,也就是说,作用域链是基于调用栈的,不是作用域嵌套
function foo() {
console.log( a ); // 2
//但是如果JS是动态作用域的话,此处应该是3
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
函数作用域与块作用域
作用域包含了一系列气泡,每一个气泡里面都可以作为一个容器,其中可以包含标识符(变量,函数)的定义
这些气泡互相嵌套并且整齐地排列成蜂窝型,排列的结构是在写代码时定义的
函数中的作用域
function foo(a){ //foo的作用域气泡包含了标识符a,b,c,bar
var b = 2;
//....
function bar(){ //bar也拥有自己的作用域气泡
//....
}
var c = 3;
}
foo作用域中的标识符都无法从foo外部对他们进行访问,否则会导致ReferenceError
报错
但是在foo内部,就可以访问这些变量,同样这些标识符也可以在bar里面访问到(bar中不能有同名标识符声明)
所以函数作用域的含义:属于这个函数的全部变量都可以在整个函数的范围内使用及复用(事实上在嵌套的作用域中也可以使用),这么设计可以充分利用JS变量可以根据需要改变值类型的动态特性
函数的内部隐藏
对函数传统的认知:声明一个函数,然后再向里面添加代码
但是也可以不这么想:从所写的代码中挑选出一部分,然后用函数声明对他进行包装,也就是把这些代码隐藏起来
实际上:在这个代码周围产生了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,用这个作用域隐藏他们
最小授权原则(最小暴露原则)
指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计
也就是说尽量不暴露过多的变量或函数到全局作用域中
规避冲突
function foo(){
function bar(a){
i = 3;
console.log(a+i);
}
for(var i = 0; i<10; i++){
bar(i*2); //无限循环了
}
}
foo();
i=3
会一直改变for循环中的i,导致死循环
解决:
- 将
i = 3
改成var i = 3
,为i声明一个遮蔽变量- 采用一个完全不同的标识符,如j
避免冲突的方法:
-
全局命名空间
在全局作用域中容易产生变量冲突,特别是引入大量第三方库,如果没有将内部变量隐藏起来,则容易产生冲突
但是这些库一般都会在全局作用域中用一个名字足够独特的变量(一般是对象),将需要暴露出去的功能作为这个对象的属性
-
模块管理
挑选一个模块管理器使用,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中
利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突
函数作用域
var a = 2;
function foo(){
var a = 3;
console.log(a); // 3
}
foo();
console.log(a); // 2
用这种方法虽然可以隐藏一些变量,但是又产生了foo这个名称污染了所在作用域,而且还需要显式调用这个函数才能执行里面的代码
所以现在我们需要有一个既没有函数名又能自动执行的方法:
//JS提供:
var a = 2;
(function foo(){ //这里的函数会被当成函数表达式而不是一个标准的函数声明,foo不会污染全局
var a = 3;
console.log(a);
})();
console.log(a)
区分函数声明和表达式的方法:
看function关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置),如果是声明中的第一个词,那就是函数声明,不然就是函数表达式
匿名和具名
setTimeout(function(){ //匿名函数表达式
...
},2000)
匿名函数表达式优点:简单便捷
缺点:
在栈追踪中不会显示出有意义的函数名,调试困难
没有函数名难以递归,只能引用过期的
arguments.callee
callee属性是一个指针,指向拥有这个arguments对象的函数
没有语义化的名字
立即执行函数表达式(IIFE)
由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数
var a = 2;
(function foo(){
var a = 3;
console.log(a); // 3
})();
console.log(a) // 2
IIFE用法:
-
(function(){..})()
-
(function(){..}())
-
把他们当作函数调用并传参进去
var a = 2; (function IIFE( global ) { //将传进去的参数命名为global var a = 3; console.log( a ); // 3 console.log( global.a ); // 2 })( window ); //将window传进去 console.log( a ); // 2
块作用域
JS中的作用域有全局作用域,函数作用域,但是并没有块级作用域的概念
但是在ES6中新增了块级作用域,块作用域由{}
包括,if语句和for语句里面的{}
也属于块作用域
-
通过var变量可以跨块作用域访问到
{ var a = 1; console.log(a); //1 } console.log(a); //1
-
通过var定义的变量不能跨函数作用域访问到
(function foo(){ var b = 2; console.log(b); //2 })(); console.log(b); //报错,访问不到b
with
with从对象中创建出来的作用域仅在with声明中而非外部作用域中有效
try/catch
ES3中规定,try/catch
的catch
分句会创建一个块作用域,其中声明的变量仅在catch内部有效
注:同一个作用域下的两个或者多个catch
分句,用同样的标识符声明错误变量时,很多静态检查工具还是会发出警告,所以一般避免不必要的警告,我们可以将catch
的参数写成err1,err2
等
let
let可以将变量绑定到任意作用域({...})
中,也就是说,let为其声明的变量隐式地劫持在了所在地块作用域中
let用处:
- 垃圾回收
- let循环
if(foo){
{ //这是一个显示的块,可以防止由于整块代码的移动导致里面的私有变量污染外部作用域
let bar = foo * 2;
bar = something(bar);
console.log(bar);
}
}
const
const也是同样来创建块作用域变量,其值时固定的常量,任何修改的操作都会引起报错
提升
问题
a = 2;
var a;
console.log(a); //输出2
console.log(b); //输出undefined
var b = 2;
编译器角度
引擎会在解析JS代码之前进行编译,词法作用域机制会找到所有的声明,并用核实的作用域将他们关联起来
所有var a = 2
这个语句,JS会解析成var a
和a = 2
两个语句,第一个定义声明在编译阶段进行的,第二个赋值声明会被留在原地等待执行
所以变量和函数声明从他们在代码中出现的位置被移动到了最上面,这个过程就叫做提升
注意:
-
每个作用域都会进行提升操作,但是并不是所有的声明都提升到整个程序的最上方
foo(); function foo(){ console.log(a); //undefined var a = 2; } //等同于 function foo(){ var a; //不能跨函数作用域提升 console.log(a); a = 2; } foo();
-
函数声明会被提升,函数表达式不会被提升
foo(); //不是ReferenceError,而是TypeError //因为foo变量会提升到程序顶部,所以可以调用到foo() //但是没有对foo进行赋值,所以现在的foo并不是一个函数,强行调用就会报类型错误(TypeError) var foo = function bar(){ ... }
-
具名的函数表达式,名称标识符在赋值之前也无法在所在作用域中使用
foo(); //TypeError bar(); //ReferenceError var foo = function bar(){ ... }; //等同于 var foo; foo(); //TypeError bar(); //ReferenceError foo = function(){ var bar = ...self... }
函数优先
函数声明与变量声明都会被提升,但是函数声明会先被提升,然后才是变量
foo(); //1
var foo;
function foo(){
console.log(1);
}
foo = function(){
console.log(2);
}
//等同于
function foo(){
console.log(1);
}
//var foo因为时普通变量的声明,函数的声明会在他的前面,所以他是重重复的声明,会被忽略
foo(); //1
foo = function(){
console.log(2);
}
虽然重复的var声明会被忽略,但是出现在后面的函数声明还是可以覆盖前面的
作用域闭包
定义
如何产生闭包:
当一个嵌套的内部函数引用了嵌套的外部函数的变量时,就产生了闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行
function foo(){
var a = 2;
function bar(){
console.log(a); //2 可以引用到外部作用域中的变量a(RHS查询)
}
bar();
}
foo();
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); //2
解析:
函数bar
的词法作用域能够访问到foo()
的内部作用域,然后将bar
函数本身当成一个值类型进行传递,在这里就是把bar
本身当作返回值,执行后,其返回值赋给变量baz
并调用baz()
,实际上只是通过不同的标识符引用调用了内部函数bar()
,而bar
能够正常执行,也就是说,他在自己定义的词法作用域以外的地方执行
而且闭包能够阻止垃圾回收,在foo
执行完之后,通常foo()
的整个内部作用域都会被销毁,因为引擎有垃圾回收器用来释放不再使用的内存空间,但是因为有bar()
所声明的位置,所以它拥有涵盖foo()
内部作用域的闭包,使得该作用域能够一直存活,以供bar
在以后任何时间被引用,bar()
依然持有对这个作用域的引用,这个引用就叫做闭包
循环和闭包
问题:
for(var i = 1; i<=5; i++){
setTimeout(function timer(){
console.log(i);
},i*1000);
}
上面代码的输出并不是分别输出1~5,每秒一次
而是以每秒一次的频率输出五次6
延迟函数的回调会在循环结束时才执行
我们试图假设循环中每个迭代在运行时都会给自己捕获一个i的副本,但是根据作用域的工作原理,实际情况时五个函数虽然是在各迭代中分别定义的,但是他们都被封闭在一个共享的全局作用域中,实际上只有一个i
for(var i = 1; i<=5; i++){
(function(){ //这样虽然拥有了更多的词法作用域,但是这些作用域都是空的
setTimeout(function timer(){
console.log(i);
},i*1000);
})();
}
//改进
for(var i = 1; i<=5; i++){
(function(){
var j = i; //在每一个迭代中存储i
setTimeout(function timer(){
console.log(j);
}j*1000);
})();
}
上面的解决方案是使用IIFE在每次迭代时都创建一个新的作用域
但是现在我们可以使用let声明,劫持块作用域,并且在这个块作用域中声明一个变量,本质上这是将一个块转换成一个可以被关闭的作用域
for(var i = 1; i<=5; i++){
let j = i; //此处是闭包的块作用域
setTimeout(function timer(){
console.log(j);
}j*1000);
}
//改进
for(let i = 1; i<=5; i++){ //每一次迭代都会重新声明
setTimeout(function timer(){
console.log(j);
}j*1000);
}
模块
模块暴露
function CoolModule(){ //foo的内部作用域就是闭包
var something = 'cool';
var another = [1, 2, 3];
function doSomething(){
console.log(something);
}
function doAnother(){
console.log(another.join("!"));
}
return { //模块暴露
doSomething,
doAnother
}
}
var foo = CoolModule();
foo.doSomething(); //cool
foo.doAnother(); //1!2!3
解析:
CoolModule只是一个函数,要调用它才能创建模块实例,如果不执行外部函数,内部作用域和闭包都无法创建
doSomething() 和 doAnother() 函数具有涵盖模块实例内部作用域的闭包(通过调用 CoolModule() 实现)
总之,模块模式需要两个必要条件:
- 必须要有外部的封闭函数,该函数必须至少被调用一次(每次调用都被创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态
一个只具有函数属性的对象本身并不是一个真正的模块,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块
上面是一个叫做CoolModule的独立的模块创建器,可以被调用多次,当我们只需要一个实例的时候,可以对这个模式进行简单的改写为单例模式(改写成IIFE)
var foo = (funciton CoolModule(){
...
})();
foo.doSomething(); //cool
foo.doAnother(); //1!2!3
命名将要作为公共 API 返回的对象
var foo = (function CoolModule(id){
function change(){ //从内部对模块实例进行修改
publicAPI.identify = identify2;
}
function identify1(){
console.log(id);
}
function identify(){
console.log(id.toUpperCase());
}
var publicAPI = {
change:change,
identify:identify1
};
return publicAPI;
})('module');
foo.identify(); //module
foo.change();
foo.identify(); //MODULE