执行环境及作用域

  • 2021.05.06

执行环境(execution context,为简单起见,有时也称为“环境”)是 JavaScript 中最为重要的一个概念。执行环境定义了变量函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

执行环境

  1. 全局执行环境:

这是默认或者说是最基础的执行上下文,一个程序中只会存在一个全局上下文,它在整个 javascript 脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁。全局上下文会生成一个全局对象(以浏览器环境为例,这个全局对象是 window),并且将 this 值绑定到这个全局对象上。

  1. 函数执行环境:

每当一个函数被调用时,都会创建一个新的函数执行上下文(不管这个函数是不是被重复调用的)。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,将其环境弹出,把控制权返回给之前的执行环境

  1. Eval 函数执行上下文:

执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于并不经常使用 eval,所以在这里不做分析。

TIP

  • 当函数运行的时候,会生成一个叫做 “执行上下文” 的东西,也可以叫做执行环境,它用于保存函数运行时需要的一些信息,它是一个栈结构数据,全局上下文永远在该栈的最底部,每当一个函数执行生成了新的上下文,该上下文对象就会被压入栈,但是上下文栈有容量限制,如果超出容量就会栈溢出。

  • 执行上下文内部存储了包括:变量对象作用域链this 指向 这些函数运行时的必须数据。

  • 变量对象构建的过程中会触发变量和函数的声明提升。

  • 函数内部代码执行时,会先访问本地的变量对象去尝试获取变量,找不到的话就会攀爬作用域链层层寻找,找到目标变量则返回,找不到则 undefined

// 示例代码
var name = "Jack";
function func() {
  console.log(name); // 访问全局作用域
}

function func2() {
  var name = "啊哈哈";
  console.log(name); // 访问函数内部作用域
}

func(); // Jack
func2(); // 啊哈哈
  • 一个函数能够访问到的上层作用域,在函数创建的时候就已经被确定且保存在函数的 [[scope]] 属性里,和函数拿到哪里去执行没有关系。

  • 一个函数调用时的 this 指向,取决于它的调用者,通常有以下几种方式可以改变函数的 this 值:对象调用callbindapply

  • 在 Web 浏览器中,全局执行环境被认为是 window 对象,因此所有全局变量和函数都是作为 window 对象的属性和方法创建的。

  • 某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退出——例如关闭网页或浏览器时才会被销毁)。

作用域链

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)

作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。

如果这个环境是函数,则将其活动对象(activation object)作为变量对象。活动对象在最开始时只包含一个变量,即 arguments 对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延 续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生)。

延长作用域链

虽然执行环境的类型总共只有两种——全局和局部(函数),但还是有其他办法来延长作用域链。这么说是因为有些语句可以在作用域链的前端临时增加一个变量对象,该变量对象会在代码执行后被移除。

  1. try-catch 语句的 catch 块;
  2. with 语句。

词法作用域

词法作用域(Lexical Scopes)javascript 中使用的作用域类型,词法作用域 也可以被叫做 静态作用域,与之相对的还有 动态作用域。那么 javascript 使用的 词法作用域动态作用域 的区别是什么呢?看下面这段代码:

var value = 1;

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

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

bar();

// 结果是: 1

上面这段代码中,一共有三个作用域:

  • 全局作用域
  • foo 的函数作用域
  • bar 的函数作用域

一直到这边都好理解,可是 foo 里访问了本地作用域中没有的变量 value 。根据前面说的,引擎为了拿到这个变量就要去 foo 的上层作用域查询,那么 foo 的上层作用域是什么呢?是它 调用时 所在的 bar 作用域?还是它 定义时 所在的全局作用域?

这个关键的问题就是 javascript 中的作用域类型——词法作用域

TIP

词法作用域,就意味着函数被定义的时候,它的作用域就已经确定了,和拿到哪里执行没有关系,因此词法作用域也被称为 静态作用域

