每日一记 3分钟从编译后的代码里学 let 和 const 命令

2,105 阅读6分钟

新系列导读

学习编程语言是一件持之以恒的事情,从学会简单的语法就能写出程序,到理解类型和设计模式,再到考虑代码的组织架构。谁不是从这样一点点深入和积累的呢?入门总是轻松又令人愉悦的,但随着知识点越来越多学习的曲线却骤然陡峭。但随着对语言的深入理解,再回头来重新审阅基本的知识,又会有柳暗花明又一村的豁然感,「啊,原来是这样的」那种感觉。

这个 「3分钟系列」 将利用 babel 编译工具,来学习分析 es6+ 的部分特性。通过编译后的 es5 代码,我们可以从中了解到 es6+ 特性的实现细节,更好的掌握新特性的适用性。

本文大量使用了阮一峰「 ECMAScript 6 入门」和「你不知道的 JavaScript」书中的代码。

cutting line

环境和配置

// @babel/core: 7.2.2
// @babel/preset-env: 7.2.3

// .babelrc
{
  "presets": [
    ["@babel/preset-env", {
      "ignoreBrowserslistConfig": true
    }]
  ]
}

cutting line

块级作用域

ES5 只有全局作用域和函数作用域,没有块级作用域,带来了很多不合理的场景:

  • 内层变量可能会覆盖外层变量。
  • for 循环中的计数变量会泄露为全局变量。

在 ES5 中为了创建一个块级作用域,除了普通的函数声明外,就是立即执行函数表达式了(IIFE)。

// es5
var a = 2;
(function IIFE () {
	var a = 3;
	console.log(a); // 3
})();

console.log(a); // 2

cutting line

ES6 中引入了块级作用域,当在花括号中存在 let 或者 const 时,花括号内为块级作用域:

  • 外层作用域无法读取内层 letconst 所声明的变量。
  • 内层 letconst 所声明的变量名可以和外层相同。
  • 立即执行函数表达式不再必要了。
// ------ 源码区 ------
var x = 1;
let y = 1;
if (true) {
  var x = 2;
  let y = 2;
}
console.log(x); // 2
console.log(y); // 1


// ------ 编译区 ------
"use strict";
var x = 1;
var y = 1;

if (true) {
  var x = 2;
  var _y = 2;
}
console.log(x); // 2
console.log(y); // 1

特性:由于 let 使花括号提升为块级作用域,使得即使声明了相同的变量名 y 也互不干扰。

Babel:为了实现此效果,Babel 重命名了块级作用域内 let 声明的变量名。

cutting line

for 循环

// ------ 源码区 ------
let a = [];
for (let i = 0; i < 10; i++) {
  a[i] = function () {
    console.log(i);
  };
}
a[6]();


// ------ 编译区 ------
"use strict";
var a = [];

var _loop = function _loop(i) {
  a[i] = function () {
    console.log(i);
  };
};

for (var i = 0; i < 10; i++) {
  _loop(i);
}
a[6]();

特性:当用 var 声明变量 i 时,a[6]() 输出的是 10,因为循环体没有缓存变量 i

Babel:当用 let 声明时,Babel 创建了一个闭包 _loop 来缓存变量。

cutting line

cutting line

// ------ 源码区 ------
let i = 1;

for (let i = 0; i < 3; i++) {
  let i = 'abc';
  console.log(i);
}


// ------ 编译区 ------
"use strict";
var i = 1;
for (var _i = 0; _i < 3; _i++) {
  var _i2 = 'abc';
  console.log(_i2);
}

特性for 循环中,初始化变量的部分和循环体内分别是两个作用域。

Babel:Babel 重命名了循环体内的变量 i

cutting line

let

ES6 新增了 let 命令,用来声明变量。

let 拥有如下特性:

  • 仅在其作用域内有效。
  • 不存在变量提升。
  • 产生暂时性死区。
  • 不允许重复声明。

仅在其作用域内有效

// ------ 源码区 ------
{
  let a = 10;
  var b = 1;
}
a // ReferenceError: a is not defined.
b // 1


// ------ 编译区 ------
"use strict";
{
  var _a = 10;
  var b = 1;
}
a; // ReferenceError: a is not defined.
b; // 1

特性let 所声明的变量只会在其作用域内有效,作用域外调用该变量会报错。

Babel:为了用 ES5 实现相同的特性,Babel 重命名了 let 声明的变量名,使得作用域内外的变量名不同。

