在开始之前,我们先来看一看如下几个现象:
console.log(str); //undefined
console.log(fn); //ƒ fn() {}
var str = "fantasy";
function fn() {}
如果对上述输出结果不感到陌生,那么你或许对变量提升有了一定了了解。
没错,这就是JS的"特性"之一。但与其说是一种特性,其实更是一种JS最初的设计缺陷。这种令人费解的特性时常会产生某些迷惑的行为。而JS为了解决这种缺陷,在ES6引入了const和let。
那const和let是否真的就不存在变量提升了?接下来会从底层出发,介绍变量提升的本质,帮助你对其有更深刻的理解。
变量提升的根本原因
首先我们需要了解,什么是变量提升?
变量提升并不是说某个变量会被真的提升到代码块的顶部,而是指 在代码执行之前,变量被提前创建于内存之中,这个现象就叫变量提升(对变量提升的其中一种理解)。
JS对一个变量声明语句的执行,可以分为以下三部分:创建,初始化,赋值。
创建
创建,就是将变量创建至内存的步骤。
在执行JS文件的过程中,解析引擎的方式是 先编译,后执行(在执行之时存在"边执行边解析"的JIT解析模式,与内容无关,不在此展开)。而解释,就是将JS代码生成一个AST抽象语法树,然后生成二进制编码的过程。在此过程中,就会将声明语句的变量,添加至进程所分配的内存当中,这也就对应着 "创建"。
也就是说,对于以下任意变量声明语句,在编译的过程中,都会将变量存放于内存当中:
var a = 10;
let b = 10;
const c = 10;
初始化
初始化介于声明与赋值之间,其也是const、let与var 在声明之前使用变量却存在不同现象的主要原因。
我们通过下述差异现象来区别分析var和let:
console.log(a); //undefined
var a = 10;
console.log(b); //Uncaught ReferenceError: Cannot access 'b' before initialization
let b = 10;
对于var声明的a变量,在其声明语句之前使用a并不会报错,而会获得一个undefined值;但是对于let声明的b变量,却直接报了ReferenceError。为什么同样存在被创建过的两个变量,会存在如此差异呢?
首先在JS引擎对文件的编译阶段,二者都会将变量创建至内存之中。对于var声明语句,还会还编译的过程中执行一个 "初始化" 的操作,将其初始化一个undefined的值;而对于let,则不会在编译过程中执行这一步,let的初始化会在执行过程中,真正的执行到了这一行代码才会进行初始化,也就是发生在执行阶段。
而初始化的值,就会存在于其对应的执行上下文的变量环境对象当中。当真正执行到读取变量的代码行之时,对于变量a来说,会去变量环境中查找该变量,因为其此时已经初始化,因此能够获取到一个undefined的值;但是对于变量b来说,并没有初始化的操作,也就无法在变量环境对象中找到该变量,因此在执行到读取b的代码行直接抛出了 Cannot access 'b' before initialization 。这也就是所谓的 "暂时性死区"。
对于执行上下文和变量环境对象不了解也没关系,我们先说概念,介绍完毕再到最后统一介绍。
赋值
赋值操作发生在JS引擎的执行过程中,也就是将企图赋予的值真正赋值给变量的步骤。
当执行到声明语句所在代码行的时候,才会进行赋值。其发生在执行阶段。如下述所示:
var a = 10; //10
let b = 10; //10
当赋值发生之后,会将执行上下文中的变量环境对象中相应的值进行更新,之后的代码语句再次读取相应的变量,从该对象中找到的就是最新赋值的值。
经过上述过程,一个变量才完成了一个声明流程。但是对于不同的情况,会有不同的规则。我们可以将它称为"提升规则"。
变量的提升规则
首先明确一点:这里的提升,是指在编译阶段发生的"提升"。
var | 创建提升;初始化提升 |
const | 创建提升 |
let | 创建提升 |
函数声明 | 创建提升;初始化提升;赋值提升 |
这也就是为什么在函数声明之前可以获取函数体,但是在var声明之前获取到的变量只是一个undefined,其根本原因是因为,函数在编译阶段会额外的进行赋值提升,而var只会初始化提升。
大家可以再思考以下问题:
console.log(a);
var a = 123;
function a() {}
console.log(b);
function b() {}
var b = 123;
a和b的输出分别会是什么?
对于第一个示例,在JS引擎编译解析代码的过程中,首先遇到var,发生创建提升和初始化提升;然后继续解析,遇到函数声明,发现变量a已经创建并初始化,于是只进行复制提升;
对于第二个示例,编译过程中会先遇到函数声明,于是此时变量b创建提升,初始化提升,且赋值提升。当继续解析,遇到var只是,发现变量已经全部提升完毕,则不做任何事情。
因此,最终二者的输出结果是相同的,都是一个函数对象。
经过上述介绍,现在你或许知道如下例题会打印出什么:
console.log(a);
console.log(b);
function a() {}
var b = function () {};
了解了var和函数声明的差异,我们再来看看const和let的区别。
对于let和const来说,在编译阶段都会进行创建提升。也就是会将变量存于内存中,但是由于其没有进行初始化提升,因此不会存在于词法环境对象(与变量环境对象相似,但是这是一个针对于let和const的变量对象)。而当代码执行到相应的声明语句的时候,才会进行初始化。如:
let a
const b = 1
在初始化之前,二者的行为都暂时保持统一。但是如果声明的时候包含了赋值语句,则二者会存在这样的差别:
let a = 1;
const b = 2;
对于let来说,会在初始化之后,立刻进行赋值操作;但是对于const,其本身不存在赋值这一说,只有初始化操作。也就是说,const b = 2 从本质上并不是先初始化b,然后赋值为2,正确的解释应该是给b初始化为2,并且以后永远都不能进行赋值操作。这也是const的特性:"常量"。
变量环境和词法环境
解释完变量提升,我们再来解释一下在之前的描述过程中出现的变量环境对象、词法环境对象、执行上下文等概念。
首先,什么是执行上下文:在每一个函数调用之时,都会创建一个对应的执行上下文活动对象,该活动对象会在函数执行完毕之后销毁(不考虑闭包的情况下)。而对于全局环境来说,同样的,会存在一个全局执行上下文。
对于执行上下文活动对象,可以参考下图理解:
在执行上下文栈(也就是函数调用栈)中,存在的就是一个个如此的活动对象。
对于var、函数声明来说,其存放的位置为变量环境对象;而对于const和let来说,其存放的位置为词法环境对象。之所以会存在两个不同的环境对象,是因为在支持ES6的新语法的同时,依旧需要保持对旧语法的支持,也就是向下兼容。
结合代码来看:
var a = 123;
function b() {}
let c = 321;
const d = 456;
如上代码在执行到a声明语句之前的执行上下文中,应该是这样的:
而当其执行完d的声明语句,则应该是这样的:
这也就是为什么在声明语句的前面可以访问var和函数声明的变量,但是却无法访问let和const声明的的变量的原因。而这这种现象,也是为什么会在有些地方将const和let解释为不存在变量提升的根本原因。
对于变量提升这一说法,本身就是一个"伪命题",它并没有很清晰的定义与边界。因此,对于const和let是否存在变量提升,无论怎么回答都是有道理的。
如果边界定义为能否在变量的声明语句执行之前使用,也就是变量是否会提升至变量环境对象或词法环境对象(初始化提升),那么const和let的确没有变量提升;
但是如果将变量提升从本质上去理解,他又确实存在提升,因为在JS引擎的编译阶段const和let的变量也会像var和函数声明一样,添加至内存当中(创建提升)。
甚至是在MDN文档当中,对于变量提升这一现象,也是存在着分歧的。从一开始所说的存在变量提升,到现在的解释:
let
允许你声明一个作用域被限制在块作用域中的变量、语句或者表达式。与 var 关键字不同的是,var
声明的变量作用域是全局或者整个函数块的。var
和let
的另一个重要区别,let
声明的变量不会在作用域中被提升。
因此,除非"变量提升"这一概念被明确的定义,只要你能够自圆其说,怎么解释都可行。比如我就更倾向于将变量提升理解为创建提升。
经过了上述的介绍,你应该对于变量提升有了不一样的理解了吧!
文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。