Javascript执行上下文和作用域
执行上下文
JavaScript 中的执行上下文(Execution Context)概括就是代码(全局代码、函数代码)执行前进行的准备工作,也称之为“执行上下文环境”。
是JavaScript 引擎创建的一个内部数据结构,用来管理函数执行过程中的变量、作用域、this 指向等信息。每当执行 JavaScript 代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,并将其推入执行上下文栈(Execution Context Stack,简称:执行栈,或函数调用栈)中。
JavaScript 中执行环境
- 全局环境
- 函数环境
- eval 函数环境 (已不推荐使用)
那么与之对应的执行上下文类型同样有 3 种:
- 全局执行上下文
- 函数执行上下文
- eval 函数执行上下文 (已不推荐使用)
JavaScript 运行时首先会进入全局环境,对应会生成全局上下文。程序代码中基本都会存在函数,那么调用函数,就会进入函数执行环境,对应就会生成该函数的执行上下文。
当代码中声明多个函数,对应的函数执行上下文也会存在多个,就通过执行上下文栈存取方式来管理执行上下文。
执行上下文栈(执行栈)
栈中放入/取出,称为入栈/出栈。
栈数据结构的特点:
- 后进先出,先进后出
- 出口在顶部,且仅有一个
执行栈中的执行上下文
程序执行进入一个执行环境时,它的执行上下文就会被创建,并被推入执行栈中(入栈);程序执行完成时,它的执行上下文就会被销毁,并从栈顶被推出(出栈),控制权交由下一个执行上下文。
因为JavaScript在执行代码时最先进入全局环境,所以处于栈底的永远是全局环境的执行上下文。而处于栈顶的是当前正在执行函数的执行上下文。当函数调用完成后,它就会从栈顶被推出(理想的情况下,闭包会阻止该操作,闭包在后面会进行仔细讲解);全局执行上下文当页面被关闭之后它才会从执行栈底被推出。
<script>
// 全局上下文global
var name = 'Tom';
function foo () {
// 函数上下文foo
function bar () {
// 函数上下文bar
console.log('function bar');
}
return bar();
}
foo();
</script>
在上面js代码中:
- 进入
script标签中即生成全局执行上下文 - 当调用foo时生成函数执行上下文foo Context
- 在函数foo中调用bar时,生成函数上下文bar Context
执行上下文的数量限制(执行栈溢出)
执行上下文可存在多个,虽然没有明确的数量限制,但如果超出栈分配的空间,会造成堆栈溢出。常见于递归调用,没有终止条件造成死循环的场景。
例:
function foo() {
foo(); // 递归调用自身
}
foo();
// 报错: Uncaught RangeError: Maximum call stack size exceeded
执行上下文的生命周期
开头介绍中我们有提到,运行JavaScript代码时,当代码执行进入一个环境时,就会为该环境创建一个执行上下文,它会在你运行代码前做一些准备工作。具体会做哪些准备工作,和执行上下文的生命周期有关。
执行上下文的生命周期有两个阶段:
- 创建阶段(
进入执行上下文):函数被调用时,进入函数环境,为其创建一个执行上下文,此时进入创建阶段。 - 执行阶段(代码
执行):执行函数中代码时,此时执行上下文进入执行阶段。
创建阶段
创建阶段要做的事情主要如下:
-
创建
变量对象(VO:variable object)- 确定函数的形参(并赋值)
- 函数环境会初始化创建 Arguments 对象(并赋值)
- 确定函数声明(并赋值)
- 变量声明,函数表达式声明(未赋值)
-
确定this指向(作为函数直接调用为window,作为方法调用指向调用者)
-
确定作用域(词法环境决定,哪里声明定义,就在哪里确定)
变量对象
当处于执行上下文的建立阶段时,我们可以将整个上下文环境看作是一个对象。该对象拥有 3 个属性,如下:
executionContext = { variableObject : {}, // 变量对象,里面包含 Arguments 对象,形式参数,函数和局部变量 scopeChain : {},// 用于解析变量和函数查找的规则,由当前执行环境的变量对象和所有外层执行环境的作用域链组成。 this : {}// 上下文中 this 的指向对象 }
在函数的建立阶段,首先会建立 Arguments 对象。然后确定形式参数,检查当前上下文中的函数声明,每找到一个函数声明,就在 VO对象 下面用函数名建立一个属性,属性值就指向该函数在内存中的地址的一个引用。
如果上述函数名已经存在于 VO对象 下面,那么对应的属性值会被新的引用给覆盖。
最后,是确定当前上下文中的局部变量,如果遇到和函数名同名的变量,则会忽略该变量(个人理解为函数声明优先级高,无法覆盖)。
执行阶段
- 变量对象赋值
- 变量赋值
- 函数表达式赋值
- 调用函数
- 顺序执行其它代码
举例
const foo = function(i){
var a = 'Hello';
var b = function b() {}; // 函数表达式
function c() {}; // 函数声明
}
foo(10);
在调用函数foo时,执行上下文foo Context创建阶段:
foo Context = {
variableObject: {
arguments: [10], // 确定 Arguments 对象
i: 10, // 确定参数
c: Function c, // 确定函数声明
a: undefined, // 变量声明
b: undefined, // 变量声明
},
scopeChain: {}, // 作用域,在函数foo声明时已经创建
this: {}, // 作为函数直接调用,指向window
}
在建立阶段,除了确定Arguments,形参,函数的声明,并赋予了具体的属性值外,其它的变量属性默认的都是undefined。并且函数声明的的提升是在变量的上面的。这其实也就解释了变量提升的原理。
一旦上述创建阶段结束,引擎就会进入代码执行阶段,这个阶段完成后,上述执行上下文对象如下,变量会被赋上具体的值。
在调用函数foo时,进入执行上下文foo Context执行阶段:
foo Context = {
variableObject: {
arguments: [10], // 确定 Arguments 对象
i: 10, // 确定参数
c: Function c, // 确定函数声明
a: 'Hello', // 变量声明
b: Function b, // 变量声明
},
scopeChain: {}, // 作用域,在函数foo声明时已经创建
this: {}, // 作为函数直接调用,指向window
}
我们看到,只有在代码执行阶段,局部变量才会被赋予具体的值。
运用
理解了执行期上下文,一些面试中询问输出的题就变得极其简单:
(function () {
console.log(typeof foo); // Function foo
console.log(typeof bar); // undefined
var foo = 'Hello';
var bar = function () { // bar被赋值 function(){}
return 'World';
}
function foo() {
return 'good';
}
console.log(foo, typeof foo); // Hello, string (被var foo覆盖)
})()
总结
JavaScript 中的执行上下文(Execution Context)概括就是代码(全局代码、函数代码)执行时进行的准备工作。当代码执行进入一个环境时,就会为该环境创建一个执行上下文,用来管理函数执行过程中的变量、作用域、this 指向等信息。
作用域
JavaScript 中的作用域(Scope)是运行时代码中某些特定部分中变量,函数和对象的可访问性。
例:
function test() {
var name = 'Tom';
}
test();
console.log(name); // ReferenceError: name is not defined
在函数test声明name变量,在函数外部无法访问。
作用域就是一个独立的地盘,让变量不会外泄、暴露出去。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
作用域分类
全局作用域
在代码中任何地方都能访问到的对象拥有全局作用域,一般来说以下几种情形拥有全局作用域:
- 最外层函数和在最外层函数外面定义的变量拥有全局作用域
<script>
var outVariable = '我是最外层变量'; // 最外层变量
function outFun() { // 最外层函数
var inVariable = "内层变量";
function innerFun() { // 内层函数
console.log(inVariable);
}
innerFun();
}
console.log(outVariable); // 我是最外层变量
outFun(); // 外层函数
console.log(inVariable); // inVariable is not defined
innerFun(); // innerFun is not defined
</script>
- 所有未定义直接赋值的变量自动声明为拥有全局作用域
<script>
function outFun() {
variable = '未定义直接赋值的变量';
var inVariable2 = '内层变量2';
}
outFun();// 需要要先执行这个函数
console.log(variable); //未定义直接赋值的变量全局可访问
console.log(inVariable2); //inVariable2 is not defined
</script>
- 所有
window对象的属性拥有全局作用域
一般情况下,window对象的内置属性都拥有全局作用域,例如 window.location 等。
全局作用域有个弊端:如果我们写了很多行JS代码,变量定义都没有用函数包括,那么它们就全部都在全局作用域中。
例如开发中两个程序员在一个全局作用域中,命名了相同的变量这样就会污染全局命名空间,容易引起命名冲突(后者变量值覆盖前者)。
// A写的代码中
var name = 'Tom';
// B写的代码中
var name = 'Sam';
函数作用域
函数作用域,是指声明在函数内部的变量,和全局作用域相反,局部作用域一般只在固定的代码片段内可访问到,最常见的例如函数内部。
function outFun() {
var name = 'Tom';
function inFun() {}
console.log(name); // Tom
inFun();
}
console.log(name); // name is not defined
inFun(); // inFun is not defined
例如jQuery库的源码,所有的代码都会放在(function(){....})( ) 中。
因为放在里面的所有变量,都不会被外泄和暴露,不会污染到外面,不会对其他的库或者 JS 脚本造成影响。这是函数作用域的一个体现。
块级作用域
块级作用域可通过ES6新增命令 let 和 const 声明,所声明的变量在指定块的作用域外无法被访问。
块级作用域在如下情况被创建:
- 在一个函数内部
function test() {
// 有let命令声明
let name = 'Tom';
}
- 在一个代码块(由一对花括号
{}包裹)内部
遇到花括号,使用了let/const就会创建一个块级作用域,花括号结束,销毁块级作用域
{
// 有let命令声明
let name = 'Tom';
}
let 声明的语法与 var 的语法一致。你基本上可以用 let 来代替 var 进行变量声明,但会将变量的作用域限制在当前代码块中。块级作用域有以下几个特点:
- 声明变量不会提升到代码块顶部
let、const 声明并不会被提升到当前代码块的顶部(上文执行上下文准备阶段提及),因此你需要手动将let、const声明放置到顶部,以便让变量在整个代码块内部可用。
例如:
function getName(condition) {
// name 在此处 Cannot access 'name' before initialization
if (condition) {
let name = 'Tom';
return name;
} else {
// name 在此处 Cannot access 'name' before initialization
return null;
}
// name 在此处 Cannot access 'name' before initialization
}
- 禁止重复声明
如果一个标识符已经在代码块内部被定义,那么在此代码块内使用同一个标识符进行 let、const 声明就会导致抛出错误。
例如:
var name = 'Tom';
let name = 'Sam'; // SyntaxError: Identifier 'name' has already been declared
let name = 'Tom';
let name = 'Sam'; // SyntaxError: Identifier 'name' has already been declared
var name = 'Tom';
const name = 'Sam'; // SyntaxError: Identifier 'name' has already been declared
在示例中:name 变量被声明了两次,let、const 不能在同一作用域内重复声明一个已有标识符,就会抛出错误。但如果在嵌套的作用域内使用 let、const 声明一个同名的新变量,则不会抛出错误。
var name = 'Tom';
if (condition) {
let name = 'Sam'; // 不会抛出错误
}
- 循环中的绑定块作用域的妙用
开发者可能最希望实现 for 循环的块级作用域了,因为可以把声明的计数器变量限制在循环内。
在循环中,用let声明的循环变量,会特殊处理,每次进入循环体,都会开启一个新的作用域,并且将循环变量绑定到该作用域(每次循环,使用的是一个全新的循环变量);在循环中使用let声明的循环变量,在循环结束后会销毁。
作用域分层
作用域是分层的,内层作用域可以访问外层作用域的变量,反之则不行。
// 全局作用域
var out = 'out';
function test() {
// 函数作用域
var func = 'function';
console.log(out); // out
}
{
var block2 = 'block2';
// let块级作用域
let block = 'block';
console.log(out); // out
console.log(func); // func is not defined
}
console.log(out); // out
console.log(func); // func is not defined
console.log(block2); // block2
console.log(block); // block is not defined
值得注意的是:块语句(大括号“{ }”中间的语句),如 *if* 和 *switch* 条件语句或 *for* 和 *while* 循环语句,不像函数,它们不会创建一个新的作用域。区块中存在let和const命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域,称为“暂时性死区”,所以块级作用域只跟let、const声明的变/常量有关,。
暂时性死区
只要存在
let、const命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
作用域链
首先认识一下什么叫做自由变量 。
自由变量
当前的作用域中使用的没有定义某个变量。
什么是作用域链
当前的作用域中使用的没有定义某个变量,一层一层向外层作用域寻找,直到找到全局作用域还是没找到,就宣布放弃。这种一层一层的关系,就是作用域链 。
var a = 100;
function test() {
var b = 200;
function test2() {
var c = 300;
console.log(a); // 100 自由变量,顺作用域链向外层作用域找
console.log(b); // 200 自由变量,顺作用域链向外层作用域找
console.log(c); // 300 本作用域的变量
}
test2();
}
test();
自由变量的取值
关于自由变量的值,上文提到要到外层作用域中取,有时候这会理解错误。
例如:
var x = 10
function out() {
console.log(x)
}
function test(f) {
var x = 20;
(function () {
f() // 10,而不是 20
})()
}
test(out)
在 out 函数中,取自由变量 x 的值时,要到哪个作用域中取 ?
要到 创建out 函数的那个作用域中取,无论 out 函数将在哪里调用。这就是所谓的"静态作用域"。
再来看一个例子:
const name = 'Tom';
const test = function () {
console.log(name);
};
(function () {
const name = 'Sam';
test(); // Tom
})();
在本示例中,最终打印的结果为 Tom。因为对于 test 函数来说,创建该函数时它的父级上下文为全局上下文,所以 name 的值为 Tom。
如果我们将代码稍作修改,改成如下:
const name = 'Tom';
(function () {
const name = 'Sam';
const test = function () {
console.log(name);
};
eat(); // Sam
})();
这个时候,打印出来的值就为 Sam。因为对于 test 函数来讲,创建它的时候父级上下文为 立即执行函数,所以 name 的值为 Sam。
作用域与执行上下文
上文分开介绍执行上下文和作用域的概念,可能存在误认为它们是相同的概念,但事实并非如此。
我们知道 JavaScript 属于解释型语言,JavaScript 的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样。
解释阶段
- 词法分析
- 语法分析
- 作用域规则确定
执行阶段
- 创建执行上下文
- 执行函数代码
- 垃圾回收
JavaScript 解释阶段便会确定作用域规则,因此作用域在函数定义时就已经确定了,而不是在函数调用时确定,但是执行上下文是函数执行之前创建的。
执行上下文最明显的就是 this 的指向是执行时确定的。而作用域访问的变量是编写代码的结构确定的。
作用域和执行上下文之间最大的区别是:
执行上下文在运行时确定,随时可能改变,作用域在定义时就确定,并且不会改变。