问题
今天看笔记发现自己之前记了一个关于同名标识符优先级的内容,具体是下面这样的:
- 形参优先级高于当前函数名,低于内部函数名
- 形参优先级高于
arguments
- 形参优先级高于只声明却未赋值的局部变量,但是低于声明且赋值的局部变量
- 函数和变量都会声明提升,函数名和变量名同名时,函数名的优先级要高。执行代码时,同名函数会覆盖只声明却未赋值的变量,但是它不能覆盖声明且赋值的变量
- 局部变量也会声明提升,可以先使用后声明,不影响外部同名变量
然后我就想,为什么会有这样的优先级呢,规定的?但是好像没有这个规定,于是开始查阅资料,就有了下文
初识Execution Context
Execution Context
是Javascript
中一个抽象概念,它定义了变量或函数有权访问的其他数据,决定了它们各自的行为。为了便于理解,我们可以近似将其等同于执行当前代码的环境,JavaScript
的可执行代码包括
- 全局代码
- 函数代码
-
eval()
代码
每当执行流转到这些可执行代码时,就会“新建”一个Execution Context
并进入该Execution Context
在上图中,共有4个Execution Context
,其中有一个是Global Execution Context
(有且仅有一个),还有三个Function Execution Context
再识Execution Context Stack
浏览器中的JavaScript
解释器是单线程的,每次创建并进入一个新的Execution Context
时,这个Execution Context
就会被推(push
)进一个环境栈中,这个栈称为Execution Context Stack
,当当前Execution Context
的代码执行完之后,栈又会将其弹(pop
)出,并销毁这个Execution Context
,保存在其中的变量及函数定义也随之被销毁,然后把控制权返回给之前的Execution Context
(Global Execution Context
例外,它要等到应用程序退出后 —— 如关闭网页或浏览器 —— 才会被销毁)
JavaScript
的执行流就是由这个机制控制的,以下面的代码为例说明:
var sayHello = 'Hello';
function name(){
var fisrtName = 'Cao',
lastName = 'Cshine';
function getFirstName(){
return fisrtName;
}
function getLatName(){
return lastName;
}
console.log(sayHello + getFirstName() + ' ' + getLastName());
}
name();
- 当浏览器第一次加载
script
的时候,默认会进入Global Execution Context
,所以Global Execution Context
永远是在栈的最下面。 - 然后遇到函数调用
name()
,此时新建并进入Function Execution Context name
,Function Execution Context name
入栈; - 继续执行遇到函数调用
getFirstName()
,于是新建并进入Function Execution Context getFirstName
,Function Execution Context getFirstName
入栈,由于该函数内部不会再新建其他Execution Context
,所以直接执行完毕,然后出栈,控制权交给Function Execution Context name
; - 再往下执行遇到函数调用
getLastName()
,于是新建并进入Function Execution Context getLastName
,Function Execution Context getLastName
入栈,由于该函数内部不会再新建其他Execution Context
,所以直接执行完毕,然后出栈,控制权交给Function Execution Context name
; - 执行完
console
后,函数name
也执行完毕,于是出栈,控制权交给Function Execution Context name
,至此栈中又只有Global Execution Context
了 -
关于
Execution Context Stack
有5个关键点:- 单线程
- 同步执行(非异步)
- 1个
Global Execution Context
- 无限制的函数
Function Execution Context
-
每个函数调用都会创建新的
Execution Context
,即使是自己调用自己,如下面的代码:(function foo(i) { if (i === 3) { return; } else { foo(++i); } }(0));
Execution Context Stack
的情况如下图所示:
亲密接触Execution Context
每个Execution Context
在概念上可以看成由下面三者组成:
- 变量对象(
Variable object
,简称VO
) - 作用域链(
Scope Chain
) this
变量对象(Variable object
)
该对象与Execution Context
相关联,保存着Execution Context
中定义的所有变量、函数声明以及函数形参,这个对象我们无法访问,但是解析器在后台处理数据是用到它(注意函数表达式以及没用var/let/const
声明的变量不在VO
中)
Global Execution Context
中的变量对象VO
根据宿主环境的不同而不同,在浏览器中为window
对象,因此所有的全局变量和函数都是作为window
对象的属性和方法创建的。
对于Function Execution Context
,变量对象VO
为函数的活动对象,活动对象是在进入Function Execution Context
时创建的,它通过函数的arguments
属性初始化,也就是最初只包含arguments
这一个属性。
在JavaScript
解释器内部,每次调用Execution Context
都会经历下面两个阶段:
-
创建阶段(发生在函数调用时,但是内部代码执行前,这将解释声明提升现象)
- 创建作用域链(作用域链见下文)
- 创建变量对象
VO
- 确定
this
的值
-
激活/代码执行阶段
- 变量赋值、执行代码
其中创建阶段的第二步创建变量对象VO
的过程可以理解成下面这样:
- (
Global Execution Context
中没有这一步) 创建arguments
对象,扫描函数的所有形参,并将形参名称 和对应值组成的键值对作为变量对象VO
的属性。如果没有传递对应的实参,将undefined
作为对应值。如果形参名为arguments
,将覆盖arguments
对象 -
扫描
Execution Context
中所有的函数声明(注意是函数声明,函数表达式不算)- 将函数名和对应值(指向内存中该函数的引用指针)组成组成的键值对作为变量对象
VO
的属性 - 如果变量对象
VO
已经存在同名的属性,则覆盖这个属性
- 将函数名和对应值(指向内存中该函数的引用指针)组成组成的键值对作为变量对象
-
扫描
Execution Context
中所有的变量声明- 由变量名和对应值(此时为
undefined
) 组成,作为变量对象的属性 - 如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性。
- 由变量名和对应值(此时为
好~~现在我们来看代码捋一遍:
function foo(num) {
console.log(num);// 66
console.log(a);// undefined
console.log(b);// undefined
console.log(fc);// f function fc() {}
var a = 'hello';
var b = function fb() {};
function fc() {}
}
foo(66);
-
当调用foo(66)时,创建阶段时,
Execution Context
可以理解成下面这个样子fooExecutionContext = { scopeChain: { ... }, variableObject: { arguments: { 0: 66, length: 1 }, num: 66, fc: pointer to function fc() a: undefined, b: undefined }, this: { ... } }
-
当创建阶段完成以后,执行流进入函数内部,激活执行阶段,然后代码完成执行,
Execution Context
可以理解成下面这个样子:fooExecutionContext = { scopeChain: { ... }, variableObject: { arguments: { 0: 66, length: 1 }, num: 66, fc: pointer to function fc() a: 'hello', b: pointer to function fb() }, this: { ... } }
作用域链(Scope Chain
)
当代码在一个Execution Context
中执行时,就会创建变量对象的一个作用域链,作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问
Global Execution Context
中的作用域链只有Global Execution Context
的变量对象(也就是window
对象),而Function Execution Context
中的作用域链还会有“父”Execution Context
的变量对象,这里就会要牵扯到[[Scopes]]
属性,可以将函数作用域链理解为---- 当前Function Execution Context
的变量对象VO
(也就是该函数的活动对象AO
) + [[Scopes]]
,怎么理解呢,我们继续往下看
[[Scopes]]
属性
[[Scopes]]
这个属性与函数的作用域链有着密不可分的关系,JavaScript
中每个函数都表示为一个函数对象,[[Scopes]]
是函数对象的一个内部属性,只有JavaScript
引擎可以访问。
结合函数的生命周期:
-
函数定义
-
[[Scopes]]
属性在函数定义时被存储,保持不变,直至函数被销毁 -
[[Scopes]]
属性链接到定义该函数的作用域链上,所以他保存的是所有包含该函数的 “父/祖父/曾祖父...”Execution Context
的变量对象(OV
),我们将其称为所有父变量对象(All POV
) - !!!特别注意
[[Scopes]]
是在定义一个函数的时候决定的
-
-
函数调用
- 函数调用时,会创建并进入一个新的
Function Execution Context
,根据前面讨论过的调用Function Execution Context
的两个阶段可知:先创建作用域链,这个创建过程会将该函数对象的[[Scopes]]
属性加入到其中 - 然后会创建该函数的活动对象
AO
(作为该Function Execution Context
的变量对象VO
),并将创建的这个活动对象AO
加到作用域链的最前端 - 然后确定
this
的值 - 正式执行函数内的代码
- 函数调用时,会创建并进入一个新的
通过上面的过程我们大概可以理解:作用域链 = 当前Function Execution Context
的变量对象VO
(也就是该函数的活动对象AO
) + [[Scopes]]
,有了这个作用域链, 在发生标识符解析的时候, 就会沿着作用域链一级一级地搜索标识符,最开始是搜索当前Function Execution Context
的变量对象VO
,如果没有找到,就会根据[[Scopes]]
找到父变量对象,然后继续搜索该父变量对象中是否有该标识符;如果仍没有找到,便会找到祖父变量对象并搜索其中是否有该标识符;如此一级级的搜索,直至找到标识符为止(如果直到最后也找不到,一般会报未定义的错误);注意:对于this
与arguments
,只会搜到其本身的变量(活动)对象为止,而不会继续按着作用域链搜素。
现在再结合例子来捋一遍:
var a = 10;
function foo(d) {
var b = 20;
function bar() {
var c = 30;
console.log(a + b + c + d); // 110
//这里可以访问a,b,c,d
}
//这里可以访问a,b,d 但是不能访问c
bar();
}
//这里只能访问a
foo(50);
-
当浏览器第一次加载script的时候,默认会进入
Global Execution Context
的创建阶段- 创建
Scope Chain
(作用域链) - 创建变量对象,此处为
window
对象。然后会扫描所有的全局函数声明,再扫描全局变量声明。之后该变量对象会加到Scope Chain
中 - 确定
this
的值 -
此时
Global Execution Context
可以表示为:globalEC = { scopeChain: { pointer to globalEC.VO }, VO: { a: undefined, foo: pointer to function foo(), (其他window属性) }, this: { ... } }
- 创建
-
接着进入
Global Execution Context
的执行阶段-
遇到赋值语句
var a = 10
,于是globalEC.VO.a = 10
;globalEC = { scopeChain: { pointer to globalEC.VO }, VO: { a: 10, foo: pointer to function foo(), (其他window属性) }, this: { ... } }
-
遇到
foo
函数定义语句,进入foo
函数的定义阶段,foo
的[[Scopes]]
属性被确定foo.[[Scopes]] = { pointer to globalEC.VO }
-
遇到
foo(50)
调用语句,进入foo
函数调用阶段,此时进入Function Execution Context foo
的创建阶段- 创建
Scope Chain
(作用域链) - 创建变量对象,此处为
foo
的活动对象。先创建arguments
对象,然后扫描函数的所有形参,之后会扫描foo
函数内所有的函数声明,再扫描foo
函数内的变量声明。之后该变量对象会加到Scope Chain
中 - 确定
this
的值 -
此时
Function Execution Context foo
可以表示为fooEC = { scopeChain: { pointer to fooEC.VO, foo.[[Scopes]] }, VO: { arguments: { 0: 66, length: 1 }, b: undefined, d: 50, bar: pointer to function bar(), }, this: { ... } }
- 创建
-
接着进入
Function Execution Context foo
的执行阶段-
遇到赋值语句
var b = 20;
,于是fooEC .VO.b = 20
fooEC = { scopeChain: { pointer to fooEC.VO, foo.[[Scopes]] }, VO: { arguments: { 0: 66, length: 1 }, b: 20, d: 50, bar: pointer to function bar(), }, this: { ... } }
-
遇到
bar
函数定义语句,进入bar
函数的定义阶段,bar
的[[Scopes]]
`属性被确定bar.[[Scopes]] = { pointer to fooEC.VO, pointer to globalEC.VO }
-
遇到
bar()
调用语句,进入bar
函数调用阶段,此时进入Function Execution Context bar
的创建阶段- 创建
Scope Chain
(作用域链) - 创建变量对象,此处为
bar
的活动对象。先创建arguments
对象,然后扫描函数的所有形参,之后会扫描foo
函数内所有的函数声明,再扫描bar
函数内的变量声明。之后该变量对象会加到Scope Chain
中 - 确定
this
的值 -
此时
Function Execution Context bar
可以表示为barEC = { scopeChain: { pointer to barEC.VO, bar.[[Scopes]] }, VO: { arguments: { length: 0 }, c: undefined }, this: { ... } }
- 创建
-
接着进入
Function Execution Context bar
的执行阶段-
遇到赋值语句
var c = 30
,于是barEC.VO.c = 30
;barEC = { scopeChain: { pointer to barEC.VO, bar.[[Scopes]] }, VO: { arguments: { length: 0 }, c: 30 }, this: { ... } }
-
遇到打印语句
console.log(a + b + c + d);
,需要访问变量a,b,c,d
- 通过
bar.[[Scopes]].globalEC.VO.a
访问得到a=10
- 通过
bar.[[Scopes]].fooEC.VO.b,bar.[[Scopes]].fooEC.VO.d
访问得到b=20,d=50
- 通过
barEC.VO.c
访问得到c=30
- 通过运算得出结果
110
- 通过
-
-
bar
函数执行完毕,Function Execution Context bar
销毁,变量c
也随之销毁
-
-
foo
函数执行完毕,Function Execution Context foo
销毁,b,d,bar
也随之销毁
-
- 所有代码执行完毕,等到该网页被关闭或者浏览器被关闭,
Global Execution Context
才销毁,a,foo
才会销毁
通过上面的例子,相信对Execution Context
和作用域链的理解也更清楚了,下面简单总结一下作用域链:
- 作用域链的前端始终是当前执行的代码所在
Execution Context
的变量对象; - 下一个变量对象来自其包含
Execution Context
,以此类推; - 最后一个变量对象始终是
Global Execution Context
的变量对象; - 内部
Execution Context
可通过作用域链访问外部Execution Context
;反之不可以; - 标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级的向后回溯,直到找到标识符为止(如果找不到,通常会导致错误);
- 作用域链的本质是一个指向变量对象的指针列表,只引用而不实际包含变量对象。
延长作用域链
下面两种语句可以在作用域链的前端临时增加一个变量对象以延长作用域链,该变量对象会在代码执行后被移除
-
try-catch
语句的catch
块
创建一个新的变量对象,其中包含的是被抛出的错误对象的声明 -
with
语句
将指定的对象添加到作用域链中function buildUrl(){ var qs = "?debug=true"; with(location){ var url = href + qs; } //console.log(href) 将会报href is not defined的错误,因为with语句执行完with创建的变量对象就被移除了 return url; }
with
语句接收window.location
对象,因此其变量对象就包含了window.location
对象的所有属性,而这个变量对象被添加到作用域链的前端。所以在with
语句里面使用href
相当于window.location.href
。
解答问题
现在我们来解答最开始的优先级问题
-
形参优先级高于当前函数名,低于内部函数名
function fn(fn){ console.log(fn);// cc } fn('cc');
函数
fn
属于Global Execution Context
,而形参fn
属于Function Execution Context fn
,此时作用域的前端是Function Execution Context fn
的变量对象,所以console.log(fn)
为形参的值function fa(fb){ console.log(fb);// ƒ fb(){} function fb(){} console.log(fb);// ƒ fb(){} } fa('aaa');
调用
fa
函数时,进入Function Execution Context fa
的创建阶段,根据前面所说的变量对象创建过程:先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
扫描函数声明时,如果变量对象VO
中已经存在同名的属性,则覆盖这个属性我们可以得到
fa
的变量对象表示为:fa.VO = { arguments: { 0:'aaa', length: 1 }, fb: pointer to function fb(), }
所以
console.log(fb)
得到的是fa.VO.fb
的值ƒ fb(){}
-
形参优先级高于
arguments
function fn(aa){ console.log(arguments);// Arguments ["hello world"] } fn('hello world'); function fn(arguments){ console.log(arguments);// hello world } fn('hello world');
调用
fn
函数时,进入Function Execution Context fn
的创建阶段,根据前面所说的变量对象创建过程:先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
先创建arguments对象,后扫描函数形参,如果形参名为arguments,将会覆盖arguments对象所以当形参名为
arguments
时,console.log(arguments)
为形参的值hello world
。 -
形参优先级高于只声明却未赋值的局部变量,但是低于声明且赋值的局部变量
function fa(aa){ console.log(aa);//aaaaa var aa; console.log(aa);//aaaaa } fa('aaaaa');
调用
fa
函数时,进入Function Execution Context fa
的创建阶段,根据前面所说的变量对象创建过程:先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
扫描函数内的变量声明时,如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性所以创建阶段之后
Function Execution Context fa
的变量对象表示为:fa.VO = { arguments: { 0:'aaaaa', length: 1 }, aa:'aaaaa', }
之后进入
Function Execution Context fa
的执行阶段:console.log(aa);
打印出fa.VO.aa
(形参aa
)的值aaaaa
;由于var aa;
仅声明而未赋值,所以不会改变fa.VO.aa
的值,所以下一个console.log(aa);
打印出的仍然是fa.VO.aa
(形参aa
)的值aaaaa
。function fb(bb){ console.log(bb);//bbbbb var bb = 'BBBBB'; console.log(bb);//BBBBB } fb('bbbbb');
调用
fb
函数时,进入Function Execution Context fb
的创建阶段,根据前面所说的变量对象创建过程:先创建arguments对象,然后扫描函数的所有形参,之后会扫描函数内所有的函数声明,再扫描函数内的变量声明;
扫描函数内的变量声明时,如果变量名与已经声明的形参或函数相同,此时什么都不会发生,变量声明不会干扰已经存在的这个同名属性所以创建阶段之后
Function Execution Context fb
的变量对象表示为:fb.VO = { arguments: { 0:'bbbbb', length: 1 }, bb:'bbbbb', }
之后进入
Function Execution Context fb
的执行阶段:console.log(bb);
打印出fb.VO.bb
(形参bb
)的值'bbbbb';遇到var bb = 'BBBBB';
,fb.VO.bb
的值将被赋为BBBBB
,所以下一个console.log(bb);
打印出fb.VO.bb
(局部变量bb
)的值BBBBB
。 -
函数和变量都会声明提升,函数名和变量名同名时,函数名的优先级要高。
console.log(cc);//ƒ cc(){} var cc = 1; function cc(){}
根据
Global Execution Context
的创建阶段中创建变量对象的过程:是先扫描函数声明,再扫描变量声明,且变量声明不会影响已存在的同名属性。所以在遇到var cc = 1;
这个声明语句之前,global.VO.cc
为ƒ cc(){}
。 -
执行代码时,同名函数会覆盖只声明却未赋值的变量,但是它不能覆盖声明且赋值的变量
var cc = 1; var dd; function cc(){} function dd(){} console.log(cc);//1 console.log(dd);//ƒ dd(){}
Global Execution Context
的创建阶段之后,Global Execution Context
的变量对象可以表示为:global.VO = { cc:pointer to function cc(), dd:pointer to function dd() }
然后进入
Global Execution Context
的执行阶段,遇到var cc = 1;
这个声明赋值语句后,global.VO.cc
将被赋值为1
;然后再遇到var dd
这个声明语句,由于仅声明未赋值,所以不改变global.VO.dd
的值;所以console.log(cc);
打印出1
,console.log(dd);
打印出ƒ dd(){}
- 局部变量也会声明提升,可以先使用后声明,不影响外部同名变量
每个Execution Context
都会有变量创建这个过程,所以会有声明提升;根据作用域链,如果局部变量与外部变量同名,那么最先找到的是局部变量,影响不到外部同名变量
相关资料
JavaScript基础系列---变量及其值类型
Understanding Scope in JavaScript
What is the Execution Context & Stack in JavaScript?
深入探讨JavaScript的执行环境和栈
作用域原理
JavaScript执行环境 + 变量对象 + 作用域链 + 闭包
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。