JavaScript 模块化语法

今天在补测试的时候,遇到了无法识别import的问题,在通过babel的动态导入插件解决了之后,就对import,export,exports,require这些平时经常用的关键字产生了一点疑惑,于是就梳理了一下它们之间的演变过程与关系。

我主要通过这三篇博客再结合自己平时的实际开发经验来理解这个问题:

JavaScript模块化演进历史

require和import的区别

import, require, export, module.exports混合使用详解

历史

总结起来就是,js的模块化大概经历了三个重要的阶段,第一个阶段就是完全没有模块化,所有的变量和方法都是全局的。第二个阶段是js的开发者在社区中提出的各种方式来实现模块化,这其中就有最初的CommonJS,以AMD为核心的RequireJS,以CMD为核心的SeaJS。第三个阶段就是es6推出的esModule规范,也就是import 和 export。这其中第二个阶段其实完全就不是js语法本身的规范,而是开发者自己提出的一种模块的封装方式。而第三种方式是es6的新语法,大部分情况下并没有得到很好地支持,一般情况下会被babel给转化会CommonJS的require,exports这种语法,这也是为什么我们在开发中有的时候各种关键字可以混用的原因,你以为你是在用esmodule,其实已经被babel转化成为了es5的旧语法。

在第一个阶段,所有的变量都是全局的,所以根本就没有模块化的概念。

第二个阶段,首先是开发者们提出的各种模块封装的方式,比如命名空间,可以解决遍地全局变量的问题,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// greeting.js
var app = {};
app.helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
app.writeHello = function (lang) {
document.write(helloInLang[lang]);
}

// third_party_script.js
function writeHello() {
document.write('The script is broken');
}

但是这样并没有隐私可言,所有的属性都是暴露到了全局对象上,所有地方都可以访问和操作

于是有人就结合立即执行函数和闭包 ,解决了私有变量的问题,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var greeting = (function() {
var module = {};
var helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!',
};

module.getHello = function(lang) {
return helloInLang[lang];
};

module.writeHello = function(lang) {
document.write(module.getHello(lang));
};

return module;
})();

立即执行函数本身有自己的独立的作用域,其中声明的变量仅在该作用域之下,其他地方只能通过立即执行函数返回的方法去访问私有变量。

上面两种方式虽然解决了一点模块的问题,但是还是不能很好的管理模块之间的关系,这一点直到Node.js到来,CommonJS规范的落地才得以一定程度上解决。

CommonJS是一套同步的方案,它考虑的是在服务端运行的Node.js,主要是通过require来加载依赖项,通过exports或者module.exports来暴露接口或者数据的方式。

由于服务器上通过require加载资源是直接读取文件的,因此中间所需的时间可以忽略不计,但是在浏览器这种需要依赖HTTP获取资源的就不行了,资源的获取所需的时间不确定,这就导致必须使用异步机制,代表主要有2个:

  • 基于 AMD 的RequireJS
  • 基于 CMD 的SeaJS

它们分别在浏览器实现了definerequiremodule的核心功能,虽然两者的目标是一致的,但是实现的方式或者说是思路,还是有些区别的,AMD偏向于依赖前置,CMD偏向于用到时才运行的思路,从而导致了依赖项的加载和运行时间点会不同,关于这2者的比较,网上有很多了,这里推荐几篇仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
// CMD
define(function (require) {
var a = require('./a'); // <- 运行到此处才开始加载并运行模块a
var b = require('./b'); // <- 运行到此处才开始加载并运行模块b
// more code ..
})
// AMD
define(
['./a', './b'], // <- 前置声明,也就是在主体运行前就已经加载并运行了模块a和模块b
function (a, b) {
// more code ..
}
)

通过例子,你可以看到除了语法上面的区别,这2者主要的差异还是在于:

何时加载和运行依赖项?

这也是CommonJS社区中质疑AMD最主要原因之一,不少人认为它破坏了规范,反观CMD模式,简单的去除define的外包装,这就是标准的CommonJS实现,所以说CMD是最贴近CommonJS的异步模块化方案。

再后来就出现了ES6的对于模块的正式定义

2015年6月,ECMAScript2015也就是ES6发布了,JavaScript终于在语言标准的层面上,实现了模块功能,使得在编译时就能确定模块的依赖关系,以及其输入和输出的变量,不像 CommonJS、AMD之类的需要在运行时才能确定(例如FIS这样的工具只能预处理依赖关系,本质上还是运行时解析),成为浏览器和服务器通用的模块解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// lib/greeting.js
const helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};

export const getHello = (lang) => (
helloInLang[lang];
);

export const sayHello = (lang) => {
console.log(getHello(lang));
};

// hello.js
import { sayHello } from './lib/greeting';

sayHello('ru');

与CommonJS用require()方法加载模块不同,在ES6中,import命令可以具体指定加载模块中用export命令暴露的接口(不指定具体的接口,默认加载export default),没有指定的是不会加载的,因此会在编译时就完成模块的加载,这种加载方式称为编译时加载或者静态加载

而CommonJS的require()方法是在运行时才加载的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// lib/greeting.js
const helloInLang = {
en: 'Hello world!',
es: '¡Hola mundo!',
ru: 'Привет мир!'
};
const getHello = function (lang) {
return helloInLang[lang];
};

exports.getHello = getHello;
exports.sayHello = function (lang) {
console.log(getHello(lang))
};

// hello.js
const sayHello = require('./lib/greeting').sayHello;

sayHello('ru');

可以看出,CommonJS中是将整个模块作为一个对象引入,然后再获取这个对象上的某个属性。

使用方面

require/exports 和 import/export 形式不一样

require/exports 的用法只有以下三种简单的写法:

1
2
3
const fs = require('fs')
exports.fs = fs
module.exports = fs

而 import/export 的写法就多种多样:

1
2
3
4
5
6
7
8
9
10
11
12
import fs from 'fs'
import {default as fs} from 'fs'
import * as fs from 'fs'
import {readFile} from 'fs'
import {readFile as read} from 'fs'
import fs, {readFile} from 'fs'

export default fs
export const fs
export function readFile
export {readFile, read}
export * from 'fs'

require/exports 和 import/export 本质上的差别

形式上看起来五花八门,但本质上:

  1. CommonJS 还是 ES6 Module 输出都可以看成是一个具备多个属性或者方法的对象;
  2. default 是 ES6 Module 所独有的关键字,export default fs 输出默认的接口对象,import fs from ‘fs’ 可直接导入这个对象;
  3. ES6 Module 中导入模块的属性或者方法是强绑定的,包括基础类型;而 CommonJS 则是普通的值传递或者引用传递。

1、2 相对比较好理解,3 需要看个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// counter.js
exports.count = 0
setTimeout(function () {
console.log('increase count to', ++exports.count, 'in counter.js after 500ms')
}, 500)

// commonjs.js
const {count} = require('./counter')
setTimeout(function () {
console.log('read count after 1000ms in commonjs is', count)
}, 1000)

//es6.js
import {count} from './counter'
setTimeout(function () {
console.log('read count after 1000ms in es6 is', count)
}, 1000)

分别运行 commonjs.js 和 es6.js:

1
2
3
4
5
6
test node commonjs.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in commonjs is 0
test babel-node es6.js
increase count to 1 in counter.js after 500ms
read count after 1000ms in es6 is 1

上述只是我对三篇博客以及自己理解的一点总结,具体详细的解释可以去看上面的三篇博客再自己实现一下会有更好的理解。