Skip to content

《Webpack实战》前端工程化系列 02:模块基石——CommonJS/ES6 Module与Webpack打包原理全解

约 5183 字大约 17 分钟

《Webpack实战》系列Webpack

2026-04-04

本文聚焦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实现,核心规则如下:

基础导出方式

  1. module.exports直接导出:
module.exports = {
    name: 'calculater',
    add: function(a, b) {
        return a + b;
    }
};

模块内部默认存在module对象存储模块信息,可理解为模块开头隐含以下代码:

var module = {...};
// 模块自身逻辑
module.exports = {...};
  1. 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.exportsexports:重新赋值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加载模块分两种情况:

  1. 模块首次加载:执行模块代码 → 导出内容
  2. 模块重复加载:直接复用首次执行后的导出结果,不重复执行模块代码

示例验证:

// 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';

图1:混合导入语法规范

注: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 动态与静态

这是二者最本质的区别,决定了模块依赖关系的建立阶段:

特性CommonJSES6 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在编译期解析依赖,可实现:

  1. 死代码检测:打包时剔除未使用的模块,减小体积
  2. 类型检查:提前检测模块间传递值的类型错误
  3. 编译器优化:直接导入变量,减少引用层级,提升执行效率

动态/静态特性小结

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

执行流程:

  1. index.js加载foo.js,执行到require('./bar.js')时切换到bar.js
  2. bar.js加载foo.js,此时foo.js未执行完,module.exports为默认空对象,因此foo值为{}
  3. 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

执行流程:

  1. index.js加载foo.js,执行到import bar时切换到bar.js
  2. bar.js执行完毕(foo值暂为undefined),回到foo.js完成foo函数导出
  3. 由于动态映射,bar.js中的foo值同步为已定义的函数
  4. 执行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模块的核心规则:

安装与基础加载

  1. 初始化项目并安装模块:
# 项目初始化
npm init –y
# 安装lodash
npm install lodash
  1. 直接通过模块名导入(Webpack自动到node_modules查找):
// index.js
import _ from 'lodash';

模块入口规则

npm模块的入口由其package.jsonmain字段指定,如lodash的mainlodash.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.jsonmain字段,按需加载可减少打包体积,是工程优化的常用技巧。

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;
            }
        };
    }
});

核心执行流程

  1. 初始化环境:外层匿名函数定义installedModules(模块缓存)、__webpack_require__(加载函数)
  2. 加载入口模块:调用__webpack_require__加载入口模块(ID为0)
  3. 执行模块代码:
    • 若模块未加载:初始化模块→执行模块代码→记录导出值→标记已加载
    • 若模块已加载:直接从installedModules取导出值
  4. 递归加载依赖:执行模块时遇到__webpack_require__则递归处理依赖模块
  5. 完成执行:所有依赖加载完毕后,回到入口模块执行至结束

打包原理小结

Webpack打包的核心是通过自定义__webpack_require__函数和模块缓存,模拟CommonJS的模块加载逻辑,在浏览器中实现模块的有序执行,本质是保留模块执行逻辑的同时,封装模块加载环境。

【本篇核心知识点速记】

  1. 模块基础:CommonJS和ES6 Module均以文件为模块单元,拥有独立作用域,ES6 Module自动启用严格模式
  2. 导出/导入:
    • CommonJS:module.exports/exports导出,require导入(值拷贝、动态路径)
    • ES6 Module:export(命名/默认)导出,import导入(静态路径、只读动态映射)
  3. 核心差异:
    • 解析阶段:CommonJS运行时(动态),ES6 Module编译时(静态)
    • 值传递:CommonJS值拷贝,ES6 Module动态映射
    • 循环依赖:ES6 Module支持(动态映射),CommonJS不支持(值拷贝+缓存)
  4. 特殊模块:非模块化文件直接导入(注意作用域)、AMD异步加载、UMD多环境适配、npm模块通过main字段加载
  5. 打包原理:Webpack通过installedModules缓存、__webpack_require__函数,模拟模块加载逻辑,实现浏览器端模块执行