webpack 的辉煌与瓶颈

webpack 的辉煌与瓶颈

写在前面

上一篇讲了前端构建工具的整体演进,这一篇聚焦 webpack——它曾经是事实标准,现在依然是存量最大的项目在用。理解 webpack 的核心架构,既是为了用好它,也是为了后面理解 Rspack / Vite 的设计思路。

一、webpack 的核心架构

1.1 一句话理解

webpack 把一切资源当作模块,从入口出发建立依赖图,然后把模块打包成静态资源。

1.2 四大核心概念

概念 作用
Entry 入口,依赖图的起点
Output 输出,告诉 webpack 打包到哪里
Loader 转换器,把 webpack 不识别的文件转成 JS 模块
Plugin 插件,在 webpack 生命周期的各个阶段注入逻辑

1.3 一个完整的 webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
    entry: './src/index.js',

    output: {
        path: path.resolve(__dirname, 'dist'),
        filename: '[name].[contenthash:8].js',
        chunkFilename: '[name].[contenthash:8].chunk.js',
        clean: true,
        publicPath: '/',
    },

    module: {
        rules: [
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/,
            },
            {
                test: /\.css$/,
                use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
            },
            {
                test: /\.(png|jpg|gif|svg)$/,
                type: 'asset',
                parser: {
                    dataUrlCondition: {
                        maxSize: 8 * 1024,
                    },
                },
            },
        ],
    },

    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html',
        }),
        new MiniCssExtractPlugin({
            filename: '[name].[contenthash:8].css',
        }),
    ],

    optimization: {
        splitChunks: {
            chunks: 'all',
            cacheGroups: {
                vendor: {
                    test: /[\\/]node_modules[\\/]/,
                    name: 'vendors',
                },
            },
        },
        runtimeChunk: 'single',
    },

    cache: {
        type: 'filesystem',
    },

    mode: 'production',
};

二、Loader 机制

2.1 Loader 是什么

Loader 是函数,接收源文件内容,返回转换后的内容。

// 最简单的 loader
module.exports = function(source) {
    return source.replace(/foo/g, 'bar');
};

webpack 默认只认识 JS 和 JSON,其他文件(CSS、图片、字体、TS、Vue SFC)都需要 loader 转换。

2.2 Loader 链

多个 loader 串联,顺序很重要——从右往左执行(或从下往上):

// 顺序:postcss-loader → css-loader → style-loader
{
    test: /\.css$/,
    use: ['style-loader', 'css-loader', 'postcss-loader'],
}

处理流程:

.css 文件 → postcss-loader(加浏览器前缀)
        → css-loader(把 CSS 转成 JS 模块)
        → style-loader(把 CSS 注入到 <style>)

2.3 常用 Loader

Loader 用途
babel-loader ES6+ → ES5,JSX → JS
ts-loader TypeScript → JS
css-loader CSS → JS 模块
style-loader 注入 CSS 到 DOM
MiniCssExtractPlugin.loader 提取 CSS 到文件
postcss-loader PostCSS(autoprefixer 等)
sass-loader Sass → CSS
less-loader Less → CSS
file-loader / asset/resource 文件输出
url-loader / asset/inline 转 base64
vue-loader Vue SFC
svgr-loader SVG → React 组件

2.4 写一个自定义 Loader

// markdown-loader.js
const marked = require('marked');

module.exports = function(source) {
    const callback = this.async();

    // 异步处理
    marked(source, (err, result) => {
        if (err) return callback(err);

        // 把 markdown 转成 JS 模块,导出 HTML 字符串
        callback(null, `module.exports = ${JSON.stringify(result)};`);
    });
};

module.exports.raw = false;  // 输入是字符串,不是 Buffer
// webpack.config.js
{
    test: /\.md$/,
    use: './markdown-loader.js',
}

// 代码里
import readme from './README.md';
console.log(readme);  // HTML 字符串

三、Plugin 机制

3.1 Plugin 是什么

Plugin 是 webpack 生命周期的钩子函数,可以做任何 loader 做不了的事情。

// 一个最简单的 plugin
class MyPlugin {
    apply(compiler) {
        compiler.hooks.done.tap('MyPlugin', (stats) => {
            console.log('Build complete!');
        });
    }
}

module.exports = MyPlugin;

3.2 生命周期

webpack 的编译流程:

初始化参数 → 创建 compiler → 加载 plugin
    ↓
开始编译 → 从 entry 找到依赖模块
    ↓
编译模块(用 loader 转换)
    ↓
完成模块编译
    ↓
输出资源(根据 dependency graph 合并 chunk)
    ↓
写入文件

3.3 核心 hook(Tapable)

webpack 用 Tapable 管理 hook:

