浅谈前端模块化开发

在JavaScript发展初期就是为了实现简单的页面交互逻辑,寥寥数语即可,现在的前端开发, 不仅仅是完成浏览的基本需求,并且通常是一个单页面应用,每一个视图通过异步的方式加载,这导致页面初始化和使用过程中会加载越来越多的JavaScript代码。如何在开发环境组织好这些碎片化的代码和资源,并且保证他们在浏览器端快速、优雅的加载和更新,就需要一个模块化系统。

模块化概述

什么是模块化

模板化是在文件层面上,对代码和资源的拆分。就是将一个大文件拆分成相互依赖的小文件,再进行统一的拼装和加载。

那具体什么是模块化呢,举一个简单的例子,我们要写一个实现A功能的JS代码,这个功能在项目其他位置也需要用到,那么我们就可以把这个功能看成一个模块采用一定的方式进行模块化编写,既能实现复用还可以分而治之,同理在写样式的时候,如果我们需要某种特殊的样式,会在很多地方应用,那么我们也可以采用一定的方式进行CSS的模块化。具体说来,JS模块化方案很多有AMD/CommonJS/UMD/ES6 Module等,CSS模块化开发大多是在less、sass、stylus等预处理器的import/mixin特性支持下实现的。

分而治之:将一个大问题分解成多个较为独立的与原问题性质相同的小问题,将所有的小问题的解答组合起来即可得到大问题的答案。

说到模块化,肯定会想起组件化,那么什么是组件化

组件化是在设计层面上,对于UI的拆分。

组件化将页面视为一个容器,页面上各个独立部分例如:头部、导航、焦点图、侧边栏、底部等视为独立组件,不同的页面根据内容的需要,使用对应的组件即可组成完整的页面。

组件具有独立性,因此组件与组件之间可以自由组合,当不需要某个组件,或者想要替换组件时,可以整个目录删除/替换。

组件化模块化优点:开发调试效率高、可维护性强、避免阻断、版本管理更容易

模块化的发展过程

1. 原始写法(文件划分方式)

将每个功能以及相关状态的数据,单独存放在不同的文件中,约定每个文件就是一个独立的模块,将不同的功能封装成不同的全局函数,使用模块就是通script标签将模块引入到页面中,一个script标签就对应一个模块,在代码中直接调用模块的全局成员(变量或者函数)

1
2
3
4
5
6
7
8
9
// module-a.js 
// module a 相关状态数据和功能函数
var name ="module-a"
function foo(){
console.log(name+'#foo')
}
function bar(){
console.log(name+'#bar')
}
1
2
3
4
5
6
7
8
9
// module-b.js 
// module b 相关状态数据和功能函数
var name ="module-b"
function foo(){
console.log(name+'#foo')
}
function bar(){
console.log(name+'#bar')
}
1
2
3
4
5
6
7
8
9
10
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
// 命名冲突
foo()
// 模块成员可以被修改
name='foo'
</script>
<body>

存在问题:污染全局作用域, 容易引起命名冲突或数据不安全,无法管理模块依赖关系

2. 对象的写法(命名空间的方式)

为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面

1
2
3
4
5
6
7
8
9
10
// module-a.js 
var moduleA = {
name: "module-a",
foo: function{
console.log(name+'#foo')
}
bar: function{
console.log(name+'#bar')
}
}
1
2
3
4
5
6
7
8
9
10
// module-b.js 
var moduleB = {
name: "module-b",
foo: function{
console.log(name+'#foo')
}
bar: function{
console.log(name+'#bar')
}
}
1
2
3
4
5
6
7
8
9
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.foo()
moduleB.foo()
moduleA.name='foo'
</script>
<body>

存在问题: 这样的写法会暴露所有模块成员,内部状态可以被外部改写,外部可以直接修改模块内部的数据

3. 立即执行函数写法(IIFE Immediately-Invoked Function Expression)

