ES6模块化
模块化的背景
JavaScript 程序本来很小——在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 JavaScript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js
)。
因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制。Node.js 已经提供这个能力很长时间了,还有很多的 JavaScript 库和框架 已经开始了模块的使用(例如, CommonJS 和基于 AMD 的其他模块系统 如 RequireJS, 以及最新的 Webpack 和 Babel)。
好消息是,最新的浏览器开始原生支持模块功能了,这是本文要重点讲述的。这会是一个好事情 — 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
早期模块化
模块化发展
原始写法
模块就是实现特定功能的一组方法。
只要把不同的函数(以及记录状态的变量)简单地放在一起,就算是一个模块。
1 | function m1(){ |
上面的函数m1()和m2(),组成一个模块。使用的时候,直接调用就行了。
这种做法的缺点很明显:**”污染”了全局变量,无法保证不与其他模块发生变量名冲突,而且模块成员之间看不出直接关系。**
对象写法
为了解决上面的缺点,可以把模块写成一个对象,所有的模块成员都放到这个对象里面。
1 | var module1 = new Object({ |
上面的函数m1()和m2(),都封装在module1对象里。使用的时候,就是调用这个对象的属性。
1 | module1.m1(); |
但是,这样的写法会暴露所有模块成员,内部状态可以被外部改写。比如,外部代码可以直接改变内部计数器的值。
1 | module1._count = 5; |
立即执行函数写法
使用**”立即执行函数”(Immediately-Invoked Function Expression,IIFE),可以达到不暴露私有成员的目的**。
1 | var module1 = (function(){ |
使用上面的写法,外部代码无法读取内部的_count变量。
1 | console.log(module1._count); //undefined |
module1就是Javascript模块的基本写法。下面,再对这种写法进行加工。
将数据和行为封装到一个函数内部, 通过给window添加属性来向外暴露接口
1 | // index.html文件 |
IIFE模式增强 : 引入依赖
这就是现代模块实现的基石
1 | // module.js文件 |
上例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。
模块化的好处
- 避免命名冲突(减少命名空间污染)
- 更好的分离, 按需加载
- 更高复用性
- 高可维护性
引入多个<script>
后出现出现问题
- 请求过多
首先我们要依赖多个模块,那样就会发送多个请求,导致请求过多
- 依赖模糊
我们不知道他们的具体依赖关系是什么,也就是说很容易因为不了解他们之间的依赖关系导致加载先后顺序出错。
- 难以维护
以上两种原因就导致了很难维护,很可能出现牵一发而动全身的情况导致项目出现严重的问题。 模块化固然有多个好处,然而一个页面需要引入多个js文件,就会出现以上这些问题。而这些问题可以通过模块化规范来解决,下面介绍开发中最流行的commonjs, AMD, ES6, CMD规范。
CommonJS
Node.js是commonJS规范的主要实践者,它有四个重要的环境变量为模块化的实现提供支持:module
、exports
、require
、global
。实际使用时,用module.exports
定义当前模块对外输出的接口(不推荐直接用exports
),用require
加载模块。
1 | // 定义模块math.js |
CommonJS用同步的方式加载模块。在服务端,模块文件都存在本地磁盘,读取非常快,所以这样做不会有问题。
但是在浏览器端,限于网络原因,更合理的方案是使用异步加载。
AMD和require.js
AMD规范采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。这里介绍用require.js实现AMD规范的模块化:用require.config()
指定引用路径等,用define()
定义模块,用require()
加载模块。
首先我们需要引入require.js文件和一个入口文件main.js。main.js中配置require.config()
并规定项目中用到的基础模块。
1 | /** 网页中引入require.js及main.js **/ |
引用模块的时候,我们将模块名放在[]
中作为reqiure()
的第一参数;如果我们定义的模块本身也依赖其他模块,那就需要将它们放在[]
中作为define()
的第一参数。
1 | // 定义math.js模块 |
CMD和sea.js
require.js在申明依赖的模块时会在第一之间加载并执行模块内的代码:
1 | define(["a", "b", "c", "d", "e", "f"], function(a, b, c, d, e, f) { |
CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。
1 | /** AMD写法 **/ |
ES6模块化
.mjs
与 .js
纵观此文,我们使用 .js
扩展名的模块文件,但在其它一些文章中,你可能会看到 .mjs
扩展名的使用。V8推荐了这样的做法,比如有下列理由:
但是我们决定继续使用 .js
扩展名,未来可能会更改。为了使模块可以在浏览器中正常地工作,你需要确保你的服务器能够正常地处理 Content-Type
头,其应该包含 JavaScript 的MIME 类型 text/javascript
。如果没有这么做,你可能会得到 一个严格 MIME 类型检查错误:“The server responded with a non-JavaScript MIME type (服务器返回了非 JavaScript MIME 类型)”,并且浏览器会拒绝执行相应的 JavaScript 代码。多数服务器可以正确地处理 .js
文件的类型,但是 .mjs
还不行。已经可以正常响应 .mjs
的服务器有 GitHub 页面 和 Node.js 的 http-server
。
如果你已经在使用相应的环境了,那么一切正常。或者如果你还没有,但你知道你在做什么(比如你可以配置服务器以为 .mjs
设置正确的 Content-Type
)。但如果你不能控制提供服务,或者用于公开文件发布的服务器,这可能会导致混乱。
为了学习和保证代码的可移植的目的,我们建议使用 .js
。
如果你认为使用 .mjs
仅用于模块带来的清晰性非常重要,但不想引入上面描述的相应问题,你可以仅在开发过程中使用 .mjs
,而在构建过程中将其转换为 .js
。
另注意:
- 一些工具不支持
.mjs
,比如 TypeScript。 <script type="module">
属性用于指示引入的模块。
导出模块的功能
为了获得模块的功能要做的第一件事是把它们导出来。使用 export
语句来完成。
最简单的方法是把它(指上面的export语句)放到你想要导出的项前面,比如:
1 | export const name = 'square'; |
你能够导出函数,var
,let
,const
, 和类。export要放在最外层;比如你不能够在函数内使用export
。
一个更方便的方法导出所有你想要导出的模块的方法是在模块文件的末尾使用一个export 语句, 语句是用花括号括起来的用逗号分割的列表。比如:
1 | export { name, draw, reportArea, reportPerimeter }; |
导入功能到你的脚本
你想在模块外面使用一些功能,那你就需要导入他们才能使用。最简单的就像下面这样的:
1 | import { name, draw, reportArea, reportPerimeter } from '/js-examples/modules/basic-modules/modules/square.mjs'; |
使用 import
语句,然后你被花括号包围的用逗号分隔的你想导入的功能列表,然后是关键字from,然后是模块文件的路径。模块文件的路径是相对于站点根目录的相对路径,对于我们的basic-modules
应该是/js-examples/modules/basic-modules
。
当然,我们写的路径有一点不同—我们使用点语法意味 “当前路径”,跟随着包含我们想要找的文件的路径。这比每次都要写下整个相对路径要好得多,因为它更短,使得URL 可移植 —如果在站点层中你把它移动到不同的路径下面仍然能够工作。(
那么看看例子吧:
1 | /js/examples/modules/basic-modules/modules/square.mjs |
变成了
1 | ./modules/square.mjs |
你可以在main.mjs
中看到这些。
备注:在一些模块系统中你可以忽略文件扩展名(比如'/model/squre'
.这在原生JavaScript 模块系统中不工作。
因为你导入了这些功能到你的脚本文件,你可以像定义在相同的文件中的一样去使用它。下面展示的是在 main.mjs
中的import 语句下面的内容。
1 | let myCanvas = create('myCanvas', document.body, 480, 320); |
应用模块到你的HTML
现在我们只需要将main.mjs模块应用到我们的HTML页面。 这与我们将常规脚本应用于页面的方式非常相似,但有一些显着的差异。
首先,你需要把 type="module"
放到<script/>
标签中, 来声明这个脚本是一个模块:
1 | <script type="module" src="main.mjs"></script> |
你导入模块功能的脚本基本是作为顶级模块。 如果省略它,Firefox就会给出错误“SyntaxError: import declarations may only appear at top level of a module。
你只能在模块内部使用 import
和export
语句 ;不是普通脚本文件。
备注:您还可以将模块导入内部脚本,只要包含 type="module"
,例如 <script type="module"> //include script here </script>
.
其他模块与标准脚本的不同
- 你需要注意本地测试 — 如果你通过本地加载Html 文件 (比如一个
file://
路径的文件), 你将会遇到 CORS 错误,因为JavaScript 模块安全性需要。你需要通过一个服务器来测试。 - 另请注意,您可能会从模块内部定义的脚本部分获得不同的行为,而不是标准脚本。 这是因为模块自动使用严格模式。
- 加载一个模块脚本时不需要使用
defer
属性,模块会自动延迟加载。 - 最后一个但不是不重要,你需要明白模块功能导入到单独的脚本文件的范围 — 他们无法在全局获得。因此,你只能在导入这些功能的脚本文件中使用他们,你也无法通过 JavaScript console 中获取到他们,比如,在DevTools 中你仍然能够获取到语法错误,但是你可能无法像你想的那样使用一些debug 技术 。
默认导出 vs 命名导出
到目前为止我们导出的功能都是由named exports 组成— 每个项目(无论是函数,常量等)在导出时都由其名称引用,并且该名称也用于在导入时引用它。
还有一种导出类型叫做 default export —这样可以很容易地使模块提供默认功能,并且还可以帮助JavaScript模块与现有的CommonJS和AMD模块系统进行互操作。
看个例子来解释它如何工作。在我们的基本模块square.mjs
中,您可以找到一个名为randomSquare()
的函数,它创建一个具有随机颜色,大小和位置的正方形。我们想作为默认导出,所以在文件的底部我们这样写 :
1 | export default randomSquare; |
注意,不要大括号。
我们可以把 export default
放到函数前面,定义它为一个匿名函数,像这样:
1 | export default function(ctx) { |
在我们的main.mjs
文件中,我们使用以下行导入默认函数:
1 | import randomSquare from './modules/square.mjs'; |
同样,没有大括号,因为每个模块只允许有一个默认导出, 我们知道 randomSquare
就是需要的那个。上面的那一行相当于下面的缩写:
1 | import {default as randomSquare} from './modules/square.mjs'; |
重命名导出与导入
在你的 import
和 export
语句的大括号中,可以使用 as
关键字跟一个新的名字,来改变你在顶级模块中将要使用的功能的标识名字。因此,例如,以下两者都会做同样的工作,尽管方式略有不同:
1 | // inside module.mjs |
1 | // inside module.mjs |
让我们看一个真实的例子。在我们的重命名目录中,您将看到与上一个示例中相同的模块系统,除了我们添加了circle.mjs
和triangle.mjs
模块以绘制和报告圆和三角形。
在每个模块中,我们都有export
相同名称的功能,因此每个模块底部都有相同的导出语句:
1 | export { name, draw, reportArea, reportPerimeter }; |
将它们导入main.mjs
时,如果我们尝试使用
1 | import { name, draw, reportArea, reportPerimeter } from './modules/square.mjs'; |
浏览器会抛出一个错误,例如“SyntaxError: redeclaration of import name”(Firefox)。
相反,我们需要重命名导入,使它们是唯一的:
1 | import { name as squareName, |
请注意,您可以在模块文件中解决问题,例如
1 | // in square.mjs |
1 | // in main.mjs |
它也会起作用。 你使用什么样的风格取决于你,但是单独保留模块代码并在导入中进行更改可能更有意义。 当您从没有任何控制权的第三方模块导入时,这尤其有意义。
创建模块对象
上面的方法工作的挺好,但是有一点点混乱、亢长。一个更好的解决方是,导入每一个模块功能到一个模块功能对象上。可以使用以下语法形式:
1 | import * as Module from '/modules/module.mjs'; |
这将获取module.mjs
中所有可用的导出,并使它们可以作为对象模块的成员使用,从而有效地为其提供自己的命名空间。 例如:
1 | Module.function1() |
再次,让我们看一个真实的例子。如果您转到我们的module-objects目录,您将再次看到相同的示例,但利用上述的新语法进行重写。在模块中,导出都是以下简单形式:
1 | export { name, draw, reportArea, reportPerimeter }; |
另一方面,导入看起来像这样:
1 | import * as Canvas from './modules/canvas.mjs'; |
在每种情况下,您现在可以访问指定对象名称下面的模块导入
1 | let square1 = Square.draw(myCanvas.ctx, 50, 50, 100, 'blue'); |
因此,您现在可以像以前一样编写代码(只要您在需要时包含对象名称),并且导入更加整洁。
模块与类(class)
正如我们之前提到的那样,您还可以导出和导入类; 这是避免代码冲突的另一种选择,如果您已经以面向对象的方式编写了模块代码,那么它尤其有用。
您可以在我们的classes目录中看到使用ES类重写的形状绘制模块的示例。 例如,square.mjs
文件现在包含单个类中的所有功能:
1 | class Square { |
然后我们导出:
1 | export { Square }; |
在main.mjs
中,我们像这样导入它:
1 | import { Square } from './modules/square.mjs'; |
然后使用该类绘制我们的方块:
1 | let square1 = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, 'blue'); |
合并模块
有时你会想要将模块聚合在一起。 您可能有多个级别的依赖项,您希望简化事物,将多个子模块组合到一个父模块中。 这可以使用父模块中以下表单的导出语法:
1 | export * from 'x.mjs' |
备注:这实际上是导入后跟导出的简写,即“我导入模块x.mjs
,然后重新导出部分或全部导出”。
有关示例,请参阅我们的module-aggregation。 在这个例子中(基于我们之前的类示例),我们有一个名为shapes.mjs
的额外模块,它将circle.mjs
,square.mjs
和riangle.mjs
中的所有功能聚合在一起。 我们还将子模块移动到名为shapes的modules目录中的子目录中。 所以模块结构现在是这样的:
1 | modules/ |
在每个子模块中,输出具有相同的形式,例如,
1 | export { Square }; |
接下来是聚合部分。 在shapes.mjs
里面,我们包括以下几行:
1 | export { Square } from '/js-examples/modules/module-aggregation/modules/shapes/square.mjs'; |
它们从各个子模块中获取导出,并有效地从shapes.mjs
模块中获取它们。
备注:即使shapes.mjs
文件位于modules目录中,我们仍然需要相对于模块根目录编写这些URL,因此需要/modules/
。 这是使用JavaScript模块时混淆的常见原因。
备注:shapes.mjs
中引用的导出基本上通过文件重定向,并且实际上并不存在,因此您将无法在同一文件中编写任何有用的相关代码。
所以现在在main.mjs
文件中,我们可以通过替换来访问所有三个模块类
1 | import { Square } from './modules/square.mjs'; |
使用以下单行:
1 | import { Square, Circle, Triangle } from './modules/shapes.mjs'; |
动态加载模块
浏览器中可用的JavaScript模块功能的最新部分是动态模块加载。 这允许您仅在需要时动态加载模块,而不必预先加载所有模块。 这有一些明显的性能优势; 让我们继续阅读,看看它是如何工作的。
这个新功能允许您将import()
作为函数调用,将其传递给模块的路径作为参数。 它返回一个 promise
,它用一个模块对象来实现,让你可以访问该对象的导出,例如
1 | import('/modules/myModule.mjs') |
我们来看一个例子。 在dynamic-module-imports目录中,我们有另一个基于类示例的示例。 但是这次我们在示例加载时没有在画布上绘制任何东西。 相反,我们包括三个按钮 - “圆形”,“方形”和“三角形” - 按下时,动态加载所需的模块,然后使用它来绘制相关的形状。
在这个例子中,我们只对index.html和main.mjs文件进行了更改 - 模块导出保持与以前相同。
在main.mjs
中,我们使用document.querySelector()
调用获取了对每个按钮的引用,例如:
1 | let squareBtn = document.querySelector('.square'); |
然后,我们为每个按钮附加一个事件监听器,以便在按下时,相关模块被动态加载并用于绘制形状:
1 | squareBtn.addEventListener('click', () => { |
请注意,由于promise履行会返回一个模块对象,因此该类成为对象的子特征,因此我们现在需要使用 Module
访问构造函数。 在它之前,例如 Module.Square( ... )
。
ES6 模块与 CommonJS 模块的差异
CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
- CommonJS 模块输出的是值的拷贝,也就是说,一旦输出一个值,模块内部的变化就影响不到这个值。
- ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令
import
,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的import
有点像 Unix 系统的“符号连接”,原始值变了,import
加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
- 运行时加载: CommonJS 模块就是对象;即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,这种加载称为“运行时加载”。
- 编译时加载: ES6 模块不是对象,而是通过
export
命令显式指定输出的代码,import
时采用静态命令的形式。即在import
时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”。
CommonJS 加载的是一个对象(即module.exports
属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
总结
CommonJS规范主要用于服务端编程,加载模块是同步的,这并不适合在浏览器环境,因为同步意味着阻塞加载,浏览器资源是异步加载的,因此有了AMD CMD解决方案。
AMD规范在浏览器环境中异步加载模块,而且可以并行加载多个模块。不过,AMD规范开发成本高,代码的阅读和书写比较困难,模块定义方式的语义不顺畅。
CMD规范与AMD规范很相似,都用于浏览器编程,依赖就近,延迟执行,可以很容易在Node.js中运行。不过,依赖SPM 打包,模块的加载逻辑偏重。
ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。