函数执行
JS函数执行机制
前几天看到一片js深入的系列文,写到我心巴上,把作用域链这部分整理了一下,搞了这篇出来😊
1. 词法作用域
虽然js是一门动态的语言,但是它对于作用域的审查理念却是静态的
来看代码:
1 | |
我们先来假设:
如果js采用的是静态检查,那么调用
bar的时候,会调用foo,而foo中会使用value这个变量,而foo中并未定义,那么就会**沿着这个定义的位置,向上寻找value,如果一直查到顶部作用域global,仍未找到,就throw ReferenceError**,在这里,顶部作用域定义了value为1,因此会打印出1;而如果是动态检查,就会从
foo调用的位置往外层作用域找,找到的自然就是2
来看看吧👀
1 | |
前边也说了,js的作用域是静态的,所以显然会输出1,而这个作用域,就叫做词法作用域
前面我标中的部分,也就是js处理作用域的方式(理论方式,接下来我会从代码层面讲讲它的实现,但是现在先不急,我们先再来看一个例子☝️,来自我们亲爱的🦏书)
1 | |
猜猜这两段代码分别会输出什么?
揭晓答案😘:均会输出local scope
但是二者其实还是有点区别的,现在告诉你们,他们的执行上下文栈会有区别,但是有什么区别呢?我们接下来好好唠唠第一个重点:执行上下文栈
2. 执行上下文栈🤪
在这我们要好好唠唠js的函数执行,不光是执行上下文栈哦,
但还是来看看执行上下文,特别简单,就是执行函数的静态作用域链。主要由三部分组成:
- 变量对象(Variable Object)
- 作用域链
- this
首先,js执行函数分为两个部分:
准备部分:把变量、函数都定义好,放在AO/VO中,以备后用执行部分:执行函数,更改AO/VO的值
至于AO/VO是什么,别急,下面会讲👇,现在只用知道它分为两个阶段就行咯
2.1 执行部分
举个🌰先:
1 | |
执行这段代码的时候,会发生什么?
我们一步一步来看,只看fun1的执行,假设执行上下文栈为EStack,首先得有个全局上下文
1 | |
先执行fun1,把fun1入栈
1 | |
发现fun2被调用了,fun2入栈
1 | |
发现fun3被调用了,fun3入栈
1 | |
fun3执行完,出栈,之后fun2,fun1依次出栈
简单吧😇,再来看看上边的例子咯,这个例子要一直用到结尾,贯穿全文的线索
1 | |
看出来了吧,执行上下文栈还是有很大区别的,但是这还没完,这只是执行部分哦,还有准备部分,来看看吧
2.2 准备部分
准备部分要涉及到 变量对象(VO)的知识,我们下面来讲,别着急,现在只需要知道,它会把变量什么的都定义好
3. 变量对象🤨
前面卖了这么久的关子,那这个VO是个什么呢?ECMA上是这么定义的:
变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。
每个执行上下文都会有自己的变量对象,而因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象
在全局上下文中,变量对象就是VO本身,也就是全局变量本身,在浏览器中,全局变量被具化为window;而函数中,则需要活化,变成AO(Activated Object),才能算是一个真正的变量对象
那么执行过程是怎么样的呢,简单写写:
进入执行上下文
当进入执行上下文时,这时候还没有执行代码,
变量对象会包括(次序不能乱!!!):
- 函数的所有形参 (如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建
- 没有实参,属性值设为 undefined
- 函数声明
- 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
- 如果变量对象已经存在相同名称的属性,则完全替换这个属性
- 变量声明
- 由名称和对应值(undefined)组成一个变量对象的属性被创建;
- 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
由于这个次序问题,也可以引出很多奇怪的问题,比如:
1 | |
但是你可能会好奇,那值什么时候加进来呢?别急,马上就讲
进入上下文的时候,首先会初始化一个变量对象
- 如果是
全局环境,就把所有全局定义的变量拉过来初始化; - 如果是
函数环境,就按上边说的,把参数拿来初始化
举个🌰:
1 | |
进入foo执行上下文后,会激活foo的变量对象,并拿arguments初始化它,就是上边讲的三步依次执行得到如下的AO:
1 | |
执行代码
逐行依次执行,更新AO
1 | |
到这一步,AO就算构造完毕了,就要把它扔到函数的[[scope]]中,也就是接下来要讲的作用域链,但是先别急,来看个例子先:
1 | |
这两个会有什么差别吗
4. 作用域链😏
每个函数都有一个[[scope]]属性,用来存放作用域链,其实说白了,还记得咱们一开始讲的词法作用域吗?就是这玩意啦
1 | |
就像这个函数,初始化的时候是这样的:
gg.[[scope]] = [globalContext.VO]
bond.[[scope]] = [ggContext.AO, global.VO]
之后,函数被调用,激活的时候,就会把当前的AO推入到执行上下文的作用域链中
这么说可能有点抽象,来看🌰:
1 | |
首先,进入全局上下文,EStack推入globalContext并且初始化globalContext
1 | |
遇到checkscope,也推进去,同时把当前作用域保存到[[scope]]中
1 | |
然后,把scope传给checkscopeContext,并用arguments初始化checkscopeContext(忘了的自己回去看):
1 | |
等AO创建完毕,把AO压到scope的最顶端:
1 | |
执行checkscope函数,此时的Context:
1 | |
之后发现要执行f,就把f也压进EStack,同样的,fContext最终为:
1 | |
注意,这里的f中本身未定义scope,是通过作用域链向上寻找找到的, 上一层是checkscope,所以最后会打印出localscope
之后返回f(),f出栈,checkscope出栈
第二段代码的实现就交给大家咯,不清楚没关系,下一节也要讲🤣
1 | |
4. 闭包
经过上边的解释,闭包就很好理解了,无非就是引用上层变量嘛,其实说白了,任何函数其实都可以说是一个闭包,看这个:
1 | |
分析一下不难发现,在f函数调用的时候,checkscope已经被弹出了,但是为什么他仍然能引用到checkscope的东西呢,原因就在scope上。
除了调用栈,js中还可以通过scope访问上层变量,这也是js闭包形成的原因
话不多说,看题:
1 | |
这里要涉及到一个很黑的知识点:var没有块级作用域
因此i的值存在global中,在执行函数之前,globalContext是这样的
1 | |
而function的scope是这样的:
1 | |
因此答案就呼之欲出咯:
1 | |
那如果要改成 let,let是有块级作用域的,因此,每次循环都会保留一份i的副本在forContext中,因此会打印出:
1 | |
那现在请思考:
1 | |
这样会输出什么?
其实道理和let相似,你不是没有块级作用域吗,那我就想办法让你有呗,咋整?
那不是传入的参数会被用来初始化吗,而js中所有传参都是值传递,那这不就结了吗,再搞个函数,把i当参数穿进去,这不就相当于有了一个有变量的块作用域了吗?因此,会输出
1 | |
5. 总结🤓
- 先进入全局上下文,把所有全局变量放到
global.VO中,之后一行行扫描,其实也可以把global看作特殊的函数 - 之后扫描遇到的函数声明,先把
[[scope]]定义好 - 调用的时候,把
Context推入调用栈,用arguments初始化AO - 执行函数,更改
AO的引用 - 执行完毕,推出栈
最后还有一道看到的有意思的题目,与上边无关嗷,看看咯:
1 | |