匿名函数自调用(闭包),将变量设置为私有成员,外部只能通过暴露的方法操作,确保了私有变量的安全

具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现

1
2
3
4
5
6
7
8
9
10
11
12
// module-a.js
(function(window) {
var name= "module-a"
function foo(){
console.log(name+'#foo')
}
function bar(){
console.log(name+'#bar')
}
//暴露行为ES6
window.moduleA = { foo, bar }
})(window)
1
2
3
4
5
6
7
8
9
10
11
12
// module-b.js
(function(window) {
var name= "module-b"
function foo(){
console.log(name+'#foo')
}
function bar(){
console.log(name+'#bar')
}
//暴露行为ES6
window.moduleB = { foo, bar }
})(window)
1
2
3
4
5
6
7
8
9
 <!-- index.html -->
<script type="text/javascript" src="module.js"></script>
<script type="text/javascript">
moduleA.foo()
moduleB.bar()
console.log(moduleA.name) //undefined 不能访问模块内部数据
moduleA.name = 'xxxx' //不是修改的模块内部的data
moduleA.foo() //没有改变
</script>

IIFE模式增强 : 引入依赖
自执行函数的参数作为依赖的声明使用,是的每个模块的之间的依赖关系变得更加明显

这就是现代模块实现的基石

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// module-a.js文件
(function ($) {
var name = 'module-a'

function foo () {
console.log(name + '#foo')
$('body').animate({ background: 'red' })
}

function bar () {
console.log(name + '#bar')
}

window.moduleA = {foo, bar}
})(jQuery)
1
2
3
4
5
6
7
// index.html文件
<!-- 引入的js必须有一定顺序 -->
<script type="text/javascript" src="jquery-1.10.1.js"></script>
<script type="text/javascript" src="module-a.js"></script>
<script type="text/javascript">
moduleA.foo()
</script>

上例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显

模块化好处

  • 避免命名冲突(减少命名空间污染)
  • 更好的分离, 按需加载
  • 更高复用性
  • 高可维护性

引入多个<script>后出现出现问题

  • 请求过多

首先我们要依赖多个模块,那样就会发送多个请求,导致请求过多

  • 依赖模糊

我们不知道他们的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。

  • 难以维护

以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。
模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。而这些问题可以通过模块化规范来解决,下面介绍开发中最流行的commonjs, AMD, CMD, ES6规范。

模块化的规范

CommonJS

概述

CommonJS是服务器端模块的规范,NodeJs采用了这个规范,并且是以同步模式加载模块,约定如下:

  • 一个文件就是一个模块,每个模块有单独的作用域
  • 在一个文件里面定义的变量、函数、类,都是私有的,对其他文件不可见
  • 在服务器端,模块的加载是运行时同步加载的
  • 在浏览器端,模块需要提前编译打包处理
  • 导出模块:module.exports = value或exports.xxx = value
    CommonJS规范规定,每个模块内部,module变量代表当前模块。这个变量是一个对象,它的exports属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的module.exports属性。
  • 引入模块:require(xxx),如果是第三方模块,xxx为模块名;如果是自定义模块,xxx为模块文件路径
    require命令用于加载模块文件。require命令的基本功能是,读入并执行一个JavaScript文件,然后返回该模块的exports对象。如果没有发现指定模块,会报错。
1
2
3
4
5
6
7
// example.js
var x = 5;
var addX = function (value) {
return value + x;
};
module.exports.x = x;
module.exports.addX = addX;
1
2
3
4
//如果参数字符串以“./”开头,则表示加载的是一个位于相对路径
var example = require('./example.js');
console.log(example.x); // 5
console.log(example.addX(1)); // 6

优缺点

  • 优点:
    • 所有代码都运行在模块作用域,不会污染全局作用域。
    • 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
    • 模块加载的顺序,按照其在代码中出现的顺序。
  • 缺点:
    • 同步的模块加载方式不适合在浏览器环境中,同步意味着阻塞加载,浏览器资源是异步加载的
    • 不能非阻塞的并行加载多个模块

