什么是模块化开发?我们可以类比 C++ 中的面向对象和 Java 中的类,我们的做法是,为了避免因为项目过大而导致变量名发生冲突,同时为了便于解耦合的实现,我们将具有某个特定功能的一些属性和方法组织为一个类,单独放在一个文件之中。

事实上,在前端开发中,我们的习惯是,要么将用到的模块全部打包,要么通过 CDN 引入。前者通过 Node.js 实现,而后者则直接将导出的模块挂在在 window 下,也即成为全局变量,这也就是早期 JavaScript 的问题,通过全局变量解决一切问题。

本文我们梳理 JavaScript 对于项目模块化的范式的历史发展进程,以此我们在今后编写项目时提出以下建议:使用最新的 ES6 标准,使用 Babel 向前兼容。

全局变量

window 对象

最初的时候,JavaScript 脚本之间的通信完全依靠 window 对象:

// config.js
var api = 'https://github.com/ronffy';
var config = {
  api: api,
}
// utils.js
var utils = {
  request() {
    console.log(window.config.api);
  }
}
// main.js
window.utils.request();
<!-- 所有 script 标签必须保证顺序正确,否则会依赖报错 -->
<script src="./js/config.js"></script>
<script src="./js/utils.js"></script>
<script src="./js/main.js"></script>

IIFE

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

(function () {
    statements
})();
(function (root) {
  var api = 'https://github.com/ronffy';
  var config = {
    api: api,
  };
  root.config = config;
}(window));

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

AMD / CMD

随着前端业务增重,代码越来越复杂,靠全局变量通信的方式开始捉襟见肘,前端急需一种更清晰、更简单的处理代码依赖的方式,将 JS 模块化的实现及规范陆续出现,其中被应用较广的模块规范有 AMD 和 CMD。

面对一种模块化方案,我们首先要了解的是:1. 如何导出接口;2. 如何导入接口。

AMD + RequireJS

AMD(Asynchronous Module Definition,异步加载模块定义)规范,一个单独的文件就是一个模块。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。

这里异步指的是不堵塞浏览器其他任务(dom 构建,css 渲染等),而加载内部是同步的(加载完模块后立即执行回调)。

AMD 是一种异步模块规范,RequireJS 是 AMD 规范的实现。官网介绍 RequireJS是一个 js 文件和模块的加载器,提供了加载和定义模块的 api,当在页面中引入了 RequireJS 之后,我们便能够在全局调用 definerequire

require

require([module], callback);

第一个参数 [module],是一个数组,里面的成员是要加载的模块,callback是加载完成后的回调函数,回调函数中参数对应数组中的成员(模块)。

AMD 的标准中,引入模块需要用到方法 require,由于 window 对象上没定义 require 方法,RequireJS 这个库将其具体实现。

define

define(id?, dependencies?, factory);
  • id:模块的名字,如果没有提供该参数,模块的名字应该默认为模块加载器请求的指定脚本的名字
  • dependencies:模块的依赖,已被模块定义的模块标识的数组字面量。依赖参数是可选的,如果忽略此参数,它应该默认为 ["require", "exports", "module"]。然而,如果工厂方法的长度属性小于 3,加载器会选择以函数的长度属性指定的参数个数调用工厂方法。
  • factory:模块的工厂函数,模块初始化要执行的函数或对象。如果为函数,它应该只被执行一次。如果是对象,此对象应该为模块的输出值。

接下来,我们用 RequireJS 重构上面的项目。

// config.js
define(function() {
  var api = 'https://github.com/ronffy';
  var config = {
    api: api,
  };
  return config;
});

// utils.js
define(['./config'], function(config) {
  var utils = {
    request() {
      console.log(config.api);
    }
  };
  return utils;
});

// main.js
require(['./utils'], function(utils) {
  utils.request();
});
<!-- index.html  -->
<script data-main="./js/main" src="./js/require.js"></script>

CMD

CMD 和 AMD 一样,都是 JS 的模块化规范,也主要应用于浏览器端。

AMD 是 RequireJS 在的推广和普及过程中被创造出来。

CMD 是 SeaJS 在的推广和普及过程中被创造出来。

二者的的主要区别是 CMD 推崇依赖就近,AMD 推崇依赖前置:

// AMD
// 依赖必须一开始就写好
define(['./utils'], function(utils) {
  utils.request();
});

// CMD
define(function(require) {
  // 依赖可以就近书写
  var utils = require('./utils');
  utils.request();
});

