JavaScript程序模块化及模块系统

大程序需要模块化技术,JS程序亦不例外,然而后生的JS,其模块化技术还不成熟,处理相当的复杂,主要源于客户端环境,JS程序运行模式特殊的原因。模块化绝对是一个「任务」,并且和模块内的计算任务的性质有明显不同,而解决的「技术」,JS有了多种:

  • 任务:程序结构化设计(架构师的任务)
  • 技术:CJS AMD UMD ESM

 本文通过略译《Creating JavaScript Modules》,收集实例来总结有关JS模块化任务更细致的理解,掌握模块化更专业的技术。

选译这篇文章原因是,它比较全面,简洁,虽然没有深入模块系统内部原理(有没有必要了解内部原理是值得商榷的,尤其是当ES标准模块系统快成熟时),但例子选得较好(数学模块),同时介绍了模块系统的API使用(包括模块的制作与引用),只有一点,例子中数学模块只有输出,没有依赖,还不够通用。

什么是模块系统

最简单的理解,「JS模块系统」[注1]就是一种将相关代码封装在一个独立作用域的模块里(模块的功能通常是通用的),程序内的其它部分可共享/使用这个模块的技术或机制[注2]

本文简单介绍目前常见的几个JS模块系统。

注1:模块系统和模块在概念上是不同的。另外,JS模块化技术有多种的称谓,模块系统或者模块格式等,名不重要,重要是它们都是指代同一个任务,并且演化出多种技术,例如CommonJS。

注2:这个定义假设了「模块」的概念,事实上「模块」需要更形式的定义。

IIFE

即时调用函数表达式(Immediately-Invoked Function Expression IIFE) 是指一个无名函数定义完后就立即调用,在内存中创建一个「轻便的函数对象」,供后续代码使用。IIFE利用函数对象的「函数作用域」特性实现模块封装,由于其表现出模块特性,故是最早的一种创建「通用模块」的方式。

IIFE模块的使用方式是约定的,虽然你也可以将它独立成一个文件,但是它的依赖是手动管理的,依赖/使用它的程序其它部分必须后于IIEF模块的创建。如下是一个例子:

EM:严格上说,IIFE模块不是一种完整的模块系统,使用是JS固有一些特性(函数作用域,和按顺序全局加载),不能完整完成模块化的任务,虽然它使用了函数作用域,实现了模块必须的封装性,但它是会污染全局,并且需手动管理依赖,也不能灵活共享给他们使用。只是它能揭示了「模块」的形式属性。

//Module Starts
(function(window){

    var sum = function(x, y){
        return x + y;
    }
    var sub = function(x, y){
        return x - y;
    }
    var math = {
        findSum: function(a, b){
            return sum(a,b);
        },
    
        findSub: function(a, b){
            return sub(a, b);
        }
    }
    window.math = math;
})(window);
//Module Ends

console.log(math.findSum(1, 2)); //3
console.log(math.findSub(1, 2)); //-1

这是一个典型例子,代码创建了一个数学(math)模块,提供了加法(sum)和减法(sub)算术的通用功能。

由于 「sumsub函数的具体实现代码」是在无名函数之内的,所以它们是被封装在函数内部的,而它们的功能是通一个内置math对象「间接地」输出到模块外面——通过将math“挂接”到全局的window对象上去。

有名的JS库——JQuery就是一个IIFE模块,它也是全局window对象上的一个对象引用——$jQuery

CommonJS

CommonJS是第一个较完整的JS模块系统,然而它只适用了服务端,是利NodeJS运行时环境实现的。我们来看一个例子。

假想我们有一个主程序(main.js)引用了一个通用的数学模块(math.js)(或者我们要将一个主程序内的数学计算功能模块化),我们看看模块是怎样制作的,和引用的。

如下就是模块(math.js):

var sum = function(x, y){
    return x + y;
}
var sub = function(x, y){
    return x - y;
}
var math = {
    findSum: function(a, b){
        return sum(a,b);
    },

    findSub: function(a, b){
        return sub(a, b);
    }
}
//All the variable we want to expose outside needs to be property of "exports" object.
exports.math = math;

「模块的作用」就是将出通用的计算功能(数据或函数)通过「模块接口」提供给使用者,所以「制作模块」必须定义模块的接口。CommonJS规定模块的所有对外接口必须是 exports对象的属性。exports对象是挂在全局的module对象的属性,所以也可用module.exports.xxx来输出接口。module对象是Nodejs在加载程序时做的“手脚”,每一个.js文件都是一个module对象,更多详细,看这里

下面是引用/使用了数学模块的主程序(main.js):

//no file extension required
var math = require("./math").math;
console.log(math.findSum(1, 2)); //3
console.log(math.findSub(1, 2)); //-1

使用模块,就像使用自己定义的对象或函数,只是要先引入模块。CommonJS规定了模块引入命令require,require其实也是Nodejs背后做的“手脚”,require是module对象的方法,接受一个「模块标识」参数,返回引入模块的module.exports对象。「模块标识」和require的加载逻辑比较复杂,请看这里,和参考。

从引入数学模块的代码可以看到,math是本地模块(main.js)的局部变量,不会污染全局,并且只能通过接口使用math的功能,做到实现细节的封装。