cutting line

不存在变量提升

// ------ 源码区 ------
// var 的情况
console.log(foo); // 输出undefined
var foo = 2;

// let 的情况
console.log(bar); // 报错ReferenceError
let bar = 2;


// ------ 编译区 ------
"use strict";
// var 的情况
console.log(foo); // 输出undefined
var foo = 2; // let 的情况

console.log(bar); // 报错ReferenceError
var bar = 2;

特性let 必须先声明再使用,这种语法行为纠正了 var 变量提升的现象。

Babel:Babel 在此处并没有做特殊的处理。

重要提示: let 在编译后没有添加异常提示,Babel 在变量提升细节上处理不佳,你的代码运行结果可能会和你预想中的有差异。

养成良好的代码习惯,有助于避免此坑。

变量提升 | MDN

cutting line

暂时性死区

缩写为「TDZ」(temporal dead zone)。

当区块中存在 letconst 命令,这个区块对这些命令声明的变量就形成了封闭区域,凡是在声明前就使用这些变量,就会报错。

// ------ 源码区 ------
var tmp = 123;

{
  tmp = 'abc'; // ReferenceError
  let tmp;
}


// ------ 编译区 ------
"use strict";
var tmp = 123;
{
  _tmp = 'abc'; // ReferenceError
  var _tmp;
}

特性:上面的源码区中,期望给外部的 tmp 赋值 abc 。但由于在区块中声明了同名变量,所以此时 tmp 变量被内部占用。

Babel:Babel 很好的处理了这个特性,将区块内的 tmp 变量更名为 _tmp 以区分。但是,仍然会存在变量提升的问题。

cutting line

不允许重复声明

// ------ 源码区 ------
function foo() {
  let a = 10;
  let a = 1;
}

// ------ 编译区 ------
// 编译报错 Duplicate declaration "a"

特性let不允许在相同作用域内,重复声明同一个变量。

Babel:当重复声明同一个变量时,编译无法通过。

cutting line

const

const 声明一个只读的常量。

const 有如下特性:

  • 变量一旦声明,其值(内存地址)就不可改变。
  • 声明时必须赋值。
// ------ 源码区 ------
const a = 1;


// ------ 编译区 ------
var a = 1;

特性:最普通的使用方式。

Babel:如果上下文没有违背规范,则会直接用 var 来声明。

cutting line

变量一旦声明,其值(内存地址)就不可改变。

// ------ 源码区 ------
const a = 0;
a = 1;


// ------ 编译区 ------
"use strict";
function _readOnlyError(name) { throw new Error("\"" + name + "\" is read-only"); }

var a = 0;
a = (_readOnlyError("a"), 1);

特性:一旦 const 声明了一个变量后尝试再次赋值,会报异常。

Babel:Babel 检测到变量被在此赋值,主动插入了一个报错,并终止程序运行。

cutting line

声明时必须赋值

// ------ 源码区 ------
const a;


// ------ 编译区 ------
// 编译报错 Unexpected token

cutting line

总结

Babel 在处理 letconst 的大部分特性时都不错,但是在 变量先声明后使用 的细节上处理不佳。需要我们保持良好的变量声明习惯。

cutting line

兼容性表

目前 86% 左右的浏览器都原生支持 letconst

cutting line

后续

当我文章写到此处,我仍然疑惑为什么 Babel 为什么会没有正确编译暂时性死区的特性,留下这样的问题。

直到我找到了 @babel/plugin-transform-block-scoping 插件。

原来想要 Babel 编译时正确实现该特性,需要引入这个插件并开启配置。

// .babelrc
{
  "presets": [
    ["@babel/preset-env", {
      "ignoreBrowserslistConfig": true
    }]
  ],
  "plugins": [
    ["@babel/plugin-transform-block-scoping", {
      "tdz": true
    }]
  ]
}

cutting line

此时 Babel 会为代码插入一个异常。

// ------ 源码区 ------
i;
let i = 1;


// ------ 编译区 ------
"use strict";
(function () {
  throw new ReferenceError("i is not defined - temporal dead zone");
})();

var i = 1;

但是文档中也说了,这个插件没有覆盖所有边界情况,也应该小心使用。

Temporal Dead Zone · Issue #826 · babel/website · GitHub

@babel/plugin-transform-block-scoping · Babel

cutting line

相关阅读

2019 年的 JavaScript 新特性学习指南