AMD 也支持依赖就近,但 RequireJS 作者和官方文档都是优先推荐依赖前置写法。

考虑到目前主流项目中对 AMD 和 CMD 的使用越来越少,大家对 AMD 和 CMD 有大致的认识就好,此处不再过多赘述。

CommonJS

CommonJS 是一个更偏向于服务器端的规范。NodeJS 采用了这个规范。CommonJS 的一个模块就是一个脚本文件。

exportsmodule.exports

定义一个模块导出通过 exports 或者 module.exports 挂载即可。

require

require 命令第一次加载该脚本时就会执行整个脚本,然后在内存中生成一个对象

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

id 是模块名,exports是该模块导出的接口,loaded 表示模块是否加载完毕。

以后需要用到这个模块时,就会到 exports 属性上取值。即使再次执行 require 命令,也不会再次执行该模块,而是到缓存中取值

例子

// config.js
var api = 'https://github.com/ronffy';
var config = {
  api: api,
};
module.exports = config;

// utils.js
var config = require('./config');
var utils = {
  request() {
    console.log(config.api);
  }
};
module.exports = utils;

// main.js
var utils = require('./utils');
utils.request();
console.log(global.api)

执行node main.jshttps://github.com/ronffy 被打印了出来。

在 main.js 中打印 global.api,打印结果是 undefined

node 用 global 管理全局变量,与浏览器的 window 类似。与浏览器不同的是,浏览器中顶层作用域是全局作用域,在顶层作用域中声明的变量都是全局变量,而 node 中顶层作用域不是全局作用域,所以在顶层作用域中声明的变量非全局变量。

注意:

  • CommonJS 是同步导入模块
  • CommonJS 导入时,它会给你一个导入对象的副本
  • CommonJS 模块不能直接在浏览器中运行,需要进行转换、打包

由于 CommonJS 是同步加载模块,这对于服务器端不是一个问题,因为所有的模块都放在本地硬盘。等待模块时间就是硬盘读取文件时间,很小。但是,对于浏览器而言,它需要从服务器加载模块,涉及到网速,代理等原因,一旦等待时间过长,浏览器处于”假死”状态。所以在浏览器端,不适合于 CommonJS 规范。

CommonJS 与 AMD 的对比

  1. CommonJS 是服务器端模块规范,AMD 是浏览器端模块规范。
  2. CommonJS 加载模块是同步的,即执行var a = require('./a.js'); 时,在 a.js 文件加载完成后,才执行后面的代码。AMD 加载模块是异步的,所有依赖加载完成后以回调函数的形式执行代码。
  3. 如下代码中,fschalk 都是模块,不同的是,fs 是 node 内置模块,chalk 是一个 npm 包。这两种情况在 CommonJS 中才有,AMD 不支持。
var fs = require('fs');
var chalk = require('chalk');

UMD

UMD 代表通用模块定义(Universal Module Definition)。所谓的通用,就是兼容了 CommonJSAMD 规范,这意味着无论是在 CommonJS 规范的项目中,还是 AMD 规范的项目中,都可以直接引用 UMD 规范的模块使用。

原理其实就是在模块中去判断全局是否存在 exportsdefine,如果存在 exports,那么以 CommonJS 的方式暴露模块,如果存在 define 那么以 AMD 的方式暴露模块:

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    define(["jquery", "underscore"], factory);
  } else if (typeof exports === "object") {
    module.exports = factory(require("jquery"), require("underscore"));
  } else {
    root.Requester = factory(root.$, root._);
  }
}(this, function ($, _) {
  // this is where I defined my module implementation
  const Requester = { // ... };
  return Requester;
}));

ES6 Module

AMD、CMD 等都是在原有 JS 语法的基础上二次封装的一些方法来解决模块化的方案,ES6 module(在很多地方被简写为 ESM)是语言层面的规范,ES6 module 旨在为浏览器和服务器提供通用的模块解决方案

长远来看,未来无论是基于 JS 的 Web 端,还是基于 node 的服务器端或桌面应用,模块规范都会统一使用 ES6 module。因此,使用 ES6 Module 规范是我们今后的开发首选。

ES6 模块是前端开发同学更为熟悉的方式,使用 import, export 关键字来进行模块输入输出。ES6 不再是使用闭包和函数封装的方式进行模块化,而是从语法层面提供了模块化的功能。

使用 Node 原生 ES6 模块需要将 js 文件后缀改成 mjs,或者 package.json “type” 字段改为 “module”,通过这种形式告知 Node 使用 ES Module 的形式加载模块。(这里我们推荐使用后者)

