likes
comments
collection
share

JavaScript 模块化发展简史

作者站长头像
站长
· 阅读数 39

1. 前言

JavaScript 作为现如今web开发的基石之一,诞生于1995年。

一开始JavaScript被创造出来的目的很单纯,只是为了给网页增加一些互动性,比如表单验证,弹出对话框这类很简单的功能。

谁也没想到互联网的发展如此迅速,从一开始的 web1.0 ,互联网由一个个静态的站点组成,互联网更多的是作为网上资料室存在,用户单纯作为信息的接受者。

到后面的 web 2.0,互联网由网站向 web 应用逐渐转变,用户由信息的接受者,转变成发布者。

web 应用越来越复杂,而作为其中基础支柱的 JavaScript 居然没有一个模块化系统!这让人感觉到不可思议。

当然,如果回到1995年,JavaScript 之父 Brendan Eich 怎么也不会想到自己花10天时间设计出来的,只是为了实现一些简单的脚本操作的一个“玩具”语言,有一天会复杂到需要一个模块化系统。

本文将对 JavaScript 模块化发展的历史进行简单回顾。

先上一张 JavaScript 模块化发展的历史演进及相关工具关系图,有助于读者有一个全局认识:

JavaScript 模块化发展简史

2. 为什么需要模块化?

在介绍 JavaScript 模块化发展的历史之前,首先让我们来想一个问题,为什么需要模块?

或者说如果没有模块的话会怎么样?

  • 如果没有模块,那么是不是所有的 JavaScript 代码都堆在一块儿?这样导致不好管理代码,特别是代码规模较大的时候。
  • 如果没有模块,那么是不是很容易在给变量或函数命名的时候发生重名的情况?
  • 如果没有模块,那么是不是所有的 JavaScript 变量都成了全局变量,任何函数和都能读取和修改这些变量的值?

模块化的存在就是为了解决上面这些情况存在的问题。

总的来说,模块化大概有以下几个方面的作用:

  • 可维护性

    将代码按功能进行划分,分别放入一个个单独的文件中,便于管理和维护。

  • 命名空间

    每一个模块都有自己的命名空间,不同模块之间的变量不会发生命名冲突。

  • 复用性

    每一个模块都能被多次重复使用。

  • 封装性

    对于一个模块,我们只要知道它有什么用,接口怎么用就可以了。至于内部的具体实现细节是不需要去了解的。

3. 模块模式

不同于 Java 等语言天然具有模块化系统和封装性。

比如在 Java 中有包系统,可以将类放在不同的包里面,需要使用时直接导入包即可,即使两个类重名了也可以加上包名用来区分。

同时 Java 的对象也具有封装性,每个对象内部就是一个隐秘地带,外界只能通过对象暴露的公共方法访问对象的私有数据。

而 JavaScript 什么都没有,对象里所有的数据都是对外公开的,就像是一个裸奔的家伙,尴尬的不行。

3.1 模块模式的原理

为了解决上面的问题,人们首先将目光转向了 JavaScript 的函数。

因为在 JavaScript 中除了全局作用域外,只有函数作用域。

而函数天然具有封闭性,从外面是无法访问函数内部数据的。

JavaScript 模块化发展简史

虽然无法从外面访问函数内部的数据,但是,可以从函数内部访问函数的数据

JavaScript 模块化发展简史

如果函数的内部函数一不小心跑出去了(被return):

JavaScript 模块化发展简史

那么,外界就可以通过这个跑出去的内部函数访问到函数内部的数据了,这就是 闭包

因为函数作用域可以用来存储私有数据,并且我们可以通过函数暴露出去的 “接口” 访问这些私有数据。

所以说,这就是一个很好的可以用来进行封装的完美工具。

就这样,基于 闭包IIFE模块模式 诞生了,比如下面这个例子:

这里我们封装了一个私有数据 pi ,在外面我们是无法访问到 pi 的,只会显示 undefined 或者 Reference error

但是我们暴露出去的函数 area 是可以访问到 pi 的。

var utils = (function() {
    // 私有数据
    var pi = 3.142;

    // 向外界暴露的接口函数
    function area(radius) {
        return pi * radius * radius;
    }

    return { area };
})();

console.log(utils.area(5)); // 78.55
console.log(pi); // Reference error, `pi` is not defined

这样,我们就能这样把代码放在一个一个文件中:

// utils.js
var utils = (funciton(){
    //...
})()
// app.js
// 依赖utils.js
(funciton(){
    //...
    // do something with utils
    //...
})()

这就是 JavaScript 模块化的 起点