compiler.hooks.compilation.tap('MyPlugin', (compilation) => {
    compilation.hooks.optimize.tap('MyPlugin', () => {
        console.log('Optimizing chunks');
    });
});
Hook 阶段
compiler.hooks.beforeRun 启动前
compiler.hooks.compile 编译前
compiler.hooks.compilation compilation 创建
compilation.hooks.buildModule 模块编译
compilation.hooks.optimize 优化
compilation.hooks.processAssets 处理资源(webpack5)
compiler.hooks.emit 输出(webpack4)
compiler.hooks.done 完成

3.4 实战:写一个 build 时间统计 plugin

// build-time-plugin.js
class BuildTimePlugin {
    apply(compiler) {
        const startTime = Date.now();

        compiler.hooks.done.tap('BuildTimePlugin', (stats) => {
            const time = Date.now() - startTime;
            const hash = stats.hash;
            const warnings = stats.compilation.warnings.length;
            const errors = stats.compilation.errors.length;

            console.log(`Build completed in ${time}ms`);
            console.log(`Hash: ${hash}`);
            console.log(`Warnings: ${warnings}, Errors: ${errors}`);

            // 把构建时间写到文件
            const fs = require('fs');
            const path = require('path');
            fs.writeFileSync(
                path.join(compiler.options.output.path, 'build-info.json'),
                JSON.stringify({ time, hash, warnings, errors }, null, 2)
            );
        });
    }
}

module.exports = BuildTimePlugin;

四、模块联邦(Module Federation)

4.1 解决的问题

微前端 / 多应用共享代码。

// 应用 A (Host)
const RemoteButton = React.lazy(() => import('remoteApp/Button'));

// 应用 B (Remote)
new ModuleFederationPlugin({
    name: 'remoteApp',
    filename: 'remoteEntry.js',
    exposes: {
        './Button': './src/Button',
    },
});

应用 A 在运行时去应用 B 加载 Button 组件,实现跨应用共享代码。

4.2 实战:共享 UI 组件库

// 远程模块(组件库)
new ModuleFederationPlugin({
    name: 'uiLibrary',
    filename: 'remoteEntry.js',
    exposes: {
        './Button': './src/components/Button',
        './Modal': './src/components/Modal',
    },
    shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
    },
});

// 主应用
new ModuleFederationPlugin({
    name: 'mainApp',
    remotes: {
        uiLibrary: 'uiLibrary@https://cdn.company.com/ui/remoteEntry.js',
    },
    shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
    },
});

五、splitChunks 与代码分割

5.1 为什么需要代码分割

如果不分割,所有代码打成一个 bundle.js:

bundle.js  5.2 MB

首屏加载慢,用户等了 30 秒白屏。

分割后:

main.chunk.js    200 KB   ← 首屏需要
vendor.chunk.js  1.5 MB   ← 第三方库(React、Lodash)
home.chunk.js    300 KB   ← 首页路由
admin.chunk.js   1.2 MB   ← 后台路由(用户不进就不加载)

5.2 webpack 默认行为

optimization: {
    splitChunks: {
        chunks: 'all',         // async + initial 都分割
        minSize: 20000,        // 最小 20KB 才分割
        minChunks: 1,          // 至少被 1 个 chunk 引用
        maxAsyncRequests: 30,
        maxInitialRequests: 30,
        cacheGroups: {
            defaultVendors: {
                test: /[\\/]node_modules[\\/]/,
                priority: -10,
                reuseExistingChunk: true,
            },
            default: {
                minChunks: 2,
                priority: -20,
                reuseExistingChunk: true,
            },
        },
    },
}

5.3 实战:精细化分包

splitChunks: {
    cacheGroups: {
        // React 全家桶 - 一个 chunk
        react: {
            test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
            name: 'react-vendor',
            priority: 30,
        },

        // 工具库
        utils: {
            test: /[\\/]node_modules[\\/](lodash|date-fns|dayjs|axios)[\\/]/,
            name: 'utils',
            priority: 20,
        },

        // UI 库
        ui: {
            test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
            name: 'ui-vendor',
            priority: 25,
        },

        // 其他第三方
        vendors: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
            priority: 10,
        },

        // 公共业务代码
        common: {
            minChunks: 2,
            priority: 5,
            reuseExistingChunk: true,
        },
    },
},

效果:首屏 chunk 从 5MB 降到 200KB,首屏加载从 30 秒降到 2 秒。

5.4 动态导入

// 路由级别代码分割
const Home = lazy(() => import('./pages/Home'));
const Admin = lazy(() => import('./pages/Admin'));
const Settings = lazy(() => import('./pages/Settings'));

// 组件级别
const HeavyChart = lazy(() => import('./components/HeavyChart'));

// 条件加载
if (user.isAdmin) {
    const { AdminPanel } = await import('./AdminPanel');
}

webpack 自动把 import() 拆成独立的 chunk。

六、Tree Shaking

