函数执行

JS函数执行机制

前几天看到一片js深入的系列文,写到我心巴上,把作用域链这部分整理了一下,搞了这篇出来😊

1. 词法作用域

虽然js是一门动态的语言,但是它对于作用域的审查理念却是静态的

来看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let value = 1;

function foo() {
console.log(value);
}

function bar() {
let value = 2;
foo();
}

bar();

// 结果是 ???

我们先来假设:

  • 如果js采用的是静态检查,那么调用bar的时候,会调用foo,而foo中会使用value这个变量,而foo中并未定义,那么就会**沿着这个定义的位置,向上寻找value,如果一直查到顶部作用域global,仍未找到,就throw ReferenceError**,在这里,顶部作用域定义了value为1,因此会打印出1;

  • 而如果是动态检查,就会从foo调用的位置往外层作用域找,找到的自然就是2

来看看吧👀

1
1

前边也说了,js的作用域是静态的,所以显然会输出1,而这个作用域,就叫做词法作用域

前面我标中的部分,也就是js处理作用域的方式(理论方式,接下来我会从代码层面讲讲它的实现,但是现在先不急,我们先再来看一个例子☝️,来自我们亲爱的🦏书)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 第一个例子
let scope = "global scope";
function checkscope(){
let scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();

//第二个例子
let scope = "global scope";
function checkscope(){
let scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

猜猜这两段代码分别会输出什么?

揭晓答案😘:均会输出local scope

但是二者其实还是有点区别的,现在告诉你们,他们的执行上下文栈会有区别,但是有什么区别呢?我们接下来好好唠唠第一个重点:执行上下文栈

2. 执行上下文栈🤪

在这我们要好好唠唠js的函数执行,不光是执行上下文栈哦,

但还是来看看执行上下文,特别简单,就是执行函数的静态作用域链。主要由三部分组成:

  • 变量对象(Variable Object)
  • 作用域链
  • this

首先,js执行函数分为两个部分:

  • 准备部分:把变量、函数都定义好,放在AO/VO中,以备后用
  • 执行部分:执行函数,更改AO/VO的值

至于AO/VO是什么,别急,下面会讲👇,现在只用知道它分为两个阶段就行咯

2.1 执行部分

举个🌰先:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}

function fun2() {
fun3();
}

function fun1() {
fun2();
}

fun1();

执行这段代码的时候,会发生什么?

我们一步一步来看,只看fun1的执行,假设执行上下文栈为EStack,首先得有个全局上下文

1
2
3
EStack:[
global
]

先执行fun1,把fun1入栈

1
2
3
4
EStack:[
fun1,
global
]

发现fun2被调用了,fun2入栈

1
2
3
4
5
EStack:[
fun2,
fun1,
global
]

发现fun3被调用了,fun3入栈

1
2
3
4
5
6
EStack:[
fun3,
fun2,
fun1,
global
]

fun3执行完,出栈,之后fun2,fun1依次出栈

简单吧😇,再来看看上边的例子咯,这个例子要一直用到结尾,贯穿全文的线索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 第一版的栈
EStack: [
f,
checkscope,
global
]
// 随后返回执行的f,f出栈,随后checkscope出栈

// 第二版的栈
EStack: [
checkscope,
global
]
// 由于返回的是f函数,因此栈先这样
// 之后checkscope执行完毕,出栈,调用f,f入栈
EStack: [
f,
global
]

看出来了吧,执行上下文栈还是有很大区别的,但是这还没完,这只是执行部分哦,还有准备部分,来看看吧

2.2 准备部分

准备部分要涉及到 变量对象(VO)的知识,我们下面来讲,别着急,现在只需要知道,它会把变量什么的都定义好

3. 变量对象🤨

前面卖了这么久的关子,那这个VO是个什么呢?ECMA上是这么定义的:

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明。

每个执行上下文都会有自己的变量对象,而因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象函数上下文下的变量对象

在全局上下文中,变量对象就是VO本身,也就是全局变量本身,在浏览器中,全局变量被具化为window;而函数中,则需要活化,变成AO(Activated Object),才能算是一个真正的变量对象

那么执行过程是怎么样的呢,简单写写:

进入执行上下文

当进入执行上下文时,这时候还没有执行代码,

变量对象会包括(次序不能乱!!!):

  1. 函数的所有形参 (如果是函数上下文)
    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  2. 函数声明
    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  3. 变量声明
    • 由名称和对应值(undefined)组成一个变量对象的属性被创建;
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

由于这个次序问题,也可以引出很多奇怪的问题,比如:

1
2
3
4
5
6
7
console.log(typeof fun1)
function fun1() {}
var fun1 = 2

// 会输出什么?
// 如果把 第二行改成 var fun1 = function() {} 呢?
// 如果把 var 改成 let 呢?

但是你可能会好奇,那值什么时候加进来呢?别急,马上就讲


进入上下文的时候,首先会初始化一个变量对象

  • 如果是全局环境,就把所有全局定义的变量拉过来初始化;
  • 如果是函数环境,就按上边说的,把参数拿来初始化

举个🌰:

1
2
3
4
5
6
7
8
9
10
function foo(a) {
var b = 2;
function c() {}
var d = function() {};

b = 3;

}

foo(1);

进入foo执行上下文后,会激活foo的变量对象,并拿arguments初始化它,就是上边讲的三步依次执行得到如下的AO

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c() {}
d: undefined
}

执行代码

逐行依次执行,更新AO

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}

