谈谈前端模块化规范

JavaScript模块化

模块化的开发方式可以提高代码复用率,方便进行代码的管理。通常一个文件就是一个模块,有自己的作用域,只向外暴露特定的变量和函数。

JavaScript 语言诞生至今,模块规范化之路曲曲折折。社区先后出现了各种解决方案,包括 AMD、CMD、CommonJS 等,而后 ECMA 组织在 JavaScript 语言标准层面,增加了模块功能(因为该功能是在 ES2015 版本引入的,所以在下文中将之称为 ES6 module)。

为什么会出现这些不同的模块规范,它们在所处的历史节点解决了哪些问题?

为什么需要模块化

前端开发和其他开发工作的主要区别,首先是前端是基于多语言多层次的编码和组织工作,其次前端产品的交付是基于浏览器,这些资源是通过增量加载的方式运行到浏览器端,如何在开发环境组织好这些碎片化的代码和资源,并且保证他们在浏览器端快速、优雅的加载和更新,就需要一个模块化系统。

模块化的好处

  1. 可维护性。 因为模块是独立的,一个设计良好的模块会让外面的代码对自己的依赖越少越好,这样自己就可以独立去更新和改进。
  2. 命名空间。 在 JavaScript 里面,如果一个变量在最顶级的函数之外声明,它就直接变成全局可用。因此,常常不小心出现命名冲突的情况。使用模块化开发来封装变量,可以避免污染全局环境。
  3. 重用代码。 我们有时候会喜欢从之前写过的项目中拷贝代码到新的项目,这没有问题,但是更好的方法是,通过模块引用的方式,来避免重复的代码库。我们可以在更新了模块之后,让引用了该模块的所有项目都同步更新,还能指定版本号,避免 API 变更带来的麻烦。

模块化进程

全局function

在早期的开发过程中,就是将重复的代码封装到函数中,再将一系列的函数放到一个文件中。这种方式存在一些问题:存在污染全局变量、看不出相互的直接关系。

这种方式并不能解决根本的问题:命名冲突和文件依赖,于是演变为对象命名空间。命名空间

对象命名空间

通过对象命名空间的形式,从某种程度上解决了变量命名冲突的问题,但是并不能从根本上解决命名冲突。存在问题:内部状态可被外部改写、命名空间越来越长。

let module = {
  data'aaa',
  func () {
    console.log(`${this.data}`)
  }
}
module.data = 'bbb' // 直接修改模块内部的数据
module.fn() // bbb

IIFE

浏览器环境下,在全局作用域声明的变量都是全局变量。全局变量存在命名冲突、占用内存无法被回收、代码可读性低等诸多问题。

这时,IIFE(立即调用函数表达式)出现了,IIFE 是一个在定义时就会立即执行的 JavaScript 函数。