模块的加载机制

CommonJS模块的加载机制是,输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。这点与ES6模块化有重大差异,请看下面这个例子:

1
2
3
4
5
6
7
8
9
// module.js
var count = 3;
function addCount() {
count++;
}
module.exports = {
count,
addCount
};

上面代码输出内部变量count和改写这个变量的内部方法addCount。

1
2
3
4
5
6
// main.js
const {count,addCount}= require('./module.js')

console.log(count); // 3
addCount();
console.log(count); // 3

上面代码说明,count输出以后,module.js模块内部的变化就影响不到count了。这是因为count是一个原始类型的值,会被缓存。除非写成一个函数,才能得到内部变动后的值。

AMD(Asynchronous Module Definition)

CommonJS规范加载模块是同步的,所以只有加载完成才能执行后面的操作。像Node.js主要用于服务器编程,模块文件一般都已经存在于本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以CommonJS规范比较适用。但如果是浏览器环境,要从服务器端加载模块,这是就必须采用异步模式,结合浏览器的特殊情况,AMD 规范应运而生。

基本语法

  • 定义模块
    AMD约定需要通过一个叫做define()去定义一个模块

define(id?, dependencies?, factory)它要在声明模块的时候指定所有的依赖dependencies,并且还要当做形参传到factory 中,对于依赖的模块提前执行,依赖前置。

  • id:字符串,表示模块名,方便后期通过模块名进行使用
  • dependencies:数组,用来声明这个模块的一些依赖项,此参数按需添加,可有可无
  • factory:函数,函数的参数和依赖项一一对应,分别是前面依赖项各自导出的成员。函数的作用则是为模块提供一个私有的空间,通过return的方式导出外部所需的成员。
1
2
3
4
define("module", ["dep1", "dep2"], function(d1, d2) {
return someExportedValue;
});
require(["module", "../file"], function(module, file) { /* ... */ });

定义一个名为 module 的模块,它依赖 jQuery和module2 模块

1
2
3
4
5
6
7
8
9
//module.js
define("module", ["jquery", "./module2"], function($, module2) {
return {
start: function (){
$(body).animate({margin:'200px'})
module2()
}
}
});

  • 引入模块
1
2
3
require(["./module"], function(module1) {
module1.start()
})

Require.js 是 AMD 规范诞生同期非常出名的库,它实现了 AMD 规范,且它本身也是一个强大的模块加载器。

