浅谈:JavaScript 变量预声明和作用域提升

829 阅读13分钟

关键字: 变量声明、预编译、作用域、严格模式

变量声明

在 JavaScript 中创建变量通常称为"声明"变量,声明一个变量有三种方式,包括 var、let 以及会被大家忽略的直接赋值方式。

友情提醒: 下文中出现的代码如果您要验证,请使用浏览器访问 about:blank 页面,在纯净的浏览器控制台拷贝执行进行验证,以防止不干净的网页环境已声明过部分测试用变量名。 image.png

var 关键词声明变量

我们使用 var 关键词来声明变量:

var a;

变量声明之后,该变量是空的(undefined)。如需向变量赋值,请使用变量赋值符号(等号):

var a;
a = 'var_a';

这里多说一句,如果赋值为 undefined,这样和不赋值没有区别:

var alm = undefined

当然我们也可以使用 var 关键字声明一个变量同时给它赋值,像这样:

var a = 'var_a';

空变量声明

说完了 var 变量声明方式,我们放着 let 变量声明方式先不说,我们先说下前言部分提到的直接赋值方式的变量声明。顾名思义,直接赋值方式的变量声明就是直接赋值,而不使用 var 和 let 关键字,我们姑且叫它空变量声明吧,类似以下代码:

a = 'a'

可能看到这样的代码,你就会有疑问了,不是说变量声明吗?这写的哪有声明,不就一个变量赋值吗?! 对,你说的很对,上面的代码在绝大部分情况下是变量赋值,但仅限于变量 a 已经声明的情况下。严格地说,应该叫做:在当前及以上作用域,变量 a 已声明

作用域

这里就不得不先说一下作用域的概念了,作用域基本是所有编程语言的标配,JavaScript 也不例外。没有作用域的区分,可想而知我们写的代码得多混乱,我们得处处小心自己声明的变量是不是已经声明过,担心自己新声明的变量无法声明或覆盖了已经声明的同名变量,导致各种不可预知的问题。

作用域可以分为全局作用域和函数作用域两种,全局作用域是所有函数作用域都可以访问到的区域。当前作用域可以访问到当前及父级作用域所有已声明过的变量和函数,无法访问子级作用域任何变量和函数,详细介绍请自行搜索“作用域链”相关知识。

浏览器环境的全局作用域

我们都知道浏览器是 JavaScript 代码最常见的运行环境,如果我们在浏览器的 devtools(开发者工具)的控制台中直接编写 js 代码,那其实就是一个浏览器提供的 JavaScript 全局作用域。我们在里面直接声明的所有变量(不包括后面要介绍的使用 let 关键词声明的变量),其实都是在 window 对象上新增加了一个属性。 我们可以验证一下,使用 var 关键词声明一个变量试试:

var a = 'var_a'
console.log(a) // var_a
console.log(window.a) // var_a
console.log(window.a === a) // true

而函数作用域可以有上级作用域(一级或多级)和子作用域(一级或多级),所以全局作用域也可以理解为一种根作用域,是所有函数作用域最顶层的上级作用域。额。。好像说得有点复杂了,其实很简单,画张图就很一目了然了!

stateDiagram-v2
全局作用域 --> 函数作用域1
全局作用域 --> 函数作用域2
全局作用域 --> 函数作用域3
函数作用域2 --> 函数作用域4
函数作用域2 --> 函数作用域5
函数作用域5 --> 函数作用域6

上面这颗树就可以用来描述 JavaScript 的各个作用域之间的关系,全局作用域是这颗树的根节点,函数作用域6的当前及以上作用域包括:函数作用域6、函数作用域5、函数作用域2以及全局作用域。如果我们在函数作用域6中声明一个在以上这些范围内的都未曾声明的变量,那么 JavaScript 才会为我们在全局作用域的顶部声明一个新的变量,代码如下:

// console.log(a); // a is not defined

function f1() {
    var a = 'f2_a'
}

function f2() {
    function f4() {
        var a = 'f4_a'
    }

    function f5() {
        function f6() {
            a = 'f6_a'
            console.log(a); // f6_a
        }
        f6()
    }

    f4()
    f5()
}

function f3() {
}

f1();
f2();
console.log(a); // f6_a

解释下上面的代码,如果我们在浏览器的控制台直接输入执行以上代码,就相当于构建了一个和上面树图一模一样的作用域环境。f6 函数体就是图中的作用域6,我们在 f6 中直接给一个变量 a 赋值 'f6_a'。可以看到其实在代码中除了 f6 作用域中有变量 a 以外,在 f1 和 f4 中都声明并且赋值了变量 a,但函数 f6 和 全局作用域中的console.log(a)输出的都是 f6 中赋的值,这就说明 f6 中虽然只是给变量 a 进行了赋值操作,但 JavaScript 却在全局作用域下声明了一个新变量 a,然后在 f6 函数执行时赋值为 'f6_a'。

