[TOC]
# ES Module
## 模块导出
模块导出只有一个关键词:`export`,最简单的方法就是在声明的变量前面直接加上 export 关键词。
~~~
export const name = 'Shenfq'
~~~
可以在 const、let、var 前直接加上 export,也可以在 function 或者 class 前面直接加上 export。
~~~
export function getName() {
return name
}
export class Logger {
log(...args) {
console.log(...args)
}
}
~~~
上面的导出方法也可以使用大括号的方式进行简写。
~~~
const name = 'Shenfq'
function getName() {
return name
}
class Logger {
log(...args) {
console.log(...args)
}
}
export { name, getName, Logger }
~~~
最后一种语法,也是我们经常使用的,导出默认模块。
~~~
const name = 'Shenfq'
export default name
~~~
## 模块导入
模块的导入使用`import`,并配合`from`关键词。
~~~
// main.js
import name from './module.js'
// module.js
const name = 'Shenfq'
export default name
~~~
这样直接导入的方式,`module.js`中必须使用`export default`,也就是说 import 语法,默认导入的是`default`模块。如果想要导入其他模块,就必须使用对象展开的语法。
~~~
// main.js
import { name, getName } from './module.js'
// module.js
export const name = 'Shenfq'
export const getName = () => name
~~~
如果模块文件同时导出了默认模块,和其他模块,在导入时,也可以同时将两者导入。
~~~
// main.js
import name, { getName } from './module.js'
//module.js
const name = 'Shenfq'
export const getName = () => name
export default name
~~~
当然,ESM 也提供了重命名的语法,将导入的模块进行重新命名。
~~~
// main.js
import * as mod from './module.js'
let name = ''
name = mod.name
name = mod.getName()
// module.js
export const name = 'Shenfq'
export const getName = () => name
~~~
上述写法就相当于于将模块导出的对象进行重新赋值:
~~~
// main.js
import { name, getName } from './module.js'
const mod = { name, getName }
~~~
同时也可以对单独的变量进行重命名:
~~~
// main.js
import { name, getName as getModName }
~~~
## 导入同时进行导出
如果有两个模块 a 和 b ,同时引入了模块 c,但是这两个模块还需要导入模块 d,如果模块 a、b 在导入 c 之后,再导入 d 也是可以的,但是有些繁琐,我们可以直接在模块 c 里面导入模块 d,再把模块 d 暴露出去。
![模块关系](https://image-static.segmentfault.com/400/999/4009990148-3706cf784198653f_articlex)
~~~
// module_c.js
import { name, getName } from './module_d.js'
export { name, getName }
~~~
这么写看起来还是有些麻烦,这里 ESM 提供了一种将 import 和 export 进行结合的语法。
~~~
export { name, getName } from './module_d.js'
~~~
上面是 ESM 规范的一些基本语法,如果想了解更多,可以翻阅阮老师的[《ES6 入门》](http://es6.ruanyifeng.com/#docs/module)。
## ESM 与 CommonJS 的差异
* CommonJS 是同步导入,因为用于服务端,文件都在本地,同步导入即使卡住主线程影响也不大。而后者是异步导入,因为用于浏览器,需要下载文件,如果也采用同步导入会对渲染有很大影响
* 语法上的差异,前面也已经简单介绍过了,一个使用`import/export`语法,一个使用`require/module`语法。
* CommonJS 支持动态导入,也就是`require(${path}/xx.js)`,后者目前不支持,但是已有提案
* ESM 导入模块的变量都是强绑定,导出模块的变量一旦发生变化,对应导入模块的变量也会跟随变化,而 CommonJS 中导入的模块都是值传递与引用传递,类似于函数传参(基本类型进行值传递,相当于拷贝变量,非基础类型【对象、数组】,进行引用传递)。
下面我们看下详细的案例:
**CommonJS**
~~~
// a.js
const mod = require('./b')
setTimeout(() => {
console.log(mod)
}, 1000)
// b.js
let mod = 'first value'
setTimeout(() => {
mod = 'second value'
}, 500)
module.exports = mod
~~~
~~~
$ node a.js
first value
~~~
**ESM**
~~~
// a.mjs
import { mod } from './b.mjs'
setTimeout(() => {
console.log(mod)
}, 1000)
// b.mjs
export let mod = 'first value'
setTimeout(() => {
mod = 'second value'
}, 500)
~~~
~~~
$ node --experimental-modules a.mjs
# (node:99615) ExperimentalWarning: The ESM module loader is experimental.
second value
~~~
另外,CommonJS 的模块实现,实际是给每个模块文件做了一层函数包裹,从而使得每个模块获取`require/module`、`__filename/__dirname`变量。那上面的`a.js`来举例,实际执行过程中`a.js`运行代码如下:
~~~
// a.js
(function(exports, require, module, __filename, __dirname) {
const mod = require('./b')
setTimeout(() => {
console.log(mod)
}, 1000)
});
~~~
而 ESM 的模块是通过`import/export`关键词来实现,没有对应的函数包裹,所以在 ESM 模块中,需要使用`import.meta`变量来获取`__filename/__dirname`。`import.meta`是 ECMAScript 实现的一个包含模块元数据的特定对象,主要用于存放模块的`url`,而 node 中只支持加载本地模块,所以 url 都是使用`file:`协议。
~~~
import url from 'url'
import path from 'path'
// import.meta: { url: file:///Users/dev/mjs/a.mjs }
const __filename = url.fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
~~~
## 加载的原理
步骤:
1. Construction(构造):下载所有的文件并且解析为module records。
2. Instantiation(实例):把所有导出的变量入内存指定位置(但是暂时还不求值)。然后,让导出和导入都指向内存指定位置。这叫做『linking(链接)』。
3. Evaluation(求值):执行代码,得到变量的值然后放到内存对应位置。
### 模块记录
所有的模块化开发,都是从一个入口文件开始,无论是 Node.js 还是浏览器,都会根据这个入口文件进行检索,一步一步找到其他所有的依赖文件。
~~~
// Node.js: main.mjs
import Log from './log.mjs'
~~~
~~~
<!-- chrome、firefox -->
<script type="module" src="./log.js"></script>
~~~
值得注意的是,刚开始拿到入口文件,我们并不知道它依赖了哪些模块,所以必须先通过 js 引擎静态分析,得到一个模块记录,该记录包含了该文件的依赖项。所以,一开始拿到的 js 文件并不会执行,只是会将文件转换得到一个模块记录(module records)。所有的 import 模块都在模块记录的`importEntries`字段中记录,更多模块记录相关的字段可以查阅[tc39.es](https://tc39.es/ecma262/#table-38)。
![模块记录](https://image-static.segmentfault.com/316/684/316684428-224c3f381640c464_articlex)
### 模块构造
得到模块记录后,会下载所有依赖,并再次将依赖文件转换为模块记录,一直持续到没有依赖文件为止,这个过程被称为『构造』(construction)。
模块构造包括如下三个步骤:
1. 模块识别(解析依赖模块 url,找到真实的下载路径);
2. 文件下载(从指定的 url 进行下载,或从文件系统进行加载);
3. 转化为模块记录(module records)。
对于如何将模块文件转化为模块记录,ESM 规范有详细的说明,但是在构造这个步骤中,要怎么下载得到这些依赖的模块文件,在 ESM 规范中并没有对应的说明。因为如何下载文件,在服务端和客户端都有不同的实现规范。比如,在浏览器中,如何下载文件是属于 HTML 规范(浏览器的模块加载都是使用的`<script>`标签)。
虽然下载完全不属于 ESM 的现有规范,但在`import`语句中还有一个引用模块的 url 地址,关于这个地址需要如何转化,在 Node 和浏览器之间有会出现一些差异。简单来说,在 Node 中可以直接 import 在 node\_modules 中的模块,而在浏览器中并不能直接这么做,因为浏览器无法正确的找到服务器上的 node\_modules 目录在哪里。好在有一个叫做[import-maps](https://github.com/WICG/import-maps)的提案,该提案主要就是用来解决浏览器无法直接导入模块标识符的问题。但是,在该提案未被完全实现之前,浏览器中依然只能使用 url 进行模块导入。
~~~
<script type="importmap">
{
"imports": {
"jQuery": "/node_modules/jquery/dist/jquery.js"
}
}
</script>
<script type="module">
import $ from 'jQuery'
$(function () {
$('#app').html('init')
})
</script>
~~~
下载好的模块,都会被转化为模块记录然后缓存到`module map`中,遇到不同文件获取的相同依赖,都会直接在`module map`缓存中获取。
~~~
// log.js
const log = console.log
export default log
// file.js
export {
readFileSync as read,
writeFileSync as write
} from 'fs'
~~~
![module map](https://image-static.segmentfault.com/423/593/4235939352-bc60b81e9f86602c_articlex)
### 模块实例
获取到所有依赖文件并建立好`module map`后,就会找到所有模块记录,并取出其中的所有导出的变量,然后,将所有变量一一对应到内存中,将对应关系存储到『模块环境记录』(module environment record)中。当然当前内存中的变量并没有值,只是初始化了对应关系。初始化导出变量和内存的对应关系后,紧接着会设置模块导入和内存的对应关系,确保相同变量的导入和导出都指向了同一个内存区域,并保证所有的导入都能找到对应的导出。
![模块连接](https://image-static.segmentfault.com/146/031/1460314296-c2712eb7fea56a0a_articlex)
由于导入和导出指向同一内存区域,所以导出值一旦发生变化,导入值也会变化,不同于 CommonJS,CommonJS 的所有值都是基于拷贝的。连接到导入导出变量后,我们就需要将对应的值放入到内存中,下面就要进入到求值的步骤了。
### 模块求值
求值步骤相对简单,只要运行代码把计算出来的值填入之前记录的内存地址就可以了。到这里就已经能够愉快的使用 ESM 模块化了。
## ESM的进展
因为 ESM 出现较晚,服务端已有 CommonJS 方案,客户端又有 webpack 打包工具,所以 ESM 的推广不得不说还是十分艰难的。
### 客户端
我们先看看客户端的支持情况,这里推荐大家到[Can I Use](https://caniuse.com/#feat=es6-module)直接查看,下图是`2019/11`的截图。
![Can I use](https://image-static.segmentfault.com/252/323/2523237886-b5b1331b310899c3_articlex)
目前为止,主流浏览器都已经支持 ESM 了,只需在`script`标签传入指定的`type="module"`即可。
~~~
<script type="module" src="./main.js"></script>
~~~
另外,我们知道在 Node.js 中,要使用 ESM 有时候需要用到 .mjs 后缀,但是浏览器并不关心文件后缀,只需要 http 响应头的MIME类型正确即可(`Content-Type: text/javascript`)。同时,当`type="module"`时,默认启用`defer`来加载脚本。这里补充一张 defer、async 差异图。
![img](https://image-static.segmentfault.com/215/179/2151798436-59da4801c6772_articlex)
我们知道浏览器不支持`script`的时候,提供了`noscript`标签用于降级处理,模块化也提供了类似的标签。
~~~
<script type="module" src="./main.js"></script>
<script nomodule>
alert('当前浏览器不支持 ESM !!!')
</script>
~~~
这样我们就能针对支持 ESM 的浏览器直接使用模块化方案加载文件,不支持的浏览器还是使用 webpack 打包的版本。
~~~
<script type="module" src="./src/main.js"></script>
<script nomodule src="./dist/app.[hash].js"></script>
~~~
#### 预加载
我们知道浏览器的 link 标签可以用作资源的预加载,比如我需要预先加载`main.js`文件:
~~~
<link rel="preload" href="./main.js"></link>
~~~
如果这个`main.js`文件是一个模块化文件,浏览器仅仅预先加载单独这一个文件是没有意义的,前面我们也说过,一个模块化文件下载后还需要转化得到模块记录,进行模块实例、模块求值这些操作,所以我们得想办法告诉浏览器,这个文件是一个模块化的文件,所以浏览器提供了一种新的 rel 类型,专门用于模块化文件的预加载。
~~~
<link rel="modulepreload" href="./main.js"></link>
~~~
#### 现状
虽然主流浏览器都已经支持了 ESM,但是根据[chrome 的统计](https://www.chromestatus.com/metrics/feature/timeline/popularity/2062),有用到`<script type="module">`的页面只有 1%。截图时间为`2019/11`。
![统计](https://image-static.segmentfault.com/361/915/3619154756-91784f64ed63265d_articlex)
### 服务端
浏览器能够通过 script 标签指定当前脚本是否作为模块处理,但是在 Node.js 中没有很明确的方式来表示是否需要使用 ESM,而且 Node.js 中本身就已经有了 CommonJS 的标准模块化方案。就算开启了 ESM,又通过何种方式来判断当前入口文件导入的模块到底是使用的 ESM 还是 CommonJS 呢?为了解决上述问题,node 社区开始出现了 ESM 的相关草案,具体可以在[github](https://github.com/nodejs/node-eps/blob/master/002-es-modules.md)上查阅。
2017年发布的 Node.js 8.5.0 开启了 ESM 的实验性支持,在启动程序时,加上`--experimental-modules`来开启对 ESM 的支持,并将`.mjs`后缀的文件当做 ESM 来解析。早期的期望是在 Node.js 12 达到 LTS 状态正式发布,然后期望并没有实现,直到最近的 13.2.0 版本才正式支持 ESM,也就是取消了`--experimental-modules`启动参数。具体细节可以查看 Node.js 13.2.0 的[官方文档](https://nodejs.org/api/esm.html#esm_ecmascript_modules)。
关于`.mjs`后缀社区有两种完全不同的态度。支持的一方认为通过文件后缀区分类型是最简单也是最明确的方式,且社区早已有类似案例,例如,`.jsx`用于 React 组件、`.ts`用于 ts 文件;而支持的一方认为,`.js`作为 js 后缀已经存在这么多年,视觉上很难接受一个`.mjs`也是 js 文件,而且现有的很多工具都是以`.js`后缀来识别 js 文件,如果引入了`.mjs`方案,就有大批量的工具需要修改来有效的适配 ESM。
所以除了`.mjs`后缀指定 ESM 外,还可以使用`pkg.json`文件的`type`属性。如果 type 属性为 module,则表示当前模块应使用 ESM 来解析模块,否则使用 CommonJS 解析模块。
~~~
{
"type": "module" // module | commonjs(default)
}
~~~
当然有些本地文件是没有`pkg.json`的,但是你又不想使用`.mjs`后缀,这时候只需要在命令行加上一个启动参数`--input-type=module`。同时`input-type`也支持 commonjs 参数来指定使用 CommonJS(`-—input-type=commonjs`)。
总结一下,Node.js 中,以下三种情况会启用 ESM 的模块加载方式:
1. 文件后缀为`.mjs`;
2. `pkg.json`中 type 字段指定为`module`;
3. 启动参数添加`--input-type=module`。
同样,也有三种情况会启用 CommonJS 的模块加载方式:
1. 文件后缀为`.cjs`;
2. `pkg.json`中 type 字段指定为`commonjs`;
3. 启动参数添加`--input-type=commonjs`。
虽然 13.2 版本去除了`--experimental-modules`的启动参数,但是按照文档的说法,在 Node.js 中使用 ESM 依旧是实验特性。
> [Stability: 1](https://nodejs.org/api/documentation.html#documentation_stability_index)\- Experimental
不过,相信等到 Node.js 14 LTS 版本发布时,ESM 的支持应该就能进入稳定阶段了,这里还有一个 Node.js 关于 ESM 的整个[计划列表](https://github.com/nodejs/modules/blob/master/doc/plan-for-new-modules-implementation.md)可以查阅。
~~~
// 引入模块 API
import XXX from './a.js'
import { XXX } from './a.js'
// 导出模块 API
export function a() {}
export default function() {}
~~~
# 参考资料
* [模块化的今生](https://segmentfault.com/a/1190000021167250)
- 第一部分 HTML
- meta
- meta标签
- HTML5
- 2.1 语义
- 2.2 通信
- 2.3 离线&存储
- 2.4 多媒体
- 2.5 3D,图像&效果
- 2.6 性能&集成
- 2.7 设备访问
- SEO
- Canvas
- 压缩图片
- 制作圆角矩形
- 全局属性
- 第二部分 CSS
- CSS原理
- 层叠上下文(stacking context)
- 外边距合并
- 块状格式化上下文(BFC)
- 盒模型
- important
- 样式继承
- 层叠
- 属性值处理流程
- 分辨率
- 视口
- CSS API
- grid(未完成)
- flex
- 选择器
- 3D
- Matrix
- AT规则
- line-height 和 vertical-align
- CSS技术
- 居中
- 响应式布局
- 兼容性
- 移动端适配方案
- CSS应用
- CSS Modules(未完成)
- 分层
- 面向对象CSS(未完成)
- 布局
- 三列布局
- 单列等宽,其他多列自适应均匀
- 多列等高
- 圣杯布局
- 双飞翼布局
- 瀑布流
- 1px问题
- 适配iPhoneX
- 横屏适配
- 图片模糊问题
- stylelint
- 第三部分 JavaScript
- JavaScript原理
- 内存空间
- 作用域
- 执行上下文栈
- 变量对象
- 作用域链
- this
- 类型转换
- 闭包(未完成)
- 原型、面向对象
- class和extend
- 继承
- new
- DOM
- Event Loop
- 垃圾回收机制
- 内存泄漏
- 数值存储
- 连等赋值
- 基本类型
- 堆栈溢出
- JavaScriptAPI
- document.referrer
- Promise(未完成)
- Object.create
- 遍历对象属性
- 宽度、高度
- performance
- 位运算
- tostring( ) 与 valueOf( )方法
- JavaScript技术
- 错误
- 异常处理
- 存储
- Cookie与Session
- ES6(未完成)
- Babel转码
- let和const命令
- 变量的解构赋值
- 字符串的扩展
- 正则的扩展
- 数值的扩展
- 数组的扩展
- 函数的扩展
- 对象的扩展
- Symbol
- Set 和 Map 数据结构
- proxy
- Reflect
- module
- AJAX
- ES5
- 严格模式
- JSON
- 数组方法
- 对象方法
- 函数方法
- 服务端推送(未完成)
- JavaScript应用
- 复杂判断
- 3D 全景图
- 重载
- 上传(未完成)
- 上传方式
- 文件格式
- 渲染大量数据
- 图片裁剪
- 斐波那契数列
- 编码
- 数组去重
- 浅拷贝、深拷贝
- instanceof
- 模拟 new
- 防抖
- 节流
- 数组扁平化
- sleep函数
- 模拟bind
- 柯里化
- 零碎知识点
- 第四部分 进阶
- 计算机原理
- 数据结构(未完成)
- 算法(未完成)
- 排序算法
- 冒泡排序
- 选择排序
- 插入排序
- 快速排序
- 搜索算法
- 动态规划
- 二叉树
- 浏览器
- 浏览器结构
- 浏览器工作原理
- HTML解析
- CSS解析
- 渲染树构建
- 布局(Layout)
- 渲染
- 浏览器输入 URL 后发生了什么
- 跨域
- 缓存机制
- reflow(回流)和repaint(重绘)
- 渲染层合并
- 编译(未完成)
- Babel
- 设计模式(未完成)
- 函数式编程(未完成)
- 正则表达式(未完成)
- 性能
- 性能分析
- 性能指标
- 首屏加载
- 优化
- 浏览器层面
- HTTP层面
- 代码层面
- 构建层面
- 移动端首屏优化
- 服务器层面
- bigpipe
- 构建工具
- Gulp
- webpack
- Webpack概念
- Webpack工具
- Webpack优化
- Webpack原理
- 实现loader
- 实现plugin
- tapable
- Webpack打包后代码
- rollup.js
- parcel
- 模块化
- ESM
- 安全
- XSS
- CSRF
- 点击劫持
- 中间人攻击
- 密码存储
- 测试(未完成)
- 单元测试
- E2E测试
- 框架测试
- 样式回归测试
- 异步测试
- 自动化测试
- PWA
- PWA官网
- web app manifest
- service worker
- app install banners
- 调试PWA
- PWA教程
- 框架
- MVVM原理
- Vue
- Vue 饿了么整理
- 样式
- 技巧
- Vue音乐播放器
- Vue源码
- Virtual Dom
- computed原理
- 数组绑定原理
- 双向绑定
- nextTick
- keep-alive
- 导航守卫
- 组件通信
- React
- Diff 算法
- Fiber 原理
- batchUpdate
- React 生命周期
- Redux
- 动画(未完成)
- 异常监控、收集(未完成)
- 数据采集
- Sentry
- 贝塞尔曲线
- 视频
- 服务端渲染
- 服务端渲染的利与弊
- Vue SSR
- React SSR
- 客户端
- 离线包
- 第五部分 网络
- 五层协议
- TCP
- UDP
- HTTP
- 方法
- 首部
- 状态码
- 持久连接
- TLS
- content-type
- Redirect
- CSP
- 请求流程
- HTTP/2 及 HTTP/3
- CDN
- DNS
- HTTPDNS
- 第六部分 服务端
- Linux
- Linux命令
- 权限
- XAMPP
- Node.js
- 安装
- Node模块化
- 设置环境变量
- Node的event loop
- 进程
- 全局对象
- 异步IO与事件驱动
- 文件系统
- Node错误处理
- koa
- koa-compose
- koa-router
- Nginx
- Nginx配置文件
- 代理服务
- 负载均衡
- 获取用户IP
- 解决跨域
- 适配PC与移动环境
- 简单的访问限制
- 页面内容修改
- 图片处理
- 合并请求
- PM2
- MongoDB
- MySQL
- 常用MySql命令
- 自动化(未完成)
- docker
- 创建CLI
- 持续集成
- 持续交付
- 持续部署
- Jenkins
- 部署与发布
- 远程登录服务器
- 增强服务器安全等级
- 搭建 Nodejs 生产环境
- 配置 Nginx 实现反向代理
- 管理域名解析
- 配置 PM2 一键部署
- 发布上线
- 部署HTTPS
- Node 应用
- 爬虫(未完成)
- 例子
- 反爬虫
- 中间件
- body-parser
- connect-redis
- cookie-parser
- cors
- csurf
- express-session
- helmet
- ioredis
- log4js(未完成)
- uuid
- errorhandler
- nodeclub源码
- app.js
- config.js
- 消息队列
- RPC
- 性能优化
- 第七部分 总结
- Web服务器
- 目录结构
- 依赖
- 功能
- 代码片段
- 整理
- 知识清单、博客
- 项目、组件、库
- Node代码
- 面试必考
- 91算法
- 第八部分 工作代码总结
- 样式代码
- 框架代码
- 组件代码
- 功能代码
- 通用代码