未使用AMD规范与使用require.js比较

  • 未使用AMD规范

    1
    2
    3
    4
    5
    6
    7
    8
    // dataService.js文件
    (function (window) {
    let msg = 'www.baidu.com'
    function getMsg() {
    return msg.toUpperCase()
    }
    window.dataService = {getMsg}
    })(window)
    1
    2
    3
    4
    5
    6
    7
    8
    // alerter.js文件
    (function (window, dataService) {
    let name = 'Tom'
    function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
    }
    window.alerter = {showMsg}
    })(window, dataService)
    1
    2
    3
    4
    // main.js文件
    (function (alerter) {
    alerter.showMsg()
    })(alerter)
    1
    2
    3
    4
    5
    <!-- index.html文件 -->
    <div><h1>Modular Demo 1: 未使用AMD(require.js)</h1></div>
    <script type="text/javascript" src="js/modules/dataService.js"></script>
    <script type="text/javascript" src="js/modules/alerter.js"></script>
    <script type="text/javascript" src="js/main.js"></script>

    运行结果:

    这种方式缺点很明显:首先会发送多个请求,其次引入的js文件顺序不能搞错,否则会报错,可维护性也比较差。

  • 使用require.js
    RequireJS是一个工具库,主要用于客户端的模块管理。它的模块管理遵守AMD规范,RequireJS的基本思想是,通过define方法,将代码定义为模块;通过require方法,实现代码的模块加载。
    接下来介绍AMD规范在浏览器实现的步骤:

    1. 下载require.js, 并引入
    官网 github

    然后将require.js导入项目: js/libs/require.js
    2. 创建项目结构

    1
    2
    3
    4
    5
    6
    7
    8
    |-js
    |-libs
    |-require.js
    |-modules
    |-alerter.js
    |-dataService.js
    |-main.js
    |-index.html

    3. 定义require.js的模块代码并引入

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // dataService.js文件
    // 定义没有依赖的模块
    define(function() {
    let msg = 'www.baidu.com'
    function getMsg() {
    return msg.toUpperCase()
    }
    return { getMsg } // 暴露模块
    })
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    //alerter.js文件
    // 定义有依赖的模块,引入第三方库jQuery
    define(['dataService', 'jquery'], function(dataService, $) {
    let name = 'Tom'
    function showMsg() {
    alert(dataService.getMsg() + ', ' + name)
    }
    $('body').css('background', 'green')
    // 暴露模块
    return { showMsg }
    })
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // main.js文件
    (function() {
    require.config({
    baseUrl: 'js/', //基本路径 出发点在根目录下
    paths: {
    //自定义模块
    alerter: './modules/alerter', //此处不能写成alerter.js,会报错
    dataService: './modules/dataService',
    // 第三方库模块
    jquery: './libs/jquery-1.10.1' //注意:写成jQuery会报错
    }
    })
    require(['alerter'], function(alerter) {
    alerter.showMsg()
    })
    })()

    上例是在alerter.js文件中引入jQuery第三方库,main.js文件也要有相应的路径配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!-- index.html文件 -->
    <!DOCTYPE html>
    <html>
    <head>
    <title>Modular Demo</title>
    </head>
    <body>
    <!-- 引入require.js并指定js主文件的入口 -->
    <script data-main="js/main" src="js/libs/require.js"></script>
    </body>
    </html>

    通过两者的比较,可以得出AMD模块定义的方法非常清晰,不会污染全局环境,能够清楚地显示依赖关系。AMD模式可以用于浏览器环境,并且允许异步加载模块,并行加载多个模块,也可以根据需要动态加载模块。
    缺点

    • 开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅;
    • 不符合通用的模块化思维方式,是一种妥协的实现;

CMD

CMD(Common Module Definition),通用的模块定义规范,类似 CommonJs 规范。Sea.js 是由淘宝推出的库,遵循CMD规范。

CMD规范基本语法

定义模块:

  • 定义没有依赖的模块

    1
    2
    3
    4
    define(function(require, exports, module){
    exports.xxx = value
    module.exports = value
    })
  • 定义有依赖的模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    define(function(require, exports, module){
    //引入依赖模块(同步)
    var module2 = require('./module2')
    //引入依赖模块(异步)
    require.async('./module3', function (m3) {
    })
    //暴露模块
    exports.xxx = value
    })

引入使用模块:

1
2
3
4
5
6
define(function (require) {
var m1 = require('./module1')
var m4 = require('./module4')
m1.show()
m4.show()
})

优点:依赖就近,延迟执行 可以很容易在 Node.js 中运行;
缺点:依赖 SPM 打包,模块的加载逻辑偏重;

ES6 Modules(模块化的最佳实践)

目前前端模块化规范上统一为:Node.js上使用 CommonJS 规范,浏览器 上使用 ES Modules 规范
ES Modules规范是在 ECMAScript2015(ES6)中提出的 Module 模块标准,其设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量,而CommonJS 和 AMD 模块,都只能在运行时确定这些东西,比如,CommonJS 模块就是对象,输入时必须查找对象属性。

ES Module基本特性

通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码

  • ESM 会自动采用严格模式,忽略 ‘use strict’

也就是即使不加 use strict ,它内部的 JS 也将是严格模式执行的。严格模式下,this 为 undefined;非严格模式下,this 为 window

