Skip to main content

模块化

module 加载实现

一、浏览器加载

传统方法

//异步加载
<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>
//defer是“渲染完再执行”,多个defer按顺序执行。
//async是“下载完就执行”,多个async脚本是不能保证加载顺序的。
二、加载规则
//异步加载
<script type="module" src="./foo.js"></script>
//相当于
<script src="path/to/myModule.js" defer></script>

script 标签的 async 属性也可以打开

<script type="module" src="./foo.js" async></script>

ES6 模块也允许内嵌在网页中

<script type="module">import utils from "./utils.js"; // other code</script>

jQuery 支持模块加载。

<script type="module">
import $ from "./jquery/src/jquery.js"; $('#message').text('Hi from jQuery!');
</script>
三、ES6 模块与 CommonJS 模块的差异
-CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
-CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
-CommonJS 模块的require()是同步加载模块,ES6 模块的import命令是异步加载,
有一个独立的模块依赖的解析阶段。

ES6 模块特性

ES6 模块的运行机制遇到import ,就会生成一个只读引用,等到脚本真正执行时,
再根据这个只读引用,到被加载的那个模块里面去取值。
原始值变了,import加载的值也会跟着变

举个栗子

// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}

// main.js
import { counter, incCounter } from "./lib";
console.log(counter); // 3
incCounter();
console.log(counter); // 4

举个栗子

// m1.js
export var foo = "bar";
setTimeout(() => (foo = "baz"), 500);

// m2.js
import { foo } from "./m1.js";
console.log(foo);
setTimeout(() => console.log(foo), 500);
//m1.js的变量foo,在刚加载时等于bar,过了 500 毫秒,又变为等于baz。

由于 ES6 输入的模块变量,只是一个“符号连接”,所以这个变量是只读的,对它进行重新赋值会报错。

// lib.js
export let obj = {};

// main.js
import { obj } from "./lib";

obj.prop = 123; // OK
obj = {}; // TypeError

export 通过接口,输出的是同一个值。不同的脚本加载这个接口,得到的都是同样的实例。

// mod.js
function C() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.show = function () {
console.log(this.sum);
};
}

export let c = new C();
// x.js
import { c } from "./mod";
c.add();

// y.js
import { c } from "./mod";
c.show();

// main.js
import "./x";
import "./y";
// 执行main 输出1
四、Node.js 的模块加载方法
JavaScript 现在有两种模块。
一种是 ES6 模块,简称 ESM;
一种是 CommonJS 模块,简称 CJS (node.js专用)

CommonJS 模块使用require()和module.exports
ES6 模块使用import和export。

Node.js 要求 ES6 模块采用.mjs 后缀文件名, 也可以在项目的 package.json 文件中,指定 type 字段为 module。

{
"type": "module"
}

总结为一句话:.mjs 文件总是以 ES6 模块加载,.cjs 文件总是以 CommonJS 模块加载,.js 文件的加载取决于 package.json 里面 type 字段的设置。

注意,ES6 模块与 CommonJS 模块尽量不要混用。require 命令不能加载.mjs 文件,会报错,只有 import 命令才可以加载.mjs 文件。反过来,.mjs 文件里面也不能使用 require 命令,必须使用 import。

package.json 的 main 字段
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"//入口文件
}
//上面代码指定项目的入口脚本为./src/index.js
//它的格式为 ES6 模块。
//如果没有type字段,index.js就会被解释为 CommonJS 模块。

然后,import 命令就可以加载这个模块。

// ./my-app.mjs

import { something } from "es-module-package";
// 实际加载的是 ./node_modules/es-module-package/src/index.js
package.json 的 exports 字段

exports 字段的优先级高于 main 字段。它有多种用法 1-子目录别名 package.json 文件的 exports 字段可以指定脚本或子目录的别名

// ./node_modules/es-module-package/package.json
{
"exports": {
"./submodule": "./src/submodule.js"
}
}
//指定src/submodule.js别名为submodule,
//从别名加载文件
import submodule from "es-module-package/submodule";
// 加载 ./node_modules/es-module-package/src/submodule.js

