欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

JS入门难点解析2-JS的变量提升和函数提升

程序员文章站 2022-07-08 14:08:16
...

(注1:本文首发于我的简书!)
(注2:更多内容请查看我的目录。)

关于本篇文章所要讨论的问题,若要寻根究底,可能需要从编译和引擎的角度来进行分析。但是正如驾驶一辆汽车一样,我们不可能第一天就去了解发动机的工作原理,这只会让我们畏怯止步。而应该是了解使用它时的驾驶理论和交通规则,然后在兴趣的驱使下去探索其深层的构造。(本篇着重现象,原理详见 JS入门难点解析5-变量对象

1. JavaScript是否需要编译

这节内容并不会对此做深层次的探讨,而是普及一个知识。主要节选百度百科和《你不知道的JavaScript》的部分内容给读者一个初步的印象。

众所周知,JavaScript是一门解释型脚本语言。它的具体特征,我们可以从百度百科javascript的定义读到(节选,有删改,完整内容请自行百度):

JavaScript是一种脚本语言,其源代码在发往客户端运行之前不需经过编译,而是将文本格式的字符代码发送给浏览器由浏览器解释运行。直译语言的弱点是安全性较差,而且在JavaScript中,如果一条运行不了,那么下面的语言也无法运行。
Javascript被归类为直译语言,因为主流的引擎都是每次运行时加载代码并解译。V8是将所有代码解译后再开始运行,其他引擎则是逐行解译(SpiderMonkey会将解译过的指令暂存,以提高性能,称为实时编译),但由于V8的核心部份多数用Javascript撰写(而SpiderMonkey是用C++),因此在不同的测试上,两者性能互有优劣。与其相对应的是编译语言,例如C语言,以编译语言编写的程序在运行之前,必须经过编译,将代码编译为机器码,再加以运行。

很多同学看到这一段,就想当然的认为JS就是一行行往下执行的语言,只要对着源码往下一路走即可。按照这种思路,我们来看一个例子,请看下面这段代码:

a = 2;
console.log(a);
var a; 

按照顺序,console.log(a);在声明a的语句var a;之前,应该打印出undefined来才对,可事实是打印出来的结果是2。为什么会出现这种情况呢?难道JS不是一行行顺序执行的吗?我们再来看一段节选自《你不知道的JavaScript》一书对JS的解释(节选,有删改,完整内容参考该书第1章):

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。 这个事实对你来说可能显而易见,也可能你闻所未闻,取决于你接触过多少编程语言,具有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
+ 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。
+ 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
+ 代码生成
将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。

比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
首先,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如 JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此, JavaScript 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

所以,我的理解是,之所以说JS不需要编译,只是它不像其他编译语言一样需要翻译成等价的另一种语言。但是仍然需要进行语法分析和代码生成,并且通常是立即执行。而本篇文章所要讨论的内容——JS的变量提升和函数提升就发生在编译阶段。(随着自己进一步了解执行上下文,觉得这里所指的编译器的作用有点类似于执行上下文生命周期的第一阶段)。

2. 变量声明与函数声明

2.1 变量声明和函数声明的定义

首先我们来看一下,何谓变量声明与函数声明。

变量声明就是 var XXX;。例如:

var a;  // 声明变量a;
var b;  // 声明变量b;

函数声明则是function XXX () {…}。例如:

// 声明函数sayHello
function sayHello () {
  console.log('hello');
}

2.2变量声明与赋值操作

在日常代码编写中,我们经常会写如下形式的代码:

var a = 1;  // 声明变量a并赋值1;

实际上编译阶段会将代码进行如下处理:

var a;  // 声明变量a;
a = 1;  // 将a赋值为1;

特别需要注意的是:

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

其实进行的是一个变量声明,而非函数声明。

而我们接下来要讨论的变量提升和函数提升实质上指的是变量声明提升和函数声明提升,赋值操作会留在原地。

3. 变量提升

所谓变量提升,就是变量的声明在执行前会被提升到该作用域顶部。

回过头来看第1节所举的例子:

a = 2;
console.log(a);  // 2
var a; 

代码在执行前被处理为如下形式:

var a;   // 变量声明被提升到该作用域顶部
a = 2;
console.log(a);  // 2

现在,再来顺序执行这一段代码,是否就很容易理解了。

不过,我们要注意这里有一个坑,那就是对声明变量进行函数赋值操作。看下面这段代码:

sayHello();
var sayHello = function () {
  console.log('hello');
}

会有如下代码提示错误:VM3188:1 Uncaught TypeError: sayHello1 is not a function。

会有人问了,难道这里sayHello没被提升吗?是否是这个原因呢,我们来看一下,直接执行一个未被声明的函数会报什么错:

sayNothing();

会有如下代码提示错误:VM3059:1 Uncaught ReferenceError: sayNothing is not defined。这里报的是未定义的错误,而前面报的是类型错误。也就是说明,其实sayHello被定义了,但它不是一个函数。我们来看一下提升以后的代码:

var sayHello;
// 如果这里尝试打印会发现sayHello是undefined
// console.log(sayHello); 
sayHello(); 
sayHello = function () {
  console.log('hello');
}

在执行sayHello();时,sayHello是undefined,这就是报错的原因。

4. 函数提升

所谓函数提升,就是函数的声明在执行前会被提升到该作用域顶部。这里参考变量提升,很容易理解。我们将sayHello的声明做一个简单的改变:

sayHello(); 
function sayHello () {
  console.log('hello');
}

会发现成功打印出’hello’。因为函数声明提升后实际的代码形式如下(这里的实际不是说编译器实际会将代码编译成这样,而是代码的实际执行效果,下同)

function sayHello () {
  console.log('hello');
}
sayHello(); 

5.提升的优先级

既然声明的提升都是提升到当前作用域的顶端,那么如果两个声明拥有同一个名字的时候,谁才拥有对这个变量的冠名权呢?我们来通过实际的例子看一下。

5.1变量声明之间的比较

看下面这段代码:

var a = 1;
var a = 2;
console.log(a);

事实上,对于var a =2;编译器会进行如下处理(参见《你不知道的JavaScript》第1章):

  1. 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看 1.3 节)。

