《Webpack实战》前端工程化系列 02:模块基石——CommonJS/ES6 Module与Webpack打包原理全解
本文聚焦Webpack核心的模块打包能力,系统拆解CommonJS、ES6 Module两大主流模块标准的定义、语法、使用规范,对比二者核心差异,并深入剖析Webpack模块打包的底层原理,同时讲解非模块化文件、AMD、UMD等特殊模块的加载方式。学完本文,你将彻底掌握前端模块化的核心逻辑,理解Webpack如何将分散的模块有序打包为浏览器可执行的代码。
【本篇核心收获】
- 掌握CommonJS模块的定义、导出/导入语法及使用避坑点
- 精通ES6 Module的命名导出、默认导出、复合写法及导入规范
- 理解CommonJS与ES6 Module的核心差异(动态/静态、值拷贝/动态映射、循环依赖处理)
- 学会处理非模块化文件、AMD、UMD等特殊模块的加载方式,掌握npm模块的加载逻辑
- 吃透Webpack模块打包的底层原理,理解模块缓存、加载执行的全流程
2.1 CommonJS
模块是程序的功能单元,CommonJS是JavaScript社区2009年提出的标准,Node.js采用其部分规范并调整,最初为服务端设计,借助Browserify可实现客户端CommonJS模块打包,结合npm的包共享能力,使其在前端开发中广泛流行。
2.1.1 模块
CommonJS规定每个文件是一个独立模块,与直接通过script标签插入页面的代码核心区别在于:
- script标签代码:顶层作用域为全局作用域,变量/函数声明会污染全局环境
- CommonJS模块:拥有自身独立作用域,内部变量/函数仅模块内可访问,对外不可见
示例验证:
// calculator.js
var name = 'calculator.js';
// index.js
var name = 'index.js';
require('./calculator.js');
console.log(name); // index.js上述代码中,calculator.js的name变量不会影响index.js,验证了CommonJS模块的独立作用域特性。
模块小结
CommonJS以文件为模块单元,通过独立作用域避免全局污染,这是其核心特性之一,也是与全局脚本代码的核心区别。
2.1.2 导出
导出是模块对外暴露内容的唯一方式,CommonJS通过module.exports或简化的exports实现,核心规则如下:
基础导出方式
module.exports直接导出:
module.exports = {
name: 'calculater',
add: function(a, b) {
return a + b;
}
};模块内部默认存在module对象存储模块信息,可理解为模块开头隐含以下代码:
var module = {...};
// 模块自身逻辑
module.exports = {...};exports简化导出:
exports.name = 'calculater';
exports.add = function(a, b) {
return a + b;
};底层机制:模块首部默认添加代码,使exports指向module.exports(初始化为空对象):
var module = {
exports: {},
};
var exports = module.exports;因此exports.add赋值等价于给module.exports添加属性。
导出避坑点
禁止直接给
exports赋值:赋值会让exports指向新对象,module.exports仍为原空对象,导致导出失效// 错误示例 exports = { name: 'calculater' }; // name不会被导出禁止混用
module.exports与exports:重新赋值module.exports会覆盖原有exports绑定的属性// 错误示例 exports.add = function(a, b) { return a + b; }; module.exports = { name: 'calculater' }; // 最终仅导出name,add丢失导出语句不代表模块结束:
module.exports/exports后的代码仍会执行(如下方console.log会输出"end"),但建议将导出语句放在模块末尾以提升可读性module.exports = { name: 'calculater' }; console.log('end'); // 会执行并输出"end"
导出小结
CommonJS核心通过module.exports导出内容,exports是简化写法但需避免直接赋值和混用,导出语句位置不影响后续代码执行,建议放在模块末尾。
2.1.3 导入
CommonJS使用require函数实现模块导入,核心规则和特性如下:
基础导入方式
// calculator.js
module.exports = {
add: function(a, b) {return a + b;}
};
// index.js
const calculator = require('./calculator.js');
const sum = calculator.add(2, 3);
console.log(sum); // 5导入执行规则
require加载模块分两种情况:
- 模块首次加载:执行模块代码 → 导出内容
- 模块重复加载:直接复用首次执行后的导出结果,不重复执行模块代码
示例验证:
// calculator.js
console.log('running calculator.js');
module.exports = {
name: 'calculator',
add: function(a, b) {
return a + b;
}
};
// index.js
const add = require('./calculator.js').add;
const sum = add(2, 3);
console.log('sum:', sum);
const moduleName = require('./calculator.js').name;
console.log('end');输出结果:
running calculator.js
sum: 5
end可见即使两次require,calculator.js仅执行一次,底层依赖module.loaded属性(首次加载后置为true,后续加载直接判断该值)。
特殊导入场景
仅执行模块不获取导出内容:直接调用
require即可(如模块将接口挂载到全局)require('./task.js');动态指定加载路径:
require支持接收表达式,可动态加载模块const moduleNames = ['foo.js', 'bar.js']; moduleNames.forEach(name => { require('./' + name); });
导入小结
CommonJS通过require实现模块导入,核心特性是模块缓存(仅首次执行),支持动态路径和仅执行模块的特殊场景,需掌握其执行规则以避免逻辑错误。
2.2 ES6 Module
JavaScript最初无模块概念,2015年6月TC39发布ES6,正式将模块纳入语言标准,ES6 Module是原生模块化方案,相比CommonJS有更规范的语法和特性。
2.2.1 模块
ES6 Module同样以每个文件为一个模块,拥有独立作用域,核心区别在于:
- 语法:使用
import/export保留关键字(CommonJS的module非关键字) - 严格模式:ES6 Module自动启用严格模式,无论是否显式声明
"use strict",改写非严格模式代码为ES6 Module时需注意此特性
基础示例:
// calculator.js
export default {
name: 'calculator',
add: function(a, b) {
return a + b;
}
};
// index.js
import calculator from './calculator.js';
const sum = calculator.add(2, 3);
console.log(sum); // 5模块小结
ES6 Module以文件为单元,拥有独立作用域,自动启用严格模式,通过import/export关键字实现模块交互,是JavaScript原生的模块化方案。
2.2.2 导出
ES6 Module使用export命令导出,分为命名导出和默认导出两种形式,核心规则如下:
命名导出
一个模块可拥有多个命名导出,两种等价写法:
// 写法1:声明+导出一体
export const name = 'calculator';
export const add = function(a, b) { return a + b; };
// 写法2:先声明后导出
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add };可通过as关键字重命名导出变量:
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add as getSum }; // 导入时为name和getSum默认导出
一个模块仅能有一个默认导出,无需变量声明,可直接导出值:
// 导出对象
export default {
name: 'calculator',
add: function(a, b) {
return a + b;
}
};
// 导出字符串
export default 'This is calculator.js';
// 导出class
export default class {...}
// 导出匿名函数
export default function() {...}底层可理解为对外输出名为default的变量,这是默认导出的核心逻辑。
导出小结
ES6 Module的导出分为命名导出(多导出)和默认导出(单导出),支持重命名,语法规范且原生集成到语言标准中,是与CommonJS导出的核心区别之一。
2.2.3 导入
ES6 Module使用import语法导入,需匹配导出方式,核心规则如下:
命名导出的导入
导入变量名需与导出名完全一致,通过{}包裹,导入的变量为只读(不可修改):
// calculator.js
const name = 'calculator';
const add = function(a, b) { return a + b; };
export { name, add };
// index.js
import { name, add } from './calculator.js';
add(2, 3);导入重命名
通过as关键字重命名导入变量:
import { name, add as calculateSum } from './calculator.js';
calculateSum(2, 3);整体导入
将所有导出变量作为属性挂载到指定对象,减少对当前作用域的影响:
import * as calculator from './calculator.js';
console.log(calculator.add(2, 3));
console.log(calculator.name);默认导出的导入
import后直接跟自定义变量名(可自由指定),指代默认导出的值:
// calculator.js
export default {
name: 'calculator',
add: function(a, b) {
return a + b;
}
};
// index.js
import myCalculator from './calculator.js';
myCalculator.add(2, 3);底层等价于:
import { default as myCalculator } from './calculator.js';混合导入
同时导入默认导出和命名导出,默认导出变量需写在大括号前(顺序不可颠倒,否则语法错误):
import React, { Component } from 'react';注:React对应默认导出,Component对应命名导出,默认导出变量必须前置,否则提示语法错误。
导入小结
ES6 Module导入需匹配导出方式,命名导入严格匹配变量名、支持重命名和整体导入,默认导入可自定义变量名,混合导入有固定语法顺序要求,导入变量均为只读。
2.2.4 复合写法
工程中需将模块导入后立即导出(如组件入口文件),可使用复合写法,核心规则:
仅支持命名导出的复合写法:
export { name, add } from './calculator.js';默认导出无复合写法,需拆分导入和导出:
import calculator from "./calculator.js "; export default calculator;
复合写法小结
ES6 Module复合写法仅适用于命名导出,默认导出需拆分操作,这是工程中整合模块时的常用技巧。
2.3 CommonJS与ES6 Module的区别
CommonJS和ES6 Module是前端最主流的两种模块标准,核心差异体现在动态/静态特性、值传递方式、循环依赖处理三个维度,是理解模块化的关键。
2.3.1 动态与静态
这是二者最本质的区别,决定了模块依赖关系的建立阶段:
| 特性 | CommonJS | ES6 Module |
|---|---|---|
| 依赖解析阶段 | 运行阶段 | 编译阶段 |
| 路径支持 | 支持表达式(动态路径) | 仅支持静态路径(不可用表达式) |
| 语句位置 | 可放在任意位置(如if内) | 必须在模块顶层作用域 |
| 优化能力 | 无静态分析优化能力 | 支持死代码检测、类型检查、编译器优化 |
CommonJS动态特性示例
// calculator.js
module.exports = { name: 'calculator' };
// index.js
const name = require('./calculator.js').name;
// 支持动态路径
const moduleNames = ['foo.js', 'bar.js'];
moduleNames.forEach(name => require('./' + name));CommonJS在运行时解析依赖,无法提前确定依赖关系,因此不支持编译期优化。
ES6 Module静态特性示例
// calculator.js
export const name = 'calculator';
// index.js
import { name } from './calculator.js';
// 不支持动态路径(报错)
// if (true) { import { name } from './calculator.js'; }ES6 Module在编译期解析依赖,可实现:
- 死代码检测:打包时剔除未使用的模块,减小体积
- 类型检查:提前检测模块间传递值的类型错误
- 编译器优化:直接导入变量,减少引用层级,提升执行效率
动态/静态特性小结
CommonJS动态解析依赖,灵活性高但无优化能力;ES6 Module静态解析依赖,语法受限但支持编译期优化,这是二者核心设计理念的差异。
2.3.2 值拷贝与动态映射
导入模块时,二者的数值传递方式完全不同,直接影响模块间的数据同步逻辑:
CommonJS:值拷贝
导入的是导出值的拷贝,原模块值变化不会同步到导入侧,且导入值可修改:
// calculator.js
var count = 0;
module.exports = {
count: count,
add: function(a, b) {
count += 1;
return a + b;
}
};
// index.js
var count = require('./calculator.js').count;
var add = require('./calculator.js').add;
console.log(count); // 0(拷贝值)
add(2, 3);
console.log(count); // 0(原模块值变化不影响拷贝)
count += 1;
console.log(count); // 1(拷贝值可修改)ES6 Module:动态映射
导入的是导出值的只读动态映射,原模块值变化会实时同步到导入侧,且导入值不可修改:
// calculator.js
let count = 0;
const add = function(a, b) {
count += 1;
return a + b;
};
export { count, add };
// index.js
import { count, add } from './calculator.js';
console.log(count); // 0(动态映射值)
add(2, 3);
console.log(count); // 1(原模块值变化实时同步)
// count += 1; // 报错:"count" is read-only可将这种映射理解为“镜子”:能实时看到原模块值的变化,但无法修改镜子中的影像。
值传递特性小结
CommonJS是值拷贝,导入值与原模块值解耦且可修改;ES6 Module是只读动态映射,导入值与原模块值实时同步且不可修改,这是二者数据交互的核心差异。
2.3.3 循环依赖
循环依赖指模块A依赖B,B又依赖A(或间接依赖),工程中应尽量避免,但需掌握二者的处理逻辑:
CommonJS处理循环依赖
无法获取预期导出值,核心原因是值拷贝+模块缓存:
// foo.js
const bar = require('./bar.js');
console.log('value of bar:', bar);
module.exports = 'This is foo.js';
// bar.js
const foo = require('./foo.js');
console.log('value of foo:', foo);
module.exports = 'This is bar.js';
// index.js
require('./foo.js');输出结果:
value of foo: {}
value of bar: This is bar.js执行流程:
- index.js加载foo.js,执行到
require('./bar.js')时切换到bar.js - bar.js加载foo.js,此时foo.js未执行完,
module.exports为默认空对象,因此foo值为{} - bar.js执行完毕,回到foo.js继续执行,bar值正确
Webpack底层逻辑:__webpack_require__函数会将模块存入installedModules,重复加载时直接取缓存(此时foo.js的exports为空对象):
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
...
}ES6 Module处理循环依赖
借助动态映射特性,可实现正确的循环依赖处理(需保证导入值使用时已正确导出):
//index.js
import foo from './foo.js';
foo('index.js');
// foo.js
import bar from './bar.js';
function foo(invoker) {
console.log(invoker + ' invokes foo.js');
bar('foo.js');
}
export default foo;
// bar.js
import foo from './foo.js';
let invoked = false;
function bar(invoker) {
if(!invoked) {
invoked = true;
console.log(invoker + ' invokes bar.js');
foo('bar.js');
}
}
export default bar;输出结果:
index.js invokes foo.js
foo.js invokes bar.js
bar.js invokes foo.js执行流程:
- index.js加载foo.js,执行到
import bar时切换到bar.js - bar.js执行完毕(foo值暂为undefined),回到foo.js完成foo函数导出
- 由于动态映射,bar.js中的foo值同步为已定义的函数
- 执行index.js的foo函数,循环调用逻辑正常执行
循环依赖小结
CommonJS因值拷贝+缓存机制,循环依赖时无法获取正确导出值;ES6 Module借助动态映射特性,可支持循环依赖(需开发者保证值使用时已导出),这是ES6 Module的重要优势。
2.4 加载其他类型模块
除CommonJS和ES6 Module外,开发中还会遇到非模块化文件、AMD、UMD等模块类型,需掌握其加载方式和核心特性。
2.4.1 非模块化文件
非模块化文件指不遵循任何模块标准的文件(如旧项目中的jQuery插件),Webpack加载规则:
直接导入即可执行文件:
import './jquery.min.js',效果与script标签引入一致(jQuery绑定到全局)注意点:隐式全局变量声明的文件会因Webpack的函数作用域包装,无法挂载到全局(如下代码的calculator无法全局访问)
// 非模块化文件 var calculator = { /* ... */ }
非模块化文件小结
Webpack可直接加载非模块化文件,但需注意隐式全局变量的作用域问题,避免接口无法访问。
2.4.2 AMD
AMD(异步模块定义)是专注浏览器端的异步模块标准,核心特性是异步加载模块,避免阻塞浏览器,核心语法:
模块定义
使用define函数,参数包括模块ID、依赖、导出内容:
define('getSum', ['calculator'], function(math) {
return function(a, b) {
console.log('sum: ' + calculator.add(a, b));
}
});模块加载
使用异步require函数,加载完成后执行回调:
require(['getSum'], function(getSum) {
getSum(2, 3);
});优缺点
- 优点:异步加载非阻塞,适配浏览器环境
- 缺点:语法冗长,易造成回调地狱,目前使用场景极少
AMD小结
AMD是浏览器端异步模块标准,语法冗长且使用场景有限,了解其核心语法即可应对旧项目兼容场景。
2.4.3 UMD
UMD(通用模块定义)并非独立模块标准,而是兼容CommonJS、AMD、非模块化环境的通用方案,核心逻辑是环境判断+适配导出:
核心实现
// calculator.js
(function (global, main) {
// 根据当前环境采取不同的导出方式
if (typeof define === 'function' && define.amd) {
// AMD环境
define(...);
} else if (typeof exports === 'object') {
// CommonJS环境
module.exports = ...;
} else {
// 非模块化环境
global.add = ...;
}
}(this, function () {
// 模块主体逻辑
return {...}
}));注意点
UMD优先判断AMD环境(检查define函数),Webpack中若工程使用CommonJS但UMD模块以AMD导出,会导致导入错误,需调整判断顺序(优先CommonJS)。
UMD小结
UMD通过环境判断适配多模块标准,是跨环境模块的通用方案,需注意Webpack中的环境判断顺序问题。
2.4.4 加载npm模块
npm是JavaScript主流包管理器,可快速引入第三方模块,Webpack加载npm模块的核心规则:
安装与基础加载
- 初始化项目并安装模块:
# 项目初始化
npm init –y
# 安装lodash
npm install lodash- 直接通过模块名导入(Webpack自动到
node_modules查找):
// index.js
import _ from 'lodash';模块入口规则
npm模块的入口由其package.json的main字段指定,如lodash的main为lodash.js,因此import _ from 'lodash'实际加载node_modules/lodash/lodash.js。
按需加载
通过<package_name>/<path>加载模块内部指定文件,减小打包体积:
import all from 'lodash/fp/all.js';
console.log('all', all);加载npm模块小结
Webpack可通过模块名自动查找node_modules中的npm模块,核心依赖package.json的main字段,按需加载可减少打包体积,是工程优化的常用技巧。
2.5 模块打包原理
Webpack将分散的模块打包为浏览器可执行的单个文件,核心是通过模块缓存、自定义require函数实现模块的有序加载和执行,底层逻辑如下:
核心打包结果结构
以calculator示例为例,Webpack打包后的代码核心结构:
// 立即执行匿名函数
(function(modules) {
// 模块缓存:避免重复执行
var installedModules = {};
// 自定义require函数:实现模块加载
function __webpack_require__(moduleId) {
// 缓存判断:已加载则直接返回导出值
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 初始化模块:存入缓存
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 执行模块代码
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 标记模块已加载
module.l = true;
// 返回导出值
return module.exports;
}
// 加载入口模块
return __webpack_require__(__webpack_require__.s = 0);
})({
// 所有模块的键值对:key为模块ID,value为模块执行函数
0: function(module, exports, __webpack_require__) {
// 入口模块逻辑:加载实际的index.js
module.exports = __webpack_require__("3qiv");
},
"3qiv": function(module, exports, __webpack_require__) {
// index.js内容
const calculator = __webpack_require__("jkzz");
const sum = calculator.add(2, 3);
console.log('sum', sum);
},
"jkzz": function(module, exports) {
// calculator.js内容
module.exports = {
add: function(a, b) {
return a + b;
}
};
}
});核心执行流程
- 初始化环境:外层匿名函数定义
installedModules(模块缓存)、__webpack_require__(加载函数) - 加载入口模块:调用
__webpack_require__加载入口模块(ID为0) - 执行模块代码:
- 若模块未加载:初始化模块→执行模块代码→记录导出值→标记已加载
- 若模块已加载:直接从
installedModules取导出值
- 递归加载依赖:执行模块时遇到
__webpack_require__则递归处理依赖模块 - 完成执行:所有依赖加载完毕后,回到入口模块执行至结束
打包原理小结
Webpack打包的核心是通过自定义__webpack_require__函数和模块缓存,模拟CommonJS的模块加载逻辑,在浏览器中实现模块的有序执行,本质是保留模块执行逻辑的同时,封装模块加载环境。
【本篇核心知识点速记】
- 模块基础:CommonJS和ES6 Module均以文件为模块单元,拥有独立作用域,ES6 Module自动启用严格模式
- 导出/导入:
- CommonJS:
module.exports/exports导出,require导入(值拷贝、动态路径) - ES6 Module:
export(命名/默认)导出,import导入(静态路径、只读动态映射)
- CommonJS:
- 核心差异:
- 解析阶段:CommonJS运行时(动态),ES6 Module编译时(静态)
- 值传递:CommonJS值拷贝,ES6 Module动态映射
- 循环依赖:ES6 Module支持(动态映射),CommonJS不支持(值拷贝+缓存)
- 特殊模块:非模块化文件直接导入(注意作用域)、AMD异步加载、UMD多环境适配、npm模块通过
main字段加载 - 打包原理:Webpack通过
installedModules缓存、__webpack_require__函数,模拟模块加载逻辑,实现浏览器端模块执行
