JavaScript 作用域与声明提升

Author: Guang

我们在JavaScript起源一篇中提到过,var声明变量的作用域问题是一个经典的JavaScript声明提升问题。在我们讨论JavaScript声明提升之前,需要讨论清楚JavaScript作用域及作用域链的概念。

作用域

作用域是高级编程语言中通用的概念。作用域可以看作当前执行上下文,值和表达式在执行上下文范围进行绑定以提供访问性,这个绑定我们称为变量绑定(name binding)。在作用域之外,值和表达式的绑定失效,对外部不可见。下面代码展示了作用域的隔离效果。

函数作用域外不可见:

function exampleFunction() {
  var x = '函数内定义'; // x 只能在 exampleFunction 函数中使用
  console.log(x); // 输出 "函数内定义"
}

exampleFunction();
console.log(x); // 运行报错,x is not defined

函数作用域间不可见:

function exampleFunction1() {
  const y = '函数内定义y-[exampleFunction1]'; // x 只能在 exampleFunction 函数中使用
  console.log(y); // 输出 "函数内定义y-[exampleFunction1]",exampleFunction2 中的 y 不会造成影响
}

function exampleFunction2() {
  const y = '函数内定义y-[exampleFunction2]'; // x 只能在 exampleFunction 函数中使用
  console.log(y); // 输出 "函数内定义y-[exampleFunction2]",exampleFunction1 中的 y 不会造成影响
}

exampleFunction1();
exampleFunction2();

