模块化
模块化开发是当下最重要的前端开发范式之一。
模块化是一种最主流的代码组织方式。通过把复杂代码按照功能不同划分为不同模块单独维护,去提高开发效率,降低维护成本。
模块化只是思想,并不包括具体实现。
模块化演进过程
1. 基于文件划分模块
web 中最原始的模块系统。将每个功能以及相关状态数据存放到不同文件中,约定每个文件就是独立的模块。
使用模块时,将模块引入到页面中,一个 script 标签对应一个模块。
// module a 相关状态数据和功能函数
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
// module b 相关状态数据和功能函数
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 1</title>
</head>
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
// 命名冲突
method1()
// 模块成员可以被修改
name = 'foo'
</script>
</body>
</html>
缺点十分明显,所有模块都直接在全局工作,没有私有空间,所有成员都可以在模块外部被访问或者修改,而且模块一段多了过后,容易产生命名冲突,另外无法管理模块与模块之间的依赖关系
- 污染全局作用域
- 命名冲突问题
- 无法管理模块依赖关系
早期模块化完全依赖约定。
2. 命名空间方式
每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象中。具体做法就是在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,有点类似于为模块内的成员添加了「命名空间」的感觉。
// module a 相关状态数据和功能函数
var moduleA = {
name: 'module-a',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
// module b 相关状态数据和功能函数
var moduleB = {
name: 'module-b',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 2</title>
</head>
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块成员可以被修改
moduleA.name = 'foo'
</script>
</body>
</html>
通过「命名空间」减小了命名冲突的可能,但是同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改,而且也无法管理模块之间的依赖关系。
3. IIFE 方式
使用立即执行函数表达式(IIFE:Immediately-Invoked Function Expression)为模块提供私有空间。
具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现。
// module a 相关状态数据和功能函数
;(function () {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})()
// module a 相关状态数据和功能函数
;(function ($) {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})(jQuery)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 3</title>
</head>
<body>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块私有成员无法访问
console.log(moduleA.name) // => undefined
</script>
</body>
</html>
有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。确保私有变量安全。
还可以用自执行函数的参数作为依赖声明使用,具体做法就是在第三阶段的基础上,利用立即执行函数的参数传递模块依赖项。可以使模块依赖关系更加明显。这使得每一个模块之间的关系变得更加明显。
// module a 相关状态数据和功能函数
;(function ($) {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
$('body').animate({ margin: '200px' })
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})(jQuery)
4. 总结
以上这几种方式就是早期没有工具和规范的情况下,对模块化的落地方式。
模块加载方式都是通过 <script></script>
标签去引入每一个模块,这也就意味着模块加载不受代码控制。
如果开发时间跨度很长,维护非常麻烦。
如果代码中依赖一个模块,但是 html
中未引用模块,这时就会出现问题。
如果在模块中移除了某个模块的引用,但是未在 html
中删除引用,就会产生问题。
模块化规范的出现
模块化标准 + 模块加载器。
CommonJS 规范
NodeJS 所提出的一套标准,NodeJS 中所有的模块代码必须遵循 CommonJS 规范。
- 每一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过 module.exports 导出成员
- 通过 require 函数载入模块
CommonJS 约定以同步方式加载模块。NodeJS 的执行机制是在启动时加载模块,执行过程中不需要加载,只会使用模块。
CommonJS 规范在浏览器端使用,必然会导致效率低下,因为每次页面加载都会导致大量的同步模块请求出现。
所以说在早期的浏览器前端模块化规范中,并没有选择 CommonJS 规范,而是专门为浏览器新定义了规范,AMD 规范。同时还推出了一个非常出名的库,Require.js。Require.js 实现了 AMD 规范,其本身也是非常强大的模块加载器。
AMD 规范
AMD(Asynchronous Module Definition),即异步模块定义规范。
Require.js 提供了 AMD 模块化规范,以及一个自动化模块加载器。
// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
// 但是 jQuery.js 并不在同级目录下,所以需要指定路径
define('module1', ['jquery', './module2'], function ($, module2) {
return {
start: function () {
$('body').animate({ margin: '200px' })
module2()
}
}
})
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Modular evolution stage 5</title>
</head>
<body>
<script src="lib/require.js" data-main="main"></script>
</body>
</html>
require.config({
paths: {
// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
// 但是 jQuery.js 并不一定在同级目录下,所以需要指定路径
jquery: './lib/jquery'
}
})
require(['./modules/module1'], function (module1) {
module1.start()
})
目前绝大多数第三方库都支持 AMD 规范。但是使用起来相对比较复杂,模块 JS 文件请求频繁(根据文件划分模块)。
AMD 只能算是前端模块化演进过程中的一步,是一种妥协的实现方式,并不是最终的解决方案。在当时的环境背景下,还是很有意义的,它为前端模块化提供了一个标准。
require(['./modules'], function (module1) {
module1.start();
})
// 兼容 CMD 规范(类似 CommonJS 规范)
define(function (require, exports, module) {
// 通过 require 引入依赖
var $ = require('jquery')
// 通过 exports 或者 module.exports 对外暴露成员
module.exports = function () {
console.log('module 2~')
$('body').append('<p>module2</p>')
}
})
CMD 规范
Sea.js 和 CMD(Common Module Definition) 规范。
define(function (require, exports, module) {
var a = [1, 2, 3, 4, 5];
return {
a: a.reverse()
}
});
seajs.use(['module_a.js'], function (moduleA) {
console.log(moduleA.a);
});
<script type="text/javascript" src="js/sea.js" />
<script type="text/javascript" src="js/index.js" />
CMD 是依赖就近,按需加载模块,与 CommonJS、AMD 有本质区别; AMD 是依赖前置,CMD 是需要的时候去加载模块;
模块化标准规范
NodeJS 开发中遵循 CommonJS 规范去组织模块。浏览器环境中会采用 ES Module 规范。
ES Module 是 ECMAScript 2015(ES6)中定义的最新的模块系统,它是最近几年定义的标准,所以也存在环境兼容问题。
各浏览器支持情况:https://caniuse.com/#feat=es6-module
ES Module
基本特性
通过给 script 标签添加 type = module
的属性,就可以直接使用 ES Module 的标准执行 JS 代码。
<script type="module">
console.log('this is es module')
</script>
ES Module 自动采用严格模式,忽略 'use strict'
,每个 ES Module 都由自己的私有作用域。
<script type="module">
console.log('this is es module')
var foo = 100;
console.log(foo); // 100
</script>
<script type="module">
console.log('this is es module')
console.log(foo); // 报错
</script>
ES Module 通过 CORS 的方式请求外部 JS 模块
<script type="module" src="https://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script> 报错
<script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script>
ES Module 的 script 标签会自动延迟执行脚本,等同于 script 的 defer 属性
- 自动采用严格模式,忽略
use strict
- 每个 ES Module 模块都是单独的私有作用域
- ES Module 通过 CORS 去请求外部 JS 模块
- ES Module 的 script 标签会延迟执行脚本,等同于 defer 属性
导入、导出
基本使用
const foo = 'e module';
export { foo };
import { foo } from './module.js';
console.log(foo);
module.js
export var name = 'foo module';
export function hello () {
console.log('hello');
}
class Person { }
export {
name as fooName,
hello,
Person
}
app.js
import { fooName } from './module.js';
console.log(fooName);
index.html
<script type="module" src="./app.js"></script>
注意事项
export {} 不等于对象字面量的简写形式,两者含义是不同的。
export default {} 和 对象字面量的简写形式一致。
export {} 导出的是对象引用,修改内部值,会影响到导出值。导入的成员是只读的成员,不能修改。
var name = 'jack';
var obj = { name };
export { name };
setTimeout(function () {
name = 'ben';
}, 1000);
import {} 也不是对象解构形式,就是固定用法。
import { name } from './module.js';
name = 'tom'; // 报错
setTimeout(function () {
console.log(name);
}, 1500)
导入注意事项
// import { name } from './module'
// import { name } from './module.js'
// console.log(name)
// import { lowercase } from './utils'
// import { lowercase } from './utils/index.js'
// console.log(lowercase('HHH'))
// import { name } from 'module.js'
// import { name } from './module.js'
// import { name } from '/04-import/module.js'
// import { name } from 'http://localhost:3000/04-import/module.js'
// console.log(name)
// --------------
// import {} from './module.js'
// import './module.js'
// ---------------
// import * as mod from './module.js'
// console.log(mod)
// ---------------
// var modulePath = './module.js'
// import { name } from modulePath
// console.log(name) // 报错
// if (true) {
// import { name } from './module.js' // 报错
// }
// import('./module.js').then(function (module) {
// console.log(module)
// })
// ----------------
// import { name, age, default as title } from './module.js'
import abc, { name, age } from './module.js'
console.log(name, age, abc)
import 只能出现在最顶层。
ES Module 提供了 import 函数,支持异步加载,模块内部数据可以通过参数获取到。
导入、导入成员
var foo = 'hello'
var bar = 'world'
export { foo, bar }
// import { Button } from './button.js'
// import { Avatar } from './avatar.js'
// export { Button, Avatar }
export { default as Button } from './button.js'
export { Avatar } from './avatar.js'
// export { foo, bar } from './module.js'
// console.log(foo, bar)
import { Button, Avatar } from './components/index.js'
console.log(Button)
console.log(Avatar)
浏览器环境 Polyfill
ES Module 存在兼容性问题。同样代码 Chrome 可以正常使用,IE 浏览器则不行。
export var foo = 'bar'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>ES Module 浏览器环境 Polyfill</title>
</head>
<body>
<script nomodule src="https://unpkg.com/promise-polyfill@8.1.3/dist/polyfill.min.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/babel-browser-build.js"></script>
<script nomodule src="https://unpkg.com/browser-es-module-loader@0.4.1/dist/browser-es-module-loader.js"></script>
<script type="module">
import { foo } from './module.js'
console.log(foo)
</script>
</body>
</html>
通过 es-module-loader 读取代码交由 babel 转换。不支持的特性通过 polyfill 进行兼容。
这种兼容 ES Module 的方式,只适合本地测试。
ES Module 兼容 Node.js
支持情况
module.mjs
export const foo = 'hello'
export const bar = 'world'
bar.txt
es module working~
foo.txt
es module working~
index.mjs
// 第一,将文件的扩展名由 .js 改为 .mjs;
// 第二,启动时需要额外添加 `--experimental-modules` 参数;
// es module in node 目前还是实验特性,不建议在生产环境使用
import { foo, bar } from './module.mjs'
console.log(foo, bar)
// 此时我们也可以通过 esm 加载内置模块了
import fs from 'fs'
fs.writeFileSync('./foo.txt', 'es module working')
// 也可以直接提取模块内的成员,内置模块兼容了 ESM 的提取成员方式
import { writeFileSync } from 'fs'
writeFileSync('./bar.txt', 'es module working')
// 对于第三方的 NPM 模块也可以通过 esm 加载
import _ from 'lodash'
_.camelCase('ES Module')
// 不支持,因为第三方模块都是导出默认成员
// import { camelCase } from 'lodash'
// console.log(camelCase('ES Module'))
// 内置模块兼容 ESM 的提取成员方式
import { wirteFileSync } from 'fs';
wirteFileSync('./bar.txt', 'es module working~');
运行脚本
node --experimental-modules index.mjs
与 CommonJS 交互
// CommonJS 模块始终只会导出一个默认成员
// module.exports = {
// foo: 'commonjs exports value'
// }
// exports.foo = 'commonjs exports value'
// 不能在 CommonJS 模块中通过 require 载入 ES Module
// const mod = require('./es-module.mjs')
// console.log(mod)
// ES Module 中可以导入 CommonJS 模块
// import mod from './commonjs.js'
// console.log(mod)
// 不能直接提取成员,注意 import 不是解构导出对象
// import { foo } from './commonjs.js'
// console.log(foo)
// export const foo = 'es module export value'
- ES Modules 中可以导入 CommonJS 模块
- CommonJS 中不能导入 ES Modules 模块
- CommonJS 始终只会导出一个默认成员
- 注意 import 不是解构导出对象
与 CommonJS 差异
cjs
// 加载模块函数
console.log(require)
// 模块对象
console.log(module)
// 导出对象别名
console.log(exports)
// 当前文件的绝对路径
console.log(__filename)
// 当前文件所在目录
console.log(__dirname)
esm
// ESM 中没有模块全局成员
// // 加载模块函数
// console.log(require)
// // 模块对象
// console.log(module)
// // 导出对象别名
// console.log(exports)
// // 当前文件的绝对路径
// console.log(__filename)
// // 当前文件所在目录
// console.log(__dirname)
// -------------
// require, module, exports 可以通过 import 和 export 代替
// __filename 和 __dirname 通过 import 对象的 meta 属性获取
// const currentUrl = import.meta.url
// console.log(currentUrl)
// 通过 url 模块的 fileURLToPath 方法转换为路径
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log(__filename)
console.log(__dirname)
新版本进一步支持
common.cjs
// 如果需要在 type=module 的情况下继续使用 CommonJS,
// 需要将文件扩展名修改为 .cjs
const path = require('path')
console.log(path.join(__dirname, 'foo'))
module.js
export const foo = 'hello'
export const bar = 'world'
index.js
// Node v12 之后的版本,可以通过 package.json 中添加 type 字段为 module,
// 将默认模块系统修改为 ES Module
// 此时就不需要修改文件扩展名为 .mjs 了
import { foo, bar } from './module.js'
console.log(foo, bar)
package.json
{
"type": "module"
}
Babel 兼容方案
Babel 是目前最主流的一款 JavaScript 编译器,它可以将一些新特性代码编译成当前环境支持的代码。
module.js
export const foo = 'hello'
export const bar = 'world'
index.js
// 对于早期的 Node.js 版本,可以使用 Babel 实现 ES Module 的兼容
import { foo, bar } from './module.js'
console.log(foo, bar)
.babelrc
{
"plugins": [
"@babel/plugin-transform-modules-commonjs"
]
}
package.json
{
"devDependencies": {
"@babel/core": "^7.6.0",
"@babel/node": "^7.6.1",
"@babel/plugin-transform-modules-commonjs": "^7.6.0"
}
}
运行命令
yarn babel-node index.js