JavaScript 里在代码块中行事诡异的函数声明

    技术2022-07-12  65

    最近在贴吧里看到了一种关于在代码块中赋值到函数所在的变量上的问题,我觉得从这类问题中可以提炼出一些写代码的时候要注意的方面,因此以我自己的理解来谈谈。 注意:本文仅代表作者个人观点,只是以作者的理解来阐述。欢迎指正拙见中的错误。

    令人捉摸不透的赋值结果

    最近在贴吧看到了如下的一种代码:

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

    很多人表示无法理解这个结果,并且怀疑自己的执行器出了问题。那么让我们来一步步看看到底这段代码做了什么。

    //于 ES2020 环境的测试结果(使用 Chrome) var a = 0; // [全局] a: 0 { // [全局] a: 0, [块级] a: function () {} a = 1; // [全局] a: 0, [块级] a: 1 function a() {} // [全局] a: 1, [块级] a: 1 a = 2; // [全局] a: 1, [块级] a: 2 console.log(a) // ← [块级] a: 2 } // [全局] a: 1 console.log(a); // ← [全局] a: 1

    是不是一脸懵逼了呢?怎么会突然出现一个块级作用域?块级作用域上又怎么会有个变量 a 呢?别急别急,让我们从头到尾地把原因梳理一遍。

    令人舒适的块级作用域

    不知道你有没有遇到过这种情况,有一个你必须要缓存起来的东西,但是这个东西在过了某个步骤之后就再也用不到了,并且某些时候你又希望它能够保留在某个你访问不到的地方。 这时候就会有一个让人头疼的问题,这个东西会占用一个变量,而且还不能随便地覆盖掉它,也会占用内存,而且某些时候,即使可能需要将其保留,但并不需要保留它的全部,并且对于有起名困难症的人来说,其他变量要叫什么名有时候也是一个麻烦的问题……

    if (a = 0) { var b = func1("data"); // ← {data: "bit", value: 32, …} var c = b.value * 2, d = c + b.data; }

    在 ES6 标准发布以前,对于上面的问题,程序员只能是通过别的方法,来尽量缓和状况,又或者另辟蹊径来解决某些问题。ES6 发布之后,情况终于有了改善。ES6 提出了一个新的关键字“let”,以及一个新的概念“块级作用域”,而且 let 关键字与块级作用域是配套的。什么意思呢?就是说,现在,被 { } 包裹的被称为“代码块”的代码可以有自己的作用域了,let 关键字用于在代码块的块级作用域中创建局部变量。{ } 这样的代码块有了新的实用意义,如果只是为了存放一个使用周期很短的数据就要占用一个变量的话,实在是太浪费了。有了块级作用域以后,可以拥有一个临时的空间去存放某些计算过程中要记下来但是在结束了某一个步骤之后就不再需要的东西。但是不要高兴得太早,因为这个新东西还牵扯到了 JavaScript 中的一个方面:作用域。 JavaScript 中以前有两种作用域:全局作用域与函数作用域,当初并没有块级作用域这个东西,以前的 { } 只是为了将代码整合到一起而已。而与作用域相关的有一个很重要的东西:函数的声明与定义。

    行事诡异的函数声明

    详细了解过 function 关键字、变量提升的同行应该会清楚一个概念:为了避免查询问题以及遵循函数提前可用原则,虽然我们的变量声明写在哪里都没有问题,但是引擎在解析的时候为了避免查询问题,会把在当前所处的作用域中变量的声明于作用域里的代码真正开始执行前提前进行声明,可以想象成提前挖好一个坑,准备好放东西进去。而函数声明就更狠了,它会在声明的同时把函数的定义也给放进去,也就是提前记录好函数作用域的从属关系、以及函数会做什么。 但是新来的 let 跟块级作用域要搞特殊,块级作用域并不是独立的作用域,在块级作用域里面,var 关键字声明的变量会直接穿透块级作用域,声明到块级作用域外面的函数作用域或者全局作用域,而 let 关键字声明的变量只能在它所处的块级作用域中访问。而且与 var 不同的是,虽然 let 关键字也会将变量提前声明到块级作用域的开头,但是如果其还没有初始化就直接访问的话,会报错,而不是像 var 变量一样返回 undefined,就是指执行器经过了 let 关键字声明的变量所在的那一句,才算初始化,这个局部变量才可以用,而且在同一个代码块内,对于一个变量名只能写一次 let,写多了也报错,但是在多层块级作用域可以用同一个变量名。而这正好为 function 关键字带来了麻烦。function 关键字的声明和 var 关键字采取一样的做法,无视块级作用域。 但是函数定义表示有异议,再怎么说,如果函数是写在代码块里面的话,那函数就一定要能访问到代码块的块级作用域才行,但是如果连函数定义也提前写好的话就访问不了块级作用域了,毕竟 JavaScript 是解释型语言,没办法事先确定好所有的数据关系。但是 function 关键字也不能在这一点上妥协,估计是想避免 function 关键字与以前产生的效果大相庭径而影响到对以前编写的脚本的兼容。那怎么办呢?这次函数定义做出了一点点妥协,遇到函数被写在代码块里的情况,函数定义不再要求在声明函数的时候完成了,它会在函数所在的函数作用域或者全局作用域先声明个变量占个坑,但先不把函数定义写进去,等进入了函数所在的块级作用域,在函数所在的块级作用域占个坑,声明一个局部变量,然后立刻写入函数定义,如果代码块外面还套有代码块,那就……不管它!到这一步还没有结束,外面的那个坑还等着呢,怎么办呢?脚本会等到执行到函数所在的那一句把局部变量的内容扔给外面的那个变量。但是这里有一个问题,因为要等到执行到函数所在的那一句时才会把局部变量的内容传给外面,也就是说直到这个局部变量的内容被交给外面前,把这个变量的内容改成任何东西都可以,而且因为交给外面的是那个时候的局部变量的内容,而不是一开始的函数定义,所以归根结底,对以前脚本的兼容以及随便往已经分配给函数的变量写入其他的内容才是导致这种现象的罪魁祸首。

    注:虽然函数的代码和代码块的代码都是用 { } 包裹起来的,但毕竟它们的表现是不一样的,所以我所讲的代码块不包括函数。

    其实所有发生的事情就和下面的代码差不多:

    var a; var a = 0; var local = {}; //因为在块级作用域里访问不了同名的外层变量,所以我用另一个变量来模拟一个通道 Object.defineProperty(local, "a", { get: function() { return a }, set: function(value) { a = value } }); { let a = function a() {}; a = 1; local.a = a; a = 2; console.log(a) } console.log(a);

    无法割舍的爱

    上面说了种种云云,那么既然把函数写在代码块里会有块级作用域的问题,那是不是就不要这么写了呢?也不尽然…… 就比如说,我就会用这样的写法:

    var a = { b: {c: 0, d: 0, e:0} }; for (let i of ["c", "d", "e"]) { Object.defineProperty(a, i, { get: function() { return a.b[i] }, set: function(value) { a.b[i] = value }, enumerable: true }) };

    我在上文说过这样的这样的话:

    有一个你必须要缓存起来的东西,但是这个东西在过了某个步骤之后就再也用不到了,并且某些时候你又希望它能够保留在某个你访问不到的地方。

    把函数写在代码块里自然有它的妙用,若不是这种写法,那么我肯定要写多达 3 个 Object.defineProperty ,这真的是太傻了。 事实上,块级作用域结合 for 语句实际上等同于这样写:

    { let p = ["c", "d", "e"]; let n = 0; { let i = p[n]; Object.defineProperty(a, i, { get: function() { return a.b[i] }, set: function(value) { a.b[i] = value }, enumerable: true }) } n++; { let i = p[n]; Object.defineProperty(a, i, { get: function() { return a.b[i] }, set: function(value) { a.b[i] = value }, enumerable: true }) } n++; { let i = p[n]; Object.defineProperty(a, i, { get: function() { return a.b[i] }, set: function(value) { a.b[i] = value }, enumerable: true }) } }

    由于在块级作用域里声明了局部变量,有了函数的存在,只要对函数的引用没有丢失,块级作用域里的 i 会一直保留,函数对 i 的查询可以查询到每个块级作用域的 i,相当于每个函数引用的 i 都分别被写死了。for 循环可以帮我完成一部分繁琐的工作,何乐而不为呢? 其实把函数写在代码块里,只要运用得当,还是能轻松办到许多事的。只是像文章开头提到的那种随便往已经分配给函数的变量写入其他的内容的做法很容易导致问题,所以需要注意避免这样的不良做法。而且,你如果觉得这种写法用直觉不好判断的话,还有严格模式可以用。在严格模式下,这种写法会有截然不同的结果:

    //于 ES2020 环境使用严格模式的测试结果(使用 Chrome) var a = 0; // [全局] a: 0 { // [全局] a: 0, [块级] a: function () {} a = 1; // [全局] a: 0, [块级] a: 1 function a() {} // [全局] a: 0, [块级] a: 1 //↑这一句此时不会有任何动作,相当于 function 关键字的处理模式从 var 变成了 let a = 2; // [全局] a: 0, [块级] a: 2 console.log(a) // ← [块级] a: 2 } // [全局] a: 0 console.log(a); // ← [全局] a: 0

    旧时光景

    上面说的都是在 ES6 标准出来后的情况,但是你知道在 ES6 标准出来以前,开头写的代码是什么情况吗?

    //于 ??? 环境的测试结果(使用 Internet Explorer 10) // [全局] a: function () {} var a = 0; // [全局] a: 0 { // [全局] a: 0 a = 1; // [全局] a: 1 function a() {} // [全局] a: 1 a = 2; // [全局] a: 2 console.log(a) // ← [全局] a: 2 } // [全局] a: 2 console.log(a); // ← [全局] a: 2

    所以嘛,其实说这种结果是执行器出了问题,在某种情况下也没啥毛病…… 最后再来欣赏一下微软公司的特立独行吧:

    //于 ???[2019] 环境的测试结果(使用 Internet Explorer 11) var a = 0; // [全局] a: 0 { // [全局] a: function () {}, [块级] a: function () {} a = 1; // [全局] a: function () {}, [块级] a: 1 function a() {} // [全局] a: function () {}, [块级] a: 1 a = 2; // [全局] a: function () {}, [块级] a: 2 console.log(a) // ← [块级] a: 2 } // [全局] a: function () {} console.log(a); // ← [全局] a: function () {}
    Processed: 0.011, SQL: 9