大家应该写过下面类似的代码吧,其实这里我想要表达的是有时候一个方法定义的地方和使用的地方会相隔十万八千里,那方法执行时,它能访问哪些变量,不能访问哪些变量,这个怎么判断呢?这个就是我们这次需要分析的问题——词法作用域
const classA = function(){
this.prop1 = 1;
}
classA.prototype.func1 = function(){
let that = this,
var1 = 2;
function a(){
return function(){
console.log(var1);
console.log(this.prop1);
}.apply(that);
};
a();
}
let objA = new ClassA();
objA.func1();
词法作用域:变量的作用域是在定义的时候已经确定而不是执行时决定,换句话说,变量的作用域在你编写代码的时候已经确定了,也就是说词法作用域取决于源码,当解析器对源码进行静态分析就能确定,因此词法作用域也叫做静态作用域。而javascript的作用域机制与词法作用域几乎一样。 with和eval除外。
下面通过几个小小的案例,让我热热身,这是我们深入的了解对理解词法作用域和闭包必不可少的,JS执行时底层的一些概念和理论知识。
1.案例一
/*全局(window)域下的一段代码*/
function a(i) {
var i;
console.log(i);
};
a(10);
疑问:上面的代码会输出什么呢?
答案:没错,就是弹出10。具体执行过程应该是这样的
a 函数有一个形参 i,调用 a 函数时传入实参 10,形参 i=10
接着定义一个同名的局部变量 i,未赋值
console.log 输出 10
但是疑问来了,我们明明重新定义了变量i,却没有赋值啊,为什么打印出来是10,不是undefined啊?
引出思考:局部变量 i 和形参 i 是同一个存储空间吗?
2.案例二
/*全局(window)域下的一段代码*/
function a(i) {
console.log(i);
console.log(arguments[0]); //arguments[0]应该就是形参 i
var i = 2;
console.log(i);
console.log(arguments[0]);
};
a(10);
疑问:上面的代码又会输出什么呢?10,10,2,10 || 10,10,2,2
答案:10,10,2,2,下面简单说一下具体执行过程
a 函数有一个形参i,调用 a 函数时传入实参 10,形参 i=10
第一个 console.log 把形参 i 的值 10 输出
第二个 console.log 把 arguments[0] 输出,应该也是 i
接着定义个局部变量 i 并赋值为2,这时候局部变量 i=2
第三个 console.log 就把局部变量 i 的值 2 输出
第四个console.log再次把 arguments[0] 输出
这里好像能说明局部变量 i 和形参 i 的值引用是相同啊!!当形参 i,argument[0]为10时,内部变量i也是10,当内部变量i重新定义却赋值为10,形参 i也变为10。哦,原来局部变量 i 和形参 i 指向了同一个存储地址!
喂!等等,你这个专题好像说词法作用域啊,怎么一直说一些有的没的?
好,我们先做完第三题先
/*全局(window)域下的一段代码*/
var i=10;
function a() {
console.log(i);
var i = 2;
console.log(i);
};
a();
疑问:上面的代码又会输出什么呢?
答案:undefined , 2
第一个console.log输出undefined
第二个console.log输出 2
思考:为什么运行结果第一个不是10,而是undefined,第2个是2不是10?
这里肯定有同学问,因为函数内部存在变量提升啊,这么简单的问题。我想说,你说的对,但是还未够深入,为什么函数解析会有变量提升,我就这样问你。不如让我们把 JS 引擎对一个方法的解析过程进行一个稍微深入理解吧!!
1.代码解析过程
总所周知,javascript是解析类语言,而解析类语言的的执行顺序一般为: 通过词法分析和语法分析得到语法分析树后,就可以开始解释执行了。
这里简单说一下javascript的执行过程(以多个script代码段为例)
1.读入第一个代码段(js执行引擎并非一行一行地执行程序,而是一段一段地分析执行的)
2.当读取了第一个代码段之后,javascript引擎就对着代码段进行语法分析和词法分析,如果有错则报语法错误(比如括号不匹配等),并跳转到步骤5
3.对【var,let,const】变量和【function】定义做“预解析“。(这就是变量为什么会提升的原因,先被与解析,然后在执行)
4.执行代码段,有错则报错(比如变量未定义)
5.如果还有下一个代码段,则读入下一个代码段,重复步骤2
6.ending
2.特殊说明
这里我先提一个假设,我们都知道window/global(node.js),这个最顶层的域,在我们打开网页的那一刻。它好像一个被立刻执行的匿名函数,提早预定一些变量和方法等等。我们暂且这样理解,window是一个立刻执行函数,里面定义着我们所写的源码,其它方法则是在被显示调用的时候才被执行
3、关键步骤
这里我们再理一理javascript的执行步骤
javascript解析:就是通过语法分析,词法分析和预解析构造合法的语法分析树。
javascript执行:执行具体的某个function,JS引擎在执行每个函数实例时,都会创建一个执行环境(ExecutionContext)和活动对象(activeObject)(它们属于宿主对象,与函数实例的生命周期保持一致)
4、关键概念
到这里,我们再更强调以下一些概念,这些概念都会在下面用一个一个的实体来表示,便于大家理解
语法分析树(SyntaxTree)
可以直观地表示这段代码的相关信息,具体的实现就是JS引擎创建了一些表,用来记录每个方法内的变量集(variables),方法集(functions)和作用域(scope)等。(是不是跟v-dom树有点相似,纪录每个dom的相关信息)
执行环境(ExecutionContext)
我们可以理解为一个纪录当前执行的方法的(外部信息描述对象),,记录所执行方法的类型,名称,参数和活动对象(activeObject)。
活动对象(activeObject)
可理解为一个记录当前执行的方法【内部执行信息】的对象,记录内部变量集(variables)、内嵌函数集(functions)、实参(arguments)、作用域链(scopeChain)等执行所需信息,其中内部变量集(variables)、内嵌函数集(functions)是直接从第一步建立的语法分析树复制过来的。
作用域链(scopeChain)
词法作用域的实现机制就是作用域链(scopeChain)。作用域链是一套按名称查找(Name Lookup)的机制,首先在当前执行环境的 ActiveObject 中寻找,没找到,则顺着作用域链到父 ActiveObject 中寻找,一直找到全局调用对象(Global Object)。
词法作用域
变量的作用域是在定义时决定而不是执行时决定,也就是说词法作用域取决于源码,通过静态分析就能确定,因此词法作用域也叫做静态作用域。
5.例子表示
javascript解析模拟
估计,看了这么多非人话的信息,大家会很朦胧吧,什么是语法分析树,语法分析树到底长什么样子,作用域链又怎么实现的,活动对象又有什么内容等等,还是不是太清晰,下面我们就通过一段实际的代码来模拟整个解析过程,我们就把语法分析树,活动对象实实在在的创建出来,理解作用域,作用域链的到底是怎么实现的
例子代码
/*全局(window)域下的一段代码*/
var i = 1,j = 2,k = 3;
function a(o,p,x,q){
var x = 4;
console.log(i);
function b(r,s) {
var i = 11,y = 5;
console.log(i);
function c(t){
var z = 6;
console.log(i);
};
//函数表达式
var d = function(){
console.log(y);
};
c(60);
d();
};
b(40,50);
}
a(10,20,30);
1.构造语法分析树
下面我们以一种简单的结构:一个 JS 对象(为了清晰表示个各种对象间的引用关系,这里的只是伪对象表示,可能无法运行)来描述语法分析树(这是我们比较熟悉的,实际结构我们不去深究,肯定复杂得多,这里是为了帮助理解解析过程)。
我们回顾一下语法分析树纪录了什么?
答:变量集,方法集,作用域,好,我们开始造一颗语法分析树
/**
* 模拟建立一棵语法分析树,存储function内的变量和方法
*/
const SyntaxTree = {
// 全局对象在语法分析树中的表示
window: {
variables:{
i:{ value:1},
j:{ value:2},
k:{ value:3}
},
functions:{
a: this.a
}
},
a:{
variables:{
x:'undefined'
},
functions:{
b: this.b
},
scope: this.window
}
b:{
variables:{
y:'undefined'
},
functions:{
c: this.c,
d: this.d
},
scope: this.a
},
c:{
variables:{
z:'undefined'
},
functions:{},
scope: this.b
},
d:{
variables:{},
functions:{},
scope: this.b
}
}
上面就是关于语法分析树的一个简单表示,正如我们前面分析的,语法分析树主要记录了每个 function 中的变量集(variables),方法集(functions)和作用域(scope)。
语法分析树关键点
1.变量集(variables)中,只有变量定义,没有变量值,这时候的变量值全部为“undefined”
2.作用域(scope),根据词法作用域的特点,这个时候每个变量的作用域就已经明确了,而不会随执行时的环境而改变。【什么意思呢?就是我们经常将一个方法 return 回去,然后在另外一个方法中去执行,执行时,方法中变量的作用域是按照方法定义时的作用域走。其实这里想表达的意思就是不管你在多么复杂,多么远的地方执行该方法,最终判断方法中变量能否被访问还是得回到方法定义时的地方查证】
2.构造执行环境
语法分析完成,开始执行代码。我们调用每一个方法的时候,JS 引擎都会自动为其建立一个执行环境和一个活动对象,它们和方法实例的生命周期保持一致,为方法执行提供必要的执行支持
我们回顾一下执行环境纪录了什么?
答:执行环境纪录了我们的方法的类型,名称,参数和活动对象
/**
* 执行环境:函数执行时创建的执行环境
*/
const ExecutionContext = {
window: {
type: 'global',
name: 'global',
body: ActiveObject.window
},
a:{
type: 'function',
name: 'a',
body: ActiveObject.a,
scopeChain: this.window.body
},
b:{
type: 'function',
name: 'b',
body: ActiveObject.b,
scopeChain: this.a.body
},
c:{
type: 'function',
name: 'c',
body: ActiveObject.c,
scopeChain: this.b.body
},
d:{
type: 'function',
name: 'd',
body: ActiveObject.d,
scopeChain: this.b.body
}
}
上面每一个方法的执行环境都存储了相应方法的类型(function)、方法名称(funcName)、活动对象(ActiveObject)、作用域链(scopeChain)等信息,其关键点如下:
body属性,直接指向当前方法的活动对象
scopeChain属性, scopeChain属性,作用域链,它是一个链表结构,根据语法分析树中当前方法对应的scope属性,它指向scope对应的方法的活动对象(ActivceObject),变量查找就是跟着这条链条查找的
3.构造活动对象
我们回顾一下构造活动对象纪录了什么?
答:内部变量集(variables)、内嵌函数集(functions) 实参(arguments)、作用域链(scopeChain)。
const ActiveObject = {
window: {
variables:{
i: { value:1},
j: { value:2},
k: { value:3}
},
functions:{
a: this.a
}
},
a:{
variables:{
x: {value:4}//与内部实参同一引用
},
functions:{
b: SyntaxTree.b
},
parameters:{
o: {value: 10},
p: {value: 20},
x: this.variables.x,//与内部变量同一引用
q: 'undefined'
},
arguments:[this.parameters.o,this.parameters.p,this.parameters.x]
},
b:{
variables:{
y:{ value:5}
},
functions:{
c: SyntaxTree.c,
d: SyntaxTree.d
},
parameters:{
r:{value:40},
s:{value:50}
},
arguments:[this.parameters.r,this.parameters.s]
},
c:{
variables:{
z:{ value:6}
},
functions:{},
parameters:{
u:{value:70}
},
arguments:[this.parameters.u]
},
d:{
variables:{},
functions:{},
parameters:{},
arguments:[]
}
}
1.上面每一个活动对象都存储了相应方法的内部变量集(variables)、内嵌函数集(functions)、形参(parameters)、实参(arguments)等执行所需信息。
2.创建活动对象,从语法分析树复制方法的内部变量集(variables)和内嵌函数集(functions)
方法开始执行,活动对象里的内部变量集全部被重置为 undefined
- 创建形参(parameters)和实参(arguments)对象,同名的实参,形参和变量之间是【引用】关系
执行方法内的赋值语句,这才会对变量集中的变量进行赋值处理
4.变量查找规则是首先在当前执行环境的 ActiveObject 中寻找,没找到,则顺着执行环境中属性 ScopeChain 指向的ActiveObject 中寻找,一直到 Global Object(window)
- 方法执行完成后,内部变量值不会被重置,至于变量什么时候被销毁,请参考下面一条
方法内变量的生存周期取决于方法实例是否存在活动引用,如没有就销毁活动对象
这里我们就可以对面试几乎必定会问的闭包进行一翻详尽的解析:
面试官:请解析一下闭包是什么?
萌新:要了解闭包是什么,我们首先要知道javascript的执行步骤分为两步,第一是javascript解析,通过语法分析,词法分析,预解析构造语法分析树(SyntaxTree),
然后当我们执行某个function时,js引擎在执行当前函数时,会分别创造一个执行环境(ExecutionContext)和对应活动对象(activeObject),当函数从内部返回一个引用供外部变量使用时(这个引用常见的就是返回一个function),而这个外部变量所引用的就是当前返回引用可以访问的作用域区域,而这个作用域区域就是这个引用可以访问的对应的所有活动对象(activeObject),而这个活动对象(activeObject)的总集就是我们平时说的闭包。
而内存泄漏就是,外部变量持续访问这个返回的引用,导致这个活动对象(activeObject)的总集所对应的函数周期未能结束,因此导致这些活动对象和执行环境未能被js的垃圾回收机制回收和销毁,所以导致内存泄漏。
哈哈,好像太长,我都觉得我自己啰嗦,虽然还能更详细的说明,还是算了。哈哈
总结
以上是我在学习和使用了JS一段时间后,为了更深入的了解它, 也为了更好的把握对它的应用, 从而在对闭包的学习过程中,自己对于词法作用域的一些理解和总结,中间可能有一些地方和真实的JS解释引擎有差异