CommonJS 模块系统在服务端有完整的实现,而在客户端,只有一些有严重性能损耗的模拟实现,例如 Browserify

Asynchronous Module Definition

与服务端不同,客户端JS程序有自己的加载特性[em],而AMD是较成熟的为了客户端JS程序设计的模块系统。不像CommonJS有官方Node的实现,AMD由第三方的库实现,而不是浏览器(运行环境)实现。其中, RequireJS是最为流行的一个实现。

EM:主要是客户端JS程序从远端服务端HTTP加载过来,还有就是「客户端JS程序」与浏览器解释HTML构建DOM的错综复杂的关系。当然后者的问题,是假问题,可以通过简化程序逻辑去除(例如只使用延迟式外链.js),客户端JS程序模块系统的“局限”主要在模块加载途经(从网络加载),模块系统同样要完成第一,提供制作和使用模块的API;第二,自动化模块依赖管理。

我们来看一个例子,看RequireJS 是怎样制作模块,和使用模块的。假说,我们现在有一个网站(首页是“index.html”),它有一个主程序 “index.js” 文件,包含全站的交互行为代码,现在我们想创建一个数学模块——math,以便后续开发共享。

使用RequireJS 的第一步是使用script标签加载库,为模块制作和使用提供API,如下的“index.html” :

<!doctype html>
<html>
    <head></head>
    <body>

        <!-- Load RequireJS library and then provide relative path to our website's JS file. File extension not required. -->
        <script type="text/javascript" src="http://requirejs.org/docs/release/2.1.16/minified/require.js" data-main="index"></script>
    </body>
</html>

完成基本的模块系统配置,script标签还指定程序主文件,通过一个自定义的标签属性  data-main

好了,我们可以制作模块了。如下 “math.js” ,我们使用了define API 制作数学模块:

define(function(){

    var sum = function(x, y){
        return x + y;
    }
    var sub = function(x, y){
        return x - y;
    }
    var math = {
        findSum: function(a, b){
            return sum(a,b);
        },
        findSub: function(a, b){
            return sub(a, b);
        }
    }
    return math;
});

这个是标准的 RequireJS 模块,任何网站只要配置了 RequireJS 都可import它。例如我们的主程序“index.js” :

//list of modules required
require(\["math"\], function(math){

    //main program
    console.log(math.findSum(1, 2)); //3
    console.log(math.findSub(1, 2)); //-1
})

这里我们使用了「模块使用API」—— require,在执行主程序前先加载数学模块(主程序模块依赖数学模块)。

RequireJS 还有一些使用上的任务,例如全局配置,此外不详述,可以官方或这里

Universal Module Definition

UMD 并不是一个模块系统,它只是一种类似于语法糖的技术,提供一种统一的方式制作适用于(被imported)以上三种模块系统的“通用模块”。

UMD有多个实现技术,此处简单介绍一种—— returnExports。下面我们用 returnExports来制作通用的数学模块“math.js” :

// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {

    //Environment Detection
    
    if (typeof define === 'function' && define.amd) {
        //AMD
        define(\[\], factory);
    } else if (typeof exports === 'object') {
        //CommonJS
        module.exports = factory();
    } else {
        // Script tag import i.e., IIFE
        root.returnExports = factory();
  }
}(this, function () {

    //Module Defination
    var sum = function(x, y){
        return x + y;
    }
    var sub = function(x, y){
        return x - y;
    }
    var math = {
        findSum: function(a, b){
            return sum(a,b);
        },
    
        findSub: function(a, b){
            return sub(a, b);
        }
    }
    return math;
}));

这个“通用模块”能成功的被 CommonJS, RequireJS 甚至 IIFE 系统导入。UMD只能制作模块,没有导入技术。

ECMAScript 6 Modules

由于JS模块化应用需求越来越重,JS语言(主要靠运行时环境)内置模块化支持,服务端和客户端运行时提供一致的模块系统呼声也越来越高,终于ES6引入了ESM。

EM:将社区面对「同一个开发任务」——例如这里JS程序结构化任务——的提出的多样化技术统一起来,是很多ecosystem演化常见现象。就EMS而言,统一模块系统能做到多少则是一个很有趣的问题,因为就程序逻辑结构来说,服务端和客户端可以是一致的,但是二者在程序加载方式存在很大差异,ESM提供的规范能统一多少内容,服务端(Nodejs)和客户端(浏览器)能从CJS和AMD等规范集成些什么,是个待探讨的问题。

以下是ESM制作的数学模块“math.js”:

export class Math {

    constructor()
    {
        this.sum = function(x, y){
            return x + y;
        }
    
        this.sub = function(x, y){
            return x - y;
        }
    }
    
    findSum(a, b)
    {
        return this.sum(a, b);
    }
    
    findSub(a, b)
    {
        return this.sub(a, b);
    }
}

如下是在主程序中引用数学模块:

import {Math} from 'math';

var math = new Math();

console.log(math.findSum(1, 2)); //3
console.log(math.findSub(1, 2)); //-1

参考

裸男
Nakeman.cn 2023 Build by Gatsby and Tailwind, Deploy on Netlify.