最详细的前端模块化解决方案梳理

2,291 阅读10分钟

前言

美好的一天从 npm run dev 开始,对于现在的前端而言从百家争鸣到逐渐统一的辅助开发工具对前端效率的提升有着不可替代的作用,这一切都必须依赖前端的模块化。在前端还处在刀耕火种的年代想实现模块化只能通过闭包也就是 IIFE ,而如今 ES6 Modules 可能是前端最常用的模块解决方案,那么本篇从 IIFE 来开始归纳前端模块化的前世今生。哦,对了,没说为什么要琢磨模块化了,模块化便于拆封代码,避免变量冲突,没有模块化想开发一个大工程,呃,不太行。

IIFE(闭包)

我们该怎么理解模块这个概念,在刀耕火种的年代很自然的可以把一个js文件理解成一个模块,看下面的demo:

<!DOCTYPE html>
<html>
<head>
  <title></title>
</head>
<body>
  <script src="./main.js"></script>
  <script src="./app.js"></script>
  <script src="./api.js"></script>
</body>
</html>

main.js、app.js、api.js 把他们仨理解成是三个模块,但是这三个js文件里面的代码应该如何构建呢?因为js本身机制的原因只能将代码暴露到全局作用域下,如果不去避免全局变量污染那么这样的模块是毫无意义的也是后患无群的。在刀耕火种的年代自然而然想到了闭包,我们尝试用下面的方法去构建这三个js文件达到隔离作用域的目的。

(function(){
  var name = 'main.js'
  ...
})()

我们用匿名函数而不是声明一个function再调用是因为匿名函数,执行完后很快就会被释放,这种机制不存在函数名污染全局作用域的情况。这种匿名函数的方式就是IIFE,虽然它可以有效解决命名冲突的问题,但是对于依赖管理,还是束手无策。由于浏览器解析器是从上至下执行脚本,因此为了保证脚本间的依赖关系,就必须手动维护好script标签的引入顺序,涉及到模块间的通信可以用参数的方式传入。

CommonJS(node.js)

CommonJS 是一位叫 Kevin Dangoor 的国际友人提出的关于js模块的一种规范,注意了是规范不是实现方案,而 node.js 就是基于 CommonJS 规范实现的模块化。

我们先看一下CommonJS规范都规定了那些内容:

  1. 一个单独的文件就是一个模块。
  2. 每一个模块都是一个单独的作用域。

node.js 对 CommonJS 规范做了实现,那么 node.js 到底做了什么?

  1. node.js 的实现中,给每个文件赋予了一个 module 对象,这个对象包括了描述当前模块的所有信息,它的 exports 属性(即module.exports)是对外的接口。加载某个模块,其实是加载该模块的 module.exports 属性。对 module 对象拥有的属性简单列一下:
  • module.id 模块的识别符,通常是带有绝对路径的模块文件名。
  • module.filename 模块的文件名,带有绝对路径。
  • module.loaded 返回一个布尔值,表示模块是否已经完成加载。
  • module.parent 返回一个对象,表示调用该模块的模块。
  • module.children 返回一个数组,表示该模块要用到的其他模块。
  • module.paths 表示模块的搜索路径,路径的多少取决于目录的深度。
  • module.exports 表示模块对外输出的值。
  1. 同时为每个文件提供了一个 require 方法用于加载模块。
  2. 为了方便 node.js 为每个模块提供一个 exports 变量,指向 module.exports。这等同在每个模块头部声明 var exports = module.exports 。

module.exports 和 exports 在使用上有什么区别?

上面说过 exports 的实现方法等同于 var exports = module.exports,也就是说想要通过 exports 关节字向外输出值只能给 exports 添加属性比如下面这种写法。

exports.myName = 'tom'
exports.getMyName = function(){
  return 'tom'
}

要注意的是,不能直接将 exports 变量指向一个值,因为这样等于切断了exports 与 module.exports 的联系。比如下面这种写法是无效的,因为 exports 不再指向 module.exports 了。

exports = function(){
  return 'tom'
}
// or
exports = 'tom'

require() 方法的加载机制:

require 命令用于加载模块文件。require 命令的基本功能是,读入并执行一个 js 文件,然后返回该模块的 exports 对象,如果没有发现指定模块,会报错。require 方法默认读取 js 文件,所以可以省略 js 后缀名,js文件名前面需要加上路径,可以是相对路径也可以是绝对路径。如果省略路径,node.js 会认为你要加载一个内部模块,或者已经安装在本地的 node_modules 目录中的模块。如果加载的是一个目录,node.js会首先寻找该目录中的 package.json 文件,加载该文件 main 字段配置的模块,否则就寻找该目录下的 index.js 文件。在加载 node_modules 目录中的模块时按照下面的路径顺序去查找。

/usr/local/lib/node/x.js
/home/user/projects/node_modules/x.js
/home/user/node_modules/x.js
/home/node_modules/x.js
/node_modules/x.js

require() 方法的缓存机制:

在 node.js 中通过 require 多次引入同一模块,模块内的代码并不会重复执行多次,这依赖于 node.js 的缓存机制,第一次加载某个模块时,node.js 会缓存该模块,一个模块被加载一次之后,就会在缓存中维持一个副本,如果遇到重复加载的模块会直接提取缓存中的副本,也就是说在任何时候每个模块都只在缓存中有一个实例,模块加载的顺序,按照其在代码中出现的顺序。

关于运行时:

