导入/导出
模块本身具有私有作用域,所以内部成员都无法被外部所访问到。如果要把模块内的成员暴露出去,需要使用 export 关键词去修饰要暴露的变量,而如果需要导入其他模块的成员,则需要 import 关键词。
下面演示一下导入导出。
创建 module.js,其中暴露一个变量。
export var msg = "hello world";
创建 app.js 文件,导入这个变量并在控制台打印。
import { msg } from "./module.js"; console.log(msg);
创建 index.html,导入 app.js,用来测试。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <script type="module" src="./app.js"></script> </body> </html>
在浏览器中打开 index.html,就可以看到正常的输出结果。
这就是最简单的导入导出用法。
export 除了可以导出变量以外,还可以导出 function 和 class。
export var msg = "hello world"; export function log(...args) { console.log(args); } export class Clazz {}
除了可以直接在变量声明前面使用 export,也可以在模块底部统一导出需要导出的成员。
var msg = "hello world"; function log(...args) { console.log(args); } class Clazz {} export { msg, log, Clazz };
这种写法更为常用,因为可以很直观的知道这个模块导出了哪些成员,而且通过这种方式导出的成员,可以被重命名。
export { msg as myMsg, log, Clazz };
如果导出的成员被重命名了,导入的地方就需要使用重命名后的名字。
import { myMsg } from "./module.js";
如果导出成员的名字为 default,那么 default 的值就会作为这个模块的默认导出。由于 default 是关键字,所以导入的地方必须重命名。
export { msg as default, log, Clazz };
import { default as msg } from "./module.js";
针对这种情况,还有一种更简单的写法。通过 export 和 default 关键字的组合来导出默认的模块。
export { log, Clazz }; export default msg;
这样在导入模块的默认导出时,就可以随便起一个名字。
import iMsg from "./module.js";
导入/导出注意事项
语法上和字面量对象的区别
export 的语法和对象字面量语法完全一致,这就会产生迷惑行为。
修改 module.js 内容。
var name = "jack"; var age = 18; const obj = { name, age }; export { name, age };
这就导致很多人认为 export 导出的就是一个对象,而导入就是对这个对象的解构。实际上这是错误的。
虽然写法一样,但意义却没有任何关系。
export 单独使用时,就必须用花括号包裹要导出的成员。
当 export 和 default 同时使用时,后面的花括号就表示一个对象。
这一点要区分开。
var name = "jack"; var age = 18; const obj = { name, age }; // export { name, age };// 这是导出语法 export default { name, age }; // 这是默认导出一个对象
这时通过 import 来获取 module.js 的默认导出。
修改 app.js 的内容。
import { name, age } from "./module"; console.log(name, age);
会得到一个错误。
Uncaught SyntaxError: The requested module './module.js' does not provide an export named 'age'
这说明 import 和 export 一样,导出的不是一个对象的解构。花括号同样是固定写法。
成员的引用
模块中导出的成员都不是值传递,而是引用传递。即使是基础类型也是这样。
可以通过下面的实例来观察。
修改 module.js。
var name = "jack"; var age = 18; const obj = { name, age }; export { name, age }; setTimeout(function () { age = 20; }, 1000);
修改 app.js。
import { name, age } from "./module.js"; console.log(name, age); setTimeout(function () { console.log(age); }, 1500);
这样在 1 秒后,age 的值被修改为 20,1.5 秒后打印 age 的值,如果是 20,就意味着是引用传递。
1.5 秒后打印的结果是 20。
这和 nodejs 的 CommonJS 规范是完全不同的。
暴露出来的成员都是只读的
我们没有办法在模块外部修改模块内部暴露的成员。
比如尝试在 app.js 中修改 module.js 中暴露的成员 name。
import { name, age } from "./module.js"; name = "tom";
会得到一个错误。
Uncaught TypeError: Assignment to constant variable.
这和 const 有点像。
导入
引用路径
import 导入模块时,from 后面的字符串实际上是模块的路径。这个路径必须是完整的文件名,不可以省略扩展名。
但是在一些打包工具中,比如 Webpack,可以省略文件扩展名,或者只填写目录名,省略 index.js。
import utils from "./utils/index.js"; // 标准语法 import utils from "./utils/index"; // 打包工具的语法,省略扩展名 import utils from "./utils"; // 打包工具的语法,省略 index.js
这一点和 CommonJS 是不同的。
在网页开发中,引用网络资源时,如果使用的是相对路径,可以省略掉./,但是 import 不可以省略。
<img src="cat.png" /> <!-- 等价于 --> <!-- <img src='./cat.png'/> -->
但是 import 不可以省略。
import utils from "./utils/index.js"; // 正确语法 import utils from "utils/index.js"; // 错误语法
如果省略了/ ./ 或者 ../,那就会以字母开头,会被认为是加载第三方模块,这点和 CommonJS 是相同的。
除了相对路径,也可以通过项目的绝对路径或者 http 的 URL 来导入模块。
只去执行某个模块代码
比如模块 A 只是输出一串文字,并没有导出任何成员。
// moduleA.js console.log("hello, world");
在模块 B 中想要执行模块 A 的内容,只需要导入这个模块,不需要导入成员,就可以执行里面的脚本。
// moduleB.js import {} from "./moduleA.js";
除此之外,还有一种简单写法。就是省略成员列表和 from 关键词。
import "./moduleA.js";
导出成员较多
如果一个模块导出的成员非常多,而且其中很多成员都会被使用到,那么可以使用星号(*)的方式导入。
这种导入需要使用 as 关键词给导出的所有成员重命名,然后导出的成员都会被挂载到这个对象身上。
比如在 module.js 中导出了两个成员。
var name = "jack"; var age = 18; export { name, age };
那么在 app.js 中用星号的方式导入。
import * as mod from "./module.js"; console.log(mod);
就可以在控制台看到这个对象。
{ "age": 18, "name": "jack" }
import 不可以导入变量
当一个模块路径是在代码运行阶段得到的,那么无法使用 import 导入。
import 关键词只可以导入在代码运行前已知的模块路径。
所以 import 关键词只可以出现在模块文件的最顶部。
错误示例。
var modulePath = "./module.js" import * as mod from modulePath console.log(mod)
当我们遇到需要动态导入的机制时,就需要使用 import 函数。
因为这里的 import 是一个函数,而不是关键词,所以可以在代码的任何位置去执行。
import 函数返回一个 Promise 对象。模块暴露的对象会通过 Promise 中 then 参数函数的参数拿到。
var modulePath = "./module.js"; import(modulePath).then((module) => { console.log(module); });
同时导出默认成员和命名成员
如果 module.js 同时暴露了默认成员和命名成员,而在 app.js 想同时导入它们,可以用下面这种写法。
module.js
var name = "jack"; var age = 18; export { name, age }; export default "hi";
app.js
import { name, age, default as title } from "./module.js"; console.log(name, age, title);
除了重命名这种写法,还可以直接在花括号外前面直接导出默认成员,更加简洁。
这种写法必须让 default 成员在命名成员的前面,保证花括号和 from 关键词是在一起的。
import title, { name, age } from "./module.js"; console.log(name, age, title);
直接导出导入成员
import 可以和 export 组合使用,便于我们直接导出。
export { name, age, default as title } from "./module.js"; // console.log(name, age, title);
但是这种导出需要注意两个点。
第一是 default 要写到花括号中。
第二是直接 export 的话,模块内部无法访问到这些成员。
这种语法通常在一个模块文件夹中的 index.js 上使用。
比如有一个 components 文件夹,里面存放了很多组件。
每个组件都以一个模块的形式维护。这样会有 button.js、table.js、avatar.js 等等。
使用的时候就需要挨个导入,非常麻烦。
import { Button } from "./components/button.js"; import { Table } from "./components/table.js"; import { Avatar } from "./components/avatar.js";
优化这种导入导出体验的办法是在 components 文件夹下创建一个 index.js,由它负责导出所有的组件。
index.js
export { Button } from "./button.js"; export { Table } from "./table.js"; export { Avatar } from "./avatar.js";
这样在导入时会非常简单。
import { Button, Table, Avatar } from "./components/index.js";
ES Modules in Browser
由于 ES Module 是 2014 年才提出的,到现在还是有很多浏览器原生不支持这种用法。
所以使用 ES Module 时,还需要考虑兼容性的问题。
Polyfill 兼容方案
一般来说,我们使用 ES Module 编写的源代码都会经过编译工具的编译转换为 ES5,再拿去浏览器中工作。
但是也有办法让浏览器直接支持 ES Module。
社区提供了一个叫做 browser-es-module-loader 的模块,其实就是两个 js 文件。在 html 中先导入这两个模块,就可以让 ES Module 工作了。
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script> <script src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script> <script type="module" src="./app.js"></script> </body> </html>
上面用到的两个包,第一个是 babel 运行在浏览器的版本,第二个是用来加载 es module 的。
原理比较简单,首先 browser-es-module-loader 会通过 ajax 方式或者直接提取标签中代码的方式加载 module 文件,然后交给 babel 转换,得到浏览器可以直接运行的代码。
需要注意,这只是解决了模块导入的问题,如果代码中存在 ES6 的语法,还需要额外的 polyfill。
但是这种做法会有一个小问题。
在直接支持 es module 的浏览器中,模块的代码会被执行两次。原因很简单,浏览器本身就会执行一次模块中的代码,polyfill 也会执行一次模块中的代码。
解决这个问题的方法就是给 script 添加 nomodule 属性。
被添加 nomodule 属性的脚本,只会在不支持 es module 的浏览器中去执行,而支持 es module 的浏览器则不执行。
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js" ></script> <script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js" ></script>
polyfill 方案仅适合开发测试阶段使用,而不适合生产阶段去使用。因为所有的模块代码都是实时编译的,性能非常差。