作用域
【工作模式】
作用域共有两种主要的工作模式:
- 词法作用域,被大多数编程语言所采用
- 动态作用域,Bash 脚本、Perl 中的一些模式
大部分标准语言编译器的第一个工作阶段叫做词法化(或单词化)。词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
词法作用域就是定义在词法阶段的作用域。词法作用域是写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况如此)。
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
【欺骗词法】
其实可以在运行时来“修改”(或欺骗)词法作用域。JS 中有两种机制来实现这个目的,但是欺骗词法作用域会导致性能下降。
1、eval
2、with
var obj = {
a: 1,
b: 2,
c: 3
}
// 单调乏味重复obj
obj.a = 2
obj.b = 3
obj.c = 4
// 简单的快捷方式
with(obj) {
a = 3
b = 4
c = 5
}
function foo(obj) {
with(obj) {
a = 2
}
}
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) // a 被泄漏到全局作用域上了!!!
词法环境是JS 引擎内部用来跟踪标识符与特定变量之间的映射关系。词法环境是JS 作用域的内部实现机制,人们通常称为作用域(scopes)。
无论何时创建函数,都会创建一个与之相关联的词法环境,并存储在名为[[Environment]] 的内部属性上(也就是说无法直接访问或操作)。
无论何时调用函数,都会创建一个新的执行环境,被推入执行上下文栈。此外,还会创建一个与之相关联的词法环境。JS 引擎将调用函数的内置[[Envrionment]] 属性与创建函数时的环境进行关联。
作用域是在运行时代码中的某些特定部分中变量,函数和对象的可访问性,它决定了代码区块中变量和其他资源的可见性。
ES6 之前 JavaScript 没有块级作用域,只有全局作用域和函数作用域。
全局作用域
全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
- 最外层函数和在最外层函数外面定义的变量拥有全局作用域
- 所有末定义直接赋值的变量自动声明为拥有全局作用域
- 所有 window 对象的属性拥有全局作用域
函数作用域
函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁(闭包除外)。
var a = 1
function f1() {
var a = 2
function f2() {
var a = 3
console.log(a) // 3
}
}
// f1 的作用域指向有全局作用域(window)和它本身,而f2 的作用域指向全局作用域(window)、f1 和它本身。而且作用域是从最底层向上找,直到找到全局作用域window 为止,如果全局还没有的话就会报错。
var i = 1
function b() {
console.log(i)
}
function a() {
var i = 2
b()
}
a()
// 1
// b 函数是在全局作用域中定义的,虽然在a 函数内调用,但是它只能访问到全局的作用域而不能访问到a 函数的作用域。
var scope = 'global scope'
function a(){
function b(){
console.log(scope)
}
return b
var scope = 'local scope'
}
a()() // undefined
// 虽然var 声明是在return 语句后面,但还是会提升到a 函数作用域的顶部,然后又因为作用域是在函数定义的时候确定的,与调用位置无关,所以b 的上层作用域是a 函数,scope 在b 自身的作用域里没有找到,向上查找找到了自动提升的并且未赋值的scope 变量,所以打印出undefined。
块级作用域
ES6 提供了块级作用域,可通过新增命令 let 和 const 来体现。
function bar() {
var myName = "极客世界"
let test1 = 100
if (1) {
let myName = "Chrome浏览器"
console.log(test)
}
}
function foo() {
var myName = "极客邦"
let test = 2
{
let test = 3
bar()
}
}
var myName = "极客时间"
let myAge = 10
let test = 1
foo()
// 1
// 首先是在 bar 函数的执行上下文中查找,但因为 bar 函数的执行上下文中没有定义 test 变量,所以根据词法作用域的规则,下一步就在 bar 函数的外部作用域中查找,也就是全局作用域。
作用域链
在当前作用域中查找所需变量,但是该作用域没有这个变量,那这个变量就是自由变量。如果在自己作用域找不到该变量就去父级作用域查找,依次向上级作用域查找,直到访问到window 对象,这一层层的关系就是作用域链。
作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问,通过作用域链,可以访问到外层环境的变量和函数。
在JavaScript 执行过程中,其作用域链是由词法作用域决定的。
词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
function bar() {
console.log(myName)
}
function foo() {
var myName = "城邦"
bar()
}
var myName = "国家"
foo()
// 国家
// foo 函数调用了 bar 函数,那为什么 bar 函数的外部引用是全局执行上下文,而不是 foo 函数的执行上下文?
// 这是因为根据词法作用域,foo 和 bar 的上级作用域都是全局作用域,所以如果 foo 或者 bar 函数使用了一个它们没有定义的变量,那么它们会到全局作用域去查找。
自由变量
当前作用域没有定义的变量,成为自由变量 。
自由变量的取值:要到创建这个函数的那个域中取值,这里强调的是“创建”,而不是“调用”。
一个作用域下可能包含若干个上下文环境。 有可能从来没有过上下文环境(函数从来就没有被调用过);有可能有过,现在函数被调用完毕后,上下文环境被销毁了;有可能同时存在一个或多个(闭包)。同一个作用域下,不同的调用会产生不同的执行上下文环境,继而产生不同的变量的值。
性能调优
JavaScript 的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:
-
解释阶段:
- 词法分析
- 语法分析
- 作用域规则确定
-
执行阶段:
- 创建执行上下文
- 执行函数代码
- 垃圾回收
JavaScript 解释阶段便会确定作用域规则,因此作用域
在函数定义时就已经确定了,而不是在函数调用时确定。
但是执行上下文
是函数执行之前创建的。执行上下文最明显的就是 this 的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。
两者最大的区别: 执行上下文在运行时确定,随时可能改变;作用域在定义时就确定,并且不会改变。
执行上下文
执行上下文是内部的JS 概念,JS 引擎使用使用执行上下文来跟踪函数的执行。
执行JavaScript 代码时,JavaScript 引擎会创建一个执行上下文,它是JS 执行一段代码时的运行环境。
- 全局执行上下文:当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
- 函数内部执行上下文:当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
- 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。
调用栈(执行上下文栈)
用来管理函数调用关系的一种数据结构。
var a = 2
function add(){
var b = 10
return a + b
}
add()
在执行到函数 add() 之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量。
执行上下文准备好之后,便开始执行全局代码,当执行到 add 时,JavaScript 判断这是一个函数调用,那么将执行以下操作:
- 首先,从全局执行上下文中,取出 add 函数代码。
- 其次,对 add 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。最后,执行代码,输出结果。
当执行到 add 函数的时候,就有了两个执行上下文了——全局执行上下文和 add 函数的执行上下文。
在执行 JavaScript 时,可能会存在多个执行上下文,JavaScript 引擎是通过一种叫栈的数据结构来管理这些执行上下文的。JavaScript 引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈。
var a = 2
function add(b,c){
return b+c
}
function addAll(b,c){
var d = 10
result = add(b,c)
return a+result+d
}
addAll(3,6)
代码执行过程中,调用栈的状态变化情况:
- 第一步,创建全局上下文,并将其压入栈底。
变量a、函数add 和addAll 都保存到了全局上下文的变量环境对象中。
全局执行上下文压入到调用栈后,JavaScript 引擎便开始执行全局代码了。首先会执行 a=2 的赋值操作,执行该语句会将全局上下文变量环境中 a 的值设置为 2。
第二步是调用 addAll 函数。当调用该函数时,JavaScript 引擎会编译该函数,并为其创建一个执行上下文,最后还将该函数的执行上下文压入栈中。
addAll 函数的执行上下文创建好之后,便进入了函数代码的执行阶段了,这里先执行的是 d=10 的赋值操作,执行语句会将 addAll 函数执行上下文中的 d 由 undefined 变成了 10。
第三步,当执行到 add 函数调用语句时,同样会为其创建执行上下文,并将其压入调用栈。
当 add 函数返回时,该函数的执行上下文就会从栈顶弹出,并将 result 的值设置为 add 函数的返回值,也就是 9。
紧接着 addAll 执行最后一个相加操作后并返回,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。
至此,整个 JavaScript 流程执行结束了。
正常来讲,在函数执行完毕并出栈时,函数内的局部变量在下一个垃圾回收(GC)节点会被回收,该函数对应的执行上下文将会被销毁,这也正是在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数才可以访问该变量,该变量会在预编译阶段被创建,在执行阶段被激活,在函数执行完毕后,其相关上下文会被销毁。
在开发中,如何利用好调用栈?
-
浏览器查看调用栈的信息:开发者工具 --- source -- call stack:打断点、console.trace()
-
栈溢出(stack overflow):调用栈是有大小的,当入栈的执行上下文超过一定数目,JavaScript 引擎就会报错。
- 调用栈有两个指标,最大栈容量和最大调用深度,满足其中任意一个就会栈溢出。
- 递归代码中常见:超过了最大栈调用大小(Maximum call stack size exceeded)。
执行上下文的产生
creation phase
The execution context is created during the creation phase. Following things happen during the creation phase:
- LexicalEnvironment component is created.
- VariableEnvironment component is created.
ExecutionContext = {
LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>,
}
Lexical Environment
A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code. A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment.
A lexical environment is a structure that holds identifier-variable mapping. (here identifier refers to the name of variables/functions, and the variable is the reference to actual object [including function object and array object] or primitive value).
var a = 20
var b = 40
function foo() {
console.log('bar')
}
lexicalEnvironment = {
a: 20,
b: 40,
foo: <ref. to foo function>
}
Each Lexical Environment has three components:
- Environment Record
- Reference to the outer environment
- This binding
Environment Record
The environment record is the place where the variable and function declarations are stored inside the lexical environment.
There are also two types of environment record :
- Declarative environment record — As its name suggests stores variable and function declarations. The lexical environment for function code contains a declarative environment record.
- Object environment record — The lexical environment for global code contains a objective environment record. Apart from variable and function declarations, the object environment record also stores a global binding object (window object in browsers). So for each of binding object’s property (in case of browsers, it contains properties and methods provided by browser to the window object), a new entry is created in the record.
Note — For the function code, the environment record also contains an arguments
object that contains the mapping between indexes and arguments passed to the function and the length(number) of the arguments passed into the function. For example, an argument object for the below function looks like this:
function foo(a, b) {
var c = a + b
}
foo(2, 3)
// argument object
Arguments: {0: 2, 1: 3, length: 2}
Reference to the Outer Environment
The reference to the outer environment means it has access to its outer lexical environment. That means that the JavaScript engine can look for variables inside the outer environment if they are not found in the current lexical environment.
在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,把这个外部引用称为outer。
This Binding
In this component, the value of this
is determined or set.
In the global execution context, the value of this
refers to the global object. (in browsers, this
refers to the Window Object).
In the function execution context, the value of this
depends on how the function is called. If it is called by an object reference, then the value of this
is set to that object, otherwise, the value of this
is set to the global object or undefined
(in strict mode).
const person = {
name: 'peter',
birthYear: 1994,
calcAge: function() {
console.log(2018 - this.birthYear);
}
}
person.calcAge();
// 'this' refers to 'person', because 'calcAge' was called with //'person' object reference
const calculateAge = person.calcAge
calculateAge()
// 'this' refers to the global window object, because no object reference was given
Variable Environment
It’s also a Lexical Environment whose EnvironmentRecord holds bindings created by VariableStatements within this execution context. It has all the properties and components of a lexical environment as defined above.
In ES6, one difference between LexicalEnvironment component and the VariableEnvironment component is that the former is used to store function declaration and variable (let
and const
) bindings, while the latter is used to store the variable (var)
bindings only.
Execution Phase
In this phase assignments to all those variables are done and the code is finally executed.
let a = 20
const b = 30
var c
function multiply(e, f) {
var g = 20
return e * f * g
}
c = multiply(20, 30)
When the above code is executed, the JavaScript engine creates a global execution context to execute the global code. So the global execution context will look something like this during the creation phase:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
},
outer: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
},
outer: <null>,
ThisBinding: <Global Object>
}
}
During the execution phase, the variable assignments are done. So the global execution context will look something like this during the execution phase.
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: 20,
b: 30,
multiply: < func >
},
outer: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
},
outer: <null>,
ThisBinding: <Global Object>
}
}
When a call to function multiply(20, 30)
is encountered, a new function execution context is created to execute the function code. So the function execution context will look something like this during the creation phase:
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
g: undefined
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
After this, the execution context goes through the execution phase that means assignments to the variables inside the function are done. So the function execution context will look something like this during the execution phase:
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
g: 20
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
After the function completes, the returned value is stored inside c
. So the global lexical environment is updated. After that, the global code completes and the program finishes.
Note — As you might have noticed that the let
and const
defined variables do not have any value associated with them during the creation phase, but var
defined variables are set to undefined
.
This is because, during the creation phase, the code is scanned for variable and function declarations, while the function declaration is stored in its entirety in the environment, the variables are initially set to undefined
(in case of var
) or remain uninitialized (in case of let
and const
).
This is the reason why you can access var
defined variables before they are declared (though undefined
) but get a reference error when accessing let
and const
variables before they are declared.
This is, what we call hoisting.
Note — During the execution phase, if the JavaScript engine couldn’t find the value of let
variable at the actual place it was declared in the source code, then it will assign it the value of undefined
.
当页面加载完毕后(含有需要执行的JavaScript 代码),JavaScript 引擎会做:
- 创建一个全局的执行上下文(
this
指向window); - 每执行一个JavaScript 函数,都会创建一个对应的执行上下文;
- 函数里面可能执行嵌套函数......继续创建子函数的执行上下文;
- 最终,会创建出一个栈,当前作用域在栈顶,全局作用域在栈底;
栈顶的函数会最先运行,运行完毕后出栈,继续运行一下个函数......直到栈清空。
全局上下文是最外层的上下文。根据ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一样。
在浏览器中,全局上下文就是window 对象,因此所有通过var 定义的全局变量和函数都会成为window 对象的属性和方法。
使用let 和const 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。
上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器。)
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript 程序的执行流就是通过这个上下文栈进行控制的。
上下文中的代码在执行的时候会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object,自ES5 之后,规范中就不再存在活跃对象的定义。而是指其从ES3 延续下来的抽象含义,其意类似于活跃记录(activation record)或栈帧(stack frame) )用作变量对象。活动对象最初只有一个定义变量:arguments。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。全局上下文的变量对象始终是作用域链的最后一个变量对象。
每个执行上下文都有一个与之关联的作用域链。
当函数被创建时,JavaScript 引擎会把创建时执行上下文的作用域链赋给函数内部属性[Scope]
。然后,函数被执行,JavaScript 引擎创建一个活动对象(Active object),添加到作用域链顶部。
function add(num1, num2){
let sum = num1 + num2;
return sum;
}
let total = add(5, 10);
执行上面的JavaScript 代码,但还没有执行add
函数时,add 函数scope chain
只有一个值,指向global object
。 然后,执行add 函数,一个活动对象被创建,并且被加到scope chain顶部。由此,执行add 函数时,一个两层的作用域链被建立。
小贴士
无论是全局对象还是活动对象,都会在初始化时给
this, arguments
赋值;也会给局部变量,局部参数赋值。显而易见,add 函数被执行时,需要寻找num1 和num2 的值做计算。 如果在顶层作用域找不到这两个值,那么,JavaScript 引擎会沿着作用域链,在下一层活动对象/全局对象中查找......找到即返回,找不到继续往下......直到全局对象window。
性能优化:尽可能使用局部变量
如果在越靠近栈顶的对象中,可以找到当前函数执行时所需的变量,那么函数执行速度是最快的。
也就是说,读取变量值的总耗时随着查找作用域链的逐层深入而不断增加!
因此,为了写出更高效的JavaScript 代码,尽可能在函数内部使用局部变量。
function createChild(elemID) {
let element = document.getElementById(elemID) // 在global 对象中查找document
let newElem = document.createElement('div') // 在global 对象中查找document
element.appendChild(newElem) //总计查找两次
}
// 修改后
function createChild(elemID) {
let doc = document // 在global 对象中查找document
let element = doc.getElementById(elemID)
let newElem = doc.createElement('div')
element.appendChild(newElem) //总计查找一次
}
【参考资料】
《JS 忍者秘籍》第2版 章节5
《你不知道的JavaScript》上卷 第1部分
《极客时间:浏览器工作原理与实践》