探索 JS 中的模块化

简介: 从开始 Coding 以来,总会周期性地突发奇想进行 Code Review。既是对一段时期的代码进行总结,也是对那一段时光的怀念。
偶然的一个周末复习了一下 JS 的模块标准,刷新了一下对 JS 模块化的理解。

从开始 Coding 以来,总会周期性地突发奇想进行 Code Review。既是对一段时期的代码进行总结,也是对那一段时光的怀念。

距离上一次 Review 已经过去近两个月,这次竟然把两年前在源续写的代码翻了出来,代码杂乱无章的程度就像那时更加浮躁的自己,让人感慨时光流逝之快。

话不多说,直接上码。

当时在做的是一个境外电商项目(越南天宝商城),作为非 CS 的新手程序员,接触 Coding 时间不长和工程化观念不强,在当时的项目中出现了这样的代码:

import.js:

07bc372d70f4ccfac2a9dd6c5f6186a1c1d8c479

这段代码看起来就是不断地从 DOM 中插进 CSS 和 JS,虽然写得很烂,但是很能反映以前的 Web 开发方式。

在 Web 开发中,有一个原则叫“关注点分离(separation of concerns)“,意思是各种技术只负责自己的领域,不互相耦合混合在一起,所以催生出了 HTML、CSS 和 JavaScript。

其中,在 Web 中负责逻辑和交互 的 JavaScript,是一门只用 10 天设计出来的语言,虽然借鉴了许多优秀静态和动态语言的优点,但却一直没有模块 ( module ) 体系。这导致了它将一个大程序拆分成互相依赖的小文件,再用简单的方法拼装起来。其他语言都有这项功能,比如 Ruby 的 requirePython 的 import,甚至就连 CSS 都有 @import,但是 JavaScript 任何这方面的支持都没有。而且 JS 是一种加载即运行的技术,在页面中插入脚本时还需要考虑库的依赖,JS 在这方面的缺陷,对开发大型的、复杂的项目形成了巨大障碍。

发展过程

虽然 JS 本身并不支持模块化,但是这并不能阻挡 JS 走向模块化的道路。既然本身不支持,那么就从代码层面解决问题。活跃的社区开始制定了一些模块方案,其中最主要的是 CommonJS 和 AMD,ES6 规范出台之后,以一种更简单的形式制定了 JS 的模块标准 (ES Module),并融合了 CommonJS 和 AMD 的优点。

大致的发展过程:

CommonJS(服务端) => AMD (浏览器端) => CMD / UMD => ES Module

CommonJS 规范

2009年,Node.js 横空出世,JS 得以脱离浏览器运行,我们可以使用 JS 来编写服务端的代码了。对于服务端的 JS,没有模块化简直是不能忍。

CommonJs (前 ServerJS) 在这个阶段应运而生,制定了 Module/1.0 规范,定义了第一版模块标准。

标准内容:

  1. 模块通过变量 exports 来向外暴露 API,exports 只能是一个对象,暴露的 API 须作为此对象的属性。
  2. 定义全局函数 require,通过传入模块标识来引入其他模块,执行的结果即为别的模块暴露出来的 API。
  3. 如果被 require 函数引入的模块中也包含依赖,那么依次加载这些依赖。

特点:

  1. 模块可以多次加载,首次加载的结果将会被缓存,想让模块重新运行需要清除缓存。
  2. 模块的加载是一项阻塞操作,也就是同步加载。
它的语法看起来是这样的:

// a.js
module.exports = {
  moduleFunc: function() {
    return true;
  };
}
// 或
exports.moduleFunc = function() {
  return true;
};

// 在 b.js 中引用
var moduleA = require('a.js');
// 或
var moduleFunc = require('a.js').moduleFunc;

console.log(moduleA.moduleFunc());
console.log(moduleFunc())

AMD 规范(Asynchromous Module Definition)

CommonJS 规范出现后,在 Node 开发中产生了非常良好的效果,开发者希望借鉴这个经验来解决浏览器端 JS 的模块化。

但大部分人认为浏览器和服务器环境差别太大,毕竟浏览器端 JS 是通过网络动态依次加载的,而不是像服务端 JS 存在本地磁盘中。因此,浏览器需要实现的是异步模块,模块在定义的时候就必须先指明它所需要依赖的模块,然后把本模块的代码写在回调函数中去执行,最终衍生出了 AMD 规范

AMD 的主要思想是异步模块,主逻辑在回调函数中执行,这和浏览器前端所习惯的开发方式不谋而合,RequireJS 应运而生。

标准内容:

  1. 用全局函数 define 来定义模块,用法为:define(id?, dependencies?, factory);
  2. id 为模块标识,遵从 CommonJS Module Identifiers 规范
  3. dependencies 为依赖的模块数组,在 factory 中需传入形参与之一一对应,如果 dependencies 省略不写,则默认为 ["require", "exports", "module"] ,factory 中也会默认传入 require, exports, module,与 ComminJS 中的实现保持一致
  4. 如果 factory 为函数,模块对外暴露 API 的方法有三种:return 任意类型的数据、exports.xxx = xxx 或 module.exports = xxx
  5. 如果 factory 为对象,则该对象即为模块的返回值

