一文带你了解 JS Module 的始末
阅读原文时间:2023年07月14日阅读:1

模块化开发是我们日常工作潜移默化中用到的基本技能,发展至今非常地简洁方便,但开发者们(指我自己)却很少能清晰透彻地说出它的发展背景, 发展过程以及各个规范之间的区别。故笔者决定一探乾坤,深入浅出学习一下什么是前端模块化。

通过本文,笔者希望各位能够收获到:

  • 前端模块化发展的大致历史背景
  • 各个规范之间的基本特性和区别
  • 着重深入 ESM 和 CommonJs 的异同、优缺点
  • 深耕 CommonJS 和 ESM 的特性

本文的重点会以大家熟知的 CommonJSESM 入手,深入浅出,结合示例 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) {
  // 具体实现
}

看似拆分很细,但却有诸多的致命问题:

  • 全局变量污染:各个文件的变量都是挂载到window对象上,污染全局变量;
  • 变量可能重名:不同文件中的变量如果重名,后一份会覆盖前面的,造成错误;
  • 文件依赖顺序:多个文件之间存在依赖关系,需要保证一定加载顺序问题严重……

拿上述 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。这样的问题和依赖依然还在可控范围内,但是当项目越来越复杂,众多文件之间的依赖经常会让人抓狂。下面这些问题,在当时每天都在真实地发生着:

  1. 通用组更新了前端基础类库,却很难推动全站升级。
  2. 业务组想用某个新的通用组件,但发现无法简单通过几行代码搞定。
  3. 一个老产品要上新功能,最后评估只能基于老的类库继续开发。
  4. 公司整合业务,某两个产品线要合并。结果发现前端代码冲突。
  5. ……

以上很多问题都是因为 文件依赖 没有很好的管理起来。在前端页面里,大部分脚本的依赖目前依旧是通过人肉的方式保证。当团队比较小时,这不会有什么问题。当团队越来越大,公司业务越来越复杂后,依赖问题如果不解决,就会成为大问题。文件的依赖,目前在绝大部分类库框架里,比如国外的 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,笔者不打算带各位做源码级别的深究,笔者希望大家只是做一个了解或回顾,随后将重心放至第三、四章的 CommonJSEMS 中。

老大哥 CommonJS

介绍

2009年,美国程序员 Ryan_Dahl 创造了 node.js 项目,将 JS 用于服务器端编程。这标志《 JS 模块化编程》正式诞生。不同于纯前端的服务器端,是一定要有模块的概念的,它与操作系统或其他应用程序有着各种各样的互动,否则编程会大受限制,甚至根本无法编程。

Node.js 后端编程中最重要的思想之一就是 “模块” ,正是这个思想,让 JavaScript 的大规模工程成为可能。也是基于此,随后在浏览器端,require.js 和 sea.js 之类的工具包也出现了;在 ES module 被完全实现之前,CommonJs 统治了之前时代模块化编程的大半江山,它的出现也弥补了当时 JS 对于模块化没有统一标准的缺陷。

简单举例

在 CommonJS 中, 模块通常使用 module.exportsexports,有一个全局性方法 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 自立门户

简介

AMD -- Asynchronous Module Definition(异步模块定义)。它诞生于 Dojo 在使用 XHR+eval 时的实践经验,其支持者希望未来的解决方案都可以免受由于过去方案的缺陷所带来的麻烦。由于 CommonJS 奠定了服务器模块规范,大家便开始考虑客户端模块,而且想两者可以兼容,让一个模块可以同时在服务器和浏览器运行。

但是 CommonJS 是同步加载模块,服务器所有模块都存放在本地,硬盘读取时间很快,但对于浏览器来说,等待时间则取决于网速的快慢,如果时间过长,浏览器可能会处于“假死”。例如刚刚 main.js 的代码,当我们调用 sumIt(1,2) 的时候, 浏览器需要等待 a.js 加载完才能进行计算,所以浏览器端的模块化使用同步加载是有缺陷的,需用异步加载取代之,这也就是 AMD 规范诞生的背景。

AMD 采用异步方式加载模块,让模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。

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 的优点是适合在浏览器环境中异步加载模块。可以并行加载多个模块。

而缺点是提高了开发成本,并且不能按需加载,而是必须提前加载所有的依赖。

CMD -- 简单纯粹

简介

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  是一个对象,上面存储了与当前模块相关联的一些属性和方法

CMD 规范看这里

AMD 和 CMD 对比

  1. 对于依赖的模块 AMD 是 提前执行,CMD 是 延迟执行。不过 Require.js 从2.0开始,也改成可以延迟执行(根据写法不同,处理方式不通过)。
  2. AMD 推崇 依赖前置(在定义模块的时候就要声明其依赖的模块),CMD 推崇 依赖就近(只有在用到某个模块的时候再去 require —— 按需加载)。
  3. AMD 的 api 默认是一个当多个用,CMD 严格的区分推崇职责单一。例如:AMD 里 require 分全局的和局部的。CMD 里面没有全局的 require, 提供 seajs.use() 来实现模块系统的加载启动。CMD 里每个API 都更简单纯粹。引用一下玉伯 2012 年的自评:

简谈下 -- UMD

网络上关于 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)

新大哥 ESM

使用 Javascript 中一个标准模块系统的方案。

**在此之前的时期,社区在经历了 AMD 和 CMD 洗礼后提出了一种想法:既然都是 JS 规范,Node.js 模块能被浏览器环境下的 JS 代码随意引用吗?能! 本着这个想法,ES6 (ECMAScript 6th Edition, 后来被命名为 **ECMAScript 2015) 于 2015年6月17日 横空出世,主要被人熟知的其中一个特性就是 es6 module, 下文简称为 ESM。具体深耕内容请详见第四章,在此介绍章节不过多赘述。

import&nbsp;React&nbsp;from&nbsp;'react';
import&nbsp;{ a,&nbsp;b }&nbsp;from&nbsp;'./myPath';
......
export&nbsp;default {
  function1,
  const1,
  a,
  b
}
  1. 在很多现代浏览器可以使用

  2. 它兼具两方面的优点:具有 CJS 的简单语法和 AMD 的异步

  3. 得益于 ES6 的静态模块结构,可以进行  Tree Shaking

  4. ESM 允许像 Rollup 这样的打包器删除不必要的代码,减少代码包可以获得更快的加载

  5. 可以在 HTML 中调用,如下



    import { test } from 'your-path';
      test();