js - 模块化

js - 模块化

why

图中的代码写法,有独立的作用空间吗?

  • 没有独立作用空间,容易出现命名冲突,造成全局污染,对于大型、复杂的项目来说会非常棘手

what

  • 事实上模块化开发最终的目的是将程序划分成一个个小的结构
  • 这个结构中编写属于自己的逻辑代码,有自己的作用域,不会影响到其他的结构;
  • 这个结构可以将自己希望暴露的变量、函数、对象等导出给其他结构使用;
  • 也可以通过某种方式,导入另外结构中的遍历、函数、对象等;

发展历史

es6(2015年)之前

  • 【不支持】在es6(2015年)之前,都没有JS都没有官方的模块化开发方式。
  • 在此之前,为了让JavaScript支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS等;最主要的有 CommonJS(CommonJS Modules)和 AMD(Asynchronous Module Definition)两种模块规范,前者用于服务器,后者用于浏览器。

es6(2015年)

随着 ES6 的正式发布,全新的模块将逐步取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想尽量的静态化,是的编译时就能确定模块的依赖关系,以及输入和输出的变量。

在 rollup、 webpack 等构建工具中常见的 Tree Shaking 能力,就是依赖于 ES6 模块的静态特性实现的。

而 CommonJS 和 AMD 模块都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

// CommonJS 模块
const { stat, exists, readFile } = require('fs')

// 相当于
const _fs = require('fs')
const stat = _fs.stat
const exists = _fs.exists
const readFile = _fs.readFile

以上示例,实际上是整体加载了 fs 模块(即加载 fs 的所有方法),生成了一个对象 _fs,然后再从这个对象上读取了 3 个方法。这种方式称为“运行时加载”,原因是只有运行时才能得到这个对象,导致完全没有办法在编译时做“静态优化”。

ES6 模块不是对象,而是通过 export 命令显式指定输出的代码,再通过 import 命令输入。

import { stat, exists, readFile } from 'fs'

以上示例,实际上是从 fs 模块中加载了 3 个方法,其他方法不加载。这种方式称为“编译时加载”或“静态加载”,即 ES6 模块可以在编译时就完成模块加载,效率要高于 CommonJS 模块的加载方式。这也导致了没法引用 ES6 模块本身,因为它不是对象。

由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。

除了静态加载带来的各种好处,ES6 模块还有以下好处

  • 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。
  • 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
  • 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。

非模块化避免全局污染

对象字面量

// 声明
var namespace = {
  prop: 123,
  method: function () {},
  // ...
}

// 调用
namespace.prop
namespace.method()

缺点:

作为一个单一的、有时很长的句法结构,它对其内容施加了限制。内容必须在 {} 之间,并且属性或方法之间必须添加逗号。当模块内容复杂起来之后,维护成本高,移动内容变得更加困难。

在多个文件中使用相同的命名空间:可以将模块定义分散到多个文件中,并按如下方式创建命名空间变量,则可忽视加载文件的顺序。

var namespace = namespace || {}

使用多个模块,可以通过创建单个全局命名空间并向其添加子模块来避免全局名称的扩散。不建议进一步嵌套,如果名称冲突是一个问题,您可以使用更长的名称。这种方式称为:嵌套命名空间。

// 全局命名空间
var globalns = globalns || {}

// 添加 A 子模块
globalns.moduleA = {
  // module content
}

// 添加 B 子模块
globalns.moduleB = {
  // module content
}

尽管使用命名空间可以在一定程度上解决了命名冲突的问题,但是存在一个问题:在 moduleB 中可以修改 moduleA 的内容,而且 moduleA 可能还蒙在鼓里,不知情。

以上命名空间内的所有成员和方法,无论是否私有,对外都是可访问的。这是一个明显的缺点,模块化不应该如此设计。

Yahoo 公司的 YUI 2 就是采用了这种方案。

立即执行函数表达式

立即执行函数表达式(Immediately-Invoked Function Expression,简称 IIFE)