特点:

  1. 前置依赖,异步加载
  2. 便于管理模块之间的依赖性,有利于代码的编写和维护。

它的用法看起来是这样的:


// a.js
define(function (require, exports, module) {
  console.log('a.js');
  exports.name = 'Jack';
});

// b.js
define(function (require, exports, module) {
  console.log('b.js');
  exports.desc = 'Hello World';
});

// main.js
require(['a', 'b'], function (moduleA, moduleB) {
  console.log('main.js');
  console.log(moduleA.name + ', ' + moduleB.desc);
});

// 执行顺序:
// a.js
// b.js
// main.js

人无完人,AMD/RequireJS 也存在饱受诟病的缺点。按照 AMD 的规范,在定义模块的时候需要把所有依赖模块都罗列一遍(前置依赖),而且在使用时还需要在 factory 中作为形参传进去。


define(['a', 'b', 'c', 'd', 'e', 'f', 'g'], function(a, b, c, d, e, f, g){ ..... });

看起来略微不爽 ...

RequireJS 模块化的顺序是这样的:模块预加载 => 全部模块预执行 => 主逻辑中调用模块,所以实质是依赖加载完成后还会预先一一将模块执行一遍,这种方式会使得程序效率有点低。

所以 RequireJS 也提供了就近依赖,会在执行至 require 方法才会去进行依赖加载和执行,但这种方式的用户体验不是很好,用户的操作会有明显的延迟(下载依赖过程),虽然可以通过各种 loading 去解决。


// 就近依赖
define(function () {
  setTimeout(function () {
    require(['a'], function (moduleA) {
      console.log(moduleA.name);
    });
  }, 1000);
});

CMD 规范(Common Module Definition)

AMD/RequireJS 的 JS 模块实现上有很多不优雅的地方,长期以来在开发者中广受诟病,原因主要是不能以一种更好的管理模块的依赖加载和执行,虽然有不足的地方,但它提出的思想在当时是非常先进的。

既然优缺点那么必然有人出来完善它,SeaJS 在这个时候出现。

SeaJS 遵循的是 CMD 规范,CMD 是在 AMD 基础上改进的一种规范,解决了 AMD 对依赖模块的执行时机处理问题。

SeaJS 模块化的顺序是这样的:模块预加载 => 主逻辑调用模块前才执行模块中的代码,通过依赖的延迟执行,很好解决了 RequireJS 被诟病的缺点。

SeaJS 用法和 AMD 基本相同,并且融合了 CommonJS 的写法:


// a.js
define(function (require, exports, module) {
  console.log('a.js');
  exports.name = 'Jack';
});

// main.js
define(function (require, exports, module) {
  console.log('main.js');
  var moduleA = require('a');
  console.log(moduleA.name);
});

// 执行顺序
// main.js
// a.js

除此之外,SeaJS 还提供了 async API,实现依赖的延迟加载。


// main.js
define(function (require, exports, module) {
  var moduleA = require.async('a');
  console.log(moduleA.name);
});

SeaJS 的出现,貌似以一种比较完美的形式解决了 JS 模块化的问题,是 CommonJS 在浏览器端的践行者,并吸收了 RequestJS 的优点。

ES Module

ES Module 是目前 web 开发中使用率最高的模块化标准。

随着 JS 模块化开发的呼声越来越高,作为 JS 语言规范的官方组织 ECMA 也开始将 JS 模块化纳入 TC39 提案中,并在 ECMAScript 6.0 中得到实践。