3.2 模块模式解决了什么问题

  • 可维护性

    通过将代码放入不同的闭包里实现代码的分隔,便于管理和维护。

  • 命名空间

    每个模块里的变量都是放在自己的函数作用域里,因此不同模块之间的变量命名空间是相互独立的。

  • 封装性

    通过闭包将私有数据和私有方法进行隐藏,外界只能使用模块暴露出来的公共接口。

3.3 模块模式有什么缺点

  • 对于有依赖关系的模块,必须手动安排模块的加载顺序。

    拿上面的例子来说, app.js 文件依赖 utils.js 文件,那么在页面上它们的顺序为:

    <script src="./utils.js"></script>
    <script src="./app.js"></script>
    
  • 模块与模块之间依然会产生命名冲突。

    虽然模块内部的命名空间是彼此独立的,但是模块对外暴露的公共接口最终还是要挂载到全局变量上。

    比如有两个库都叫做 utils.js ,它们都挂载到 utils 全局变量上,就会产生命名冲突。

综上,我们希望有一个更好的模块化系统,可以在拥有模块模式的优点的同时,可以自动处理模块与模块之间的依赖关系,还能对导入的模块进行自由命名

基于此,社区发展出了 CommonJS,AMD等模块化规范。

4. CommonJS

2009年1月,Mozilla 工程师 Kevin Dangoor 发起了 ServerJS 项目,这就是 CommonJS 的前身。

在他的文章 What Server Side JavaScript needs 中,他提出服务器端的 JavaScript 需要一个可以跨解释器通用的标准库,比如数据库API,IO 等其他语言有而 JavaScript 没有的标准 API。

而标准库的实现需要一个可靠的模块化系统,一个可以打包代码,分发和模块安装的包管理系统,来作为软件开发的底层支撑。

比如在 Linux 中,人们可以使用 apt get 轻松地获取想要的代码包。

SeverJS 的创建一开始只是为了解决服务端 JavaScript 模块化问题,但作者们后面发现,卧槽,这种方案牛X啊,只用在服务端也太可惜了。

于是,2009年8月,ServerJS 项目更名为 CommonJS,意为普遍的 JavaScript,嗯,野心很大。

4.1 CommonJS的形式

CommonJS 模块系统基于两个部分:

  • 使用 require() 函数进行导入
  • 使用 module.exports 进行导出

定义一个模块

// add.js
function add(a, b) {
    return a + b
}

module.exports = add

导入一个模块

// index.js
const add = require('./add')

console.log(add(1, 2)) // 3

4.2 CommonJS的实现原理

CommonJS 模块系统的基本实现原理仍然 基于函数和闭包,但是相比模块模式复杂了许多。

当使用 require() 函数加载一个模块时,会执行以下步骤:

JavaScript 模块化发展简史

  1. require() 函数导入一个模块时,首先对文件进行路径解析,找到这个文件。

  2. 模块系统读取这个文件。

  3. 模块系统对这个文件里的内容进行包裹,大概形式如下所示:

    (function (exports, require, module, __filename, __dirname) {
      // 文件内容从这开始
      function add (a, b) {
           return a + b
      }
    
      module.exports = add
      // 文件内容在这结束
    })
    

    也就是说,模块系统会对这个文件的内容进行修改,使用一个 函数 对文件内容进行包裹。

  4. 创建一个新的模块对象 modulemodule 的定义大致如下:

    function Module(id, parent) {
        this.id = id
        this.exports = {}
        this.parent = parent
        if (parent && parent.children) {
            parent.children.push(this)
        }
        this.filename = null
        this.loaded = false
        this.children = []
    }
    

    然后将这个新创建的 module 对象和它的 exports 对象当做参数传给 包裹函数 进行执行。

    // 下面是一段演示CommonJS工作原理的代码,并不是CommonJS的源代码
    const id = path.resolve("file_path")
    const module = new Module(id)
    const exports = moudle.exports
    (function (exports, require, module, __filename, __dirname){
        // 被导入的文件的内容
    })()
    

    包裹函数执行完后,会将要对外暴露的属性或方法等挂载到 moduleexports 对象上。

    module.exports = add
    

    这样, module 就卡主了包裹函数的作用域的释放,包裹函数的作用域就会一直保存在内存中,用来存储模块的私有数据。

  5. 然后模块系统会对 module 对象进行缓存,下次再加载这个模块时,会直接从缓存里拿这个模块对象,类似单例模式。

这就是 CommonJS 的基本实现原理。

4.3 同步加载的CommonJS

CommonJS 采用的是同步加载机制:

JavaScript 模块化发展简史

由于同步加载机制,所以 CommonJS 适合用于服务端 JS 程序,但不适合用于浏览器端 JS 程序

这是因为服务器端的程序文件是存储在本地的,可以保证在一定的时间内是能读取到这个文件的。