如果是动态作用域类型,那么上面的代码运行结果应该是 bar 作用域中的 2 。也许你会好奇什么语言是动态作用域?bash 就是动态作用域,感兴趣的小伙伴可以了解一下。

块级作用域

什么是块级作用域呢?简单来说,花括号内 {...} 的区域就是块级作用域区域。

很多语言本身都是支持块级作用域的。上面我们说,javascript 中大部分情况下,只有两种作用域类型:全局作用域函数作用域,那么 javascript 中有没有块级作用域呢?来看下面的代码:

if (true) {
  var a = 1;
}

console.log(a); // 结果:1

运行后会发现,结果还是 1,花括号内定义并赋值的 a 变量跑到全局了。这足以说明,javascript 不是原生支持块级作用域的。

TIP

但是 ES6 标准提出了使用 letconst 代替 var 关键字,来创建块级作用域。也就是说,上述代码改成如下方式,块级作用域是有效的:

if (true) {
  let a = 1;
}

console.log(a); // ReferenceError

创建作用域

在 javascript 中,我们有几种创建 / 改变作用域的手段:

1. 定义函数,创建函数作用(推荐):

function foo() {
  // 创建了一个 foo 的函数作用域
}

2. 使用 let 和 const 创建块级作用域(推荐):

for (let i = 0; i < 5; i++) {
  console.log(i);
}

console.log(i); // ReferenceError

3. try catch 创建作用域(不推荐),err 仅存在于 catch 子句中:

try {
  undefined(); // 强制产生异常
} catch (err) {
  console.log(err); // TypeError: undefined is not a function
}

console.log(err); // ReferenceError: `err` not found

4. 使用 eval “欺骗” 词法作用域(不推荐):

function foo(str, a) {
  eval(str);
  console.log(a, b);
}

var b = 2;

foo("var b = 3;", 1); // 1 3

5. 使用 with 欺骗词法作用域(不推荐):

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); // 2 -- 全局作用域被泄漏了!

作用域的应用场景

作用域的一个常见运用场景之一,就是 模块化

由于 javascript 并未原生支持模块化导致了很多令人口吐芬芳的问题,比如全局作用域污染和变量名冲突,代码结构臃肿且复用性不高。在正式的模块化方案出台之前,开发者为了解决这类问题,想到了使用函数作用域来创建模块的方案。

function module1() {
  var a = 1;
  console.log(a);
}

function module2() {
  var a = 2;
  console.log(a);
}

module1(); // => 1
module2(); // => 2

上面的代码中,构建了 module1module2 两个代表模块的函数,两个函数内分别定义了一个同名变量 a ,由于函数作用域的隔离性质,这两个变量被保存在不同的作用域中(不嵌套),JS 引擎在执行这两个函数时会去不同的作用域中读取,并且外部作用域无法访问到函数内部的 a 变量。这样一来就巧妙地解决了 全局作用域污染变量名冲突 的问题;并且,由于函数的包裹写法,这种方式看起来封装性好多了。

然而上面的函数声明式写法,看起来还是有些冗余,更重要的是,module1module2 的函数名本身就已经对全局作用域造成了污染。我们来继续改写:

// module1.js
(function() {
  var a = 1;
  console.log(a);
})();

// module2.js
(function() {
  var a = 2;
  console.log(a);
})();

将函数声明改写成立即调用函数表达式(Immediately Invoked Function Expression 简写 IIFE),封装性更好,代码也更简洁,解决了模块名污染全局作用域的问题。

TIP

函数声明和函数表达式,最简单的区分方法,就是看是不是 function 关键字开头:是 function 开头的就是函数声明,否则就是函数表达式。

上面的代码采用了 IIFE 的写法,已经进化很多了,我们可以再把它强化一下,强化成后浪版,赋予它判断外部环境的权利——选择的权力

(function (global) {
  if (global...) {
    // is browser
  } else if (global...) {
    // is nodejs
  }
})(window);
上次更新时间: 2021-06-07 09:34:00