{T}

模块化

模块化演变过程

文件划分方式

  • 污染全局作用域

  • 命名冲突问题

  • 无法管理模块依赖关系

  • 原始方式完全依靠约定

将每个功能及其相关状态数据各自单独放到不同的 JS 文件中,约定每个文件是一个独立的模块。使用某个模块将这个模块引入到页面中,一个 script 标签对应一个模块,然后直接调用模块中的成员(变量 / 函数)

javascript
└─ stage-1
   ├── module-a.js
   ├── module-b.js
   └── index.html
 
// module-a.js
function foo() {
  console.log('moduleA#foo')
}
// module-b.js
var data = 'something'
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Stage 1</title>
  </head>
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      // 直接使用全局成员
      foo() // 可能存在命名冲突
      console.log(data)
      data = 'other' // 数据可能会被修改
    </script>
  </body>
</html>

缺点:

  • 模块直接在全局工作,大量模块成员污染全局作用域

  • 没有私有空间,所有模块内的成员都可以在模块外部被访问或者修改

  • 一旦模块增多,容易产生命名冲突

  • 无法管理模块与模块之间的依赖关系

  • 在维护的过程中也很难分辨每个成员所属的模块

总之,这种原始“模块化”的实现方式完全依靠约定实现,一旦项目规模变大,这种约定就会暴露出种种问题,非常不可靠,所以我们需要尽可能解决这个过程中暴露出来的问题

命名空间方式

约定每个模块只暴露一个全局对象,所有模块成员都挂载到这个全局对象中

javascript
// module-a.js
window.moduleA = {
  method1: function () {
    console.log('moduleA#method1')
  }
}
// module-b.js
window.moduleB = {
  data: 'something',
  method1: function () {
    console.log('moduleB#method1')
  }
}
html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Stage 2</title>
  </head>
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      moduleA.method1()
      moduleB.method1()
      // 模块成员依然可以被修改
      moduleA.data = 'foo'
    </script>
  </body>
</html>

通过命名空间的方式,减小命名冲突的可能。但是,这种方式仍然没有私有空间,所以说我们的模块成员仍然可以在外部被访问或者被修改。另外,我们的这个模块之间的依赖关系也没有得到解决

IIFE

立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)为模块提供私有空间。具体做法是将每个模块成员都放在一个立即执行函数所形成的私有作用域中,对于需要暴露给外部的成员,通过挂到全局对象上的方式实现

javascript
// module-a.js
;(function () {
  var name = 'module-a'
 
  function method1() {
    console.log(name + '#method1')
  }
 
  window.moduleA = {
    method1: method1
  }
})()
  
// module-b.js
;(function ($) {
  var name = 'module-b'
 
  function method1() {
    console.log(name + '#method1')
  }
 
  window.moduleB = {
    method1: method1
  }
})()

这种方式带来了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问,这就解决了前面所提到的全局作用域污染和命名冲突的问题

IIFE 依赖参数

在 IIFE 的基础之上,还可以利用 IIFE 参数作为依赖声明使用,这使得每一个模块之间的依赖关系变得更加明显

javascript
// module-a.js
;(function ($) {
  // 通过参数明显表明这个模块的依赖
  var name = 'module-a'
 
  function method1() {
    console.log(name + '#method1')
    $('body').animate({ margin: '200px' })
  }
 
  window.moduleA = {
    method1: method1
  }
})(jQuery)

通过立即调用的函数去接收一个 jquery 的参数。在后期去维护这个模块的时候,我们就可以很清楚地知道这个模块,它需要依赖 jquery

模块加载的问题

以上 4 个阶段是早期的开发者在没有工具和规范的情况下对模块化的落地方式,但是仍然存在一些没有解决的问题

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Evolution</title>
  </head>
  <body>
    <script src="https://unpkg.com/jquery"></script>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
    <script>
      moduleA.method1()
      moduleB.method1()
    </script>
  </body>
</html>

都是通过 script 标签的方式直接在页面中引入的这些模块,这意味着模块的加载并不受代码的控制,时间久了维护起来会十分麻烦。试想一下,如果你的代码需要用到某个模块,如果 HTML 中忘记引入这个模块,又或是代码中移除了某个模块的使用,而 HTML 还忘记删除该模块的引用,都会引起很多问题和不必要的麻烦。

更为理想的方式应该是在页面中引入一个 JS 入口文件,其余用到的模块可以通过代码控制,按需加载进来

模块化规范

除了模块加载的问题以外,目前这几种通过约定实现模块化的方式,不同的开发者在实施的过程中会出现一些细微的差别,因此,为了统一不同开发者、不同项目之间的差异,我们就需要制定一个行业标准去规范模块化的实现方式

接合上面模块加载的问题,我们现在的需求就是两点:

  • 一个统一的模块化标准规范;

  • 一个可以自动加载模块的基础库。

CommonJS 规范

CommonJS 规范是 Node.js 中所遵循的模块规范,该规范约定,一个文件就是一个模块,每个模块都有单独的作用域,通过 module.exports 导出成员,再通过 require 函数载入模块

CommonJs 是 Node 独有的规范,浏览器中使⽤就需要⽤到 Browserify解析了