这篇文章对第二点不做细究,我们看第一点,可以知道上述代码实际上会变成:

var a;
a = 1;
a = 2;
console.log(a);

5.2函数声明之间的比较

看下面这段代码:

function sayHello () {
  console.log('hello');
}
sayHello();
function sayHello () {
  console.log('hi');
}

这段代码实际输出的是’hi’,也就是说后面声明的函数实际上会替代前面声明的同名函数。代码实际会变成:

function sayHello () {
  console.log('hi');
}
sayHello();

5.3变量声明和函数声明的比较

var a;
function a () {
  console.log('函数a');
}
console.log(a);   
function b () {
  console.log('函数b');
}
var b;
console.log(b);

在浏览器控制台打印结果如下:
JS入门难点解析2-JS的变量提升和函数提升

说明函数声明优先级高于变量声明优先级。代码实际效果如下:

function a () {
  console.log('函数a');
}
function b () {
  console.log('函数b');
}
console.log(a); 
console.log(b);  

5.4函数声明和函数赋值给变量的区别

看下面代码:

var a;
console.log(a);  
a = function () {
  console.log('函数a');
}
var b;
console.log(b);  
function b () {
  console.log('函数a');
}

在浏览器控制台运行输出结果如下:
JS入门难点解析2-JS的变量提升和函数提升
要注意函数声明和函数赋值给变量的区别。实际代码与下面效果相同:

var a;
function b () {
  console.log('函数a');
}
console.log(a);  
a = function () {
  console.log('函数a');
}
console.log(b);  

6.参考

BOOK-《你不知道的JavaScript》 第1部分