重学ES6——ESM模块

146 阅读3分钟

前言

模块化就是将复杂的系统分解成高内聚,低耦合的模块,让系统开发变的更加易于管理、可测试和可维护,以及解决了出现全局污染的问题。

<!-- 全局污染 -->
<script type="text/javascript">
  let a = 1;
</script>
<script type="text/javascript">
  a = 2;
</script>

初代模块化

为了解决全局的问题,借助了函数作用域的语言特性,和立即执行函数 IIFE 进行封装,通过“私有变量”的特性构建局部变量,由返回函数将其形成闭包保留,这种创建模块的方式成为模块模式。代表有 YUI,jQuery 等。

var Foo = (function() {
  var count = 1;
  return {
    addCount: () => {
      count += 1;
    },
    getCount: () => {
      return count;
    },
  };
})();
Foo.addCount();
console.log(Foo.getCount());

也可以通过立即执行函数的参数作为不同“模块”之间的交互

var Foo = (function(Fn) {
  var count = 1;
  var result = Fn(count);
  return {
    getResult: () => {
      return count;
    },
  };
})(handle); // 传入参数

可以从上述看出,模块模式并没有解决依赖关系,需要开发者自己关注依赖顺序和依赖管理,这对于大型应用是非常不友好的,还有个问题就是,不能很方便的访问内部属性(只能通过闭包读取)

为了解决这两个问题,于是 JS 出现了两个标准

  • AMD:主要是服务于浏览器,实现有 RequireJS
  • CommonJS:面向通用 JAvaScript 环境(如 Node 服务端)

AMD

AMD(Asynchronous Module Definition,异步模块定义)采用了异步加载模块的方式,当其加载完毕后,通过回调的形式,执行依赖该模块的语句。

AMD 规范

  • 自动处理依赖,无需关系依赖顺序
  • 异步加载,没有阻塞
  • 在一个文件内可以定义多个模块