6.1 原理

Tree Shaking 是消除死代码(Dead Code Elimination)。

前提:代码必须是 ES Module(静态结构)。

// 静态 ES Module - 可以 tree shake
import { add } from './math';  // 静态分析知道只用了 add

// CommonJS - 不能 tree shake
const { add } = require('./math');  // 运行时才知道用了什么

6.2 sideEffects 标记

在 package.json 里声明模块无副作用:

{
    "name": "my-lib",
    "sideEffects": false
}

sideEffects: false 告诉 webpack:这个包的所有代码都是纯粹的(没有 import './style.css' 之类的副作用),可以安全 tree shake。

6.3 实战:从 lodash 改成 lodash-es

// NG - 不能 tree shake
import _ from 'lodash';
_.debounce(fn, 100);

// OK - 能 tree shake
import { debounce } from 'lodash-es';
debounce(fn, 100);

lodash-es 用 ES Module 写,webpack 知道只用了 debounce,其他函数全删掉。

打包结果:

lodash:  70KB
lodash-es(按需):  2KB

6.4 webpack 的副作用分析

module: {
    rules: [
        {
            test: /\.js$/,
            sideEffects: false,  // 标记整个目录的 JS 都无副作用
        },
    ],
},

七、实战:大型 SPA 启动 90s → 5s

7.1 问题描述

公司内部一个老项目(运营管理系统):

7.2 第一步:升级 webpack 版本

webpack 4 → webpack 5。

webpack 5 的改进:

冷启动:90 秒 → 50 秒。

7.3 第二步:优化 Loader

// 之前
{
    test: /\.tsx?$/,
    use: 'ts-loader',
}

// 之后 - 用 transpileOnly
{
    test: /\.tsx?$/,
    use: {
        loader: 'swc-loader',  // 比 ts-loader 快 5 倍
        options: {
            // swc 配置
            jsc: {
                parser: { syntax: 'typescript', tsx: true },
                target: 'es2020',
            },
        },
    },
}

冷启动:50 秒 → 25 秒。

7.4 第三步:优化 resolve

resolve: {
    // 减少文件搜索
    modules: [path.resolve(__dirname, 'src'), 'node_modules'],
    extensions: ['.tsx', '.ts', '.js', '.jsx'],  // 不要太多
    alias: {
        '@': path.resolve(__dirname, 'src'),
        // 避免大模块的深层查找
        'lodash': 'lodash-es',
    },
},

冷启动:25 秒 → 20 秒。

7.5 第四步:分包 + Tree Shaking

(前面讲过,这里应用)

main.chunk.js    800KB
react-vendor.js  450KB
ui-vendor.js     900KB
utils.js         200KB
admin.js         1.5MB
chart.js         900KB
common.js        400KB

7.6 第五步:缓存

cache: {
    type: 'filesystem',
    buildDependencies: {
        config: [__filename],  // 配置文件改了才清缓存
    },
},

// babel-loader 缓存
{
    loader: 'babel-loader',
    options: {
        cacheDirectory: true,
        cacheCompression: false,  // 不压缩缓存,加快读取
    },
}

二次冷启动:5 秒(从 20 秒降到 5 秒)。

7.7 第六步:thread-loader

{
    test: /\.tsx?$/,
    use: [
        {
            loader: 'thread-loader',
            options: { workers: 4 },  // 4 个 worker 并行
        },
        {
            loader: 'swc-loader',
            // ...
        },
    ],
}

把耗时的 loader 任务并行化。

冷启动:5 秒 → 3.5 秒。

7.8 最终效果

阶段 冷启动 HMR 产物
原始 90s 45s 5.2MB
webpack 5 50s 25s 4.8MB
swc-loader 25s 12s 4.5MB
resolve 优化 20s 10s 4.5MB
持久化缓存 5s 3s 4.5MB
thread-loader 3.5s 2s 4.5MB

90 秒压到 3.5 秒,提升 25 倍——全是配置和缓存优化。

八、webpack 的局限

8.1 JS 实现的天花板

webpack 是 JavaScript 实现,Node.js 单线程。CPU 密集任务无法突破。

8.2 内存占用大

处理大项目时,webpack worker 占用 2-4GB 内存。

8.3 配置复杂

新手配置 webpack 像在背天书。

8.4 生态老化

很多老插件不维护,新工具也不出 webpack 版本。

这些是 Rspack / Vite 解决的核心问题。

九、本篇小结

主题 关键点
Loader 文件转换,从右往左
Plugin 生命周期 hook
splitChunks 拆分代码,首屏最小
Tree Shaking ES Module + sideEffects 标记
Module Federation 跨应用共享代码
持久化缓存 filesystem cache 必备
thread-loader 多核并行

下一篇讲 esbuild 原理与速度革命——为什么 Go 写编译器这么快。

参考资料