在服务器端,模块的加载是运行时同步加载的;在浏览器端,模块需要提前编译打包处理(gulp, webpack)。

require() 加载模块是同步还是异步?

首先 CommonJS 规范加载模块是同步的,由于 node.js 主要用于服务器,模块文件一般都已经存在于本地硬盘,况且有上文提到的缓存机制模块经常被复用,所以加载起来比较快,IO开销可以忽略,不用考虑非同步加载的方式,所以 CommonJS 规范比较适用。也就是说,只有加载完成,才能执行后面的操作。但是,如果是浏览器环境,要从服务器端加载模块,这时就必须采用非同步模式,因此浏览器端一般采用AMD规范。

如何实现作用域隔离?

在浏览器端通过观察打包之后的代码可以发现实现作用域隔离的本质还是通过函数作用域也就是闭包,这部分不展开说了,后面计划出一篇分析 webpack 打包生成代码的博客...

AMD(RequireJS)

AMD 也是关于模块化的规范,RequireJS 是基于 AMD 规范实现的模块化解决方案。上文说过 CommonJS 规范中模块的加载是同步的,那么试想在浏览器环境中模块是存在于服务器的,在加载模块的过程中会导致阻塞,使得我们后面的步骤无法进行下去导致很长的时间内浏览器是被阻塞的,还可能会执行一个未定义的方法而导致出错。相对于服务端的模块化,浏览器环境下模块化的标准必须满足一个新的需求就是“异步的模块管理”,在这样的背景下,RequireJS 出现了。

RequireJS 提供了两个方法:

  • require() 用于引入其他模块
  • define() 定义新的模块

RequireJS 的内部逻辑是通过函数 define() 将需要的依赖模块加载进来,在回调里拿到被依赖也就是被加载的模块然后返回一个新的值(模块),我们所有的关于新模块的业务代码都在这个函数内部操作。

我们看下面的例子:

定义模块

// 定义不依赖其他模块的模块
define(function() {
  return {
    ...
  }
})

// 定义依赖其他模块的模块
define([ './module1', './module2' ], function(m1, m2) {
  // m1、m2 就是 './module1'、'./module2' 加载到的值
  return {
    ...
  }
})

加载模块

require(['./module1', './module2'], function(m1, m2) {
  // m1、m2 同样是 './module1''./module2' 加载到的值
  m1.fun()
  m2.fun()
})

CMD(Sea.js)

CommonJS是规范,AMD是规范,CMD当然也是规范,基于CMD的实现方案是 Sea.js。通过对 RequireJS 的分析我们发现一个问题,就是在用 RequireJS 声明一个模块时要提前指定所有的依赖,这些依赖项会被当做形参传到 define() 方法中,这加大了开发的难度,因为依赖的模块会优先全部加载,那么在阅读代码的时候要先把依赖模块都阅读一次(因为这些依赖模块是初始化的时候执行的)。偷懒是开发者提高效率的初衷,那么如果可以在业务代码中使用到依赖模块的时候再去加载该模块这样就不必提前阅读全部依赖模块代码了。这样 Sea.js 就出现了,在使用上可以理解 Sea.js 是 CommonJS 和 AMD 的结合。

define(function(require, exports, module) {
  let myHeader, myBody, myFooter
  if (status1) {
    var header = require('./header')
    myHeader = header.fun()
  }
  if (status2) {
    var body = require('./body')
    myBody = body.fun()
  }
  if (status3) {
    var footer = require('./footer')
    myFooter = footer.fun()
  }
  require.async('./module', function (m) { })

  module.exports = {
    header: myHeader,
    body: myBody,
    footer: myFooter
  }
})

要注意的是虽然 require() 方法夹杂在了业务代码中但是还是会提前加载,加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到 require 语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的。如果使用 require.async() 方法,可以实现模块的懒加载。

虽然 RequireJS 也支持 SeaJS 的写法,但是依赖的模块任然是预先加载的。

UMD(AMD + CommonJS)

CommonJS 适用于服务端,AMD、CMD 适用于web端,那么需要同时运行在这两端的模块就可以采用 UMD 的方法,使用该模块化方案,可以很好地兼容AMD、CommonJS语法。UMD 先判断是否支持Node.js的模块(exports)是否存在,存在则使用 node.js 模块模式。再判断是否支持 AMD(define是否存在),存在则使用AMD方式加载模块。由于这种通用模块解决方案的适用性强,很多JS框架和类库都会打包成这种形式的代码。

ES6 Module

在 ES6 中不必使用特殊的手段,ES6在语法层面就支持了模块化,然而受限于浏览器的实现程度,如果想要在浏览器中运行,还是需要通过 Babel 等转译工具进行编译。ES6提供了 importexport 命令,分别对应模块的导入和导出功能。es6模块相关的语法有好多,不展开说了。

特点:

  1. 语法是静态的,import 会自动提升到代码的顶层。
  2. 使用 import 导入的变量是只读的,无法被赋值,而且是引用传递。(如果你的模块在运行过程中修改了导出的变量值,就会反映到使用模块的代码中去。所以,不推荐在模块中修改导出值,导出的变量应该是静态的。)
  3. ES6使用的是基于文件的模块,也就是一个文件一个模块。
  4. ES6模块API是静态的,一旦导入模块后,无法再在程序运行过程中增添方法。

Webpack中的模块化方案

webpack 兴起之后,我们可以使用 CommonJS、AMD、CMD、UMD、ES6 任意的方法来开发最终 webpack 会打包成你需要的样子。