/**
* define(id?, dependencies?, factory);
* - id: 新创建模块的ID
* - dependencies: 当前模块依赖的模块ID列表
* - factory: 依赖模块列表工厂初始化的结果作为参
*/
define('Module1', ['jQuery'], $ => {
  const fn = () => {
    // do something
  }
  return {
    onClick: () => {
      $(document).on('click', fn);
    }
  }
}

CommonJS

CommonJS 主要是服务于服务端的,所以不需要考虑浏览器网络加载的问题(该模块语法也不能在浏览器直接运行),其实现是同步声明依赖的模块定义。

Node 模块系统借鉴了 CommonJS,但是还是有一些自己的改动

  • 语法简单
  • 同步执行
  • 以文件为单位作为模块,不能在一个文件内声明多个模块
const path = require('path');
module.exports = {
  getResolvePath: (path) => path.resolve(__dirname, path);
};

为了解决 AMD 和 CommonJS 环境不同的兼容性问题,又衍生出了一个通用方案 ———— UMD(Universal Module Definition,通用模块定义)。 UMD

ES Modules

鉴于迫切的的需求,ECMAScript 标准终于提出了一个新的模块标准————ESM。其结合了 CommonJS 和 AMD 的优点,简易的模块语法,基于文件(一个文件就是一个模块)以及支持异步加载,当然它支持两个环境的执行。

语法

含义写法
导出变量export const STATE = true;
导出函数export const Foo = () => {}; OR export function Foo() {};
导出默认export default Foo;
导出多个变量export { Foo, STATE };
使用别名导出export { Foo as MyFoo };
导入多个变量import { Foo, STATE } from './xxx.js';
导入全部内容import * as xxx from './xxx.js';
别名导入变量import { MyFoo as Foo } from './xxx.js';

工作原理

  • 获取模块文件:通过URL找到模块文件
<script src="main.js" type="module">
  • 解析依赖,生成模块映射:根据模块文件中的import导入标识符,维护一个ModuleMap来管理依赖及其状态,如果是正在请求,就会打上fetching的标识,如果请求完成就会生成Module Record (加载都不会去阻塞主线程,因为都不会直接执行)
  • 实例化:将所有匹配对应的导出和导入链接到同一个内存位置(Live Binding)
  • 评估(赋值):执行顶层代码计算导出内容,并将其值放入(导入导出指向的)内存地址
export { test }

function test () {]

ESM 和 CommonJS 的区别

导出值处理

CommonJS 是值拷贝(类似函数传参),而 ESM 是值引用,即导出的内容会根据模块内的变化而变化

这是因为 CommonJS 导出时的值和后面改变的值的内存地址不是同一个,如果导出的是对象,那么地址存储不是同一个,但是指向的内存数据是同一个,所以对象属性改变,也会改变,等同于浅拷贝。

而 ESM 使用的是实时绑定(Live Binding),即导出模块的内容和导入是指向一个内存地址,当模块内值发生改变时,直接修改的是其地址的内容,而不是新的内存地址

// b.js
let state = true;
module.exports = {
  state,
};
state = false;

// a.js
const b = require('./b');
console.log(b.state); // true
// b.js
export let state = true;
state = false;

// a.js
import * as b from './b';
console.log(b.state); // false

循环依赖

COMMONJS是同步加载的,代码执行遇到什么依赖就先执行依赖里的内容,并直接拿取依赖的当前浅拷贝的值

// a.js
const b = require('./b.js')    //【1】当遇到依赖时,先执行依赖内容
console.log(b.state)	//【5】拿到导出值,输出2
module.exports = { state: 1 } //【6】导出值,程序结束

// b.js
const a = require('./a.js') //【2】发现是循环依赖,就不在执行,直接拿到该依赖导出(浅拷贝)的值
console.log(a.state) //【3】执行导出值下的属性,输出undefined
module.exports = { state: 2 } //【4】导出值,依赖执行完成,回到a.js继续执行

// 执行a.js
// undefined
// 2

ESM是异步加载的,它首先会静态分析将代码变量提升,和将所有的依赖存入Module Map(依赖映射),映射值是该依赖的导出值引用,因此如果依赖导出值发生变更,依赖映射表也会“同步更新”

// a.mjs
import { b } from './b.js'; // 【2】当遇到依赖时,先存入依赖映射,然后执行依赖内容
console.log(b); // 【7】从依赖映射拿到引用值,输出2
var a = 1; //【1】解析遇到了var变量声明或者function函数声明,就会声明提升,但是不会赋值【8】赋值
export { a }; // 【9】导出值,程序结束


// b.js
import { a } from './a.mjs'; // 【3】发现是循环依赖,直接使用依赖映射的引用值
console.log(a); //【4】因为该变量在a文件解析中被变量提升了,所以输出undefined,如果使用const等没有变量提升,那么就会报没有初始化的错
var b = 2; // 【5】赋值
export { b }; // 【6】依赖映射值改变,依赖执行完成,回到a.js继续执行

<script type="module" src="./a.mjs"></script>
// 执行a.js
// undefined
// 2

加载方式

CommonJS 是动态加载,而 ESM 是静态加载

动态加载就是当运行时才能知道引用的模块,输出的是对象,值会被缓存

require(`${module}`);

静态加载是在编译时处理,ESM 有一个模块依赖的解析阶段(静态分析),因此可以进行依赖分析,找到没有被使用的代码,这也是能够实现 Tree Shaking 的主要原因

ES2020 支持 import 动态加载,返回一个 Promise

if (flags) {
  import(`${module}`).then();
}

只读

ESM 的 import read-only 特性

import * as b from './b';

b = 1; // Error: "b" is read-only.

提升

ESM 存在 export/import 提升

console.log(b.state); // false
import * as b from './b';

浏览器支持

多数浏览器已经支持了引入模块脚本
MDN

<script type="module" src="./a.mjs"></script>
<script type="module" src="./b.mjs"></script>
// a.mjs
import * as b from './b.mjs';
console.log(b.state);
// a.mjs
export let state = true;
state = false;

.mjs拓展名是为了更清晰的分辨是模块,也可以使用.js后缀

参考