但是在浏览器端获取 JS 文件需要通过网络进行传输,由于网速,网路波动等不可控因素的影响,网络传输不能保证在一定的时间内获取到 JS 文件。

如果在浏览器端采用同步加载机制的话,当其中一个依赖文件加载时间过长时,整个 JS 执行线程就会一直卡在那里等待文件的加载,这对于需要即时响应用户操作的浏览器来说,显然是不合理的。

因此,CommonJS 模块系统大多用于服务端,而在浏览器端需要一种异步模块系统。

5. 浏览器端的模块化

服务器端 JavaScript 比如 Node.js 可以借助 CommonJS 和 NPM 完成代码的管理和分发。

有了模块安装和依赖管理的能力,从此 JavaScript 在服务器端具备了开发大型的复杂的程序的能力。

让我们把目光转回浏览器,那么浏览器端的 JavaScript 呢?

在浏览器中,一个 HTML 文件就是一个前端应用程序。

以前的时候,网页以信息展示为主,其中的 JavaScript 代码其实比较少,JS 的作用更多的是用来添加一些网页特效和表单信息验证为主。

但随着网页 APP 化,网页中的 JavaScript 代码量越来越多,并且在 HTML 中引入 JavaScript 代码的方式只有两种:

  • <script> 内部直接写 JavaScript。
  • 通过 <script> 引入外部 JavaScript 文件。

在 HTML 中,JavaScript 的代码执行顺序是按照加载顺序执行的:

<!-- 首先加载 jquery.min.js 并执行 -->
<script src="jquery.min.js"></script>
<!-- 接着加载 app.js 并执行 -->
<script src="app.js"></script>

如前面所述,如果把每一个 JS 文件看成一个模块的话,那么我们必须手动安排这些模块的加载顺序。

那么,在浏览器端,有没有一种方法,可以像 CommonJS那样,让我们有一个模块化系统,能实现代码的模块化,同时也不用手动安排这些模块的加载顺序呢?

有的!这就是 AMD 和 CMD 规范。

6. AMD

AMD 全称 Asynchrounous Module Definition,即 异步模块定义。

与 CommonJS 的同步加载机制相反,AMD 采用的是异步加载机制:

JavaScript 模块化发展简史

其实现原理与我们常用的回调函数差不多,当加载一个模块时,依赖这个模块的代码会放在一个回调函数中。只有当模块加载成功时,回调函数才会被执行。

因此,如果模块加载耗时较长,就不会卡主 JavaScript 主线程的运行。

load(['module'], function() {
    // 依赖 module 的源代码
    // 只有当 module 加载完成后,这个回调函数才会执行
})

AMD 通过 define 函数来定义模块和这个模块的依赖,其一般形式为:

define(["myModule", "myOhterModule"], function(myModule, myOhterModule) {
    // do something
})

6.1 RequireJS:AMD 的一种实现

当然 AMD 只是一种规范,跟 ECMAScirpt 是 JavaScript 的规范一样,AMD 的实现必须得依赖具体的代码,RequireJS 是其中比较著名的一种实现。

RequireJS的使用方法大致如下:

首先,在 HTML 中导入 RequireJS 作为模块加载器。

<!-- 使用 RequireJS 作为模块加载器 -->
<script src="sea.js"></script>

接着,使用一段初始化代码告诉 RequireJS 项目的代码目录在哪里。

// RequireJS 的配置项
requirejs.config({
    baseUrl: "lib",
    paths: {
        app: "../app"
    }
});

最后,使用 requireJS 加载项目的入口文件:app.js,然后整个项目里的模块(JavaScript文件)就会通过异步加载的方式一个一个地加载到浏览器执行,这就是 AMD/RrequireJS 的大致工作原理。

// 加载项目的入口文件
requirejs("app/main");

6.2 模块加载方式

上面讲了通过 RequireJS 我们可以异步远程加载存储在服务器上的模块(JavaScript文件),那么是通过什么方式加载模块的呢?

在 RequireJS 的这篇 官方文档 中详细讨论了模块加载的几种方式,这里我简略总结一下:

  • 通过 XHR 加载模块

    • 使用 evel() 函数执行模块里的代码。
    • 生成一个 <script> 标签,把模块代码内嵌到标签内部执行。
  • 通过 Web Worker 加载模块

  • 通过 head.appendChild(script) 加载

    var head = document.getElementsByTagName('head')[0]
    var script = document.createElement('script')
    script.src = url
    head.appendChild(script)
    

以上几种方式都有各自的缺陷。

至于 RequireJS 的具体实现,如果不是特别感兴趣,我不建议读者去阅读相关源码。

