我是一名前端工程师,最近在系统梳理前端知识体系。今天第1篇,我们来聊聊前端模块化。
一、模块化:大厂面试的“第一道题”
面试官可能不会直接问“什么是模块化”,但一定会通过这些方式考察:
script标签的async和defer有什么区别?- CommonJS 和 ES6 Module 的区别是什么?
- 为什么 Tree Shaking 只在 ES6 Module 下生效?
今天,我们从实际业务场景出发,把模块化的演进史彻底搞懂。
二、业务场景:三个典型困境
某文玩电商头部企业,面临以下问题:
- 多页面/多技术栈共存:jQuery、Vue 2、Vue 3 项目并存,如何让不同“模块”和谐共处?
- 第三方 SDK 加载:直播 SDK、图片鉴定 SDK、IM SDK,体积大且依赖复杂
- 首屏性能:商品图片鉴定页需要加载多张大图,JS 阻塞渲染影响体验
带着这些问题,看看模块化如何一步步解决。
三、理论深度解析
阶段1:无模块化时代 —— script 标签的“野蛮生长”
三种加载模式对比:
| 模式 | 执行顺序 | 是否阻塞HTML解析 | 适用场景 |
|---|---|---|---|
| 默认(无属性) | 按顺序,立即执行 | 会阻塞 | 有严格依赖关系 |
async | 谁先下载完谁执行 | 会打断 | 无依赖的独立脚本 |
defer | 按顺序,HTML解析完后执行 | 不会阻塞 | 需要保证顺序,不阻塞渲染 |
html
<!-- 1. 默认:阻塞模式,保证顺序 -->
<script src="jquery.js"></script>
<script src="plugin.js"></script>
<!-- 2. async:异步加载,适合无依赖的大文件 -->
<script async src="https://cdn.xxx.com/large-sdk.js"></script>
<!-- 3. defer:延迟执行,推荐用于页面主逻辑 -->
<script defer src="app.js"></script>
带来的问题:
- 全局作用域污染,变量挂在
window上容易冲突 - 依赖管理混乱,手动保证顺序,稍有不慎就报错
阶段2:成长期 —— IIFE(立即执行函数)
javascript
var moduleA = (function() {
var privateName = '我是私有的';
function publicMethod() {
console.log('这是公开方法');
}
return { sayHello: publicMethod };
})();
moduleA.sayHello(); // 输出:这是公开方法
console.log(moduleA.privateName); // undefined
优点:创建独立作用域,初步实现模块化
缺点:无法解决依赖管理,仍需保证 script 顺序
阶段3:成熟期 —— CommonJS
Node.js 的出现让 JS 走向服务端,对同步加载有天然需求。
javascript
// math.js
module.exports = { add: (a, b) => a + b };
// main.js
const { add } = require('./math.js');
console.log(add(1, 2));
优点:从框架层面解决模块化和依赖管理
缺点:同步加载不适合浏览器
阶段4:百花齐放 —— AMD、CMD、UMD
AMD(require.js):依赖前置
javascript
define(['jquery'], function($) {
return { doSomething: () => $('#app').hide() };
});
CMD(sea.js):依赖就近
javascript
define(function(require, exports) {
var $ = require('jquery');
exports.doSomething = () => $('#app').show();
});
UMD:通用模块定义,自动适配 AMD、CJS 或全局变量
阶段5:大一统 —— ES6 Module
javascript
// math.js
export const add = (a, b) => a + b;
// main.js
import { add } from './math.js';
console.log(add(1, 2));
// 动态导入(代码分割、按需加载)
import('./heavy-module.js').then(module => {
module.doSomething();
});
四、面试必考:CommonJS vs ES6 Module
| 维度 | CommonJS | ES6 Module |
|---|---|---|
| 运行时 vs 编译时 | 运行时加载,require可写在if里 | 编译时输出静态接口,import必须在顶层 |
| 值的拷贝 vs 引用 | 输出值的拷贝,模块内部变化不影响已加载值 | 输出值的只读引用,模块内部变化外部能感知 |
| 加载方式 | 同步加载 | 异步加载 |
| Tree Shaking | 不支持 | 支持 |
CommonJS 值拷贝验证:
javascript
// lib.js
let count = 1;
function inc() { count++; }
module.exports = { count, inc };
// main.js
const { count, inc } = require('./lib.js');
console.log(count); // 1
inc();
console.log(count); // 1(还是1,因为是拷贝)
ES6 Module 引用验证:
javascript
// lib.js
export let count = 1;
export function inc() { count++; }
// main.js
import { count, inc } from './lib.js';
console.log(count); // 1
inc();
console.log(count); // 2(引用,值变了)
五、实践踩坑记录
- async/defer 误用:接入 IM SDK 时误用
async,导致 SDK 未加载完成业务代码就执行。解决:改用defer或动态import() - ESM 迁移策略:老项目从 CJS 迁移到 ESM,使用
esbuild或@babel/preset-env转换,同时在package.json中用exports字段同时输出 CJS 和 ESM - Tree Shaking 失效:打包产物包含大量未使用代码,排查发现 babel 配置把 ESM 转成了 CJS。解决:设置
modules: false
六、手写代码:简单模块加载器
javascript
const MyModule = (function() {
const modules = {};
function define(name, deps, factory) {
modules[name] = { deps, factory };
}
function require(name) {
const module = modules[name];
if (!module) throw new Error(`Module ${name} not found`);
const args = module.deps.map(dep => require(dep));
const exports = module.factory.apply(null, args);
return exports;
}
return { define, require };
})();
// 使用示例
MyModule.define('math', [], () => ({ add: (a,b) => a+b }));
MyModule.define('main', ['math'], (math) => {
console.log(math.add(2, 3)); // 5
});
MyModule.require('main');
七、思考题
下面这段代码有什么问题?如何用函数式编程思想改造?
javascript
let discount = 0.9;
let price = 100;
function calculate() {
if (user.isVip) {
discount = 0.8;
}
return price * discount;
}
欢迎在评论区留下你的答案和思考。