export

方式 1:

export const prefix = 'https://github.com';
export const api = `${prefix}/ronffy`;

方式 2:

const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

方式 1 和方式 2 只是写法不同,结果是一样的,都是把 prefix 和 api 分别导出。

方式 3(默认导出):

// foo.js
export default function foo() {}

// 等同于:
function foo() {}
export {
  foo as default
}

方式 4(先导入再导出):

export { api } from './config.js';

// 等同于:
import { api } from './config.js';
export {
  api
}

import

假设我们以方式 1 和方式 2 导出了 {prefix: prefix, api: api},那么我们可以以如下方式导入:

方式 1:

import { api } from './config.js';

// or
// 配合 import 使用的 as 关键字用来为导入的接口重命名。
import { api as myApi } from './config.js';

方式 2(整体导入):

import * as config from './config.js';
const api = config.api;

方式 3(默认导出的导入):

// foo.js
export const conut = 0;
export default function myFoo() {}

// index.js
// 默认导入的接口此处刻意命名为 cusFoo,旨在说明该命名可完全自定义。
import cusFoo, { count } from './foo.js';

// 等同于:
import { default as cusFoo, count } from './foo.js';

export default 导出的接口,可以使用 import name from 'module' 导入。这种方式,使导入默认接口很便捷。

方式 4(整体加载):

import './config.js';

这样会加载整个 config.js 模块,但未导入该模块的任何接口。

方式 5(动态加载模块):

上面介绍了 ES6 module 各种导入接口的方式,但有一种场景未被涵盖:动态加载模块。比如用户点击某个按钮后才弹出弹窗,弹窗里功能涉及的模块的代码量比较重,所以这些相关模块如果在页面初始化时就加载,实在浪费资源,import() 可以解决这个问题,从语言层面实现模块代码的按需加载。

ES6 module 在处理以上几种导入模块接口的方式时都是编译时处理,所以 importexport 命令只能用在模块的顶层,以下方式都会报错:

// 报错
if (/* ... */) {
  import { api } from './config.js'; 
}

// 报错
function foo() {
  import { api } from './config.js'; 
}

// 报错
const modulePath = './utils' + '/api.js';
import modulePath;

使用 import() 实现按需加载:

function foo() {
  import('./config.js')
    .then(({ api }) => {

    });
}

const modulePath = './utils' + '/api.js';
import(modulePath);

注意,在浏览器中加载 ES6 模块的时候,我们需要使用:

<script type="module" src="index.js"></script>

但是,对于加载外部模块,需要注意:

  • 代码是在模块作用域之中运行,而不是在全局作用域运行。模块内部的顶层变量,外部不可见
  • 模块脚本自动采用严格模式,不管有没有声明 use strict
  • 模块之中,可以使用 import 命令加载其他模块(.js 后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用 export 命令输出对外接口
  • 模块之中,顶层的 this 关键字返回 undefined,而不是指向 window。也就是说,在模块顶层使用 this 关键字,是无意义的
  • 同一个模块如果加载多次,将只执行一次

ES6 Module 与 CommonJS 的区别

  • CommonJS 输出的是一个值的拷贝,ES6 模块输出的是值的引用,加载的时候会做静态优化
  • CommonJS 模块是运行时加载确定输出接口,ES6 模块是编译时确定输出接口

Babel

目前,无论是浏览器端还是 node,都没有完全原生支持 ES6 module,如果想使用 ES6 module ,可借助 babel 等编译器。

Babel 是一个 JavaScript 编译器。

今天就开始使用下一代的 JavaScript 语法编程吧!

image-20220207214422726

image-20220207214437008

简单来说,可以理解成是编译时替换的一种 polyfill.

Babel is a toolchain that is mainly used to convert ECMAScript 2015+ code into a backwards compatible version of JavaScript in current and older browsers or environments.

Here are the main things Babel can do for you:

  • Transform syntax
  • Polyfill features that are missing in your target environment (through a third-party polyfill such as core-js)
  • Source code transformations (codemods)
  • And more! (check out these videos for inspiration)

配置方法:https://babeljs.io/setup#installation

Reference

  1. https://segmentfault.com/a/1190000023711059
  2. https://segmentfault.com/a/1190000039375332
  3. https://juejin.cn/post/6844904080955932680
  4. http://nodejs.cn/api/modules.html
  5. http://nodejs.cn/api/esm.html
  6. https://docs.net9.org/languages/node.js/
  7. https://babeljs.io/docs/en/index.html