根据值和表达式可见范围的确定方式,作用域分为静态作用域和动态作用域。作用域在代码逻辑定义时(编译时)就确定的属于静态作用域,作用域在代码逻辑运行时(运行时)确定的属于动态作用域。动态作用域在运行前难以确定,复杂度较高,所以大部分高级编程语言(诸如C, C++, Python, Java,C#)采用的是静态作用域。JavaScript采用的也是静态作用域。静态作用域又称为词法作用域。大部分时候,作用域堆叠成层次结构,子作用域可以访问父作用域,反过来则不行。 请看以下JavaScript代码。

var word = 'Hi';
function sayHi() {
  return word;
}

function greeting() {
  var word = 'Hello';
  return sayHi();
}

console.log(greeting());  // 输出'Hi'

上述代码运行结果输出HisayHi()中取到的word是函数外层定义的,它的值是Hi,即使sayHi()的调用是在greeting()内部并且greeting()内部也有个word定义。方法的变量值可以在函数定义的作用域中查找,但是不会在方法调用的作用域中查找,这种查找关系在我们写代码的时候就可以清晰理解,这就是静态作用域工作方式。动态作用域的工作方式却不同,请看同样的代码逻辑在Perl语言中的运行方式,如下。

$word = 'Hi';
sub sayHi
{
	return $word;
}

sub greeting
{
	local $word = 'Hello';
	return sayHi();
}

print greeting(); # 输出'Hello'

上述代码运行结果输出Hello,sayHi()中取到的word是函数调用的作用域中定义的,它的值是Hello。即方法中变量的作用域是沿着调用的作用域查找的,一个函数可能在多个地方调用,变量的值可能都不一样,这种运行时才能确定作用域的方式就是动态作用域。

JavaScript 作用域

在JavaScript中,作用域主要有全局作用域、模块作用域、函数作用域、块级作用域四种,其中,块级作用域属于额外的作用域。

  1. 全局作用域:脚本模式下运行所有代码的默认作用域。直接运行在<script>标签下的代码,或者浏览器端单独的一个js文件中的代码,都运行在全局作用域下。全局作用域在页面创建时打开,在页面关闭时销毁。在浏览器端中,在全局作用域中有一个全局对象 window(代表的是一个浏览器的窗口,由浏览器创建),可以直接使用。在Node中,有一个全局对象global。
  2. 模块作用域:模块模式中运行代码的作用域。在Node中,每一个js文件都是一个单独的作用域,Node的模块化会将每一个文件中的代码运行在一个单独的作用域中。每个js文件是独立的作用域不共享,所以Node中每一个文件中的变量都是私有的。
  3. 函数作用域:由函数创建的作用域。调用函数时创建函数作用域,函数执行完毕之后,函数作用域销毁;每调用一次函数就会创建一个新的函数作用域,它们之间是相互独立的。
  4. 块级作用域:用一对花括号(一个代码块)创建出来的作用域。这类代码块包括whileiftry...catch,switch,for...infor...of,甚至匿名{/*StatementList*/}。代码块运行完毕后,作用域同样会销毁。块级作用域是ES6扩展的概念,在变量绑定的时候,只支持letconstclass声明的变量或类。使用var声明的变量不会绑定到块级作用域上。

下面是一些作用域的示例代码。

var globalScope = 'global scope';

function checkScope1(){
    console.log(globalScope); // 输出 "global scope"
}
checkScope1();

function checkScope2() {
    var functionScope = 'function scope';
    console.log(functionScope); // 输出 "function scope"
}
checkScope2();

{
    var variableInBlock = 'variable in block';
    let blockScope = 'block scope';
    console.log(blockScope); // 输出 "block scope"
}
console.log(variableInBlock); // 输出"variable in block"
console.log(blockScope); // 报错 blockScope is not defined

这些代码演示了程序是如何访问不同作用域中变量。题外话,由于JavaScript的松散性,在非严格模式下,浏览器端的JavaScript运行时,没有使用任何声明的参数会被绑定在全局对象window上,对于使用var重复声明已经使用var声明的变量,会静默失败,不会报错。而使用letconstclass重复声明,或者使用var重复已经被letconstclass声明的变量,JavaScript程序会报错。

JavaScript 作用域链

上面我们说过,大部分时候作用域堆叠成层次结构,子作用域可以访问父作用域,反过来则不行。这便是JavaScript作用域的查找机制。具体来说,当程序访问一个变量的时候,首先会尝试在当前作用域下去寻找该变量,如果没找到,再到它的上层作用域寻找,以此类推直到找到该变量或是已经到了全局作用域。如果在全局作用域里仍然找不到该变量,它就会在全局范围内隐式声明该变量(非严格模式下)或是直接报错(严格模式下)。请看以下代码示例。

var scope = '[global] scope';
var globalScope = 'global scope';

function outerFunc() {
    var scope = '[outer function] scope';
    var outerScope = 'outer scope';

    function innerFunc() {
        var scope = '[inner function] scope';
        var innerScope = 'inner scope';
        console.log(scope); // 输出 "[inner function] scope"
        console.log(innerScope); // 输出 "inner scope"
        console.log(outerScope); // 输出 "outer scope"
        console.log(globalScope); // 输出 "global scope"
    }

    console.log(scope); // 输出 "[outer function] scope"
    console.log(outerScope); // 输出 "outer scope"
    console.log(globalScope); // 输出 "global scope"
    innerFunc();
}

console.log(scope); // 输出 "[global] scope"
console.log(globalScope); // 输出 "global scope"
outerFunc();

innerFunc()中,访问scope时,首先找到的是定义在innerFunc()中的scope,所以得到"[inner function] scope",同时,innerFunc()中可以访问到自身作用域中的innerScope,父作用域中的outerScope和全局作用域中的globalScope。在outerFunc()中,父作用域访问不了子作用域,outerFunc()访问不了innerFunc()中的scope,但是在当前作用域中访问到scope,得到"[outer function] scope",同时outerFunc()中可以访问到自身作用域中的outerScope和全局作用域中的globalScope。在全局作用域中,可以访问到全局作用域中的scopeglobalScope。观察scope变量的访问可以看到,程序沿着作用域链向上查找一个变量的同时,在找到第一个声明的时候会命中并停止,这个效果称为遮蔽效应。

JavaScript程序访问变量时沿着作用域链向上查找的机制,以及函数作为一等公民可以作为返回值的设定,使得在JavaScript中能够使用一种强大的编程技巧,就是闭包,这个后面我们单开一篇进行讨论。

JavaScript 声明提升

理解了作用域的概念,我们就可以探讨JavaScript声明提升的问题了。

JavaScript 提升是指解释器在执行代码之前,将函数、变量、类或导入的声明移动到其作用域的顶部的过程。提升不是 ECMAScript 规范中规范定义的术语。规范确实将一组声明定义为可提升的声明,但这只包括functionfunction*async function以及async function*声明。提升通常也被认为是 var 声明的一个特性,尽管方式不同。用通俗的话来说,以下任何行为都可以被视为提升:

  1. 能够在声明变量之前在其作用域中使用该变量的值。(“值提升”)
  2. 能够在声明变量之前在其作用域中引用该变量而不抛出 ReferenceError,但值始终是 undefined。(“声明提升”)
  3. 变量的声明导致在声明行之前的作用域中行为发生变化。
  4. 声明的副作用在评估包含该声明的其余代码之前产生。

functionfunction*async function以及async function*四种函数声明的提升表现为第1种行为;var声明的提升表现为第2种行为;letconstclass声明(也称为词法声明)的提升表现为第3种行为;import声明的提升表现为第1和第4种行为。

函数声明提升

函数声明提升也会提升到当前作用域(包括块级作用域)最前面,同时提升的,还有函数签名和函数体定义(函数值)的绑定。请看如下代码。

console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi(),运行结果是输出 "Hi"
function sayHi() {
  console.log('Hi');
}

四种函数声明具备同样的提升效果。并且对于重复的函数声明,最后的(新的)函数声明会覆盖之前的(旧的)函数声明,请看下面代码。

sayHi(); // 执行sayHi(), 运行结果输出 "Hello"
function sayHi() {
  console.log('Hi');
}
function sayHi() {
  console.log('Hello');
}

var声明提升

ES6之前没有声明提升的概念,但是var声明的表现是声明提升的。不管var变量声明是写在哪里,最后都会被提到当前作用域(非块级作用域)的顶端,并使用undefined进行初始化。请看如下代码。

console.log(x); // 输出 undefined
var x = 1;
console.log(x); // 输出 1
console.log(y); // 报错:y is not defined。对比项,因为没有任何地方声明 y,所以报错

上述代码等价于下面代码。

var x;
console.log(x); // 输出 undefined
x = 1;
console.log(x); // 输出 1
console.log(y); // 报错:y is not defined。对比项,因为没有任何地方声明 y,所以报错

另,函数声明提升的优先级是高于var变量声明的,请看如下代码。

console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi()
function sayHi() {
  console.log('Hi'); // 输出 "Hi"
}
var sayHi = 'sayHi';
console.log(sayHi); // 输出 "sayHi"

上述代码等价于下列代码。

function sayHi() {
  console.log('Hi'); // 输出 "Hi"
}
console.log(sayHi); // 输出函数体定义 ƒ sayHi() { ... }
sayHi(); // 执行sayHi()
sayHi = 'sayHi';
console.log(sayHi); // 输出 "sayHi"

letconstclass声明提升

letconstclass声明提升会被提升到当前作用域顶端,但是不会给声明的变量赋值,而是指派给它们uninitialized的状态,访问uninitialized状态下的变量程序会报错。示例代码如下。

function run() {
    console.log(a); // 报错:Cannot access 'a' before initialization
    let a = 1;
}
run();

如果没有let声明,代码的表现行为是不一样的,对比代码如下。

function run() {
    console.log(a); // 报错:a is not defined
}
run();

因为var声明无法绑定到块级作用域上,块级作用域上的var声明会提升到当前其他类型作用域上进行绑定,所以下列代码运行结果和预期结果不同。

for(var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出三次 3
  })
}