1
2
3
<script type="module">
console.log(this); //undefined
</script>
  • 每个ESM都是运行在单独的私有作用域中
    通过使用多个 script 来分割代码为不同模块,模块间的作用域都将是独立互不影响的。
1
2
3
4
5
6
7
<script type="module">
var foo = 100 //不会造成全局作用域的污染
console.log(foo); // 100
</script>
<script type="module">
console.log(foo); // Uncaught ReferenceError: foo is not defined
</script>
  • ESM 是通过 CORS 的跨域请求方式请求外部 JS 模块的
    既然是 CORS 的方式,那么说明我们通过 src 引用的 JS 必须是同源的,服务端要支持CORS,否则将会存在跨域问题
1
2
<script type="module" src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js">
</script>

  • ESM 的 script 标签会延迟执行脚本,相当于 script 的 defer 属性
1
2
3
4
<script type="module" src="./demo.js">
// 等待网页的渲染过后,再去执行脚本,不会阻碍页面的显示
</script>
<p>需要显示的内容</p>

网页默认是先执行 JS 再进行渲染的,而延迟执行则会先使网页完成渲染,再执行 JS 代码。

ES Module 导入和导出(最为核心和常用的功能)

export命令用于规定模块的对外接口,import 是在模块内导入其他模块所提供的接口。

导出导入语法
  • 可以单独成员导出,也可以集中导出,集中导出可以更加直观的看到向外部导出了哪些成员
1
2
3
4
5
6
7
8
9
10
11
/* 导出 */
// 第一种:分别导出
export var name = 'foo module' // 导出变量
export function hello () { } // 导出函数
export class Person { } // 导出类

//第二种:集中导出
var name = 'foo module'
function hello () { }
class Person { }
export { name, hello, Person }
1
2
3
/* 导入 */
// .js 不可以省略,完整路径 module_path:导入模块的名称
import { name, hello, Person } from 'module_path'
  • 别名导出,使用 as 进行重命名
1
2
3
4
5
6
/* 导出 */
var name = 'foo module'
function hello () { }
class Person { }

export { name as fooName, hello as fooHello, Person as fooPerson }
1
2
3
4
5
6
7
/* 导入 */
// 第一种
import { fooName, fooHello, fooPerson } from 'module_path'

//第二种: 提取模块导出的所有成员, 使用 as 将所有成员作为一个对象的属性
import * as mod from 'module_path'
console.log(mod.name, mod.age);
  • 默认导出,设置某一个成员的别名是 default
1
2
3
4
5
6
/* 导出 */
var name = 'foo module'

export { name as default }
// <==> 推荐下方书写方式
export default name
1
2
3
4
/* 导入 */
import { default as name } from 'module_path' // default 是关键字
// 简写为
import name from 'module_path'
  • 导入模块时,模块路径的三种写法
1
2
3
4
5
6
7
8
// ./ 不可以省略,相对路径 .js 的文件扩展名是不可省略的,省略会直接报错
import { name } from './module.js'

// / 绝对路径
import { name } from '/import/module.js'

// 完整 url 路径
import { name } from 'http://localhost:2080/module.js'
  • 加载模块,但不提取模块内的成员,一般用于导入一些不需要外部控制的子功能模块
1
2
3
import {} from 'module_path'
// 简写为
import 'module_path'
  • 在脚本运行时动态引入模块
    我们总会有这样的需求:模块的名字在脚本运行时才得到,或者在 if 条件里才可以导入而 import 关键字仅支持在全局最顶部出现 我们又不能像下面这样使用 import
1
2
3
4
5
6
7
// 不能这样用
const modulePath = './module.js'
import { text } from modulePath
// 也不能这样用
if(true){
import { text } from './module.js'
}

ESM 提供了一个全局的函数,使之能够动态地导入模块,具体用法如下

1
2
3
import('module_path').then(function (module) {
console.log(module);
})

因为模块加载是一个异步过程,该函数返回一个 Promise

  • 同时提取具名和默认成员
