模块化开发是我们日常工作潜移默化中用到的基本技能,发展至今非常地简洁方便,但开发者们(指我自己)却很少能清晰透彻地说出它的发展背景, 发展过程以及各个规范之间的区别。故笔者决定一探乾坤,深入浅出学习一下什么是前端模块化。
通过本文,笔者希望各位能够收获到:
本文的重点会以大家熟知的 CommonJS 和 ESM 入手,深入浅出,结合示例 Demo 和一些小故事,希望给大家能够带到不一样的体验。
某个技术的起源几乎都是为了解决一些棘手的问题,模块化也不例外。下面以一个简单的例子来给大家讲个故事,通过故事给大家讲一讲大致的发展史。故事并未涵盖所有时间线上发生的事件,众所周知在前端模块化的长河里 AMD 和 CMD 一直打的不可开交,这里笔者挑选以 CMD 为支线向大家阐释。
本故事的攥写参考了部分 Sea.js 开源大佬发表在《程序员》杂志 2013 年 3 月刊的文章 (侵删)
在线链接:前端模块化开发的价值 ,本文推荐大家仔细阅读,包括评论区。
故事开始! 在很久之前(可能就是2012年之前),JS 模块化概念并未诞生的年代,前端开发们面临诸多问题:Web 技术虽说日益成熟、JS 能实现的功能也愈发地多,但与此同时代码量也是越来越大。那个年代往往会出现一个项目各个页面公用同一个 JS 的情况,为了解决这个情况,JS 文件出现了按功能拆分....
慢慢地,项目代码变成了如下:
...
...
<script src="util/wxbridge.js"></script>
<script src="util/login.js"></script>
<script src="util/base.js"></script>
<script src="util/auth.js"></script>
<script src="util/logout.js"></script>
<script src="util/pay.js"></script>
...
拆分出来的代码类似于如下:
function mapList(list) {
// 具体实现
}
function canBuyIt(goodId) {
// 具体实现
}
看似拆分很细,但却有诸多的致命问题:
拿上述 util 工具函数文件举例! 大家按规范模像样地把这些函数统一放在 util.js 里,需要用到时,直接引入该文件就好,非常方便。随着团队项目越来越大,问题随之越来越多:
空山:我想定义 mapList 方法遍历商品列表,但是已经有了,很烦,我的只能叫 mapGoodsList 了。
空河:我自定义了一个 canBuyIt 方法,为什么使用的时候,空山的代码出问题了呢?
满山:我明明都用了空山的方法,为什么结果还是不对呢?
经过团队激烈讨论,决定参照 Java 的方式,用 命名空间 来解决,于是乎代码变成了如下:
// 这是新的 Utils.js 文件
var userObj = {};
userObj.Auth = {};
userObj.Auth.Utils = {};
userObj.Auth.Utils.mapGoodsList = function (list) {
// 实现
};
userObj.Auth.Utils.canBuyIt = function (goodId) {
// 实现
};
现在通过命名空间的方式极大地解决了一部分冲突,但是仔细看上面的代码,如果开发人员想要调用某一个简单的方法,就需要他有强大的记忆力,个人负担变得很重。(这里值得提一嘴的是,Yahoo 的前端团队 YUI 采用了命名空间的解决方式,同时也通过利用沙箱机制很好的解决了命名空间过长的问题,有兴趣的同学可以自行了解)
书接上回。大家现在可以基于 util.js 开发各自的 UI 层通用组件了。举一个大佬写的 dialog.js 组件
<script src="util.js"></script>
<script src="dialog.js"></script>
<script>
org.CoolSite.Dialog.init({ /* 传入配置 */ });
</script>
可是无论大佬怎么写文档,以及多么郑重地发邮件宣告,时不时总会有同事询问为什么 dialog.js 有问题。通过一番排查,发现导致错误的原因经常是在 dialog.js 前没有引入 util.js。这样的问题和依赖依然还在可控范围内,但是当项目越来越复杂,众多文件之间的依赖经常会让人抓狂。下面这些问题,在当时每天都在真实地发生着:
以上很多问题都是因为 文件依赖 没有很好的管理起来。在前端页面里,大部分脚本的依赖目前依旧是通过人肉的方式保证。当团队比较小时,这不会有什么问题。当团队越来越大,公司业务越来越复杂后,依赖问题如果不解决,就会成为大问题。文件的依赖,目前在绝大部分类库框架里,比如国外的 YUI3 框架、国内的 KISSY 等类库,目前是通过配置的方式来解决。抛一个例子,不深究。
YUI.add('my-module', function (Y) {
// ...
}, '0.0.1', {
requires: ['node', 'event']
});
上面的代码,通过 requires 等方式来指定当前模块的依赖。这很大程度上可以解决依赖问题,但不够优雅。当模块很多,依赖很复杂时,烦琐的配置会带来不少隐患。解决命名冲突和文件依赖,是前端开发过程中的两个经典问题,大佬们希望通过模块化开发来解决这些问题,所以 Sea.js 营运而生,再往后,CMD 规范也就水到渠成地形成了。(准确说来是因为先有了优秀的 Sea.js,才在后续更替过程逐渐形成了我们后来人所学习到的 CMD 规范。 )
故事讲到这里要告一段落了,是时候给大伙来个评书总结了。JS 在设计上其实并没有 模块 的概念,为了让 JS 变成一个功能强大的语言,业界大佬们各显神通,定了一个名为 CommonJS 的规范,实现了一个名为模块 的东西。但可惜当时环境下大多浏览器并不支持,只能用于 node.js,于是 CommonJS 开始分裂,变异了一个名为 AMD 规范的模块,可以用于浏览器端。由于 AMD 与 CommonJS 规范相去甚远,于是 AMD 自立门户,并且推出了 require.js 这个框架,用于实现并推广 AMD 规范。此时,CommonJS 的拥护者认为,浏览端也可以实现 CommonJS 的规范,于是稍作改动,推出了 sea.js 这个框架并形成了 CMD 规范。。
正在 AMD 与 CMD 打得火热的时候,ECMAScript6 给 JS 本身定了一个模块加载的功能,弯道超车:“你们俩别争了,JS 模块有原生的语法了”。
再后来,正因为 AMD 与CommonJS 如此不同,且用于不同的环境,为了能够兼容两个平台,UMD 就应运而生了,不过它仅仅是一个 polyfill,以兼容两个平台而已,严格意义上来说不能成为一种标准规范。
至此,大致历史背景已讲述完毕,上文出现的各大规范名词,接下来会跟大家见面。
大致了解背景之后,接下来认真地跟各位探讨一下各大规范。
开始之前,想说明一下,针对于 AMD 和 CMD,笔者不打算带各位做源码级别的深究,笔者希望大家只是做一个了解或回顾,随后将重心放至第三、四章的 CommonJS 和 EMS 中。
2009年,美国程序员 Ryan_Dahl 创造了 node.js 项目,将 JS 用于服务器端编程。这标志《 JS 模块化编程》正式诞生。不同于纯前端的服务器端,是一定要有模块的概念的,它与操作系统或其他应用程序有着各种各样的互动,否则编程会大受限制,甚至根本无法编程。
Node.js 后端编程中最重要的思想之一就是 “模块” ,正是这个思想,让 JavaScript 的大规模工程成为可能。也是基于此,随后在浏览器端,require.js 和 sea.js 之类的工具包也出现了;在 ES module 被完全实现之前,CommonJs 统治了之前时代模块化编程的大半江山,它的出现也弥补了当时 JS 对于模块化没有统一标准的缺陷。
在 CommonJS 中, 模块通常使用 module.exports 和 exports,有一个全局性方法 require(),用于加载模块,如下:(module.exports 和 exports 后文有做阐述,此处暂且不表)
// 导出 a.js
module.exports = function sumIt(a,b){
return a + b
}
// 引入 main.js
const sumIt = require('./a.js');
console.log('sumIt===', sumIt(1,2));
AMD -- Asynchronous Module Definition(异步模块定义)。它诞生于 Dojo 在使用 XHR+eval 时的实践经验,其支持者希望未来的解决方案都可以免受由于过去方案的缺陷所带来的麻烦。由于 CommonJS 奠定了服务器模块规范,大家便开始考虑客户端模块,而且想两者可以兼容,让一个模块可以同时在服务器和浏览器运行。
但是 CommonJS 是同步加载模块,服务器所有模块都存放在本地,硬盘读取时间很快,但对于浏览器来说,等待时间则取决于网速的快慢,如果时间过长,浏览器可能会处于“假死”。例如刚刚 main.js 的代码,当我们调用 sumIt(1,2) 的时候, 浏览器需要等待 a.js 加载完才能进行计算,所以浏览器端的模块化使用同步加载是有缺陷的,需用异步加载取代之,这也就是 AMD 规范诞生的背景。
AMD 采用异步方式加载模块,让模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
define(id?, dependencies?, factory)
// id: 字符串,模块名称(可选)
// dependencies: 表示需要加载的依赖模块(可选)
// factory: 工厂方法,返回一个模块函数,也可理解为加载成功后的回调函数
//引入依赖 ,回调函数通过形参传入依赖
define(['Module1', ‘Module2’], function (Module1, Module2) {
function testIt () {
/// 业务代码
Module1.test();
}
return testIt
});
require([module],callback())
define(function (require, exports, module) {
var yourModule = require("./yourModule");
yourModule.test();
exports.yourKey = function () {
//...
}
});
不难发现,AMD 的优点是适合在浏览器环境中异步加载模块。可以并行加载多个模块。
而缺点是提高了开发成本,并且不能按需加载,而是必须提前加载所有的依赖。
Common Module Definition 背景有讲,不多赘述,Sea.js 在推广中对模块定义的规范化产出,推崇依赖就近,延迟执行
//AMD
define(['./a','./b'], function (a, b) {
//依赖一开始就写好
a.xxx();
b.xxx();
});
//CMD
define(id?, function (requie, exports, module) {
// 依赖可以就近书写
var a = require('./a');
a.xxx();
// 软依赖
if (status) {
var b = requie('./b');
b.xxx();
}
});
// require 是一个方法,用来获取其他模块提供的接口
// exports 是一个对象,用来向外提供模块接口
// module 是一个对象,上面存储了与当前模块相关联的一些属性和方法
网络上关于 UMD (Universal Module Definition) 通用模块规范的说法五花八门,这里笔者不做任何评论,只做一个通用型认知的总结: UMD 像一种 polyfill,兼容支持多个模块规范。
参考引用:点这里可以看一下娜娜关于 UMD 的解释
UMD 理念、规范等官方资料: https://github.com/umdjs/umd
看一个简单的例子:
output: {
path: path.join(__dirname),
filename: 'index.js',
libraryTarget: "umd",//此处是希望打包的插件类型
library: "Swiper",
}
看一下打包之后:
!function(root,callback){
"object"==typeof exports&&"object"==typeof module?//判断是不是nodejs环境
module.exports=callback(require("react"),require("prop-types"))
:
"function"==typeof define&&define.amd?//判断是不是requirejs的AMD环境
define("Swiper",["react","prop-types"],callback)
:"object"==typeof exports?//相当于连接到module.exports.Swiper
exports.Swiper=callback(require("react"),require("prop-types"))
:
root.Swiper=callback(root.React,root.PropTypes)//全局变量
}(window,callback)
使用 Javascript 中一个标准模块系统的方案。
在此之前的时期,社区在经历了 AMD 和 CMD 洗礼后提出了一种想法:既然都是 JS 规范,Node.js 模块能被浏览器环境下的 JS 代码随意引用吗?能! 本着这个想法,ES6 (ECMAScript 6th Edition, 后来被命名为 ECMAScript 2015) 于 2015年6月17日 横空出世,主要被人熟知的其中一个特性就是 es6 module, 下文简称为 ESM。具体深耕内容请详见第四章,在此介绍章节不过多赘述。
import React from 'react';
import { a, b } from './myPath';
......
export default {
function1,
const1,
a,
b
}
<script type="module">
...
import { test } from 'your-path';
test();
...
<script/>
先看一个简单的 Demo:
let str = 'a文件导出'
module.exports = function logIt (){
return str
}
const logIt = require('./a.js')
module.exports = function say(){
return {
name: logIt(),
sex: 1
}
}
以上便是 CJS 最简单的实现,那么现在我们要带着问题了:
每个模块文件上存在 module,exports,require 三个变量(在 nodejs 中还存在 __filename 和 __dirname 变量),然而这几个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 JS 模块上直接使用它们。
在编译过程中,Commonjs 会对 JS 的代码块进行包装, 以上述的 b.js 为 🌰,包装之后如下:
(function(exports,require,module,__filename,__dirname){
const logIt = require('./a.js')
module.exports = function say(){
return {
name: logIt(),
sex: 1
}
}
})
如何执行包装的呢? 让我们来看看包装函数的本质:
function wrapper (script) {
return '(function (exports, require, module, __filename, __dirname) {' +
script +
'\n})'
}
// 然后是包装函数的执行
const modulefunction = wrapper(`
const logIt = require('./a.js')
module.exports = function say(){
return {
name: logIt(),
sex: 1
}
}
`)
script 为我们在 js 模块中写的内容,最后返回的就是如上包装之后的函数。当然这个函数暂且是一个字符串。在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 modulefunction ,传入require ,exports ,module 等参数。最终我们写的 node.js 文件就执行了。(真实的 runInThisContext 函数执行思路和上述一致,但实现细节不一样)
runInThisContext(
modulefunction
)(module.exports, require, module, __filename, __dirname)
实现详情请参照官方文档: runInThisContext 的官方文档和示例
到此,整个模块执行的原理大致梳理完毕。🎉🎉
先以 node.js 为例,看一个简单的代码片段
const fs = require('fs');
const say = require('./b.js');
const moment = require('moment');
先对文件模块做一个简单的分类:
当 require 方法执行的时候,接收的唯一参数作为一个 标识符
CJS 下对不同的标识符处理流程不同,但是目的都是找到对应的模块。
此章节借鉴了 @我不是外星人 的优秀文章中的部分内容(侵删)
在线链接:《深入浅出 Commonjs 和 Es Module》
笔者在巨人的肩膀上做了一些 Curd 润色,供大家享用 😄
CommonJS 模块同步加载并执行模块文件,CommonJS 模块在执行阶段分析模块依赖
const logIt = require('./b');
console.log('我是 a 文件');
exports.say = function(){
const message = logIt();
console.log(message);
}
const say = require('./a');
const obj = {
name:'b 文件的 object 的 name',
author:'b 文件的 object 的 author'
}
console.log('我是 b 文件');
module.exports = function(){
return obj
}
const a = require('./a');
const b = require('./b');
console.log('我是 main 文件');
运行一下:
🤔️🤔️🤔️ 问题:
我们先引入一个上文并未提及的概念:Module 和 module
module :在 Node 中每一个 js 文件都是一个 module ,module 上保存了 exports 等信息之外,
还有一个 loaded ( boolean 类型)表示该模块是否已经被加载过。
Module :以 nodejs 为例,整个系统运行之后,会用 Module 缓存每一个模块加载的信息。
然后,在回答上述思考问题之前,一起来看一下阮一峰老师关于 require 的源码解读:
// id 为路径标识符
function require(id) {
/* 查找 Module 上有没有已经加载的 js 对象*/
const cachedModule = Module._cache[id]
/* 如果已经加载了那么直接取走缓存的 exports 对象 */
if(cachedModule){
return cachedModule.exports
}
/* 创建当前模块的 module */
const module = { exports: {} ,loaded: false , ...}
/* 将 module 缓存到 Module 的缓存属性中,路径标识符作为 id */
Module._cache[id] = module
/* 加载文件 */
runInThisContext(wrapper('module.exports = "123"'))
(module.exports, require, module, __filename, __dirname)
/* 加载完成 *//
module.loaded = true
/* 返回值 */
return module.exports
}
代码还是非常容易理解的,解读总结如下:
require 会接收一个参数(文件标识符),然后分析定位文件(上一小节已经讲到),接下来从 Module 上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。
如果没有缓存,会创建一个 module 对象,缓存到 Module 上,然后执行文件;加载完文件,将 loaded 属性设置为 true ,然后返回 module.exports 对象。
模块导出其实跟 a = b 赋值一样:基本类型导出的是值, 引用类型导出的是引用地址。(exports 和 module.exports 持有相同引用,后文会专门解读)
我们先来分析刚刚的例子,下面先用一幅图来表示 a.js 的加载流程:
理解了这幅流程图后,再来看完整的流程图就不再吃力了:
此时我们需要注意一点:
当我们第一次执行 b.js 模块的时候,a.js 还没有导出 say 方法,所以此时在 b.js 同步上下文中,是获取不到 say 的,那么如果想要获取 say ,办法有两个:
const say = require('./a');
const obj = {
name:'b 文件的 object 的 name',
author:'b 文件的 object 的 author'
}
console.log('我是 b 文件');
setTimeout(()=>{
console.log('异步打印 a 模块' , say)
},0)
module.exports = function(){
return obj
}
console.log('我是 a 文件');
exports.say = function(){
const logIt = require('./b');
const message = logIt();
console.log(message);
}
const a = require('./a');
a.say();
由此我们可见:
require 本质上就是一个函数,那么函数可以在任意上下文中执行,自由地加载其他模块的属性方法。
正如上述所言,加载之后的文件的 module 会被缓存到 Module 上,比如一个模块已经 require 引入了 a 模块,如果另外一个模块再次引用 a ,那么会直接读取缓存值 module ,所以无需再次执行模块。
对应 demo 片段中,首先 main.js 引用了 a.js ,a.js 中 require 了 b.js。 此时 b.js 的 module 放入缓存 Module 中,接下来 main.js 再次引用 b.js ,那么直接走的缓存逻辑,所以 b.js 只会执行一次,也就是在 a.js 引入的时候,由此就避免了重复加载。
🤔🤔🤔🤔🤔🤔 这里给大家抛一个思考问题:
// a.js
const b = require('./b');
console.log('我是 a 文件',b);
const tets = Object.getPrototypeOf(b);
tets.aaa = 'new aaa test';
// b.js
console.log('我是 b 文件');
module.exports = {
str: 'bbbb'
}
// main.js
require('./a');
const b = require('./b');
console.log('b===', b);
console.log('proto===', Object.getPrototypeOf(b));
🤔🤔🤔 看完这个事例,你有什么启发吗?是不是和第三方侵入式的工具库很像呢?
module.exports 和 exports 在一开始都是一个空对象 { },但实际上,这两个对象应当是指向同一块内存的。在不去改变它们指向的内存地址的情况下,module.exports 和 exports 几乎是等价的。
require 引入的对象本质上其实是 module.exports 。那么这就产生了一个问题,当 module.exports和 exports 指向的不是同一块内存时,exports 的内容就会失效。
module.exports = { money: '20块 😭' };
exports.money = '一伯万!!!😊';
这时候,require 真实得到的是 { money: '20块 😭' } 。当他们二者 同时存在 的时候,会发生覆盖的情况,所以我们通常最好选择 exports 和 module.exports 两者之一。
答:不可以。通过上述讲解都知道 exports , module 和 require 作为形参的方式传入到 js 模块中。我们直接 exports = { } 修改 exports ,等于重新赋值了形参,但是不会在引用原来的形参。举个例子:
function change(myName){
return myName.name = {
name: '老板'
}
}
let myName = {
name: '小打工人'
}
fix(myName);
console.log(myName);
// 导出 a.js
const name = 'jiawen';
const game = 'lol';
const logIt = function (){
console.log('log it !!!')
}
export default {
name,
author,
logIt
}
// 引入 main.js
import { name , author , logIt } from './a.js'
// 对于引入默认导出的模块,可以自定义名称。
import allInfo from './a.js'
对于 ESM 规范中混合导出方式,日常使用,这里不再做举例。
提一下 “重署名导入和重定向导出”:
import { name as newName , say, game as newGame } from '/a.js';
console.log( newName , newGame , say );
export * from 'module'; // 1
export { name, author, ..., say } from 'module'; // 2
export { name as newName , game as newGame , ..., say } from '/a.js'; // 3
只运行,不关心导入:
import '/a.js'
动态导入:
import asyncComponent from 'dt-common/src/utils/asyncLoad';
let lazy = (async, name) => {
return asyncComponent(
() => async.then((module: any) => module.default), { name }
)
}
const ApiManage = lazy(import('./views/dataService/apiManage'), 'apiManage');
// js中 基础类型是值传递
let a = 1;
let b = a;
b = 2;
console.log(a, b) // 1 2
// js中 引用类型是引用传递
let a = { name: 'xxx' };
let b = obj
b.name = 'bbb'
console.log(a.name, b.name) // bbb bbb
// a.js
export let a = 1
export function add(){
a++
}
// main.js
import { a, add } from './a.js';
console.log(a); //1
add();
console.log(a); //2
刚刚已经举过 import () 在 TagEngine 里实际应用的例子,其核心在于返回一个 Promise 对象, 在返回的 Promise 的 then 成功回调中,可以获取模块的加载成功信息。下面举一些 import () 的社区常用:
[
...
{
path: 'home',
name: '首页',
component: ()=> import('./home') ,
},
...
]
const LazyComponent = React.lazy(() => import('./text'));
class index extends React.Component {
render() {
return (
<React.Suspense
fallback={
<div className="icon">
<SyncOutlinespin />
</div>
}
>
<LazyComponent />
</React.Suspense>
);
}
}
import() 这种加载效果,可以很轻松的实现代码分割, 避免一次性加载大量 js 文件,造成首次加载白
屏时间过长的情况。
// f1.js
import { f2 } from './f2'
console.log(f2);
export let f1 = 'f1'
// f2.js
import { f1 } from './f1'
console.log(f1);
export let f2 = 'f2'
// main.js
import { f1 } from './f1'
console.log(bar)
此时会报错 f1 未定义,我们可以采用函数声明,因为函数声明会提示到文件顶部,所以就可以直接在 f2.js 调用还没执行完毕的 f1.js的 f1 方法,但请不要在函数内使用外部变量 !!!!
// f1.js
import { f2 } from './f2'
console.log(f2());
export function f1(){
return 'f1'
}
// f2.js
import { f1 } from './f1'
console.log(f1());
export function f2(){
return 'f2'
}
// main.js
import { f1 } from './f1'
console.log(f1)
DCE: dead code elimination。简称 DCE。死代码消除
Tree Shaking 在 Webpack 中的实现是用来尽可能的删除一些被 import 了但其实没有被使用的代码。
export let num = 1;
export const fn1 = ()=>{
num ++
}
export const fn2 = ()=>{
num --
}
import { fn1 } from './a'
fn1();
此章节探讨 ESM 与 CMJ 的互转,👏欢迎各位补充指正!!!👏
特别鸣谢参考文章,排名不分先后:
https://github.com/amdjs/amdjs-api/wiki/AMD
https://github.com/seajs/seajs/issues/242
https://github.com/umdjs/umd
https://juejin.cn/post/6994224541312483336#heading-20
https://segmentfault.com/a/1190000017878394
🎉🎉🎉 完结 🎉🎉🎉