JavaScript 变量预声明和变量作用域提升

细心的朋友可能已经看到了,上面代码的第一行我注释了一行代码,后面的注释里标明了如果此行放开的执行结果,对你没有看错,如果此行代码执行,浏览器环境会因为找不到变量 a 而抛出异常。这又是为什么呢?你刚不是说 JavaScript 会在全局作用域下声明一个一个新变量 a 吗,怎么这里又说找不到 a?

其实,这里涉及到 JavaScript 语言的一种设计,即预编译机制。什么是预编译,我们都知道 JavaScript 是一门脚本语言,脚本语言的特点就是解释执行,让代码一行一行地按顺序解释执行。但是 JavaScript 有一点不一样,它在真正执行 js 代码前,会先看看当前作用域下有没有 var 关键词声明的变量或者没有使用任何关键字直接赋值的变量(当前及以上作用域都未曾声明过的变量),如果有就会提前把这些变量名声明一下,那么在哪声明呢?这个就有区别了,对于 var 关键词来说,变量的可访问范围只会提升到当前作用域顶部,而对于直接赋值的变量,JavaScript 则会把它提升至全局作用域。

严格地说,这里不应该叫预编译,换作“预解释”可能更妥帖些,毕竟 JavaScript 本质上并不是一门编译型语言。

这里着重说明一下,变量可访问范围提升的时机是在代码已执行到此作用域,如果没有则不会有变量可访问范围提升的行为。现在再看一下上面的代码,就很清晰了,因为第一行代码处在全局作用域,此时代码还未执行到 f6 作用域,所以 f6 作用域中的变量 a 还未进行变量可访问范围提升,所以全局作用域也就没有变量 a 这个声明。那这么说,如果我们在全局作用域第二行声明一个变量 a,第一行就不会报错了吗?是的!不信你执行一下下面的代码试试(请刷新下网页环境在控制台执行下):

console.log(a); // undefined
var a = 'global_a'
console.log(a); // global_a

我们再试试直接赋值的变量提升:

console.log(a); // a is not defined
a = 'global_a'
console.log(a); // global_a

完了翻车了,不是说直接赋值的变量会提升至全局吗,这咋全局作用域的第一行还是找不到变量 a?!对这的确很坑爹。。。直接赋值的变量提升并不会在预编译阶段,而是在代码执行到对应行时才会提升!所以就出现了下面两种不同的情况:

// console.log(a); // a is not defined
function f1() {
    console.log(a); // a is not defined
}

f1();
a = 'global_a'
console.log(a);
// console.log(a); // a is not defined
function f1() {
    console.log(a); // global_a
}

a = 'global_a'
f1();
console.log(a); // global_a

再看一下之前的例子,如果我们在 f1 里就提前(f1 函数先执行)把变量 a 提升至全局,那么代码依次执行了 f1、console.log(a)、f2()、f4()、f5()、f6(),当执行到 f6 时再遇到赋值语句 a = 'f6_a' 此时,JavaScript就就不会再进行变量提升了,因为全局已经有一个叫 a 的变量了,所以这里就是一个纯粹的全局变量赋值操作。

// console.log(a); // a is not defined

function f1() {
    // 变量 a 提升至全局
    a = 'f1_a'
    console.log(a);// f1_a
}

function f2() {
    function f4() {
        var a = 'f4_a'
    }

    function f5() {
        function f6() {
            // 不会再进行变量提升,因为 f1 中已经提升过一次
            a = 'f6_a'
            console.log(a); // f6_a
        }
        f6()
    }

    f4()
    f5()
}

function f3() {
}

f1();
console.log(a); // f1_a
f2();
console.log(a); // f6_a

ok,上面这么复杂的情况我们都理解了,那么下面的情况就小菜一碟啦,我们来练习一下:

var a ='var_a'
function fn(){
    a = 'fn_a'
    console.log(a); // fn_a
}

fn();
console.log(a); // fn_a

上面这段代码是如何被 JavaScript 解释运行的呢?按照上面我们分析的理论,首先全局预编译阶段,由 var 声明的变量 a 会被提升至当前作用域也就是这里的全局作用域顶部提前声明。当第一行代码执行完,变量 a 被赋值为 'var_a',当 fn 将要被执行时,同样先进行预编译操作,又遇到了变量 a,但是发现 a 已经在上层作用域中声明了,所以第三行的 a 赋值,只会被认作赋值语句,而不会再进行变量预声明变量作用域提升操作。所以答案很明了,代码执行完会输出两次 fn_a。

let、const 关键词声明变量

现在你知道 JavaScript 里变量声明有多坑了吧!使用 var 关键词声明变量得非常谨慎!所以在 es6 中,不推荐再使用 var 关键词,而使用 let 和 const 关键词!这两个关键词只在 es6 语法中才支持(不支持es6 语法的浏览器不能使用),在用法上 let 关键词声明变量和 var 没啥区别:

let a = 'let_a';

而 const 声明的变量都是不能再次修改的,在声明时就必须给变量赋值,否则会抛异常,如下:

const a; // Uncaught SyntaxError: Missing initializer in const declaration

let 关键词的出现就是为了防止出现上面说到的由于 JavaScript 预编译和变量提升特性导致的各种莫名其妙的问题。使用 let 声明的变量不会进行变量预声明作用域提升

但是,我们再来看看下面这段代码,你会有新的发现。。。

let a = 'let_a'
console.log(a) // let_a
console.log(window.a) // undefined

你没有看错,通过 let 声明的变量 a 并没有挂载到全局对象 window 上,这又是为什么呢?难道 window 对象内部不是全局作用域吗?

其实在 es6 中,新增了 let 和 const 两种变量声明关键词以外,还增加了块级作用域的概念。什么是块?一对大括号“{}”内的区域其实就是一个代码块,如 if、for 等形成的块,let 和 const 关键词的作用域就是这个块内,所声明的变量在指定块的作用域外无法被访问。

所以上文提到的函数作用域,其实只是一种局部作用域,而完整的局部作用域应当包括函数作用域和块级作用域两种。所以,以下代码执行结果就可以理解了。

function fn() {
    {
        // console.log(a)// Cannot access 'a' before initialization
        let a = 'let_a'
        console.log(a) // let_a
    }
    console.log(a) // a is not defined
}

fn()

块级作用域

问题:控制台编写的代码所在的作用域属于什么作用域?全局作用域 window?在 Sources/Snippets 呢? eval 呢?

image.png

image.png

eval('let a = 1;console.log(a)')// 1
console.log(a);// ReferenceError: a is not defined

其实并不是,这些都是块级作用域,相当于以下写法:

{
  let a = 1
  console.log(a);// 1
}

console.log(a);// ReferenceError: a is not defined

闭包

闭包是函数作用域的一种体现,前面我们说到,外层作用域下无法获取内层作用域下的变量,不同的函数作用域中也是不能相互访问彼此变量,但是我内层作用域却可以访问本身及外层作用域的变量,这其实就是闭包,闭包是绝不多数函数式语言都有的概念,究其本身还是因为作用域的问题导致的。

内存管理

let 临时死区

使用 let 关键词声明的变量虽然不会进行变量提升和预声明,但存在临时死区的概念。如下代码,如果没有临时死区的影响,理论上 console.log(a) 应该输出 vara,但是真实情况是,在函数 fn 执行前,js 同样会先进行扫描,然后将 let 声明之前的一段时间设定为TDZ(“临时死区”),变量不可被访问,编写代码时并不会提前报语法错误。

var a = 'vara';

function fn(){
    console.log(a);// Cannot access 'a' before initialization
    let a = 'leta';
}

fn();

严格模式

为了防止开发者因为一些疏漏使用了 var 关键词来声明变量而不自知,导致可能出现的异常情况。es6 中提供了严格模式来约束开发者,在不同作用域中使用 "use strict"; 即可让该作用域变为严格模式的作用域,如下:

"use strict";
fn();

function fn() {
    a = 'fn_a' // a is not defined
}

有了严格模式的约束,上面的变量 a 再也不会进行变量预声明作用域提升了,但是你会发现 fn 这个函数声明还是依然会进行预声明(不然第二行的代码执行就该报 fn is not defined 异常),哈哈神奇的 JavaScript!

总结

OK,以上就是关于 JavaScript 的变量预声明和作用域提升的全部内容,这里总结一下,JavaScript 在解释执行代码时,会先在当前作用域下进行预编译操作。在预编译阶段会先找到当前作用域中所有使用 var 关键词声明的变量,会立即在当前作用域的顶部进行变量声明操作。在代码解释执行阶段,当发现需要执行直接赋值语句,而要赋值的变量名此前从未声明过,JavaScript 会立即在全局作用域的顶部声明一个对应名称的变量,然后再进行赋值!用流程图表示如下:

graph TD
预编译阶段 --> 找到var关键词
找到var关键词 --> 当前作用域顶部声明变量
代码解释执行阶段 --> 直接赋值+从未声明过
直接赋值+从未声明过 --> 全局作用域顶部声明变量
全局作用域顶部声明变量 --> 变量赋值

声明: 以上分析结果仅本人测试分析得出,如有错误或描述不清的请大家批评指正!本人初学前端,对 JavaScript 的了解还不深入,如文章中存在的错误对您造成误导和困扰,请多多包涵!也请及时告知,我会及时更正相关内容~

参考文章

  1. 深入理解JavaScript作用域和作用域链
  2. Javascript-烦人的闭包
  3. js闭包测试