到这一步,AO就算构造完毕了,就要把它扔到函数的[[scope]]中,也就是接下来要讲的作用域链,但是先别急,来看个例子先:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 第一版
function foo() {
console.log(a);
a = 1;
}

foo(); // ???

// 第二版
function bar() {
a = 1;
console.log(a);
}
bar(); // ???

这两个会有什么差别吗

4. 作用域链😏

每个函数都有一个[[scope]]属性,用来存放作用域链,其实说白了,还记得咱们一开始讲的词法作用域吗?就是这玩意啦

1
2
3
4
5
6
7
 
function gg() {
function bond() {
...
}
}

就像这个函数,初始化的时候是这样的:

gg.[[scope]] = [globalContext.VO]

bond.[[scope]] = [ggContext.AO, global.VO]

之后,函数被调用,激活的时候,就会把当前的AO推入到执行上下文的作用域链中

这么说可能有点抽象,来看🌰:

1
2
3
4
5
6
7
8
9
10
11
// 还记得它不

let scope = "global scope";
function checkscope(){
let scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();

首先,进入全局上下文EStack推入globalContext并且初始化globalContext

1
2
3
4
5
6
7
8
9
10
11
// EStack
EStack = [
globalContext
]

// globalContext
globalContext = {
VO: [global],
this: globalContext.VO,
scope: [globalContext.VO]
}

遇到checkscope,也推进去,同时把当前作用域保存到[[scope]]

1
2
3
4
5
6
7
// EStack
EStack = [
checkScopeContext,
globalContext
]
// checkscope
checkscope.[[scope]] = [globalContext.VO]

然后,把scope传给checkscopeContext,并用arguments初始化checkscopeContext(忘了的自己回去看):

1
2
3
4
5
6
7
8
9
10
11
checkscopeContext = {
scope: checkscope.[[scope]],
this: undefined,
AO: {
arguments: {
length: 0
},
scope: undefined,
f: references to function f
}
}

AO创建完毕,把AO压到scope的最顶端:

1
2
3
4
5
6
7
8
9
10
11
checkscopeContext = {
scope: AO.concat(checkscope.[[scope]]),
this: undefined,
AO: {
arguments: {
length: 0
},
scope: undefined,
f: references to function f
}
}

执行checkscope函数,此时的Context:

1
2
3
4
5
6
7
8
9
10
11
checkscopeContext = {
scope: AO.concat(checkscope.[[scope]]),
this: undefined,
AO: {
arguments: {
length: 0
},
scope: "local scope",
f: references to function f
}
}

之后发现要执行f,就把f也压进EStack,同样的,fContext最终为:

1
2
3
4
5
6
7
8
9
fContext = {
scope: AO.concat(checkscopeContext.AO),
this: fscope.[[scope]],
AO: {
arguments: {
length: 0
},
}
}

注意,这里的f中本身未定义scope,是通过作用域链向上寻找找到的, 上一层是checkscope,所以最后会打印出localscope

之后返回f()f出栈,checkscope出栈

第二段代码的实现就交给大家咯,不清楚没关系,下一节也要讲🤣

1
2
3
4
5
6
7
8
9
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();

4. 闭包

经过上边的解释,闭包就很好理解了,无非就是引用上层变量嘛,其实说白了,任何函数其实都可以说是一个闭包,看这个:

1
2
3
4
5
6
7
8
9
10
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}

checkscope()();

分析一下不难发现,在f函数调用的时候,checkscope已经被弹出了,但是为什么他仍然能引用到checkscope的东西呢,原因就在scope上。

除了调用栈,js中还可以通过scope访问上层变量,这也是js闭包形成的原因

话不多说,看题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}

data[0]();
data[1]();
data[2]();

// 输出什么?
// 如果把 for 中的 var 变成 let 呢?

这里要涉及到一个很黑的知识点:var没有块级作用域

因此i的值存在global中,在执行函数之前,globalContext是这样的

1
2
3
4
5
6
globalContext = {
VO: {
data: [...],
i: 3
}
}

functionscope是这样的:

1
2
3
data[0]Context = {
Scope: [AO,forContext, globalContext.VO]
}

因此答案就呼之欲出咯:

1
2
3
3
3
3

那如果要改成 let,let是有块级作用域的,因此,每次循环都会保留一份i的副本在forContext中,因此会打印出:

1
2
3
0
1
2

那现在请思考:

1
2
3
4
5
6
7
8
9
10
11
12
13
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}

data[0]();
data[1]();
data[2]();

这样会输出什么?

其实道理和let相似,你不是没有块级作用域吗,那我就想办法让你有呗,咋整?

那不是传入的参数会被用来初始化吗,而js中所有传参都是值传递,那这不就结了吗,再搞个函数,把i当参数穿进去,这不就相当于有了一个有变量的块作用域了吗?因此,会输出

1
2
3
0
1
2

5. 总结🤓

  • 先进入全局上下文,把所有全局变量放到global.VO中,之后一行行扫描,其实也可以把global看作特殊的函数
  • 之后扫描遇到的函数声明,先把[[scope]]定义好
  • 调用的时候,把Context推入调用栈,用arguments初始化AO
  • 执行函数,更改AO的引用
  • 执行完毕,推出栈

最后还有一道看到的有意思的题目,与上边无关嗷,看看咯:

1
2
3
4
5
6
7
8
9
10
11
12
function Foo(){
getName = function(){
console.log(1);
};
return this
}

function getName(){
console.log(5);
}

Foo().getName();

函数执行
http://baidu.com/2024/03/16/scope_chain/
作者
KB
发布于
2024年3月16日
许可协议