前端模块化概述
随着前端项目的日复杂,代码需要特定的管理。使用模块化将复杂代码按照功能的不同拆分成不同的模块进行单独维护,通过这样的方式提高开发效率,降低维护成本。
“模块化”只是一种思想
模块化演变过程
最早期 Stage 1: 文件划分方式
将每一个功能以及其相关数据存放到不同文件夹中,约定每一个文件就是一个不同的模块。 使用模块的话就是将模块引入到页 面中(通过 script 标签),然后在代码中直接调用方法或者变量。
缺点:
- 所有的文件都在全局范围进行工作,会污染全局作用域
- 会产生命名冲突问题
- 无法管理模块依赖关系
第二阶段 Stage 2: 命名空间方式
约定每个模块暴露一个全局的对象,所有的模块成员都挂载到这个对象下。 具体就是在第一阶段的基础上通过将每个模块包裹成为一个全局对象的方式
var moduleA = {
name: 'module-a',
method: function() {}
}
-
减小了命名冲突的可能
-
模块成员没有私有空间,依然可以被外部访问修改
第三阶段 Stage 3: IIFE
通过立即执行函数为模块提供私有空间 具体就是将模块中每一个成员都放在一个函数提供的私有作用域中,对于需要暴露的成员将其挂载到全局对象上
(function () {
var name = "module-a"
function method() {
// ...
}
window.moduleA = {
method
}
})()
- 实现了私有成员的概念,私有成员只能在模块内部使用,在外部无法访问,确保了私有成员的安全
- 可以通过自执行函数的参数关联依赖关系
模块化规 范的出现
上述的方式都是以原始的模块为基础,通过约定的方式去实现模块化的代码组织。
CommonJS 规范
NodeJS 提出来的标准 CommonJS 是以同步模式加载模块
Node 的执行机制是在启动时加载模块,执行过程中不需要加载。如果在浏览器端使用 CommonJS 规范的话会导致效率低下(每一次加载都会存在大量的同步请求出现)
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过
module.exports / exports.x
导出成员 - 通过
require
函数载入模块
AMD
Asynchronouns Module Definition 浏览器端规范 同期推出了一个 Require.js 的库,实现了AMD 的规范。本身是一个非常强大的模块加载器
AMD 的规范中约定每个模块都必须通过 define
函数去定义
define
函数默认接收两个参数,也可以传递三个参数;如果传递三个参数的话,第一个参数就是模块的名字(可以在后期加载这个模块的时候使用),第二个参数就是一个数组(用来声明模块的一些依赖项),第三个参数是一个函数(函数的参数与前面的依赖项一一对应,为依赖项导出的成员)作用是为当前的模块提供一个私有的空间,如果需要向外部导出一些成员的话,可以 通过 return 的方式实现
// 定义一个模块
define("module1", ['jquery', './module2'], function($, moudle2) {
return {
start: function () {
$('body').animate({ margin: '200px' })
moudle2()
}
}
})
Require.js 还提供一个 require
函数,用来帮我们加载模块。用法和 define
函数类似,区别在于 require
只用来加载模块、define
是用来定义模块的。一旦当 requirejs需要去加载模块的话,内部会自动创建一个 script 标签去发送对应的脚本文件,并执行相应的脚本代码。
// 载入一个模块
require(['./module1'], function(module1) {
module1.start()
})
目前绝大多数第三方库都支持 AMD 规范
缺点:
- AMD 使用起来相对复杂 在代码编写过程中除了业务代码还需要使用到大量的 require \ define 操作模块的代码,会导致我们代码复杂程度提高
- 模块 JS 文件请求频繁 如果模块划分非常细致的话,在同一个页面中对JS文件请求的次数就会增多,从而拉低页面效率
⭐️ 同期淘宝还推出了 Sea.js + CMD
实现的是SeaJS 的标准:Common Module Definition 类似于 CommonJS,使用上类似 RequireJS(可以说是一个重复的轮子) 当时想法是希望CMD写出来的轮子尽可能的跟 CommonJS 类似,从而减轻开发者的学习成本。后来这种方式被RequireJS 兼容了
// CMD 规范(类似 CommonJS 规范)
define(function(require, exports, module) {
// 通过 require 引入依赖
var $ = require('jquery')
// 通过 exports 或者 module.exports 对外暴露成员
module.exports = function () {
$('body').animate({ margin: '200px' })
}
})
模块化规范
现阶段前端模块化基本统一为:在NodeJS中遵循 CommonJS 规范去组织模块,在浏览器环境中采用 ES Modules 的规范
ES Modules
基本特性
- ESM 自动采用严格模式,忽略 'use strict'
- 每个 ES Module 都是运行在单独的私有作用域中
- ESM 是通过 CORS 的方式请求外部 JS 模块的
- ESM 的 script 标签会延迟执行脚本
<!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 标签会延迟执行脚本 -->
<script defer src="demo.js"></script>
<p>需要显示的内容</p>
</body>
</html>
导入导出
由 export 和 import 两个关键词构成 export 由模块内对外暴露接口 import 在模块内导入其他模块提供的接口
// ./module.js
const foo = 'es module'
export { foo } // 导出成员:固定用法
// export default { foo } // 字面量语法
// ./app.js
import { foo } from './module.js' // 固定的用法,不是解构!!!
console.log(foo) // es modules
注意事项:
- 导入时不能省略 .js 的扩展名
- 导入时不能省略 index.js 默认文件
- import 不能省略 ./ 可以使用 / 来加载绝对路径 或者完整 url 路径
- 如果只想执行模块并不需要提取成员的话,可以使用
import {} form './module.js'
或import './module.js'
- 动态导入模块
import('./module.js')
返回一个promise - 导入默认成员和其他成员
import title, {name, age} from './module.js'
- 将导入成员变为导出成员
export { foo, bar } from './module.js'
ES Modules in Browser
Polyfill 兼容方案
ESModule 是在 2014 年的时候提出来的,早期的浏览器并没有支持这一特性,在 IE 和一些国产浏览器 截止到目前为止还没有支持,所以我们需要考虑兼容性的问题。
可以让我们浏览器支持ES Modules 绝大多数特性的 Polyfill Browser ES Module Loader
将其引入到网页中即可
<!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
的属性后,只会让其在不支持 esModule 特性的浏览器上执行
⚠️ 这种方式不要在生产阶段使用!!!
ES Modules in Node.js
在 Node 环境下使用 ES Modules
Node 版本大于 8.5
- 将 .js 扩展名修改为 .mjs
- 启动 node 命令
node --experimental-modules index.mjs
--experimental-modules 表示启用 ESModules 特性
原生模块(fs...)、第三方模块均可使用
与 CommonJS 模块交互
- ES Modules 中可以导入 CommonJS 模块
- CommonJS 中不能导入 ES Modules 模块
- CommonJS 始终只会导出一个默认成员
- 注意 import 不是解构导出对象
Es-module.mjs
// 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'
Commonjs.js
// 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)
与 CommonJS 模块的差异
// cjs.js
// 加载模块函数
console.log(require)
// 模块对象
console.log(module)
// 导出对象别名
console.log(exports)
// 当前文件的绝对路径
console.log(__filename)
// 当前文件所在目录
console.log(__dirname)
// esm.mjs
// 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 新版本对 ES Module 的支持
- 通过package.json 中添加
"type": "module"
属性,可以让文件夹下面的所有 js 文件处于 ESModule 运行(不用再修改扩展名为 .mjs 了~) - 如果在这种情况下还想要在 CommonJS 规范下运行文件,将文件扩展名改为 .cjs 即可
Babel 兼容方案
如果使用的是早期的 NodeJS 版本,可以通过 Babel 实现兼容,babel 可以帮我们将使用了新特性的代码编译为当前环境支持的代码。
安装相关依赖 yarn add @babel/node @babel/core @babel/preset-env --dev
使用命令执行:yarn babel-node index.js --presets=@babel/preset-env
或者 在文件夹下创建 .babelrc 文件,写入以下配置并执行 yarn babel-node index.js
命令即可
{
"presets": ["@babel/preset-env"]
}
babel 是由插件进行编译转换的, preset-env 是一个插件集合,我们在这里也可以使用单独插件进行操作:
-
remove preset-env :
yarn remove @babel/preset-env
-
安装相关插件:
yarn add @babel/plugin-transform-modules-commonjs --dev
-
修改 .babelrc 文件
{
"plugins": [
"@babel/plugin-transform-modules-commonjs"
]
}
// index.js
// 对于早期的 Node.js 版本,可以使用 Babel 实现 ES Module 的兼容
import { foo, bar } from './module.js'
console.log(foo, bar)
// module.js
export const foo = 'hello'
export const bar = 'world'