模块化开发
--- 当下最重要的前端开发范式之一
所谓模块化,只是思想或者理论,不是具体的某个特定的实现
模块化的演变过程
-
第一阶段:文件划分方式
早起的模块化完全依赖约定
-
缺点
污染全局作用域
命名冲突
无法管理模块的依赖关系
<!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> <h1>模块化演变(第一阶段)</h1> <h2>基于文件的划分模块的方式</h2> <p> 具体做法就是将每个功能及其相关状态数据各自单独放到不同的文件中, 约定每个文件就是一个独立的模块, 使用某个模块就是将这个模块引入到页面中,然后直接调用模块中的成员(变量 / 函数) </p> <p> 缺点十分明显: 所有模块都直接在全局工作,没有私有空间,所有成员都可以在模块外部被访问或者修改, 而且模块一段多了过后,容易产生命名冲突, 另外无法管理模块与模块之间的依赖关系 </p> <script src="module-a.js"></script> <script src="module-b.js"></script> <script> // 命名冲突 method1(); // 模块成员可以被修改 name = 'foo'; </script> </body> </html>
// module a 相关状态数据和功能函数 var name = 'module-a'; function method1() { console.log(name + '#method1'); } function method2() { console.log(name + '#method2'); }
// 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 2</title>
</head>
<body>
<h1>模块化演变(第二阶段)</h1>
<h2>每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象中</h2>
<p>
具体做法就是在第一阶段的基础上,通过将每个模块「包裹」为一个全局对象的形式实现,
有点类似于为模块内的成员添加了「命名空间」的感觉。
</p>
<p>
通过「命名空间」减小了命名冲突的可能,
但是同样没有私有空间,所有模块成员也可以在模块外部被访问或者修改,
而且也无法管理模块之间的依赖关系。
</p>
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1();
moduleB.method1();
// 模块成员可以被修改
moduleA.name = 'foo';
</script>
</body>
</html>
// 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 3</title>
</head>
<body>
<h1>模块化演变(第三阶段)</h1>
<h2>
使用立即执行函数表达式(IIFE:Immediately-Invoked Function
Expression)为模块提供私有空间
</h2>
<p>
具体做法就是将每个模块成员都放在一个函数提供的私有作用域中,
对于需要暴露给外部的成员,通过挂在到全局对象上的方式实现
</p>
<p>
有了私有成员的概念,私有成员只能在模块成员内通过闭包的形式访问。
</p>
<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');
}
function method2() {
console.log(name + '#method2');
}
window.moduleA = {
method1: method1,
method2: method2,
};
})();
// module b 相关状态数据和功能函数
(function () {
var name = 'module-b';
function method1() {
console.log(name + '#method1');
}
function method2() {
console.log(name + '#method2');
}
window.moduleB = {
method1: method1,
method2: method2,
};
})();
模块化规范
历史
早期的模块化规范,Commonjs 规范不适合浏览器,在 node 中同步加载模块依赖。
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过 module.exports 导出模块
- 通过 require 函数载入模块
于是在浏览器中又提出了 AMD(Asyncchronous Module Definition)
Require.js 实现了这个规范。目前大多数的对三方库支持 AMD 规范缺缺点:
使用起来很复杂
当我们模块划分很细的时候 js 文件就会请求的很频繁
AMD 规范是前端模块化演进道路上的一步,的历史长河中进了一步,是一种妥协的实现方式,不算是最终的姐解决方案。除此之外,同一时期,淘宝推出了 Sea.js 库,实现的是 CMD 标准,但是后面让 Require.js 兼容了
<!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>
<h1>模块化规范的出现</h1>
<h2>Require.js 提供了 AMD 模块化规范,以及一个自动化模块加载器</h2>
<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();
});
// 兼容 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>');
};
});
// 因为 jQuery 中定义的是一个名为 jquery 的 AMD 模块
// 所以使用时必须通过 'jquery' 这个名称获取这个模块
// 但是 jQuery.js 并不在同级目录下,所以需要指定路径
define('module1', ['jquery', './module2'], function ($, module2) {
return {
start: function () {
$('body').animate({ margin: '200px' });
module2();
},
};
});
最佳实践
随着技术的发展,JavaScript 标准逐渐完善,模块化被统一成了浏览器端的 ES Module,nodejs 中遵循 CommonJs 规范
<!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 - 模块的特性</title>
</head>
<body>
<!-- 通过给 script 添加 type = module 的属性,就可以以 ES Module 的标准执行其中的 JS 代码了 -->
<script type="module">
console.log('this is es module');
</script>
<!-- 1. ESM 自动采用严格模式,忽略 'use strict' -->
<script type="module">
console.log(this);
</script>
<!-- 2. 每个 ES Module 都是运行在单独的私有作用域中 -->
<script type="module">
var foo = 100;
console.log(foo);
</script>
<script type="module">
console.log(foo);
</script>
<!-- 3. ESM 是通过 CORS 的方式请求外部 JS 模块的 -->
<!-- <script type="module" src="https://unpkg.com/jquery@3.4.1/dist/jquery.min.js"></script> -->
<!-- 4. ESM 的 script 标签会延迟执行脚本 等同于defer属性 -->
<script type="module" src="demo.js"></script>
<p>需要显示的内容</p>
</body>
</html>
ES Module 核心功能
import export 注意
默认导出的是字面量对象,非默认导出则不是,且必须为{}中的成员
导入的语法中的{}为固定的语法,并非解构
导出到外部的都是内存地址的引用,并非值的拷贝
导出的成员都是只读的
<!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 导出与导入 - 注意事项</title>
</head>
<body>
<script type="module" src="app.js"></script>
</body>
</html>
// CommonJS 中是先将模块整体导入为一个对象,然后从对象中结构出需要的成员
// const { name, age } = require('./module.js')
// ES Module 中 { } 是固定语法,就是直接提取模块导出成员
import { name, age } from './module.js';
console.log(name, age);
// 导入成员并不是复制一个副本,
// 而是直接导入模块成员的引用地址,
// 也就是说 import 得到的变量与 export 导入的变量在内存中是同一块空间。
// 一旦模块中成员修改了,这里也会同时修改,
setTimeout(function () {
console.log(name, age);
}, 1500);
// 导入模块成员变量是只读的
// name = 'tom' // 报错
// 但是需要注意如果导入的是一个对象,对象的属性读写不受影响
// name.xxx = 'xxx' // 正常
var name = 'jack';
var age = 18;
// var obj = { name, age }
// export default { name, age }
// 这里的 `{ name, hello }` 不是一个对象字面量,
// 它只是语法上的规则而已
export { name, age };
// export name // 错误的用法
// export 'foo' // 同样错误的用法
setTimeout(function () {
name = 'ben';
}, 1000);
import 额外注意
导入模块的时候 import from 后的路径必须带文件后缀,不支持自动定位 index
导入本地模块的时候 import from 后面的相对路径必须是'./' or '../'; 绝对路径支持从项目跟目录查找 以'/'开头; 或者完整的 url,不然会被按照导入三方模块处理
加载这个模块并不提取任何成员
import {} from './module.js';
// 简写
import './module.js';
- 全部导出成员
import * as mod from './module.js';
- import from 后面不支持变量
var modulePath = './module.js'
import {name} from modulePath; // 报错
- 块级作用域下不能使用 import
if (true) {
import { name } from './module.js'; // 报错
}
- 动态导入需要使用全局提供的 import 函数
import('./module.js').then(module => {
console.log(module);
});
- 直接导入导出
export { for, bar } from './module.js';
- ES Module 浏览器环境 Polyfill
<!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>
注意:script 标签的 nomodule 属性可以在没有 ES Module 的环境中执行,动态编译,不推荐直接在生产环境使用
-
在 node 中使用 ES module
- 在 node 环境中运行 ES module,把 js 文件后缀改为'.mjs',在命令行中执行 node --experimental-modules index.mjs
// 第一,将文件的扩展名由 .js 改为 .mjs; // 第二,启动时需要额外添加 `--experimental-modules` 参数; 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'))
-
在 ES module 中使用 Commonjs
// es-module.mjs import mod from './commonjs.js' console.log(mod) // 正常打印 // commonJS 始终只会导出一个默认成员 // commonjs.js module.export = { foo: 'commonjs exports value' } // 该语法为上面语法的简写 export.foo = 'commonjs exports value'
不能直接提取 commonjs 的成员,import 不是解构导出对象
import { foo } from './commonjs.js';
console.log(foo); // 报错
- 不能在 CommonJS 模块中通过 require 载入 ES Module
// es-module.js
export const foo = 'es module export value';
const mod = require('./es-module.mjs');
console.log(mod); // 报错
- commonjs 和 ES Module 在 node 中区别
common js
// 加载模块函数
console.log(require);
// 模块对象
console.log(module);
// 导出对象别名
console.log(exports);
// 当前文件的绝对路径
console.log(__filename);
// 当前文件所在目录
console.log(__dirname);
ES Module
// 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);
- 新版本 node 中进一步支持 ESModule
// 在 package.json 中设置type,默认以ES Module方式工作,不用在改扩展名了 .mjs=>.js
{
"type": "module"
}
// 如果要继续支持commonjs规范,则可以把js文件后缀改为'.cjs'
- 旧版本 node 中也可以通过 babel-node 支持 ES Module 特性
$ yarn add @babel/node @babel/core @babel/preset-env -D
// or
$ yarn add @babel/node @babel/core @babel/plugin-transform-modules-commonjs -D
$ yarn babel-node [文件名]
// .babelrc
{
"plugins": [
"@babel/plugin-transform-modules-commonjs"
]
}
// or
{
"presets": ["@babel/preset-env"]
}