JavaScript模块化历程

807 阅读5分钟

为什么需要模块化

首先来看一个早期不使用模块化时资源的加载方式: 早期的资源引入可能是这样:

<html lang="en">
  <header></header>
  <body>
    <script type="text/javascript" src="../lib/juqery.js"></script>
    <script type="text/javascript" src="../lib/jquery-mobile.js"></script>
    <script type="text/javascript" src="../js/a.js"></script>
    <script type="text/javascript" src="../js/b.js"></script>
    <script type="text/javascript" src="../js/c.js"></script>
    <script type="text/javascript" src="../js/d.js"></script>
    <script type="text/javascript" src="../js/e.js"></script>
  </body>
</html>

上面的加载方式会导致以下的问题:

  • 加载资源的方式通过script标签从上到下, 文件的加载顺序非常重要。
  • 如果再加上defer和async, 加载的逻辑就会更加复杂。
  • 每个文件中的方法和变量都在全局作用域,变量和方法不容易维护,容易污染全局作用域。
  • 大型项目资源难以维护,特别是多人合作的情况下,资源的引入会让人奔溃。

所以为了前端走向工程化,模块化势在必行。

模块化发展历程

直接声明

早期的Web应用,前端页面大多采用服务端渲染,JavaScript主要是作为一种页面增强的辅助语言。所以最初的JavaScript代码都是直接写在<script>标签中的,或者定义在一个js文件中,像下面这样

<html lang="en">
  <script>
    function sum(a, b) {
      return a + b;
    }
  </script>
</html>

通过这种方式加载的所有的JavaScript代码都共享全局作用域(window)。

随着Web应用越来越复杂,共享全局作用域这种方式的弊端开始显现。

比如当创建一个新的变量时,无法确定全局作用域中是否已经存在同名的变量,所以有可能导致已存在的同名变量被覆盖。

立即执行函数(IIFE)

为了解决上面的问题,避免自己写的JavaScript代码污染全局作用域,于是出现了下面的写法:

(function () {
  function sum(a, b) {
    return a + b;
  }
})()

首先创建了一个匿名函数,然后通过最后的()自动执行它,这种写法也叫做立即执行函数, 英文叫法Immediately Invoked Function Expression(IIFE)

在JavaScript中,每个函数都有一个作用域。正是因为这个特性,所以在立即执行函数里面定义的函数和变量,只能在函数内部访问,外面是访问不到的,所以也就解决了上文提到的污染全局变量的问题。

如果要暴露一些方法给外部访问,该怎么做呢?

常用的方式是在window对象上绑定一个对象作为命名空间,一起来看下面的代码:

(function () {
  window.mathlib = window.mathlib || {}
  window.mathlib.sum = sum

  function sum(a, b) {
    return a + b;
  }
})()

mathlib.sum(1, 3)

// 输出 4

IIFE这种方式其实就是模块化的雏形。通过这种方式,我们可以把功能单独封装成一个模块,然后通过window对外暴露需要的方法。

但是如果定义多个模块,IIFE其实没有办法确定各个模块之间的依赖关系。

CommonJS

如何能够方便的在JS文件中直接加载所需要的模块?

为了解决这个问题,此时市场上出现了很多的方案,比如模板依赖定义注释依赖定义等等。在诸多方案中,随着Node.js的火爆,CommonJS规范最终脱颖而出,成了目前最为主流的JS模块加载方案。

CommonJS主要是通过两个关键字来实现依赖的加载和暴露,require和exports。

  • require: 用来加载模块
  • exports/module.exports: 用来对外暴露接口 想了解更多关于require及exports的工作原理,可以参考这篇文章require源码解读

AMD/CMD

CommonJS解决了模块的依赖关系问题,但是CommonJS所有的模块都是同步加载的。

这导致了CommonJS不太适合浏览器端,请看下面的例子:

  // math.js
  const math = require('math');
  math.sum(1, 2);

第二行代码需要等待第一行代码执行完成后才能运行, 如果第一行代码运行时间过长,整个页面就会卡在那里。

所以在浏览器端迫切需要一种能够异步加载模块的方式,于是就出现了AMD/CMD

下面分别给个AMD和CMD的实现代码:

  • CMD
  define(function (require) {
    var moduleA = require('./a'); // 运行到此处才开始加载并运行模块a
    var moduleB = require('./b'); // 运行到此处才开始加载并运行模块b
    // some code...
  })
  • AMD
  define(['./a', './b'], // q前置声明,即会预先加载并运行模块a和模块b
    function (a, b) {
      // some code
    }
  )

从代码来看,CMD更贴近CommonJS规范,关于这两者的介绍和对比,网上文章有很多,在这里就不详细展开了。

ES6 Modules

2015年随着ECMAScript 2015也就是ES6的发布,JavaScript终于在语言标准的层面上,实现了模块功能。

ES6规范中包含了一个原生的模块化系统,一般称之为 ECMAScript Modules(ESM)。 请看一段代码:

  // file b.js
  import sum from './a';
  sum(1, 3);
  // 输出4

  // file a.js
  export default function sum(a, b) {
    return a + b;
  }

ES Modules通过import加载模块,通过export来对外暴露模块。如果想了解ES Module的详细加载原理,可以参照这篇 ES6 Module

ES6规范与CommonJS明显的不同是,它在编译阶段就能确定模块的依赖关系及输入输出的变量,而CommonJS需要等到执行阶段才能确定。

自此,ES6 Modules成为了浏览器和服务器端的通用解决方案。