文章要点 :
- JS模块化的发展与变迁
- 不同模块化方案的原理与实现
- 相关面试题小结
- 初步了解整体前端模块化、工程化的脉络
发展历史 :
背景
JS本身简单的页面设计:页面动画 + 表单提交 (表单校验/提交逻辑) , 并无模块化 or 命名空间的概念
JS的模块化需求日益增长
一、幼年期: 无模块化
- 开始需要在页面中增加一些不同的JS文件: 动画、表单、格式化
- 多种JS文件被分在不同文件中
- 不同的文件又被同一个模板引用
通过 <script>
标签引入各个文件,把每个文件看成是一个模块,每个模块的接口通常是暴露在全局作用域下的
<script src="jquery.js"></script>
<script src="main.js"></script>
<script src="dep1.js"></script>
认可 :
- 文件分离是最基础的
问题出现 :
- 如果通过这种方式做模块化,当项目变得越来越大时,很容易造成全局变量冲突,项目也会变得越来越难以管理。
- 污染全局作用域 => 不利于大型项目的开发及多人团队的共建
二、成长期: 模块化的雏形 - IIFE - 立即执行函数 (语法侧的优化)
利用作用域的把控
// 定义一个全局变量
let count = 0;
// 代码块1
const increase = () => ++count;
// 代码块2
const reset = () => {
count = 0;
}
increase();
reset();
此时两次调用函数使用的是同一个变量 count
如果利用函数作用域, 局部定义 count
, 使得 count
作为局部变量, 不会污染全局
(() => {
let count = 0;
// ...
})
定义一个函数并立即执行
(() => {
let count = 0;
// ...
})();
就初步实现了一个最简单的模块化
- 尝试定义一个最简单的模块
const iifeModule = (() => { // 所有内容都在私有作用域 let count = 0; // 将方法暴露出去 return { increase: () => ++count; reset: () => { count = 0; } } })(); // 调用里面的方法 iifeModule.increase(); iifeModule.reset();
追问:有额外依赖时,如何优化IIFE相关代码?
- 优化后的代码如下 :
// 将额外依赖作为参数传进立即执行函数中 const iifeModule = ((dependencyModule1, dependencyModule2) => { let count = 0; // 将方法暴露出去 return { increase: () => ++count; reset: () => { count = 0; } } })(dependencyModule1, dependencyModule2); // 调用时也要将依赖作为参数传入 // 调用里面的方法 iifeModule.increase(); iifeModule.reset();
面试1 :你了解早期 jQuery 的依赖处理以及模块加载方案吗?/ 你了解传统 IIFE 是如何解决多方依赖的问题吗? 答:IIFE传参调配
- 实际上,jQuery等框架其实应用了
revealing
( 揭示模式 ) 写法:// 将额外依赖作为参数传进立即执行函数中 const iifeModule = ((dependencyModule1, dependencyModule2) => { let count = 0; const increase = () => ++count; const reset = () => { count = 0; } // 将方法暴露出去 return { increase, reset } })(dependencyModule1, dependencyModule2); // 调用时也要将依赖作为参数传入 // 调用里面的方法 iifeModule.increase(); iifeModule.reset();
- 小栗子:
- 创建 a.js, 将函数内的属性和方法通过 modA 对象暴露出去
(function() {
var num = 100
var flag = true
function af1() {
console.log('a.js里面的af1');
}
function af2() {
console.log('a.js里面的af2');
}
// 暴露内容为一个挂载到 window 上的对象
window.modA = {
num: num,
flag: flag,
af1: af1,
af2: af2
}
})()
- 创建 b.js, 引入 modA, 并将函数内的属性和方法通过 modB 对象暴露出去
(function(modA) {
var num = 200
var flag = false
function bf1() {
console.log('b.js里面的bf1');
}
function bf2() {
console.log('b.js里面的bf2');
}
// 暴露内容为一个挂载到 window 上的对象
window.modB = {
num: num,
flag: flag,
bf1: bf1,
bf2: bf2
}
// 控制台打印 modA 里面的值
modA.af1() // a.js里面的af1
console.log(modA.flag); // true
})(modA)
- 创建 c.js, 整合 modA 和 modB
(function(modA, modB) {
modA.af2(); // a.js里面的af2
console.log(modA.num); // 100
modB.bf1(); // b.js里面的bf1
console.log(modB.flag); // false
})(modA, modB)
- 创建 index.html 文件并引入 js 文件
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>
-
控制台打印结果如下:
其中, 前两句由 b.js 中的语句控制, 其余由 c.js 中的语句控制。
三、成熟期:CJS - CommonJS
node.js 制定
特征:
- 通过 module + exports 去对外暴露接口
- 通过 require 来调用其他模块
模块组织方式 :
- main.js 文件
// 引入依赖 const dependencyModule1 = require(./dependencyModule1) const dependencyModule2 = require(./dependencyModule2) // 处理部分 let count = 0; const increase = () => ++count; const reset = () => { count = 0; } // 暴露出去 exports.increase = increase; exports.reset = reset; module.exports = { increase, reset } ```
- 模块使用方式 :
// 引入 const { increase, reset } = require(./main.js) // 调用 increase(); reset();
可能被问到的面试问题 : 实际执行处理如何实现? (情形 : SDK - 减少依赖包)
(function() {
const dependencyModule1 = require(./dependencyModule1)
const dependencyModule2 = require(./dependencyModule2)
// 业务逻辑...
}).call(thisValue, exports, require, module);
-
为什么可以用 .call() 来减少依赖包?
-
答: 通过 call(),您能够使用属于另一个对象的方法 :
var person = { fullName: function() { return this.firstName + " " + this.lastName; } } var person = { firstName:"Bill", lastName: "Gates", } person.fullName.call(person); // 将返回 "Bill Gates"
优点:CommonJS 率先在服务器端实现了,从框架层面解决了依赖、全局变量污染的问题
缺点:主要针对服务器端(只能同步引入,无法解决异步问题)
- 追问: 为什么服务器端不考虑异步问题?
- 答: 服务器端文件存在于硬盘中或者云盘中, 读取很快(如: node 中的 fs)
新的问题 - 如何解决异步依赖? ==> AMD规范
四、AMD规范
通过异步加载 + 允许制定回调函数
经典实现框架是: require.js
-
新增定义方式 :
// 通过 define 定义一个模块, require进行加载 /* define params: 模块名, 依赖模块, 工厂方法 */ define(id, [depends], callback); require([module], callback)
-
模块定义方式 :
define('amdModule', ['dependencyModule1', 'dependencyModule2'], (dependencyModule1, dependencyModule2) => { // 业务逻辑 // 处理部分 let count = 0; const increase = () => ++count; const reset = () => { count = 0; } return { increase, reset } })
-
引入模块:
require(['amdModule'], amdModule => { amdModule.increase(); })
面试题2 : 如果在AMDModule中想兼容已有代码, 怎么办?
// callback 函数中处理
define('amdModule', [], require => { // 利用 require 兼容已有代码
// 引入依赖
const dependencyModule1 = require(./dependencyModule1)
const dependencyModule2 = require(./dependencyModule2)
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
}
//做一些和引入依赖相关的事宜
// 暴露出去
return {
increase, reset
}
})
面试题3 : 如果在AMDModule中想使用revealing, 怎么办?
// callback 函数中处理
define('amdModule', [], (require, export, module) => {
// 引入依赖
const dependencyModule1 = require(./dependencyModule1)
const dependencyModule2 = require(./dependencyModule2)
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
}
//做一些和引入依赖相关的事宜
// 暴露出去
exports.increase = increase();
exports.reset = reset();
})
define('amdModule', [], require => {
const otherModule = require('amdModule');
otherModule.increase();
otherModule.reset();
})
面试题4 : 兼容AMD&CJS/如何判断CJS和AMD
==> UMD 出现
// callback 函数中处理
(define('amdModule', [], (require, export, module) => {
// 引入依赖
const dependencyModule1 = require(./dependencyModule1)
const dependencyModule2 = require(./dependencyModule2)
// 处理部分
let count = 0;
const increase = () => ++count;
const reset = () => {
count = 0;
}
//做一些和引入依赖相关的事宜
// 暴露出去
export.increase = increase();
export.reset = reset();
}))(
// 目标: 一次性区分 CommonJS or AMD
typeof module === "object" && module.exports && typeof define !== "function" ? // 是 CommonJS
factory => module.exports = factory(require, exports, module)
: // 是AMD
define
)
- 优点: 适合在浏览器中加载异步模块, 可以并行加载多个依赖
- 缺点: 会有引入成本, 不能按需加载
==> CMD 出现
五、CMD 规范
按需加载 主要应用框架 sea.js
- 示例代码 :
define('module', (require, exports, module) => { // 使用的时候再加载依赖 let $ = require('jquery'); // jquery相关逻辑 let dependencyModule1 = require('./dependecyModule1'); // dependencyModule1相关逻辑 })
- 优点: 按需加载, 依赖就近
- 缺点: 依赖于打包, 加载逻辑存在于每个模块中, 扩大模块体积
面试题5: AMD&CMD区别?
1.对于依赖的模块, AMD 是提前执行, CMD 是延迟执行.
2.CMD 推崇依赖就近 (何时用何时写), AMD 推崇依赖前置 (所有依赖必须一开始就写好).
3.AMD 的 API 默认是一个当多个用, CMD 的 API 严格区分, 推崇职责单一 (比如 AMD 里, require 分 全局 require 和 局部 require, 都叫 require. CMD 里, 没有全局 require, 而是根据模块系统的完备性, 提供 seajs.use 来实现模块系统的加载启动. CMD 里, 每个 API 都简单纯粹)
六、ES6 模块化
EcmaScript6 标准增加了JavaScript语言层面的模块体系定义
-
新增定义:
- 引入关键字 —— import
- 导出关键字 —— export
-
模块引入、导出和定义的地方:
- 创建 esModule.js 文件
// 引入区域 import dependencyModule1 from './dependencyModule1.js'; import dependencyModule2 from './dependencyModule2.js'; // 实现代码逻辑 let count = 0; export const increase = () => ++count; export const reset = () => { count = 0; } // 导出区域 export default { increase, reset }
-
模板引入的地方
<script type="module" src="esModule.js"></script>
-
node中:
import { increase, reset } from './esModule.js'; increase(); reset(); import esModule from './esModule.js'; esModule.increase(); esModule.reset();
面试题6:动态模块
考察:export promise
- ES11原生解决方案:
import('./esModule.js').then(dynamicEsModule => { dynamicEsModule.increase(); })
- 或者手写一个 promise
- 优点(重要性):通过一种最统一的形态整合了js的模块化
- 缺点(局限性):本质上还是运行时的依赖分析
解决模块化的新思路 - 前端工程化
背景
根本问题 - 运行时进行依赖分析
前端的模块化处理方案依赖于运行时分析
-
解决方案:线下执行
grunt gulp webpack
<!doctype html> <script src="main.js"></script> <script> // 给构建工具一个标识位 require.config(__FRAME_CONFIG__); </script> <script> require(['a', 'e'], () => { // 业务处理 }) </script> </html>
define('a', () => { let b = require('b'); let c = require('c'); export.run = () { // run } })
工程化实现
-
step1: 扫描依赖关系表:
{ a: ['b', 'c'], b: ['d'], e: [] }
-
step2: 重新生成依赖数据模板
<!doctype html> <script src="main.js"></script> <script> // 构建工具生成数据 require.config({ "deps": { a: ['b', 'c'], b: ['d'], e: [] } }) </script> <script> require(['a', 'e'], () => { // 业务处理 }) </script> </html>
-
step3: 执行工具,采用模块化方案解决模块化处理依赖
define('a', ['b', 'c'], () => { // 执行代码 export.run = () => {} })
优点:
- 构建时生成配置,运行时执行
- 最终转化成执行处理依赖
- 可以拓展