letconst出现之前,开发人员使用立即执行函数来修复这个问题,letconst出现之后,在for...infor...of循环语句中可以直接使用letconst解决这个问题。

// 使用立即执行函数进行修复
for(var i = 0; i < 3; i++) {
  (function(i) {
    setTimeout(() => {
      console.log(i); // 分别输出 0, 1, 2
    })
  })(i)
}

// 使用 let/const
for(let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 分别输出 0, 1, 2
  })
}

JavaScript 为什么要有声明提升

即使V8 JavaScript引擎已经支持了即时编译(JIT),但JavaScript仍然是一门解释型高级编程语言。解释型语言在解析代码的时候是逐行的,这个特点决定了它有声明提升的必要。同时,声明提升也会提高程序运行性能。

声明提升的必要性

假如没有函数声明提升,会发生什么。首先我们遇到的第一个问题是我们在使用一个函数时,一定要首先声明它,这很麻烦。虽然开发人员加点小心是可以妥善解决这个先后问题,但是会给项目开发带来很多额外的工作。 我们遇到的第二个问题涉及到函数相互递归。审查以下函数相互递归调用的例子,如果函数声明没有提升,代码会怎么执行。

function isEven(n) {
  if (n == 0) {
    return true;
  }
  return isOdd(n - 1);
}

