ES6 import/export 静态编译

6,765 阅读5分钟

我在查 tree-shaking 的资料时,最常见的一句话就是:

ES6 模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这就是 tree-shaking 的基础。

除了静态分析,我还听过 编译时加载/静态加载 等字眼,每当看到这,我总有个疑惑,这句话的意思是不是说 ES6 模块依赖关系的确定 / 静态分析是在预编译阶段确定的呢?

预编译

之前看 《你不知道的 JavaScript 上卷》时,其中说道:

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中移植。

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

  • 分词/词法分析

    将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元。 var a = 2; 这段程序通常会被分解成这些词法单元:var, a, =, 2, ;

  • 解析/语法分析

    将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树,即抽象语法树(AST)。

  • 代码生成

    将 AST 转换为可执行代码的过程。这个过程与语言/目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指令,用来创建一个叫作 a 的变量(包括内存分配等),并将一个值存在 a 中。

对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时间内

简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此,JavaScript 编译器会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且通常马上就会执行它。

变量提升

我们来看一下 demo1

function test() {
    console.log(a);
    console.log(foo());

    var a = 1;
    function foo() {
        return 2;
    }
}
test();

// 打印
// undefined
// 2

demo1 在预编译阶段发生了变量提升。经过预编译,执行顺序就变成了这样:

function test() {
    function foo() {
        return 2;
    }
    var a;
    console.log(a);
    console.log(foo());
    a = 1;
}
test();

具体预编译阶段做了什么处理可以参考 -> 前端基础进阶(三):变量对象详解

ES6 import / export

import 变量提升

我们来看下 demo2 & demo3:

demo2 - ES6

// a.js
console.log('I am a.js...')
import { foo } from './b.js';
console.log(foo);

// b.js
console.log('I am b.js...')
export let foo = 1;

// 运行 node -r esm a.js
// I am b.js
// I am a.js
// 1

demo3 - CommonJS

// a.js
console.log('I am a.js...')
var b = require('./b');
console.log(b.foo);

// b.js
console.log('I am b.js...')
let foo = 1;
module.exports = {
   foo: foo
}

// 运行 node a.js
// I am a.js
// I am b.js
// 1

demo2 先打印 'I am b.js',而 demo3 先打印 'I am a.js'。

demo2 中因为 ES6 在语言标准层面上实现了模块功能,所以当对 a.js 预编译时发现关键词 import 后,会先去加载 b.js,所以先输出 'I am b.js'。a.js & b.js 预编译后的执行顺序如下,整个流程是:预编译 a.js -> 发现关键词 import -> 预编译 b.js -> 执行 b.js -> 执行 a.js

// a.js
import { foo } from './b.js';
console.log('I am a.js...')
console.log(foo);

// b.js
console.log('I am b.js...')
export let foo = 1; // let 定义的变量不会提升

demo3 中,对 a.js 预编译时,只会把变量 b 的声明提前,a.js & b.js 预编译后的执行顺序如下:

// a.js
var b;
console.log('I am a.js...')
b = require('./b');
console.log(b.foo);

// b.js
console.log('I am b.js...')
let foo = 1;
module.exports = {
   foo: foo
}

export 变量提升

正常的引用模块没办法看出变量声明提升的特性,需要通过循环依引用才能看出。

我们来看下 demo4:

// a.js
import { foo } from './b';
console.log('a.js');
export const bar = 1; // const 定义的变量不能提升,但是前面有 export 后,可以提升声明部分。
export const bar2 = () => {
  console.log('bar2');
}
export function bar3() {
  console.log('bar3');
}

// b.js
export let foo = 1;
import * as a from './a';
console.log(a);

// 打印
// [Module] { bar: <uninitialized>, bar2: <uninitialized>, bar3: [Function: bar3] }
// a.js

ES6 Module VS CommonJS

ES6 是在预编译阶段去加载模块的,而 CommonJS 是在运行阶段去加载模块的(demo2 & demo3)。

ES6 模块输出的是值的引用,CommonJS 模块输出的是值的拷贝。

ES6 module: JS 引擎预编译时,遇到关键词 import,就会生成一个只读引用,等到脚本真正执行时,再根据这个只读引用到被加载的模块中取值。换句话说,原始值变了,import 的值也会跟着变,不会缓存值。

demo5

// a.js
import * as b from './b';
console.log(b.foo);
console.log(b.person);
setTimeout(() => {
  console.log(b.foo);
  console.log(b.person);
  import('./b').then(({ foo, person }) => {
    console.log(b.foo);
    console.log(b.person);
  });
}, 1000);

// b.js
export let foo = 1;
export let person = {
  name: 'tb'
}
setTimeout(() => {
  foo = 2;
  person.name = 'kiki'
}, 500);

// 打印
// 1
// { name: 'tb'}
// 2
// { name: 'kiki'}
// 2
// { name: 'kiki'}

内存空间变化 gif:

CommonJS: 第一次 require 执行模块后,在内存生成如下对象,其中 exports 的值是模块内部变量值的拷贝,用到这个模块时,就会到 exports 属性上面取值。注意:再次调用 require ,也不会执行该模块,而是到 exports 中取值;此后被 require 模块内部对输出值的改变不会影响。

{
  id: '...', // 模块名
  exports: { ... }, // 输出值
  loaded: true, // 模块是否执行完毕
  ...
}

demo6

// a.js
var b = require('./b');
console.log(b.foo);
console.log(b.person);
setTimeout(() => {
  console.log(b.foo);
  console.log(b.person);
  console.log(require('./b').foo); // 测试多次 require 输出值是否还是第一次 require 的输出值
  console.log(require('./b').person);
})

// b.js
let foo = 1;
let person = {
  name: 'tb'
}
setTimeout(() => { // 测试模块内部改变输出值是否影响
  foo = 2;
  person.name = 'kiki';
}, 500);
module.exports = {
   foo,
   person
}

// 打印
// 1
// { name: 'tb'}
// 1
// { name: 'tb'}
// 1
// { name: 'tb'}

内存空间变化 gif:

node 中运行 es6

npm install esm // 我的 node 版本是 v8.14.0
node -r esm xxx.js // xxx.js 中使用 ES6 模块规范
node xxx.js        // xxx.js 中使用 CommonJS 规范

参考

深入理解ES6的模块

前端基础进阶(三):变量对象详解

必须要知道的 CommonJS 和 ES6 Modules 规范

CommonJS vs ES6 import/export

深入理解 ES6 模块机制