javascript
// a.js
module.exports = {
  a: 1 
}
 
// or
exports.a = 1
 
// b.js
var module = require('./a.js')
module.a // -> log 1

在上述代码中, module.exports 和 exports 很容易混淆,让我们来看看⼤致内部实现

javascript
var module = require('./a.js')
module.a
// 这⾥其实就是包装了⼀层⽴即执⾏函数,这样就不会污染全局变量了,
 
// 重要的是 module 这⾥,module 是 Node 独有的⼀个变量
module.exports = {
  a: 1
}
 
// 基本实现
var module = {
  exports: {} // exports 就是个空对象
}

这个是为什么 exports 和 module.exports ⽤法相似的原因

javascript
// 这个是为什么 exports 和 module.exports ⽤法相似的原因
var exports = module.exports
 
var load = function (module) {
    // 导出的东⻄
    var a = 1
    module.exports = a
    return module.exports
};

module.exportsexports ,⽤法其实是相似的,但是不能对 exports 直接赋值,不会有任何效果

AMD 规范

CommonJS 约定的是以同步的方式加载模块,因为 Node.js 执行机制是在启动时加载模块,执行过程中只是使用模块,所以这种方式不会有问题。但是如果要在浏览器端使用同步的加载模式,就会引起大量的同步模式请求,导致应用运行效率低下

在早期制定前端模块化标准时,并没有直接选择 CommonJS 规范,而是专门为浏览器端重新设计了一个规范,叫作 AMD ( Asynchronous Module Definition) 规范,即异步模块定义规范。同期还推出了一个非常出名的库,叫作 Require.js,它除了实现了 AMD 模块化规范,本身也是一个非常强大的模块加载器。

在 AMD 规范中约定每个模块通过 define() 函数定义,这个函数默认可以接收两个参数,第一个参数是一个数组,用于声明此模块的依赖项;第二个参数是一个函数,参数与前面的依赖项一一对应,每一项分别对应依赖项模块的导出成员,这个函数的作用就是为当前模块提供一个私有空间。如果在当前模块中需要向外部导出成员,可以通过 return 的方式实现

javascript
// AMD 规范定义一个模块
define(['jquery','./module2.js'],function($,module2){
  return {
    start:function(){
      $('body').animate({margin:'200px'})
    }
    module2()
  }
})

除此之外,Require.js 还提供了一个 require() 函数用于自动加载模块,用法与 define() 函数类似,区别在于 require() 只能用来载入模块,而 define() 还可以定义模块。当 Require.js 需要加载一个模块时,内部就会自动创建 script 标签去请求并执行相应模块的代码

javascript
// AMD 规范载入一个模块
require(['./modules/module1.js'],function(module1){
  module1.start()
})

目前绝大多数第三方库都支持 AMD 规范,但是它使用起来相对复杂,而且当项目中模块划分过于细致时,就会出现同一个页面对 JS 文件的请求次数过多的情况,从而导致效率降低。在当时的环境背景下,AMD 规范为前端模块化提供了一个标准,但这只是一种妥协的实现方式,并不能成为最终的解决方案

CMD 规范

同期出现的规范还有淘宝的 Sea.js,只不过它实现的是另外一个标准,叫作 CMD,这个标准类似于 CommonJS,在使用上基本和 Require.js 相同,可以算上是重复的轮子。但随着前端技术的发展,Sea.js 后来也被 Require.js 兼容了 Seajs 官网](https://seajs.github.io/seajs/docs/)

模块化的标准规范

随着技术的发展,JavaScript 的标准逐渐走向完善,如今的前端模块化已经发展得非常成熟了,对前端模块化规范的最佳实践方式也基本实现了统一

  • 在 Node.js 环境中遵循 CommonJS 规范来组织模块

  • 在浏览器环境中遵循 ES Modules 规范

all users 96.04% 都支持 https://caniuse.com/?search=module

javascript
export default function Hello(name) {
  return 'Hello world';
}
html
<script type="module">
  import sayHello from './Hello.js';
  const text = sayHello();
</script>

Vite 就是利用 js module 不打包直接运行 js module 代码所以很快

在最新的 Node.js 提案中表示,Node 环境也会逐渐趋向于 ES Modules 规范,应该重点掌握 ES Modules 规范

CommonJS 和 ES6 模块化的区别

  • CommonJS ⽀持动态导⼊,也就是 require(${path}/xx.js) ,ES6 中的模块化⽬前不⽀持,但是已有提案

  • CommonJS 是同步导⼊,因为⽤于服务端,⽂件都在本地,同步导⼊即使卡住主线程影响也不⼤

  • ⽽ ES6 中的模块化 是异步导⼊,因为⽤于浏览器,需要下载⽂件,如果也采⽤同步导⼊会对渲染有很⼤影响

  • CommonJS 在导出时都是值拷⻉,就算导出的值变了,导⼊的值也不会改变,所以如果想更新值,必须重新导⼊⼀次

  • 但是ES6 中的模块化采⽤实时绑定的⽅式,导⼊导出的值都指向同⼀个内存地址,所以导⼊值会跟随导出值变化

  • 后者会编译成 require/exports 来执⾏的