alert(isEven(2));

function isOdd(n) {
  if (n == 0) {
    return false;
  }
  return isEven(n - 1);
}

当代码执行到alert(isEven(2));一行的时候,isEven(2)成功被调用,程序执行进入函数isEven,在函数isEven内调用isOdd()时,因为此时函数isOdd未声明,所以程序会报错。

解决这两种问题的方式就是函数声明提升,在程序执行模块逻辑之前,创建当前作用域执行上下文,绑定函数定义,然后执行代码逻辑内容,看上去就是作用域中的函数声明和定义提升到作用域顶端,这样,在整个作用域便可以直接调用声明的函数,也会避免函数相互递归情况下报错的问题。JavaScript程序的执行分为代码解析(预编译)和执行两个阶段,JavaScript引擎在代码解析(预编译)阶段隐式地完成了这种提升。

性能优化

声明提升有助于程序性能优化,我们知道,在执行程序时,声明语句会为一个变量创建堆栈,分配内存并可选地初始化变量值。思考下列代码。

var count = 0;
while(count < 100) {
  var message = '第' + count + '次';
  console.log(message);
  count = count + 1;
}

如果没有声明提升,那么每次执行message变量声明的时候都需要为其开辟内存,并将其指针压入执行栈,同时还需要将旧的message变量销毁。如果应用了声明提升,message变量只会声明一次,为其开辟内存一次,后续都是在现有内存上更新变量值。这样,程序性能得到提升。函数声明同理。所以JavaScript引擎都会对声明进行提升以优化程序执行性能。这是JavaScript引擎在代码解析(预编译)阶段的优化之一。

var声明提升的问题

var声明提升能够起到性能优化的作用,但是,在一些情景下,var声明提升很容易引入程序bug,它另程序在某些情境下的行为很“反直觉”。请看以下代码。

var name = 'JavaScript';
function printName () {
  console.log(name);
  var name = 'ECMAScript';
}
printName();

但从直觉上来看,变量name有赋值,应该输出"JavaScript",考虑var声明提升,应该输出"ECMAScript",但实际上输出的是undefinedvar声明提升并不会提升赋值语句,name = 'ECMAScript'被留在原地在输出语句之后。var重复声明时也不会报错,在维护大型程序时,这很容易引入bug,为了解决这个问题,ES6才提出了letconst声明和引入块级作用域机制。JavaScript引擎是如何实现作用域机制和这些声明提升呢?这就需要深入到JavaScript执行上下文和JavaScript执行栈了。

letconstclass提升的另类说法

有关letconstclass有另一种说法是视它们的声明不会提升,并提出“暂时性死区”的概念,这种说法可接受,但不严谨。说其可接受,是因为“提升”一词也不是普遍认同的术语。说其不严谨,是因为程序在执行声明变量语句之前的“暂时性死区”仍然是可观察到的“声明语句的影响”,既然有实质的影响,这也是某种形式的提升。查看以下代码的不同

const x = 1;
function func1() {
  console.log(x); // ReferenceError: Cannot access 'x' before initialization.
  // `const x = 2;`的提升造成了上面访问x报错的结果,如果视为不提升,那么是应该能够访问到全局作用域中的x。
  const x = 2;
}
func1();

const y = 3;
function func2() {
  console.log(y); // 输出 undefined. `var y = 4;`的提升造成了这个结果
  var y = 4;
}
func2();

如果我们深入到JavaScript程序的运行堆栈,去观察letconstclass这些声明的行为,我们也会发现将其视为提升是恰当的。理解JavaScript执行上下文和执行栈,对我们去理解作用域、提升、闭包这些概念都会有很大帮助,我们后面会对这个主题专门讨论。

发表留言

历史留言

--空--