(function ({
    statements
})();

这是一个被称为 自执行匿名函数 的设计模式,主要包含两部分。第一部分是包围在 ?圆括号运算符 () 里的一个匿名函数,这个匿名函数拥有独立的词法作用域。这不仅避免了外界访问此 IIFE 中的变量,而且又不会污染全局作用域。

第二部分再一次使用 () 创建了一个立即执行函数表达式,JavaScript 引擎到此将直接执行函数。

当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问。

(function ({
    var name = "Barry";
})();
// 无法从外部访问变量 name
name // 抛出错误:"Uncaught ReferenceError: name is not defined"

将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。

var result = (function ({
    var name = "Barry";
    return name;
})();
// IIFE 执行后返回的结果:
result; // "Barry"

IIFE的出现,使全局变量的声明数量得到了有效的控制。

在 ES6 之前,社区制定了一些模块加载方案,最主要的有 CommonJS 和 AMD 两种。前者用于服务器,后者用于浏览器。ES6 在语言标准的层面上,实现了模块功能,而且实现得相当简单,完全可以取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。

ES6 模块的设计思想是尽量的静态化,使得编译时就能确定模块的依赖关系,以及输入和输出的变量。CommonJS 和 AMD 模块,都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。

目前实现模块化的规范主要有:

  • CommonJS
  • CMD
  • AMD
  • ES6模块

模块化规范

CommonJS 规范

CommonJS 规范最早在 NodeJS 中实践并被推广开来。它使用 module.exports 输出模块,一个模块写在一个独立的文件内,一个文件即是一个模块。在另一个JS文件中,使用 require 导入模块。各个模块相互隔离,模块之间的通讯,通过全局对象 global 完成。

值得特别注意的是,CommonJS 这种规范天生是为 NodeJS 服务的。NodeJS 是一种服务器端编程语言,源码文件都在硬盘上,读起来很方便。CommonJS 规范作为一种同步方案,后续代码必须等待前面的require指令加载模块完成。

使用 CommonJS 规范的代码示例如下:

// 定义模块math.js
var basicNum = 0;
function add(a, b{
  return a + b;
}
module.exports = { //在这里写上需要向外暴露的函数、变量
  add: add,
  basicNum: basicNum
}
// 在另一个文件中,引用自定义的模块时,参数包含路径,可省略后缀.js
var math = require('./math');
math.add(25);

AMD 规范

CommonJS 规范主要是为服务器端的 NodeJS 服务,服务器端加载模块文件无延时,但是在浏览器上就大不相同了。AMD 即是为了在浏览器宿主环境中实现模块化方案的规范之一。

异步模块定义规范(AMD)制定了定义模块的规则,这样模块和模块的依赖可以被异步加载。这和浏览器的异步加载模块的环境刚好适应(浏览器同步加载模块会导致性能、可用性、调试和跨域访问等问题)。

AMD是一种使用JS语言自实现的模块化规范方案,主要由require.config()、define()、require 三个函数实现。require.config() 用于声明基本路径和模块名称;define() 用于定义模块对象;require() 则用于加载模块并使用。

与 CommonJS 规范不同,AMD 规范身处浏览器环境之中,是一种异步模块加载规范,RequireJS 是 AMD 规范的实现。在使用时,首先要加载模块化规范实现文件 require.js 及 JS 主文件。

如下面的代码所示,我们需要引入require.js文件和一个入口文件main.js。main.js中配置require.config(),并规定项目中用到的基础模块。

/** 网页中引入require.js及main.js **/
<script src="js/require.js" data-main="js/main"></script>

/** main.js 入口文件/主模块 **/
// 首先用config()指定各模块路径和引用名
require.config({
  baseUrl: "js/lib",
  paths: {
    "jquery""jquery.min",  //实际路径为js/lib/jquery.min.js
    "underscore""underscore.min",
  }
});
// 执行基本操作
require(["jquery","underscore"],function($,_){
  // some code here
});

引用模块的时候,我们将模块名放在[]中作为reqiure()的第一参数;如果我们定义的模块本身也依赖其他模块,那就需要将它们放在[]中作为define()的第一参数。

// 定义math.js模块
define(function () {
    var basicNum = 0;
    var add = function (x, y) {
        return x + y;
    };
    return {
        add: add,
        basicNum :basicNum
    };
});
// 定义一个依赖underscore.js的模块
define(['underscore'],function(_){
  var classify = function(list){
    _.countBy(list,function(num){
      return num > 30 ? 'old' : 'young';
    })
  };
  return {
    classify :classify
  };
})

// 引用模块,将模块放在[]内
require(['jquery''math'],function($, math){
  var sum = math.add(10,20);
  $("#sum").html(sum);
});

需要特别说明的是,先有 RequireJS,后有 AMD 规范,随着 RequireJS 的推广和普及,AMD 规范才被创建出来。

CMD规范

CMD是另一种js模块化方案,它与AMD很类似,不同点在于:AMD 推崇依赖前置、提前执行,CMD推崇依赖就近、延迟执行。此规范其实是在sea.js推广过程中产生的。

/** AMD写法 **/
define(["a""b""c""d""e""f"], function(a, b, c, d, e, f) { 
     // 等于在最前面声明并初始化了要用到的所有模块
    a.doSomething();
    if (false) {
        // 即便没用到某个模块 b,但 b 还是提前执行了
        b.doSomething()
    } 
});

/** CMD写法 **/
define(function(require, exports, module) {
    var a = require('./a'); //在需要时申明
    a.doSomething();
    if (false) {
        var b = require('./b');
        b.doSomething();
    }
});

/** sea.js **/
// 定义模块 math.js
define(function(require, exports, module) {
    var $ = require('jquery.js');
    var add = function(a,b){
        return a+b;
    }
    exports.add = add;
});
// 加载模块
seajs.use(['math.js'], function(math){
    var sum = math.add(1+2);
});

UMD规范

存在这么多模块规范,能不能产出一个模块给其他人用,既支持全局变量的形式,也符合 AMD 规范,还能符合 CommonJS 规范?

UMD (Universal Module Definition),就是一种javascript通用模块定义规范,让你的模块能在javascript所有运行环境中发挥作用。

UMD 是一种通用模块定义规范,代码大概这样:

!function (root, factory) {
  if (typeof exports === 'object' && typeof module === 'object') {
    // CommonJS2
    module.exports = factory()
    // define.amd 用来判断项目是否应用 require.js。
    // 更多 define.amd 介绍,请[查看文档](https://github.com/amdjs/amdjs-api/wiki/AMD#defineamd-property-)
  } else if (typeof define === 'function' && define.amd) {
    // AMD
    define([], factory)
  } else if (typeof exports === 'object') {
    // CommonJS
    exports.myLibName = factory()
  } else {
    // 全局变量
    root.myLibName = factory()
  }
}(window, function () {
  // 模块初始化要执行的代码
});

ES6 Module

AMD 、 CMD 等都是在原有JS语法的基础上二次封装的一些方法来解决模块化的方案,ES6 module(在很多地方被简写为 ESM)是语言层面的规范,ES6 module 旨在为浏览器和服务器提供通用的模块解决方案。长远来看,未来无论是基于 JS 的 WEB 端,还是基于 node 的服务器端或桌面应用,模块规范都会统一使用 ES6 module。

其模块功能主要由两个命令构成:export和import。export命令用于规定模块的对外接口,import命令用于输入其他模块提供的功能。

/** 定义模块 math.js **/
var basicNum = 0;
var add = function (a, b{
    return a + b;
};
export { basicNum, add };

/** 引用模块 **/
import { basicNum, add } from './math';
function test(ele{
    ele.textContent = add(99 + basicNum);
}

如上例所示,使用import命令的时候,用户需要知道所要加载的变量名或函数名。其实ES6还提供了export default命令,为模块指定默认输出,对应的import语句不需要使用大括号。这也更趋近于ADM的引用写法。

/** export default **/
//定义输出
export default { basicNum, add };
//引入
import math from './math';
function test(ele) {
    ele.textContent = math.add(99 + math.basicNum);
}

ES6的模块不是对象,import命令会被 JavaScript 引擎静态分析,在编译时就引入模块代码,而不是在代码运行时加载,所以无法实现条件加载。也正因为这个,使得静态分析成为可能。

ES6 模块与 CommonJS 模块的差异

它们有两个重大差异:

  • CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。

  • CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。

第二个差异是因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

ES6 模块是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块

ES 动态模块(ECMAScript 2020)

2020 年的 ESCMA 标准11版中引入了内置的 import函数,用于动态加载 ES 模块。import函数返回一个 Promise,在它的then回调里使用加载后的模块:

// 用 Promise API 加载动态 ES 模块
import("./esCounterModule.js").then(({ increase, reset }) => {
    increase();
    reset();
});

import("./esCounterModule.js").then(dynamicESCounterModule => {
    dynamicESCounterModule.increase();
    dynamicESCounterModule.reset();
});

由于返回的是 Promise,那肯定也支持await用法:

// 通过 async/await 使用 ES 动态模块
(async () => {
    // 具名导出的模块
    const { increase, reset } = await import("./esCounterModule.js");
    increase();
    reset();
    // 默认导出的模块
    const dynamicESCounterModule = await import("./esCounterModule.js");
    dynamicESCounterModule.increase();
    dynamicESCounterModule.reset();
})();





《前端面试题宝典》现已推出小程序和电脑版刷题网站( https://fe.ecool.fun/ ),欢迎大家使用~

同时,我们近期还推出了面试辅导的增值服务,可以为大家提供“简历指导”和“模拟面试”服务,该服务在试运行期间有额外优惠,欢迎大家联系小助手(微信号:interview-fe)进行体验哦~