毕竟 RequireJS 已经是一种被淘汰的技术,而我们现在有更好的工具。

7. CMD

CMD 是国内前端专家 玉伯 基于 CommonJS 创建的一种用于浏览器端 JavaScript 的模块化规范。

同时,玉伯 创建了 Sea.js 作为 CMD 的实现。

对于 Sea.js,我们只需知道它跟 RequireJS 一样是一个前端模块加载器就足够了。

Sea.js 的使用方式跟 RequireJS 差不多:

首先,在 HTML 中导入 Sea.js 作为模块加载器。

<!-- 使用 Sea.js 作为模块加载器 -->
<script src="sea.js"></script>

接着再写一段 Sea.js 的配置代码:

// Sea.js 的配置项
seajs.config({
    base: "../sea-modules/",
});

最后,使用 seajs 加载项目的入口文件

seajs.use("../static/hello/src/main");

8. ES Module

无论是 CommonJS,AMD 还是 CMD,都是社区的产物,并不是 JavaScript 官方的内置模块系统。

不过,随着 ES6 的发布,JavaScript 终于有了自己的内置模块系统。

ES Module 有以下的优点:

  • 使用原生 import export 等关键字,不再需要像其他规范那样如果要使用模块化系统,首先得引入模块系统的源码,才能使用 requiremodule.exportsdefine 等功能。
  • 支持异步加载。
  • 对于循环依赖的情况有更好的解决方案。

但是在浏览器端要使用ES Module的话,得给 <script> 标签加上 type="module" ,告诉浏览器这是一个 ES module 文件。

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

9. 模块化解决方案:打包/构建工具

在使用诸如 RequireJSSea.js 这类模块加载器 (Module Loader) 的时候,我们仍需先在 HTML 中导入模块加载器的源码。

并且模块加载器只处理了 JavaScript 文件,还有其他诸如 CSS,图片部分的静态文件没有被处理。

那么,有没有办法,可以让我们不用写这些模块配置代码,还可以帮我们自动处理静态资源,最后直接导出一个生产文件呢?

此外,如果你的项目里很多第三方依赖包用的不同的模块规范,怎么让它们一起正常工作呢?

这些问题的答案就是 模块化解决方案:打包/构建工具。

我这里以目前使用率最广,功能最强大的打包工具(Bundler) Webpack 举例:

使用 Webpack,我们可以在我们的源代码中 采用任何一种模块化规范

无论是 CommonJS,AMD/CMD 还是 ES Module,Webpack 最后都会将其 转换为自己的模块规范,最后统一输出成可以被浏览器识别的代码,从而做到 统一 JavaScript 模块化。

举个例子,假如我们有3个源文件,分别采用不同的模块规范:

// cat.js 采用 CommonJS 规范
const cat = "This is a cat."

module.exports = cat
// dog.js 采用 AMD 规范
define(function() {
    const dog = "This is a dog."
})
// app.js 采用 ES6 Module 规范
import cat from "./cat.js"
import dog from "./dog.js"

console.log(cat)
console.log(dog)

这3个源文件都可以被 Webpack 识别,并将其转换为自己的模块,最后进行打包输出。

这样的好处是,不管我们使用的第三方工具包是用哪种模块规范写的,我们都可以在项目中放心使用而不用担心模块规范的不兼容性。

JavaScript 模块化发展简史

除此之外,Webpack 通过各种 Loader 能将非 JavaScript 资源文件转换成 Webpack模块,从而做到一切皆模块。

10 总结

  • JavaScript 模块化发展一路走来无比曲折,而这一切的源头都在于设计者一开始并没有想到 JavaScript 在未来居然会如此流行,从而没有给它添加内置模块系统。

  • JavaScript 模块化需求来源于 “网站”“应用” 的转变。

  • JavaScript 模块化是前端工程化的基础,所以有必要了解它的发展历史。掌握了这个脉络,有助于理解现在五花八门的前端工具产生的原因,从而抓住本质,避免陷入无穷的细节学习中。

11. 参考资料

JavaScript Modules Part 1: A beginner's guide

JavaScript Modules Part 2: Module bundling

History of Web Development: JavaScript Modules

CommonJS - Wikipedia

How the module system, CommonJS & require works - RisingStack Engineering

What Server Side JavaScript needs

Asynchronous module definition - Wikipedia

Introduction to AMD Modules - Dojo Toolkit Tutorial

JavaScript: The Definitive Guide - Chapter 10. Modules

A Web History: The Origin of Bundlers, Part 1

A Web History: The Origin of Bundlers, Part 2

A Web History: The Origin of Bundlers, Part 3

转载自:https://juejin.cn/post/7120913448879259678
评论
请登录