1
2
3
4
5
6
7
/* 导出 */
let name= "Tom"
let age =18
// 具名成员
export {name,age}
// 默认成员
export default 'default export'
1
2
3
4
/* 导入 */
import { name, age, default as d } from 'module_path'
// 简写为, 默认成员的名字可以随意命名
import d, { name, age } from 'module_path'
  • ES Modules 直接导出导入的成员(二次导出)

import配合export使用,将导入的结果,直接作为当前模块的导出成员,当前作用域就不在访问这些成员

1
2
3
// button.js
var Button = "Button Component"
export default Button
1
2
//index.js
export {default as Button} from './button.js'

默认导出时候,导入提取default需要重命名,否则会作为index.js的默认导出,外界只能通过default方式使用

注意事项
  • export 单独使用时, {} 是固定语法,导出的不是对象字面量
1
2
3
4
var name = 'jack'
var age = 18

export { name, age }

导入模块,{} 固定语法,不是对象的解构

1
import { name, age } from './module.js'
  • export default 组合使用时,{} 代表导出的是对象字面量
1
2
3
4
var name = 'jack'
var age = 18

export default { name, age }

导入模块,不可以使用 {} 写法

1
2
3
4
5
// module_obj 自定义名字,最好和模块名保持一致
import module_obj from './module.js'

// 访问导出的成员
console.log(module_obj.name, module_obj.age)
  • export 导出的是值的内存地址
1
2
3
4
5
6
7
var name = 'jack'
var age = 18
// 导出的是值的内存地址,不是把值复制一份
export { name, age }
setTimeout(function () {
name = 'ben'
}, 1000)
1
2
3
4
5
6
7
8
// 通过引用获取值
import { name, age } from './module.js'

console.log(name, age); // jack 18

setTimeout(function () {
console.log(name, age); // ben 18
}, 1500)
  • ESM 导出的引用在外部是无法进行修改的,导入时会自动转为只读常量
1
2
3
import { name, age } from './module.js'

name = 'tom' // Uncaught TypeError: Assignment to constant variable.
  • 点+斜线不可省略

这里的 ./ 开头是不可省略的,如果省略的话,ES Modules 会认为我们是在加载第三方库

1
2

import { name } from './module.js'
  • 即使是导入目录下的 index 文件,也需填写完整,不会默认导出目录下的 index
1
2

import { name } from './module/index.js'

Polyfill

ESM是ES6提出,是近几年出现的模块化标准方案,早期浏览器不支持,IE和国产浏览器目前不支持。Polyfill 这个方案,它能够让浏览器支持绝大部分 ES Modules 的特性。这个模块的名字叫做:ES Module Loader

IE不见兼容ESM解决方法:

  • 借助编译工具,把ES6代码编译成ES5形式再去工作,这里先不做介绍
  • 使用browser-es-module-loader解决

这里主要说一下如何使用browser-es-module-loader来解决

  1. 在HTML页面,手动引入browser-es-module-loader文件,对应js文件查找的地址:browser-es-module-loader
1
2
3
4
5
6
<!-- 有的IE版本不支持 Promise,因此需要引入 Promise Polyfill -->
<script nomodule src="https://unpkg.com/promise-polyfill@8.2.0/dist/polyfill.min.js"></script>
<!-- babel 即时运行在浏览器上的版本 -->
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<!-- ES Modules Loader, 读取代码,将不识别的特性交给 babel 进行转换 -->
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
  • nomodule 属性,表示只在不支持 ES Modules 的浏览器中运行,避免支持的浏览器多次运行 。
  • babel-browser-build.js 是 babel 即时运行在浏览器的版本文件,polyfill 的工作原理实际上就是通过 es module 把代码读出来,然后交给 babel 去转换,从而让代码正常工作。值得一提的是,在 IE 浏览器上,即使通过这样去引入 es module loader,也无法正常运行脚本,因为 IE 还没有支持 Promise 所以我们还需要专门为 IE 引入Promise Polyfill
  • 适合本地测试,不适合生产环境,原理都是在运行阶段动态的解析脚本,效率比较差,生产环境使用工具将ES6编译成ES6,在浏览器中正常工作

