JavaScript 模块化进化论
什么是模块化开发?我们可以类比 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
之后,我们便能够在全局调用 define
和 require
。
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
的一个模块就是一个脚本文件。
exports
与 module.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.js
,https://github.com/ronffy
被打印了出来。
在 main.js 中打印 global.api
,打印结果是 undefined
。
node 用 global
管理全局变量,与浏览器的 window
类似。与浏览器不同的是,浏览器中顶层作用域是全局作用域,在顶层作用域中声明的变量都是全局变量,而 node 中顶层作用域不是全局作用域,所以在顶层作用域中声明的变量非全局变量。
注意:
CommonJS
是同步导入模块CommonJS
导入时,它会给你一个导入对象的副本CommonJS
模块不能直接在浏览器中运行,需要进行转换、打包
由于 CommonJS
是同步加载模块,这对于服务器端不是一个问题,因为所有的模块都放在本地硬盘。等待模块时间就是硬盘读取文件时间,很小。但是,对于浏览器而言,它需要从服务器加载模块,涉及到网速,代理等原因,一旦等待时间过长,浏览器处于”假死”状态。所以在浏览器端,不适合于 CommonJS
规范。
CommonJS 与 AMD 的对比
- CommonJS 是服务器端模块规范,AMD 是浏览器端模块规范。
- CommonJS 加载模块是同步的,即执行
var a = require('./a.js');
时,在 a.js 文件加载完成后,才执行后面的代码。AMD 加载模块是异步的,所有依赖加载完成后以回调函数的形式执行代码。 - 如下代码中,
fs
和chalk
都是模块,不同的是,fs
是 node 内置模块,chalk
是一个 npm 包。这两种情况在 CommonJS 中才有,AMD 不支持。
var fs = require('fs');
var chalk = require('chalk');
UMD
UMD
代表通用模块定义(Universal Module Definition
)。所谓的通用,就是兼容了 CommonJS
和 AMD
规范,这意味着无论是在 CommonJS
规范的项目中,还是 AMD
规范的项目中,都可以直接引用 UMD
规范的模块使用。
原理其实就是在模块中去判断全局是否存在 exports
和 define
,如果存在 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 在处理以上几种导入模块接口的方式时都是编译时处理,所以 import
和 export
命令只能用在模块的顶层,以下方式都会报错:
// 报错
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 语法编程吧!
简单来说,可以理解成是编译时替换的一种 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