2023-08-04
JavaScript
00
请注意,本文编写于 450 天前,最后修改于 440 天前,其中某些信息可能已经过时。

目录

IIFE
CommonJS
基本用法
模块分类
require方法
特性
实现原理
AMD(异步模块定义规范)
为什么浏览器不使用CommonJS规范?
基本用法
实现原理
CMD
Sea.Js与CommonJS异同
Sea.Js用法
实现原理
与AMD(requireJS)区别
UMD
实现原理
ES Module
简介
export命令
import命令
模块的整体加载
export default语句
export与import的复合写法
import() 动态加载
import.meta
script标签defer和async属性
ESM与CommonJS的差异
NodeJS的模块加载方法
CommonJS加载ESM模块
ESM加载CommonJS模块
ESM在浏览器端与服务端的差异
循环加载

本篇文章将系统梳理JavaScript模块化发展历程,介绍CommonJS、AMD、CMD、UDM、ESM的语法及特性,以及部分实现原理。

思维导图

模块化:解决一个复杂问题时自顶向下逐层把系统分成若干个模块的过程

历史上(es6以前)JavaScript一直没有模块化,无模块化时代带来了一些问题。

  • 全局变量的灾难
  • 函数命名的冲突
  • 依赖关系不好管理

模块化的演进过程

  1. 基于文件划分
  2. 命名空间(口头约束)
  3. IIFE(立即执行函数表达式)
    • 解决了var声明的变量也可能会污染全局变量问题
    • 利用函数(局部)作用域(或者说闭包特性)函数内声明的变量会保存在函数的作用域上,从而避免污染全局变量

IIFE

js
// 1.常见的写法 (function(){ // ... })(); +function(){ // ... }(); (function() { // ... }()) // 一些开源项目为何要把全局、指针以及框架本身引用作为参数 (function(window, $) { // window 1. 全局作用域转化成局部作用域,提升执行效率 2. 编译时优化 // $ 1. 独立定制复写和挂载 2.防止全局串扰 const _show = function() { $("#app").val("hi zhaowa"); } window.webShow = _show; })(window, jQuery); // 揭示模式 上层无需关注具体实现,仅关注抽象 const iifeModule = ((depModule1, depModule2) => { let count = 0; const publicFn = () => ++count; const privateFn = () => { count = 0; } return { publicFn } })(depModule1, depModule2);

统一不同开发者,不同项目之间的差异,我们需要一个行业标准去规范模块化的实现方式。

如何设计模块化?

  • 主要需求就两点 一个统一的模块化标准一个可自动加载模块的基础库

CommonJS

09年 nodejs项目诞生,这标志**"Javascript模块化编程"** 正式诞生。
因为在浏览器环境下,没有模块也不是特别大的问题,毕竟网页程序的复杂性有限;
但是在服务器端,一定要有模块,与操作系统和其他应用程序互动,否则根本没法编程。

  • 该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,
  • 通过 module.exports 导出成员,再通过 require 函数载入模块。

基本用法

js
// 三种写法异同 module.exports.x = x // 1 //使用 require('./module).x module.exports = {} // { x:x, ... } exports.x=x // 等同于1 exports是moudule.exports的简写 // 无效写法 而模块导出的内容为module.exports // exports只是module.exports的引用,修改了exports的引用无意义 exports = function(x) {console.log(x)}; module.exports = x //使用 require('./module') // 不推荐这样写,会覆盖前面导出的值 require('x') // 加载文件,可省略.js 路径问题 // require与package.json main字段

模块分类

Node.js的模块分为两类,一类为原生(核心)模块,一类为文件模块。原生模块在Node.js源代码编译的时候编译进了二进制执行文件,加载的速度最快。另一类文件模块是动态加载的,加载速度比原生模块慢。但是Node.js对原生模块和文件模块都进行了缓存,于是在第二次require时,是不会有重复开销的。

实际上在文件模块中,又分为3类模块。这三类文件模块以后缀来区分,Node.js会根据后缀名来决定加载方法。

  • .js。通过fs模块同步读取js文件并编译执行。
  • .node。通过C/C++进行编写的Addon。通过dlopen方法进行加载。
  • .json。读取文件,调用JSON.parse解析加载。

require方法

js
require('fs'); // 加载原生模块。 如 http、fs、path 等 require('/xx/../xx.js'); // 绝对路径加载 require('./hello.js'); // 相对路径加载 require('./hello'); // 相对路径绝对路径都可以省略一些后缀名如 .js .json 等 require('webpack'); // 加载node_modules中的模块,读取package.json中的main字段指向的文件 require('./X'); // 按一下顺序查找 X.js X/package.json中main字段 X/index.js node_modeules/X/package.json // 以上只是简单总结,实际上nodejs的require用法非常灵活