ES Module 吸收了其他方案的优点并以更优雅的形式实现模块化,它的思想是尽量的静态化,即在编译时就确定所有模块的依赖关系,以及输入和输出的变量,和 CommonJS 和 AMD/CMD 这些标准不同的是,它们都是在运行时才能确定需要依赖哪一些模块并且执行它。ES Module 使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,实现一些只能靠静态分析实现的功能(比如引入宏(macro)和类型检验(type system)。

标准内容:

  1. 模块功能主要由两个命令构成:export 和 importexport 命令用于规定模块的对外接口,import 命令用于输入其他模块提供的功能。
  2. 通过 export 命令定义了模块的对外接口,其他 JS 文件就可以通过 import 命令加载这个模块。

ES Module 可以有多种用法:

模块的定义:


/**
 * export 只支持对象形式导出,不支持值的导出,export default 命令用于指定模块的默认输出,
 * 只支持值导出,但是只能指定一个,本质上它就是输出一个叫做 default 的变量或方法
 */
// 写法 1
export var m = 1;
// 写法 2
var m = 1;
export { m };
// 写法 3
var n = 1;
export { n as m };
// 写法 4
var n = 1;
export default n;

模块的引入:


// 解构引入
import { firstName, lastName, year } from 'a-module';
// 为输入的变量重新命名
import { lastName as surname } from 'a-module';
// 引出模块对象(引入所有)
import * as ModuleA from 'a-module';

在使用 ES Module 值得注意的是:import 和 export 命令只能在模块的顶层,在代码块中将会报错,这是因为 ES Module 需要在编译时期进行模块静态优化,import 和 export 命令会被 JavaScript 引擎静态分析,先于模块内的其他语句执行,这种设计有利于编译器提高效率,但也导致无法在运行时加载模块(动态加载)。

对于这个缺点,TC39 有了一个新的提案 -- Dynamic Import,提案的内容是建议引入 import()方法,实现模块动态加载。


// specifier: 指定所要加载的模块的位置
import(specifier)

import() 方法返回的是一个 Promise 对象。


import('b-module')
  .then(module => {
    module.helloWorld();
  })
  .catch(err => {
    console.log(err.message);
  });

import() 函数可以用在任何地方,不仅仅是模块,非模块的脚本也可以使用。它是运行时执行,也就是说,什么时候运行到这一句,就会加载指定的模块。另外,import() 函数与所加载的模块没有静态连接关系,这点也是与 import 语句不相同。import() 类似于 Node 的 require 方法,区别主要是前者是异步加载,后者是同步加载。

通过 import 和 export 命令以及 import() 方法,ES Module 几乎实现了 CommonJS/AMD/CMD 方案的所有功能,更重要的是它是作为 ECMAScript 标准出现的,带有正统基因,这也是它在现在 Web 开发中广泛应用的原因之一。

但 ES Module 是在 ECMAScript 6.0 标准中的,而目前绝大多数的浏览器并直接支持 ES6 语法,ES Module 并不能直接使用在浏览器上,所以需要 Babel 先进行转码,将 import 和 export 命令转译成 ES2015 语法才能被浏览器解析。

总结

JS 模块化的出现使得前端工程化程度越来越高,让使用 JS 开发大型应用成为触手可及的现实(VScode)。纵观 JS 模块化的发展,其中很多思想都借鉴了其他优秀的动态语言(Python),然后结合 JS 运行环境的特点,衍生出符合自身的标准。但其实在本质上,浏览器端的 JS 仍没有真正意义上的支持模块化,只能通过工具库(RequireJS、SeaJS)或者语法糖(ES Module)去 Hack 实现模块化。随着 Node 前端工程化工具的繁荣发展(Grunt/Gulp/webpack),使我们可以不关注模块化的实现过程,直接享受 JS 模块化编程的快感。

在复习 JS 模块化的过程中,对 Webpack 等工具的模块化语法糖转码产生了新的兴趣,希望有时间可以去分析一下模块化的打包机制和转译代码,然后整理出来加深一下自己对模块化实现原理的认识和理解。

期待下一篇。

参考文章:



原文发布时间为:2018年07月02日
原文作者:掘金
本文来源:  掘金  如需转载请联系原作者


相关文章
|
7月前
|
JavaScript 前端开发 测试技术
如何编写JavaScript模块化代码
如何编写JavaScript模块化代码
45 0
|
3月前
|
JavaScript UED
js之模块化(2)
js之模块化(2)
|
4月前
|
JavaScript 前端开发 编译器
解锁JavaScript模块化编程新纪元:从CommonJS的基石到ES Modules的飞跃,探索代码组织的艺术与科学
【8月更文挑战第27天】随着Web应用复杂度的提升,JavaScript模块化编程变得至关重要,它能有效降低代码耦合度并提高项目可维护性及扩展性。从CommonJS到ES Modules,模块化标准经历了显著的发展。CommonJS最初专为服务器端设计,通过`require()`同步加载模块。而ES Modules作为官方标准,支持异步加载,更适合浏览器环境,并且能够进行静态分析以优化性能。这两种标准各有特色,但ES Modules凭借其更广泛的跨平台兼容性和现代语法逐渐成为主流。这一演进不仅标志着JavaScript模块化的成熟,也反映了整个JavaScript生态系统的不断完善。
56 3
|
3月前
|
JavaScript 前端开发 开发者
js之模块化(1)
js之模块化(1)
|
2月前
|
缓存 JavaScript 前端开发
Node.js模块化的基本概念和分类及使用方法
Node.js模块化的基本概念和分类及使用方法
47 0
|
3月前
|
JavaScript
js之模块化(3)
js之模块化(3)
|
4月前
|
前端开发 JavaScript
前端必会的JavaScript模块化
【8月更文挑战第3天】JavaScript模块化
36 1
|
4月前
|
存储 缓存 JavaScript
JavaScript——请列出目前主流的 JavaScript 模块化实现的技术有哪些?说出它们的区别?
JavaScript——请列出目前主流的 JavaScript 模块化实现的技术有哪些?说出它们的区别?
36 0
|
5月前
|
JavaScript 前端开发
node.js 导入导出模块(CommonJS模块化规范,ES6模块化规范)
node.js 导入导出模块(CommonJS模块化规范,ES6模块化规范)
67 1
|
6月前
|
JavaScript 前端开发
JavaScript进阶-Class与模块化编程
【6月更文挑战第21天】**ES6引入Class和模块化,提升JavaScript的代码组织和复用。Class是原型机制的语法糖,简化面向对象编程。模块化通过`import/export`管理代码,支持默认和命名导出。常见问题包括`this`指向和循环依赖。理解这些问题及避免策略,能助你写出更高效、可维护的代码。**
67 5