ES Modules in Node.js

Node 8.5 版本过后,内部就开始以实验的方式支持ES Modules 了:
Github ES Modules in Node.js

ES Modules 在 node.js 环境的使用

如上代码所示:
添加参数 – experimental-modules , 启动 ES Modules 的实验特性

1
$ node --experimental-modules xxx.mjs # node 8.5+
ES Modules 与 CommonJS 交互
  1. ES Modules 中可以导入 CommonJS 模块
1
2
3
4
5
6
// commonJs.js
module.exports = {
foo: 'commonjs'
}
// exports 是 module.exports 的别名,二者是等价的
exports.foo = 'commonjs'
1
2
3
// es-modules.mjs
import mod from './common.js'
console.log(mod);
  1. CommonJS 中不能导入 ES Modules 模块
1
2
3
// commonJs.js
const mod = require('./es-module.mjs')
console.log(mod); // Error [ERR_REQUIRE_ESM]: Must use import to load ES Module
1
2
// es-modules.mjs
export const foo = 'es module export value'
  1. CommonJS 始终只会导出一个默认成员,不能直接提取成员
1
2
// commonJs.js
exports.foo = 'commonjs'
1
2
3
// es-modules.mjs
import { foo } from './common.js' // SyntaxError:...
console.log(foo);
  1. 注意 import 不是解构导出对象
ES Modules 与 CommonJs 的差异
  1. CommonJS 模块输出的是一个值的拷贝,ES Modules 模块输出的是值的引用。

​ CommonJS中,查看上面的模块的加载机制部分

​ ES Modules中:

1
2
3
4
5
6
7
8
9
10
 // module.js
export let count = 3;
export function addCount() {
count++;
}
// main.js
import { count, addCount } from './module.js';
console.log(count); // 3
addCount();
console.log(count); // 4

​ ES Modules 模块的运行机制与 CommonJS 不一样。ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。

  1. CommonJS 模块是运行时加载,ES Modules模块是编译时输出接口。

​ 因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定

​ 义,在代码静态解析阶段就会生成。

  1. ES Modules 中没有 CommonJs 中的那些模块全局成员
    这些成员都是commonjs把模块包装函数后通过参数提供进来的成员,ESM中的加载方式与commonjs不同,所以不再提供这几个成员了

​ CommonJs中的module.exports可以通过ESM中的export替代,require通过import替代,filename和dirname可以通过import.meta.url代替

​ 如果使用ESM运行commonjs文件,将会报错,需要将commonjs文件的后缀改为cjs,就可以正常使用commonjs的规范

低版本 Node.js,使用 Babel 进行兼容
  • 安装 babel 相关依赖模块
1
$ yarn add @babel/node @babel/core @babel/preset-env --dev
  • 运行 ES Modules 的 JS 文件,需要添加特性转换的预设参数

babel是基于插件机制实现的,核心模块并不会转换代码,具体转换代码中的每个特性是通过插件来实现的,preset-env 只是一个插件集合,包括了最新js标准的中所有的新特性,可以借助preset-env将代码中ESM转换过来

1
$ yarn babel-node index.js --presets=@babel/preset-env

若不想在执行命令时添加参数,可以配置 .babelrc 文件

1
2
3
4
5
// .babelrc
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
  • @babel/preset-env 只是一个插件集合,真正起作用的是 @babel/plugin-transform-modules-commonjs 插件
1
$ yarn add @babel/plugin-transform-modules-commonjs --dev
  • 运行命令,进行测试
1
$ yarn babel-node index.js

总结

  • CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
  • AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
  • CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重
  • ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

参考

前端模块化详解(完整版)
模块化开发
模块化编程
AMD中文版)
理解CommonJS、AMD、CMD三种规范