文件查找策略 image.png

特性

  • CommonJS输出的是值的拷贝
  • require是同步加载 (因为本地文件加载很快)
  • 单例模型,多次加载只会执行一次
  • CommonJS是运行时加载
  • CommonJS顶层this 指向当前模块

加载原理

循环加载

  1. node a.js
  2. 执行a.js遇到 require('b.js'),暂停a.js执行,导出部分内容,执行b.js
  3. 执行b.js,遇到require(a.js),使用a.js导出的部分内容,继续执行b.js
  4. b.js执行完毕,继续执行a.js

注意事项 由于 CommonJS 模块遇到循环加载时,返回的是当前已经执行的部分的值,而不是代码全部执行后的值,两者可能会有差异。所以,输入变量的时候,必须非常小心。

js
var a = require('a'); // 安全的写法 var foo = require('a').foo; // 危险的写法 exports.good = function (arg) { return a.foo('good', arg); // 使用的是 a.foo 的最新值 }; exports.bad = function (arg) { return foo('bad', arg); // 使用的是一个部分加载时的值 }; // 整体引入,在使用的时候(function调用)再尝试获取内容

实现原理

js
const fs = require('fs'); const path = require('path'); req('./module_one.js'); function req(moduleName) { const modPath = path.join(__dirname, moduleName); let content = fs.readFileSync(modPath, 'utf-8'); const fn = new Function('exports', 'require', 'module', '__filename', '__dirname', content + '\n return module.exports'); const module = { exports: {} } return fn.call(module.exports, module.exports, req, module, __filename, __dirname) } // module_one.js exports.name = 'one of CommonJS modules'; console.log('this----', this);

AMD(异步模块定义规范)

一种专门为浏览器端重新设计了一个规范。代表:有个非常出名的库Require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。

为什么浏览器不使用CommonJS规范?

  • CommonJS 在服务端采用同步加载,这种模式更加直观且符合服务端编程,因为所有的模块都存放在本地硬盘,等待时间就是硬盘的读取时间。
  • 但是,对于浏览器,这却是一个大问题,因为模块都放在服务器端,等待时间取决于网速的快慢,可能要等很长时间,浏览器处于"假死"状态。
  • 因此,浏览器端的模块不能采用“同步加载”只能采用“异步加载”

AMD优点:

  • 可以不转换代码的情况下在浏览器上运行
  • 可以加载多个依赖
  • 代码可以运行在浏览器环境和和nodejs环境中

基本用法

js
// 定义一个模块 define('a', [], function () { return 'a'; }); define('b', ['a'], function (a) { return a + 'b'; }); // 导入和使用 require(['b'], function (b) { console.log(b); });

实现原理

js
if(typeof define !== 'function') { (function() { var modules = {} define = function (modName, deps, factory) { factory.deps = deps; modules[modName] = factory; } require = function (modNames, callback) { var loadMods = modNames.map(function(modName){ var module = modules[modName]; var result; require(module.deps, function (...depResults) { result = module(...depResults) }) return result; }); callback.apply(null, loadMods); } })() } define('a', [], function () { console.log('load module a'); return 'module a'; }) define('b', ['a'], function (a) { console.log('load module b'); return 'module b --- ' + a; }) require(['b'], function (b) { console.log(b) })

手写思路 step1 step2 step3 step4

CMD

