前端模块化及进程
# commonjs (opens new window)
commonjs 关系 nodejs 书写环境,更详细点击 title 打开文档查看。
Nodejs 是基于 v8 引擎,事件驱动 I/O 的服务端 JS 运行环境,推出 CommonJS 规范主要就是 module.exports = {} 导出 require() 导入。使用以下例子可以发现:
// a.js
const i = require("./c");
require("./b");
console.log(i);
// b.js
var i = require("./c");
i = "12321" + i;
setTimeout(() => console.log(i), 1000);
// c.js
var i = Date.now();
module.exports = i;
i = 123;
2
3
4
5
6
7
8
9
10
11
12
13
14
运行以上代码我们可以看到:
- 实现了模块化,即使各模块内部会有相同的变量名,也没有造成冲突,并且其输出的值没有发生改变,保证了单例(输入的是被输出的值的拷贝)。
- commonjs 采用的是同步的方式进行导入导出并按照顺序执行,倘若导出的是一个非引用类型的对象,即使在后面对其造成了修改,其导出的数值依旧没有发生改变,倘若返回了一个 object 类型的对象,其内的值发生了改变,会影响到其他引入它的模块,在 commonjs 中,其导出的其实是这个对象的引用。
- 但是 commonjs 只能在 nodejs 的环境中才能运行,其会解析 js 环境中的导入导出方法,在其他的环境中运行这样的代码就会报错。其实际上是通过参数去找到要引入的文件的物理路径,从而通过系统调用从硬盘中读取文件内容,解析后返回文件的结果。这个过程是以同步执行来进行的,会阻塞接下来的代码的执行,这并不适用于浏览器端。
# module 对象
Node 内部提供一个 Module 构建函数。所有模块都是 Module 的实例,每个模块内部,都有一个 module 对象,代表当前模块。它有以下属性。
- module.id 模块的识别符,通常是带有绝对路径的模块文件名。
- module.filename 模块的文件名,带有绝对路径。
- module.loaded 返回一个布尔值,表示模块是否已经完成加载。
- module.parent 返回一个对象,表示调用该模块的模块。
- module.children 返回一个数组,表示该模块要用到的其他模块。
- module.exports 表示模块对外输出的值。
// example.js
var jquery = require('jquery');
exports.$ = jquery;
console.log(module);
// 输出
{ id: '.',
exports: { '$': [Function] },
parent: null,
filename: '/path/to/example.js',
loaded: false,
children:
[ { id: '/path/to/node_modules/jquery/dist/jquery.js',
exports: [Function],
parent: [Circular],
filename: '/path/to/node_modules/jquery/dist/jquery.js',
loaded: true,
children: [],
paths: [Object] } ],
paths:
[ '/home/user/deleted/node_modules',
'/home/user/node_modules',
'/home/node_modules',
'/node_modules' ]
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
如果在命令行下调用某个模块,比如 node something.js,那么 module.parent 就是 null。如果是在脚本之中调用,比如 require('./something.js'),那么 module.parent 就是调用它的模块。利用这一点,可以判断当前模块是否为入口脚本。
if (!module.parent) {
// ran with `node something.js`
app.listen(8088, function () {
console.log("app listening on port 8088");
});
} else {
// used with `require('/.something.js')`
module.exports = app;
}
2
3
4
5
6
7
8
9
# module.exports 属性
module.exports 属性表示当前模块对外输出的接口,其他文件加载该模块,实际上就是读取 module.exports 变量。
# exports 变量
为了方便,Node 为每个模块提供一个 exports 变量,指向 module.exports。
// 这等同在每个模块头部,有一行这样的命令
var exports = module.exports;
// 错误:不能直接将exports变量指向一个值,因为这样等于切断了exports与module.exports的联系。而对于module.exports,则需要接受一个引用类型的值。
exports = function (x) {
console.log(x);
};
2
3
4
5
6
# require 命令
Node 使用 CommonJS 模块规范,内置的 require 命令用于加载模块文件。require 命令的基本功能是,读入并执行一个 JavaScript 文件,然后返回该模块的 exports 对象。如果没有发现指定模块,会报错。
require 命令是 CommonJS 规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的 module.require 命令,而后者又调用 Node 的内部命令 Module._load。
Module._load = function (request, parent, isMain) {
// 1. 检查 Module._cache,是否缓存之中有指定模块
// 2. 如果缓存之中没有,就创建一个新的Module实例
// 3. 将它保存到缓存
// 4. 使用 module.load() 加载指定的模块文件,
// 读取文件内容之后,使用 module.compile() 执行文件代码
// 5. 如果加载/解析过程报错,就从缓存删除该模块
// 6. 返回该模块的 module.exports
};
2
3
4
5
6
7
8
9
上面的第 4 步,采用 module.compile()执行指定模块的脚本,逻辑如下。
Module.prototype._compile = function (content, filename) {
// 1. 生成一个require函数,指向module.require
// 2. 加载其他辅助方法到require
// 3. 将文件内容放到一个函数之中,该函数可调用 require
// 4. 执行该函数
};
2
3
4
5
6
上面的第 1 步和第 2 步,require 函数及其辅助方法主要如下。
- require(): 加载外部模块
- require.resolve():将模块名解析到一个绝对路径
- require.main:指向主模块
- require.cache:指向所有缓存的模块
- require.extensions:根据文件的后缀名,调用不同的执行函数
模拟一个 DEMO:
const path = require("path");
const fs = require("fs");
const vm = require("vm");
function r(filename) {
const pathToFile = path.resolve(__dirname, filename);
const content = fs.readFileSync(pathToFile, "utf-8");
const wrapper = ["(function(require, module, exports) {", "})"];
const wrappedContent = wrapper[0] + content + wrapper[1];
const script = new vm.Script(wrappedContent, {
filename: "index.js",
});
const result = script.runInThisContext();
const m = {
exports: {},
};
result(r, m, m.exports);
return m.exports;
}
const content = r("./module.js");
console.log(content);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 模块的缓存
第一次加载某个模块时,Node 会缓存该模块。以后再加载该模块,就直接从缓存取出该模块的 module.exports 属性, 如果再别的文件修改了引入的变量,输出值并不会发生改变,而是读取其缓存,除非写成一个函数,才能得到内部变动后的值。所有缓存的模块保存在 require.cache 之中,如果想删除模块的缓存,可以像下面这样写。
// 删除指定模块的缓存
delete require.cache[moduleName];
// 删除所有模块的缓存
Object.keys(require.cache).forEach(function (key) {
delete require.cache[key];
});
2
3
4
5
6
7
注意,缓存是根据绝对路径识别模块的,如果同样的模块名,但是保存在不同的路径,require 命令还是会重新加载该模块。
# 环境变量 NODE_PATH
Node 执行一个脚本时,会先查看环境变量 NODE_PATH。它是一组以冒号分隔的绝对路径。在其他位置找不到指定模块时,Node 会去这些路径查找。 可以将 NODE_PATH 添加到.bashrc。
export NODE_PATH
require命令是CommonJS规范之中,用来加载其他模块的命令。它其实不是一个全局命令,而是指向当前模块的module.require命令,而后者又调用Node的内部命令Module._load。="/usr/local/lib/node"
2
所以,如果遇到复杂的相对路径,比如下面这样。
var myModule = require("../../../../lib/myModule");
有两种解决方法,一是将该文件加入 node_modules 目录,二是修改 NODE_PATH 环境变量,package.json 文件可以采用下面的写法。
{
"name": "node_path",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "NODE_PATH=lib node index.js"
},
"author": "",
"license": "ISC"
}
2
3
4
5
6
7
8
9
10
11
NODE_PATH 是历史遗留下来的一个路径解决方案,通常不应该使用,而应该使用 node_modules 目录机制
# require.main
require 方法有一个 main 属性,可以用来判断模块是直接执行,还是被调用执行。
直接执行的时候(node module.js),require.main 属性指向模块本身。
require.main === module; // true
调用执行的时候(通过 require 加载该脚本执行),上面的表达式返回 false。
# AMD
AMD 是"Asynchronous Module Definition"的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。目前,主要有两个 Javascript 库实现了 AMD 规范:require.js (opens new window)和 curl.js。
// index.html
<script src="./require.js"></script>
<script src="./a.js"></script>
// a.js
require(['b', 'c'], function (moduleB, moduleC) {
console.log(moduleC)
})
// b.js
define(function (require) {
var m = require('c')
setTimeout(() => console.log(m), 1000)
})
// c.js
define(function (require) {
var m = Date.now()
return m
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
运行以上代码我们可以看到:
- AMD 模块定义默认为文件名,并写入一个回调函数,其函数返回的参数即是模块导出的值。
- AMD 的模块不能直接运行在 node 端,因为内部的 define 函数, require 函数都必须配合在浏览器中加载 require,is 这类 AMD 库才能使用。
- AMD 规范看起来完美解决了浏览器模块化开发的难题。但是它有一个天生的缺陷,对于依赖的模块无论实际需要与否,都会先加载并执行,加入我们第一个参数写入了 b、c,但是仅仅使用到了 b 的内容,但是 c 仍会被加载,从而引起了浪费。
使用 require.config()方法,我们可以对模块的加载行为进行自定义。require.config()就写在主模块(main.js)的头部。参数就是一个对象,这个对象的 paths 属性指定各个模块的加载路径。
require.config({
// baseUrl: "js/lib", 改变获取地址根路径
paths: {
jquery: "https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min", // 文件名 、 路径
underscore: "node_modules/underscore.min",
backbone: "backbone.min",
},
});
2
3
4
5
6
7
8
# CMD
CMD 也是一种 js 实现的模块化方案,不同之处在于,AMD 规范是依赖前置、模块提前加载并执行;CMD 是依赖后置、模块懒惰加载再执行。其规范的库是Sea.js (opens new window),用法如下
// 定义模块 math.js
define(function (require, exports, module) {
var $ = require("jquery.js");
var add = function (a, b) {
return a + b;
};
exports.add = add;
});
// 加载模块
seajs.use(["math.js"], function (math) {
var sum = math.add(1 + 2);
});
2
3
4
5
6
7
8
9
10
11
12
# AMD与CMD的区别
我们从requirejs和seajs中可以看到,由于requirejs与seajs遵循规范不同,requirejs在define函数中可以很容易获得当前模块依赖项。而seajs中不需要依赖声明,所以必须做一些特殊处理才能否获得依赖项。方法将factory作toString处理,然后用正则匹配出其中的依赖项,比如出现require(./a),则检测到需要依赖a模块。由于CMD规范和浏览器环境特点决定了seajs需要对代码进行一些预处理。
对于AMD来说,AMD的factory函数在其依赖的模块都执行完毕后便会执行,而CMD的factory是在被使用时才会执行的,它会等到模块的依赖项的文件完全加载完毕(amd中需要执行完毕,cmd中只需要文件加载完毕,注意这时候的factory尚未执行,当使用require请求该模块时,factory才会执行,所以在性能上seajs逊于requirejs)后执行主模块的factory函数;
# UMD
UMD( Universal ModuleDefinition)作为一种同构( isomorphic)的模块化解决方案出现,它能够让我们只需要在一个地方定义模块内容,并同时兼容 AMD 和 Commons 语法。其原理其实就是根据 AMD 和 COMMONJS 模块化规范的特征,判断当前环境是符合哪种规范的特征,从而使用相应的语法来导出。
(function (self, factory) {
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = factory();
} else if (typeof define === "function" && define.amd) {
define(factory);
} else {
//
self.umdModule = factory();
}
})(this, function () {
return function () {
return Math.random();
};
});
2
3
4
5
6
7
8
9
10
11
12
13
14
# ESModule
- 每个 JS 的运⾏环境都有⼀个解析器,否则这个环境也不会认识 JS 语法。它的作⽤就是⽤ ECMAScript 的规范去解释 JS 语法,也就是处理和执⾏语⾔本身的内容,例如按照逻辑正确执⾏ var a = "123";,function func() {console.log("hahaha");} 之类的内容。
- 在解析器的上层,每个运⾏环境都会在解释器的基础上封装⼀些环境相关的 API。例如 Node.js 中的 global 对象、process 对象,浏览器中的 window 对象,document 对象等等。这些运⾏环境的 API 受到各⾃规范的影响,例如浏览器端的 W3C 规范,它们规定了 window 对象和 document 对象上的 API 内容,以使得我们能让 document.getElementById 这样的 API 在所有浏览器上运⾏正常。
- 事实上,类似于 setTimeout 和 console 这样的 API,⼤部分也不是 JS Core 层⾯的,只不过是所有运⾏环境实现了相似的结果。setTimeout 在 ES7 规范之后才进⼊ JS Core 层⾯,在这之前都是浏览器和 Node.js 等环境进⾏实现。console 类似 promise,有⾃⼰的规范,但实际上也是环境⾃⼰进⾏实现的,这也就是为什么 Node.js 的 console.log 是异步的⽽浏览器是同步的⼀个原因。
- 同时,早期的 Node.js 版本是可以使⽤ sys.puts 来代替 console.log 来输出⾄ stdout 的。ESModule 就属于 JS Core 层⾯的规范,⽽ AMD,CommonJS 是运⾏环境的规范。所以,想要使运⾏环境⽀持 ESModule 其实是⽐较简单的,只需要升级⾃⼰环境中的 JS Core 解释引擎到⾜够的版本,引擎层⾯就能认识这种语法,从⽽不认为这是个 语法错误(syntax error) ,运⾏环境中只需要做⼀些兼容⼯作即可。
- Node.js 在 V12 版本之后才可以使⽤ ESModule 规范的模块,在 V12 没进⼊ LTS 之前,我们需要加上 --experimental-modules 的 flag 才能使⽤这样的特性,也就是通过 node --experimental-modulesindex.js 来执⾏。浏览器端 Chrome 61 之后的版本可以开启⽀持 ESModule 的选项,只需要通过 `` 这样的标签加载即可。这也就是说,如果想在 Node.js 环境中使⽤ ESModule,就需要升级 Node.js 到⾼版本,这相对来说⽐较容易,毕竟服务端 Node.js 版本控制在开发⼈员⾃⼰⼿中。但浏览器端具有分布式的特点,是否能使⽤这种⾼版本特性取决于⽤户访问时的版本,⽽且这种解释器语法层⾯的内容⽆法像 AMD 那样在运⾏时进⾏兼容,所以想要直接使⽤就会⽐较麻烦。
# ESModule 的加载实现
在浏览器加载 js 文件时,默认情况下是会同步加载 js 脚本,即浏览器引擎遇到《script》标签后就会停下来,等到脚本执行完之后再继续向下渲染。如果是外部脚本,还需要同步等待脚本下载的时间。在此,同步的代码可能就会受到脚本过大、下载过慢带来极差用户体验的问题。在此浏览器有两种异步加载的方案:
- defer <script src="xxx.js" defer></script> 等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行。
- async <script src="yyy.js" async></script> 一旦下载完,渲染引擎就会中断渲染,执行这个脚本以后,再继续渲染。
defer 是“渲染完再执行”,async 是“下载完就执行”。另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本是不能保证加载顺序的。
浏览器加载 ES6 模块,也使用 script 标签,但是要加入 type="module"属性。浏览器对于带有 type="module"的 script,都是异步加载,不会造成堵塞浏览器,即等到整个页面渲染完,再执行模块脚本,等同于打开了 script 标签的 defer 属性。
<script type="module" src="./foo.js"></script>
// 倘若增加了async属性,esmodule就不会按照页面出现的顺序执行,而是模块加载完成,就执行该模块。
<script type="module" src="./foo.js" async></script>
2
3
# commonjs 和 Es 的区别
- commonJs 是被加载的时候运行,esModule 是编译的时候运行。因为 CommonJS 加载的是一个对象(即 module.exports 属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
- commonJs 输出的是值的浅拷贝,esModule 输出值的引用
- commontJs 具有缓存。在第一次被加载时,会完整运行整个文件并输出一个对象,拷贝(浅拷贝)在内存中。下次加载文件时,直接从内存中取值
- commonJs 模块的 require()是同步加载模块,ES6 模块的 import 命令是异步加载,有一个独立的模块依赖的解析阶段
ES6 模块的运行机制与 CommonJS 不一样。JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。换句话说,ES6 的 import 有点像 Unix 系统的“符号连接”,原始值变了,import 加载的值也会跟着变。因此,ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
浏览器加载 es 的实现 (opens new window)
# CommonJS 模块加载 ES6 模块
CommonJS 的 require()命令不能加载 ES6 模块,会报错,只能使用 import()这个方法加载。
(async () => {
await import("./my-app.mjs");
})();
2
3
上面代码可以在 CommonJS 模块中运行。
require()不支持 ES6 模块的一个原因是,它是同步加载,而 ES6 模块内部可以使用顶层 await 命令,导致无法被同步加载。
# ES6 模块加载 CommonJS 模块
ES6 模块的 import 命令可以加载 CommonJS 模块,但是只能整体加载,不能只加载单一的输出项。
// 正确
import packageMain from "commonjs-package";
// 报错
import { method } from "commonjs-package";
2
3
4
5
这是因为 ES6 模块需要支持静态代码分析,而 CommonJS 模块的输出接口是 module.exports,是一个对象,无法被静态分析,所以只能整体加载。
加载单一的输出项,可以写成下面这样。
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
2
3
4
5
6
7
8
9
10
11
12
13
14
上面代码中,ES6 模块通过 module.createRequire()方法可以加载 CommonJS 模块。但是,这种写法等于将 ES6 和 CommonJS 混在一起了,所以不建议使用。
# 同时支持两种格式的模块
一个模块同时要支持 CommonJS 和 ES6 两种格式,也很容易。
如果原始模块是 ES6 格式,那么需要给出一个整体输出接口,比如 export default obj,使得 CommonJS 可以用 import()进行加载。
如果原始模块是 CommonJS 格式,那么可以加一个包装层。
import cjsModule from "../index.js";
export const foo = cjsModule.foo;
2
上面代码先整体输入 CommonJS 模块,然后再根据需要输出具名接口。
你可以把这个文件的后缀名改为.mjs,或者将它放在一个子目录,再在这个子目录里面放一个单独的 package.json 文件,指明{ type: "module" }。
另一种做法是在 package.json 文件的 exports 字段,指明两种格式模块各自的加载入口。
"exports":{
"require": "./index.js",
"import": "./esm/wrapper.js"
}
2
3
4
上面代码指定 require()和 import,加载该模块会自动切换到不一样的入口文件。
# ESM 在 webpack 下的打包
通常我们使用 webpack 对我们的 js 文件进行编译的时候,我们可以通过以下例子看到:
编译前
// ES6
import { firstName, lastName, year } from "./profile";
2
编译后
// Babel 编译后
"use strict";
var _profile = require("./profile");
2
3
你会发现 Babel 只是把 ES6 模块语法转为 CommonJS 模块语法,然而浏览器是不支持这种模块语法的,所以直接跑在浏览器会报错的,如果想要在浏览器中运行,还是需要使用打包工具将代码打包。
Webpack 打包工具在打包这些 commonjs 语法的过程中,模拟了 module、 exports、 require 等浏览器环境中没有的环境变量,并将原有的 commonjs 的文件再包裹一层函数,将这些模拟好的环境变量传入函数。