通过立即执行函数也可以实现简单的模块化,代码如下,这种方式会有哪些问题?

①namespace还是会冲突的
②namespace这个名字可不好记
③没有规范,会知道命名千差万别

var namespace = (function () {
  // private data
  var _prop = 123
  var _method = function () {}

  return {
    // read-only
    get prop() {
      return _prop
    },
    get method() {
      return _method
    }
  }
})()

这样的话,我们就不用担心,在外部直接修改 namespace 内部的成员或者方法了。

// 读取
namespace.prop // 123
namespace.method() 

// 写入
namespace.prop = 456 // 无效
namespace.method = function foo() {} // 无效

因此,结合前面的内容,就可以这样去处理:

// 全局命名空间
var globalns = globalns || {}

// 添加 A 子模块
globalns.moduleA = (function () {
  // ...

  return {
    // ...
  }
})()

// 添加 B 子模块
globalns.moduleB = (function () {
  // ...

  return {
    // ...
  }
})()

到现在,有了命名空间解决了命名冲突问题,同时使用 IIFE 来维护各模块的私有成员和方法,导出对外的开放接口即可。这似乎有了模块化该有的样子。

但是,还有一个问题。 <script> 是按书写顺序加载的(即使下载顺序可能并行的),主要包括:

  • 脚本下载
  • 脚本解析(编译和执行)

假设我们的脚本如下:

<!DOCTYPE html>
<html lang="en">
  <body>
    <script src="module-a.js"></script>
    <script src="module-b.js"></script>
  </body>
</html>

那么我们的 modueA 在(首次)解析的时候,就没办法调用 moduleB 的内容,因为它压根还没解析执行。一旦项目复杂度、模块数量上来之后,模块之间的依赖关系就很难维护了。

社区模块化方案

在 ES2015 之前,社区上已经有了很多模块化方案,流行的主要有以下几个:

  • CommonJS
  • AMD(Asynchronous Module Definition)
  • CMD(Common Module Definition)
  • UMD(Universal Module Definition)

其中 CommonJS 规范在 Node.js 环境下取得了很不错的实践,它只能应用于服务器端,而不支持浏览器环境。CommonJS 规范的模块是同步加载的,由于服务器的模块文件存在于本地硬盘,只有磁盘 I/O 的,因此同步加载机制没什么问题。

但在浏览器环境,一是会产生开销更大的网络 I/O,二是天然异步,就会产生时序上的错误。后来社区上推出了异步加载、可在浏览器环境运行的 RequireJS 模块加载器,不久之后,起草并发布了 AMD 模块化标准规范。

由于 AMD 会提前加载,很多开发者担心有性能问题。假设一个模块依赖了另外 5 个模块,不管这些模块是否马上被用到,都会执行一遍,这些性能消耗是不容忽视的。为了避免这个问题,有部分人试图保留 CommonJS 书写方式和延迟加载、就近声明(就近依赖)等特性,并引入异步加载机制,以适配浏览器特性。比如,已经凉凉的 BravoJS、FlyScript 等方案。

在 2011 年,国内的前端大佬玉伯提出了 SeaJS,它借鉴了 CommonJS、AMD,并提出了 CMD 模块化标准规范。但并没有大范围的推广和使用。

在 2014 年,美籍华裔 Homa Wong 提出了 UMD 方案:将 CommonJS 和 AMD 相结合。本质上这不算是一种模块化方案。

到了 2015 年 6 月,随着 ECMAScript 2015 的正式发布,JavaScript 终于原生支持模块化,被称为 ES Module。同时支持服务器端和浏览器端。

尽管到了 2022 年,现状仍然是多种模块化方案共存,但未来肯定是 ES Module 一统江湖...

关于 JavaScript 模块化历史线,可以看下这篇文章

参考资料

import 和 require区别
深入JavaScript Day25 - 模块化、CommonJS、module.exports、exports、require
细读 JS | JavaScript 模块化之路

posted @ 2022-03-23 15:56  zc-lee  阅读(69)  评论(0编辑  收藏  举报