SeaJS遵循CMD规范,与RequireJS类似,同样做为模块加载器。
github: [https://github.com/seajs/seajs] (https://github.com/seajs/seajs)
使用demo 在github仓库里, 官方文档在issues里面

Sea.Js与CommonJS异同

但Sea.js比起Node的不同点在于,前者的运行环境是在浏览器中,这就导致A依赖的B模块不能同步地读取过来,所以Sea.js比起Node,除了运行之外,还提供了两个额外的东西:

  1. 模块的管理
  2. 模块从服务端的同步

即Sea.js必须分为模块加载期和执行期

  • 加载期:即在执行一个模块之前,将其直接或间接依赖的模块从服务器端同步到浏览器端;
  • 执行期:在确认该模块直接或间接依赖的模块都加载完毕之后,执行该模块。

所以Sea.js需要三个接口:

  1. define用来wrapper模块,指明依赖,同步依赖;
  2. use用来启动加载期;
  3. require关键字,实际上是执行期的桥梁。

Sea.Js用法

javascript
seajs.config({ alias: { // 路径别名 配合define、require使用 'modA': '/modules/modA', }, // preload: ['seajs-text'] }) seajs.use("/modules/greet", function (Greet) { Greet.helloJavaScript(); });
javascript
// /modules/greet.js define( ['modA'], // 依赖的模块,立即加载,不管有没有使用 function (require, exports) { document.body.innerHTML = '<h2>hello sea.js</h2>' function helloJavaScript () { var modeA = require('modA'); // 必须声明依赖才能require var ele = document.createElement('h3'); ele.innerText = modeA.name; document.body.appendChild(ele); } exports.helloJavaScript = helloJavaScript; setTimeout(() => { console.log('require.async'); // 异步加载的模块,按需加载 require.async('./asyncMod', function(b) { var ele2 = document.createElement('h3'); ele2.innerText = b; document.body.insertBefore(ele2, document.body.lastChild); }); }, 1000); }); // /modules/modA.js define(function (require, exports) { exports.name = "modA.js has load"; }); // /modules/asyncMod.js define(function (require, exports, module) { module.exports = "This is a async module, please use \"require.async\" to load"; });

实现原理

example

javascript
(function (root) { var seajs = root.seajs = {}; var cid = 0; var cidMap = window.cidMap = {}; var modules = window.modules = {}; // cidMap[url] ==> cid ==> modules[cid] // cidMap[cid] ==> url seajs.use = function(url,cb) { loadScript(url) .then(() => modules[cidMap[url]].p) .then(() => { cb(require(url)) }); } function loadScript(url) { cidMap[cid] = url; cidMap[url] = cid; cid++; var oScript = document.createElement('script'); var s = document.getElementsByTagName('script')[0]; oScript.src = url + '.js'; s.parentNode.appendChild(oScript); return new Promise((resolve) => { oScript.onload = function () { console.log(url + '.js', '加载完成'); resolve(); } }) } require = function (modName){ var result = modules[cidMap[modName]].exports; return result; } define = function (deps, factory) { factory.deps = deps; var _resolve; var p = new Promise(resolve => { _resolve = resolve; }) var _module = { exports: {}, p, } modules[cid - 1] = _module; var cbs = deps.map(url => { return loadScript(url).then(() => modules[cidMap[url]].p) }); Promise.all(cbs).then(() => { _resolve(); factory(require, _module.exports, _module); }) } })(this); // 以上是个人根据SeaJS用法手写部分实现,基本未完全参考源码 // 如CMD的延时执行就没有体现 // 再者还有些API如require.sync没有实现,感兴趣可尝试自己写一写

与AMD(requireJS)区别

  1. 对于依赖的模块,AMD 是提前执行,CMD 是延迟执行。不过 RequireJS 从 2.0 开始,也改成可以延迟执行(根据写法不同,处理方式不同)。CMD 推崇 as lazy as possible.
  2. CMD 推崇依赖就近,AMD 推崇依赖前置
  3. 相对于amd的优点是方便将nodejs模块转成cmd以便在浏览器运行 image.png
  4. AMD 的 API 默认是一个当多个用,CMD 的 API 严格区分,推崇职责单一。比如 AMD 里,require 分全局 require 和局部 require,都叫 require。CMD 里,没有全局 require,而是根据模块系统的完备性,提供 seajs.use 来实现模块系统的加载启动。CMD 里,每个 API 都简单纯粹

网上有人说 seaJS是重复造轮子,个人觉得这种说法比较牵强,
但是我也在想一个问题为什么seaJS没有requireJS那么流行?
是因为先有requireJS吗,后来requireJS也兼容了CMD写法?
如果高见请指教!

UMD

UMDAMDCommonJS 的综合产物。AMD 用于浏览器,CommonJS 用于服务器。UMD 则是两者的兼容模式,解决了跨平台问题。

实现原理:if-else

详情请移步github UMD

实现原理

js
(function (global, factory) { // commonJS if(typeof exports === 'object' && typeof module !== 'undefined') { factory(exports) } // amd else if(typeof define === 'function' && define.amd) { define(['exports'], factory) } // umd else { global = typeof globalThis !== 'undefined' ? globalThis : global || self; factory(global.Calc = {}) // rollup.config.js output.name: 'Calc' } })(this, (function (exports) { 'use strict'; // exports.multi = multi; // ... Object.defineProperty(exports, '__esModule', { value: true }); }));

ES Module

yuque_mind.jpeg

简介

设计思想

  • 尽可能的静态化
    • 使得编译时就能确定模块的依赖关系,以及输入输出的变量
  • “静态优化”只能在编译时做。

静态加载的好处

  • 拓展JavaScript语法,比如引入宏和类型检查。
  • 不在需要UMD,ESM将统一服务端和浏览器端
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者navigator对象的属性。
  • 不再需要对象作为命名空间(比如Math对象),未来这些功能可以通过模块提供。

严格模式 ESM模块自动采用严格模式

export命令

  • 对外提供接口
  • 输出的接口与其值时动态绑定关系
    • commonJS输出的是值的动态绑定关系,不存在动态更新问题
js
// 两种写法 export var a = 1; var b = 1; export { b } // export {} 可以使用多次,会合并 export { b as c } // 使用as重命名 // a.mjs var c = 23; export { c as cc, } // 使用 import {cc} from './a.mjs'; // export 是静态执 所以必须在模块顶层,否则编译时报错 // import也是如此

import命令

加载模块

js
// import会提升到整个模块的头部,首先执行。 console.log('fa-', fa()); import {fa} from './math.mjs'; // as 关键字 import { lastName as surname } from './profile.js'; // 接口是只读的,禁止修改 surname = 123; // 报错 // 允许重复声明, 但是import语句是单例模式

因为 import是静态执行 所以必须在顶层代码块(不能出现在if语句中)也不能使用属性简写

模块的整体加载

js
import * as circle from './circle';

export default语句

js
// math.mjs function aa(a =1){ console.log('---',a); } export default aa // 等价于 // export {aa as default} var c = 1; export {c as default} // 报错, default 只能使用一次 // main.mjs import aa from './math.mjs' // 等价于 // import {default as aa} from './math.mjs' export * from './math.mjs'; // export * 会丢弃default接口, 下面代码是打补丁 import _m from './math.mjs'; export default _m;

export与import的复合写法

js
export { foo } from 'my_module'; // 可以理解为 import { foo } from 'my_module' export { foo } export { es6 as default } from './someModule'; // 等同于 import { es6 } from './someModule'; export default es6; export { default as es6 } from './someModule'; // 等同于 import es6 from './someModule'; export { es6 }; export * as ns from "mod"; // 等同于 import * as ns from "mod"; export {ns};

import() 动态加载

  • 与NodeJS的require()方法类似, 区别 一个同步加载一个异步加载 使用场景
  • 按需加载
  • 条件在加载
  • 动态的模块路径

注意事项

  • default需要手动解构

import.meta

返回当前模块的原信息,只能在模块内部使用

  • import.meta.url 返回当前模块的 URL 路径
  • import.meta.scriptElement 是浏览器特有的元属性,相当于document.currentScript 测了下 chrome和safari都没有实现
  • import.meta.resolve() 仅chrome有,可类比node path.resolve

script标签defer和async属性

  • 默认情况下,浏览器是同步加载 JavaScript 脚本,即渲染引擎遇到<script>标签就会停下来,等到执行完脚本,再继续向下渲染。
  • <script>标签打开deferasync属性,脚本就会异步加载。
  • 区别是:defer是“渲染完再执行”;async是“下载完就执行”。
  • <script type="module"> 相当于defer属性,

<script type="module"> 注意事项

  • 模块作用域
  • 开启严格模式 (如this指向)
  • import单例模式, 如果想执行多次url加hash参数

ESM与CommonJS的差异

  1. CommonJS输出的是值的copy,ESM输出的是值的引用(动态绑定关系)
  2. CommonJS是运行时加载,ESM是编译时输出接口 3. 运行时加载可以异步导出,动态导出(动态属性)
    1. 编译加载 要求import export必须在顶层代码块,有声明提前的作用
  3. CommonJS的require()是同步加载, ESM的import()是异步加载
  4. ESM默认严格模式
  5. 顶层this 前者指向当前模块,后者是undefined

NodeJS的模块加载方法

从 Node.js v13.2 版本开始,Node.js 已经默认打开了 ES6 模块支持。

  1. ESM模块 后缀名 .mjs
  2. CommonJS模块 后缀名 .cjs
  3. .jspkg.type字段决定类型

pkg.main

  • 指定模块的入口

pkg.exports

  • 优先级高于pkg.main 有多种用途
  1. 子目录别名
  2. mian的别名
  3. 条件加载

CommonJS加载ESM模块

js
(async () => { await import('./my-app.mjs'); })();

ESM加载CommonJS模块

js
// 报错 因为ESM静态分析阶段 CJS只有 module.exports import { method } from 'commonjs-package'; // 推荐写法如下 import packageMain from 'commonjs-package'; const { method } = packageMain; // 方式二 module.createRequire()

如何同时支持两种模块?

  1. 增加一个包装层, 用ESM包装CJS
  2. pkg.exportst 条件加载

ESM在浏览器端与服务端的差异

Node.js 的内置模块可以整体加载,也可以加载指定的输出项。

加载路径

  • ESM模块不能省略后缀名, 工程化项目中能够省略后缀名是构建工具做了特殊处理
  • Node.js 的import命令只支持加载本地模块(file:协议)和data:协议 不支持加载远程模块。
  • 另外,脚本路径只支持相对路径,不支持绝对路径(即以/或//开头的路径)。

不能使用CommonJS内置了一些变量

循环加载

口述- 略

本文作者:郭敬文

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!