举个栗子

// ./node_modules/es-module-package/package.json
{
"exports": {
"./features/": "./src/features/"
}
}

import feature from 'es-module-package/features/x.js';
// 加载 ./node_modules/es-module-package/src/features/x.js

如果没有指定别名,就不能用“模块+脚本名”这种形式加载脚本。

/ 报错
import submodule from 'es-module-package/private-module.js';

// 不报错
import submodule from './node_modules/es-module-package/private-module.js';

2-main 的别名

//exports字段的别名如果是".", 优先级高于main字段
{
"exports": {
".": "./main.js"
}
}

// 等同于 直接简写成exports字段的值
{
"exports": "./main.js"
}

由于exports字段只有支持 ES6 的 Node.js 才认识,所以可以用来兼容旧版本的 Node.js。

{
"main": "./main-legacy.cjs",//不支持es6版本的node入口文件
"exports": {
".": "./main-modern.cjs"//新版本的 Node.js 的入口文件
}
}

3-加载条件 利用.这个别名,可以为 ES6 模块和 CommonJS 指定不同的入口。

{
"type": "module",
"exports": {
".": {
"require": "./main.cjs",//CommonJS入口
"default": "./main.js"//es6入口
}
}
}

//简写
{
"exports": {
"require": "./main.cjs",
"default": "./main.js"
}
}
//注意,如果同时还有其他别名,就不能采用简写,否则会报错。
CommonJS 模块加载 ES6 模块
//CommonJS 的require()命令不能加载 ES6 模块,会报错,只能使用import()这个方法加载。
(async () => {
await import("./my-app.mjs");
})();
//require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层await命令,导致无法被同步加载。
ES6 模块加载 CommonJS 模块

只能整体加载,不能只加载单一的输出项。

// 正确
import packageMain from "commonjs-package";

// 报错
import { method } from "commonjs-package";

//加载单一的输出项,可以写成下面这样。
import packageMain from "commonjs-package";
const { method } = packageMain;

还有一种变通的加载方法,就是使用 Node.js 内置的 module.createRequire()方法。

// cjs.cjs
module.exports = 'cjs';

// esm.mjs
import { createRequire } from 'module';

const require = createRequire(import.meta.url);

const cjs = require('./cjs.cjs');
cjs === 'cjs'; // true
Node.js 的内置模块
// 整体加载
import EventEmitter from "events";
const e = new EventEmitter();

// 加载指定的输出项
import { readFile } from "fs";
readFile("./foo.txt", (err, source) => {
if (err) {
console.error(err);
} else {
console.log(source);
}
});
加载路径

ES6 模块的加载路径必须给出脚本的完整路径,不能省略脚本的后缀名。

// ES6 模块中将报错
import { something } from "./index";

为了与浏览器的 import 加载规则相同,Node.js 的.mjs 文件支持 URL 路径。

import "./foo.mjs?query=1"; // 加载 ./foo 传入参数 ?query=1
//脚本路径带有参数?query=1,Node 会按 URL 规则解读。同一个脚本只要参数不同,就会被加载多次,并且保存成不同的缓存。由于这个原因,只要文件名中含有:、%、#、?等特殊字符,最好对这些字符进行转义。
内部变量

Node.js 规定 ES6 模块之中不能使用 CommonJS 模块的特有的一些内部变量。 顶层的 this 指向 undefined; CommonJS 模块的顶层 this 指向当前模块 *以下这些顶层变量在 ES6 模块之中都是不存在的。 arguments require module exports filename dirname

循环加载

“循环加载”(circular dependency)指的是,a 脚本的执行依赖 b 脚本,而 b 脚本的执行又依赖 a 脚本。

// a.js
var b = require('b');

// b.js
var a = require('a');
CommonJS 模块的加载原理
//CommonJS 的一个模块,就是一个脚本文件。require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。
//CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。
{
id: '...',//模块名
exports: { ... },//模块输出的各个接口
loaded: true,//表示该模块的脚本是否执行完毕
...
}
CommonJS 模块的循环加载
ES6 模块的循环加载