☰
Webpack 4多页面项目构建优化实战:从80秒到20秒
2026/10/2 9:13:05 网站建设 项目流程

前段时间在搞一个内部代号叫“新东方”的教育类Web前端项目,技术栈是Vue2 + Webpack 4,后端接口是Java那一套。项目本身不算大,但涉及多页面入口、大量第三方依赖,还有不少老的jQuery插件要兼容。整个构建配置从零开始搭,前前后后踩了不少坑,最后把打包时间从80多秒压到了20秒以内,首屏体积也砍掉了差不多三分之一。这篇就把整个webpack配置和优化过程完整记录下来,包括每一步的思路、关键参数的选择、实测数据,以及一些你搜文档不一定能搜到的经验。

如果你是刚接触webpack没多久、正在准备接手或改造一个老前端项目的,这篇文章应该能帮你少走很多弯路。文章里所有的配置代码都是项目里实际跑过的,直接抄作业基本没问题,但更建议你跟着我一起把原理捋清楚,这样遇到别的场景也能自己改。

1. 项目定位与整体设计思路

1.1 为什么选webpack而不是别的打包器

先说一个很多人会纠结的问题:现在Vite已经这么流行了,新项目为什么还要用webpack?“新东方”这个项目的情况比较典型——它不是新起炉灶,而是接手一个已经跑了三四年的老项目。老项目里到处都是CommonJS模块、自定义的webpack插件、各种改自webpack的构建脚本,甚至有几个第三方库的代码必须依赖webpack的ProvidePlugin才能跑起来。

vite的dev server虽快,但Rollup生态和CommonJS的兼容性在处理老库的时候需要打各种patch,投入产出比不划算。webpack的生态最成熟,Loader和Plugin几乎能覆盖所有历史包袱,而且团队里的人基本都有webpack经验,接手成本最低。所以就一句话:老项目改造,稳定性和兼容性优先,webpack是当时的最优选。

1.2 项目结构与环境划分

整个项目拆成了三个webpack配置文件,各司其职:

  • webpack.base.js:公共配置,entry、output、resolve、loader这些两边共用的都放这里。
  • webpack.dev.js:开发环境配置,主要加devServer、HMR、更快的sourcemap、更宽松的压缩策略。
  • webpack.prod.js:生产环境配置,压缩、代码分割、文件名带hash、静态资源CDN前缀等。

不需要再单独搞一个webpack.common.js加webpack.dll.js的组合,因为实际测试下来DLL在Webpack 4年代的提速收益已经被cache: true加thread-loader替代得差不多了,还徒增维护成本。

设计上还有一个重要决策:多页面入口。项目有12个页面入口,每个入口对应一个业务模块,比如首页、课程列表、学员中心这些。入口设计直接决定了后续代码分割的方式,这个下面单独讲。

2. 配置文件拆解与核心参数详解

2.1 入口、出口与resolve设计

入口这块,我没有用那种把所有入口列成一个长数组的写法,而是用一个glob匹配src/pages/*/index.js,自动生成entries。这样新增一个业务模块只需要新建目录,不需要手动改配置。

// webpack.base.js const glob = require('glob'); const path = require('path'); const entries = {}; glob.sync('./src/pages/*/index.js').forEach((file) => { const name = file.match(/src\/pages\/(.*?)\/index\.js/)[1]; entries[name] = path.resolve(__dirname, file); }); module.exports = { entry: entries, output: { path: path.resolve(__dirname, 'dist'), filename: 'js/[name].[contenthash:8].js', chunkFilename: 'js/[name].[contenthash:8].js', publicPath: process.env.NODE_ENV === 'production' ? 'https://cdn.example.com/static/' : '/', }, // ... };

入口文件filename里的[contenthash:8]是给生产环境用的,内容变了hash才变,方便浏览器缓存。这里注意,webpack 4里hash和contenthash是有区别的,hash是整个构建的hash,任何文件变了它都变,chunkhash是入口chunk的hash,contenthash是针对文件内容的,最适合长缓存。用contenthash:8不要用完整的hash,8位足够防碰撞,还能控制文件名长度。

publicPath这个很多人忽略,其实特别关键。生产环境所有静态资源都是走CDN的,如果这里没配,你会发现页面能打开,但所有的js/css都404,因为路径请求到了你的服务端而不是CDN。这个坑我在项目上线前最后一个晚上踩过,印象非常深。

resolve配置是整个构建速度的重要源头之一。我当时做了一件事:把resolve.modules显式指定为['node_modules'],然后加上resolve.mainFields: ['browser', 'module', 'main']。前者告诉webpack只在node_modules里找依赖,不要往上层层查找;后者保证优先使用库的浏览器版本,避免打包Node环境专用代码导致polyfill满天飞。

注意:resolve.alias不要滥用。我给项目配过几个alias,比如把@指向src,但后来发现某个老库内部路径和alias冲突了,导致模块被重复打包。最后只保留了@ -> src和vue$ -> vue/dist/vue.runtime.esm.js这两个,其余全部移除。

2.2 loader链路的搭配逻辑

Loader是整个webpack配置里最值得花时间调优的部分。该项目里最关键的是babel-loader。

// webpack.base.js module.exports = { module: { rules: [ { test: /\.js$/, exclude: /node_modules/, use: [ { loader: 'thread-loader', options: { workers: 3 } }, { loader: 'babel-loader', options: { cacheDirectory: true, cacheCompression: false, }, }, ], }, { test: /\.vue$/, use: [ { loader: 'thread-loader', options: { workers: 3 } }, 'vue-loader', ], }, // 处理图片、字体等静态资源 { test: /\.(png|jpe?g|gif|webp|svg)$/, use: [ { loader: 'url-loader', options: { limit: 4096, name: 'img/[name].[contenthash:8].[ext]', publicPath: '/', }, }, ], }, ], }, };

为什么要用thread-loader?Webpack处理文件时是单线程的,大量js文件要做babel转译,CPU的一次次转译就是构建时间的大头。thread-loader把后续loader放到worker池里并行跑,等于把单个转译任务拆给多个进程。

options.workers: 3这个数字不是拍脑袋定的。我机器的CPU是6核,官方建议worker数等于os.cpus().length - 1,留一个核心给webpack主进程和其他系统任务,避免电脑直接卡死。如果强行开满6个,编译时间可能反而变慢,因为线程调度和通信有额外开销。

cacheDirectory: true的作用是让babel-loader把转译结果缓存到node_modules/.cache里,二次构建直接读缓存。这里有一个很多人不知道的小经验:cacheCompression一定要设成false,因为默认true会对缓存文件做gzip压缩,压缩本身消耗的时间比你省下的那点磁盘空间更值钱,关掉后二次构建明显更快。

Vue文件用vue-loader处理,这个loader要求.vue文件的<template>等部分还需要配合VueLoaderPlugin使用,忘了加这个plugin会直接报错。项目刚开始接手时就是少了这一句,导致所有.vue文件全部编译失败,报错信息还特别迷惑,只说“You may need an additional loader”,排查了半天。所以配置里:

const VueLoaderPlugin = require('vue-loader/lib/plugin'); // plugins里一定要加 new VueLoaderPlugin(),

2.3 插件体系的核心选择

Webpack插件的选择,重点看它对你项目的实际贡献,而不是越全越好。我给“新东方”项目配的插件列表是这样的:

// webpack.prod.js const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin'); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = merge(baseConfig, { mode: 'production', plugins: [ new CleanWebpackPlugin(), // 每个页面入口生成对应的html ...Object.keys(entries).map((name) => { return new HtmlWebpackPlugin({ filename: `${name}.html`, template: `./src/pages/${name}/index.html`, chunks: [name, 'vendor', 'common'], minify: { removeComments: true, collapseWhitespace: true, removeAttributeQuotes: false, }, }); }), new MiniCssExtractPlugin({ filename: 'css/[name].[contenthash:8].css', chunkFilename: 'css/[name].[contenthash:8].css', }), ], optimization: { minimizer: [ new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true }, }, }), new OptimizeCssAssetsPlugin(), ], }, });

这里有几个关键点值得展开说。

**第一个是HTML模板和chunks的配合。**每个页面入口的HtmlWebpackPlugin都要显式指定chunks,否则构建出来的html会把所有入口的js/css都塞进去,不仅首屏加载一堆没用代码,页面之间还会互相污染。指定chunks: [name, 'vendor', 'common']的含义是:当前页面的入口js + 公共vendor包 + 公共业务包。这个后面会在代码分割里详细说。

**第二个是mini-css-extract-plugin。**开发环境不需要这个,css直接用style-loader注入style标签,热更新更爽;生产环境才抽出来成独立css文件,利用浏览器并行加载。开发和生产两边用的loader不一样,这个注意在dev配置里覆盖掉。

**第三个是drop_console。**生产环境去掉所有console.log,这个很多人知道。但如果你项目里有用console.error做错误上报的地方,建议改成只droplog和info,保留error和warn。我是吃过这个亏的,上线后线上错误日志全没了,排查问题全靠猜。

3. 打包提速实战:从编译到产出的时间优化

3.1 多进程与持久化缓存

先说结论:这一套组合拳打完,冷启动构建时间从原来的84秒降到了22秒,二次构建基本稳定在9秒以内。

刚才提到了thread-loader和babel-loader的cache,这只是第一步。再往上加的是webpack自己自带的持久化缓存。注意,webpack 4不自带cache选项,需要单独配,webpack 5才内置了cache: { type: 'filesystem' }。如果你还在用webpack 4,推荐装一个hard-source-webpack-plugin。

这个插件我第一次用的时候遇到一个很隐蔽的问题:配置好后一次构建正常,第二次构建报错,说是某个模块的transformed code不一致。其实是写缓存的时候部分模块读取了变化中的文件状态,后来我把HardSourcePlugin的configHash用构建脚本的修改时间生成,就稳定了:

// webpack.base.js const HardSourcePlugin = require('hard-source-webpack-plugin'); const webpackConfigHash = require('crypto') .createHash('md5') .update(fs.readFileSync(require.resolve('./webpack.base.js'))) .digest('hex'); // plugins中 new HardSourcePlugin({ configHash: webpackConfigHash, cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/hard-source'), }),

原理其实很简单:webpack每次构建都要重新解析模块依赖图、执行loader转译。hard-source把每个模块的编译结果存到磁盘上,二次构建时只要源文件没变,就直接从磁盘读,省掉了最耗时的babel转译环节。

这里强调一下踩过的一个重要结论:**先开缓存,再开多进程。**很多人一上来就加thread-loader,说怎么速度没提升多少。因为如果loader结果没有缓存,多进程确实能加速第一次构建,但第二次构建的收益就很小。而缓存单独开,第二次构建就已经能快上不少。两者叠加才是正向最优解,顺序错了效果会打折。

3.2 模块范围控制与目录瘦身

有一类“隐形”的性能杀手在构建日志里看不出来,叫无效模块解析。比如项目里某个入口import了一个老库,那个老库里引用了一堆Node内置模块或json文件,webpack会去尝试解析、甚至polyfill,这部分耗时非常可憎。

解决思路是尽量减少webpack解析和扫描的文件范围:

module.exports = { module: { noParse: /jquery|lodash|moment/, }, };

noParse的含义是:这些库不需要解析内部依赖,直接当整体打包。像jQuery、lodash这种已经是UMD格式的库,内部没有require调用,关掉解析完全不影响功能,但构建速度能涨一截。

对moment这个库,我还做了更狠一点的处理:moment默认会加载所有语言包,一个locale文件就是几条命令在跑,而且它内部还用了动态require,webpack会把几十种语言全打包进去。最终处理是:

// webpack.base.js resolve: { alias: { 'moment': 'moment/min/moment-with-locales.min.js', }, }, // 业务代码里只引入需要的语言 import moment from 'moment'; import 'moment/locale/zh-cn';

还有一类问题是模块解析效率。项目里有个业务组件引用了某目录下的几十个文件,每个文件都写了一大串相对路径,比如../../../utils/api.js。这种写法本身没错,但webpack每次都要一遍遍去解析这种相对路径、逐级找文件,成本比用alias高得多。所以:

resolve: { alias: { '@': path.resolve(__dirname, 'src'), '@utils': path.resolve(__dirname, 'src/utils'), '@api': path.resolve(__dirname, 'src/api'), }, },

然后把业务代码里的相对路径引用全改成@/components/xxx这种写法。实测解析这一项也能省下大概5%的构建时间,积少成多。

注意:resolve.extensions不要配置太多。我之前在一份配置里见过extensions: ['.js', '.jsx', '.json', '.vue', '.ts', '.tsx', '.css', '.scss']这种写法,本身没问题,但每个import都要遍历一遍这些后缀,顺序靠前的命中率高,顺序靠后的基本没用。最后我只保留['.js', '.vue', '.json'],其他特例用完整路径引入。

3.3 dev模式下的按需编译

开发阶段和生产的优化方向完全不一样,dev追求的是响应的即时性,不是构建产物体积。有一件很反常识的事情:生产环境通常会开启代码分割和压缩,但dev环境最好不要,因为压缩和拆包反而会让增量编译变慢。

我的dev配置核心如下:

// webpack.dev.js module.exports = merge(baseConfig, { mode: 'development', devtool: 'eval-cheap-module-source-map', devServer: { contentBase: './dist', hot: true, inline: true, historyApiFallback: true, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, }, module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'], }, ], }, });

devtool的选择非常关键。eval-cheap-module-source-map在webpack 4里是个性价比极高的配置:source map质量足够定位到具体行,编译速度又比完整的source-map快不少。如果你在开发阶段发现控制台报错位置总是定位到打包后的代码,就是这里配置的问题。

proxy解决的就是前端本地调试时接口跨域的问题。把/api开头的请求全部转发到后端服务,changeOrigin: true这个选项容易漏,不写的话有些后端会因为你请求头里的Host不对而拒绝。我在项目里遇到过接口全部401的情况,排查半天发现是Host头部被带成了localhost:8080,后端认证逻辑校验的是真实域名。

另外,dev环境会配置HMR(模块热替换)。Vue项目要在入口文件里加这么一段:

// main/index.js if (module.hot) { module.hot.accept(); }

没有这一句,Vue组件文件改了之后不会局部刷新,而是整页reload,之前的编辑状态、弹窗、表单输入全丢,开发体验极差。这个细节如果你接手别人配好的环境,可能根本不会注意到,但自己从零搭的时候很容易漏。

4. 体积优化:把首屏体积降下来的几种招

4.1 代码分割的正确姿势

构建速度搞定之后,第二个大问题是体积。“新东方”项目接手时首屏js差不多有4MB(未压缩前),gzip后也有1.2MB,页面打开白屏时间在三秒以上。这个体量明显不正常,核心原因是所有页面的代码全打包在一个大bundle里了。

代码分割就是拆。我用的是SplitChunksPlugin,webpack 4默认内置,配置方式如下:

// webpack.prod.js optimization: { splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendor', priority: 10, chunks: 'all', }, common: { minChunks: 2, minSize: 30000, name: 'common', priority: 5, }, styles: { test: /\.css$/, minChunks: 2, enforce: true, }, // 把echarts这种大库单独拆出来 echarts: { test: /[\\/]node_modules[\\/]echarts[\\/]/, name: 'echarts', priority: 20, chunks: 'all', }, }, }, },

这里的核心是cacheGroups。webpack会扫描所有模块的引用关系,把符合条件的模块组合成一个新的chunk。

vendors组负责把node_modules里的库全部抽到vendor.js里,这样业务代码和依赖库分开,改业务代码时vendor的contenthash不变,可以继续用缓存。common组负责把多个页面共同引用的业务代码抽出来,minChunks: 2表示至少被两个页面引用的代码才抽,避免把只在一个页面用到的代码也拆出去,反而增加请求数。echarts单独拆,是因为这个库本身就快500KB,而且只在课程数据页用到,放vendors里会让所有页面都背上这个重量,单独拆出来只有用到echarts的页面才加载它。

这里有个实战误区要提醒:很多人以为拆得越细越好。实际上,HTTP请求数是有成本的,每个chunk都要一次网络请求。拆成二三十个小文件,HTTP2下还好,HTTP1.1下浏览器一个域名并发只有6个连接,十几个chunk排队下载的时间比省下那点体积还亏。项目里我最终控制拆出来的chunk总数在8个以内。

我实际效果是:优化后首屏依赖的js体积从4MB降到1.9MB(未压缩),gzip后约580KB,白屏时间从3.2秒降到了1秒以内。

4.2 压缩配置与Tree Shaking

体积优化的第二个大头是压缩和摇树。

Tree Shaking在webpack 4里是生产模式自动开启的,但有个前提:代码必须是用ES Module写的。项目里老代码全是CommonJS的module.exports写法,Tree Shaking对它们完全无效。这就导致了一些看起来没被引用的代码依然被打进了bundle。

对于业务代码,我没法做到短时间内全部改造完成,但做了一件性价比极高的事:排查那些把整个库引入却只用其中一两个函数的地方。最典型的就是lodash:

// 之前 import _ from 'lodash'; const result = _.cloneDeep(data); // 改成 import cloneDeep from 'lodash/cloneDeep'; const result = cloneDeep(data);

就这么一条,lodash的打包体积从大概150KB降到20KB以内。类似这种“按需引库”的改造,我梳理了项目里12处,整体体积直接降了不少。

再就是压缩。TerserPlugin的配置里有一个容易被忽略的点:

new TerserPlugin({ parallel: true, terserOptions: { compress: { drop_console: true, drop_debugger: true, }, output: { comments: false, }, }, extractComments: false, }),

extractComments: false这个一定要加。默认情况下Terser会把代码里的@license等注释提取成一个.LICENSE.txt文件,交给html引用。如果项目里有很多第三方库,这些license文件会积累一堆,而且会在产物里生成一堆碎文件,看着非常乱。关掉后注释直接丢弃,产物干净很多。

gzip压缩这个事也要说清楚。生产环境一般由Nginx开启gzip,所以webpack打包时不需要再压缩一遍。但如果你用CDN且CDN没有自动压缩,需要在配置里加上compression-webpack-plugin生成.gz文件,再让Nginx优先使用。我构建时顺手配了,因为公司CDN没开gzip,直接放.gz文件比让CDN现压缩更稳。

4.3 图像与静态资源处理

项目里的图片资源之前是散落在各个目录,有的走url-loader转base64,有的走file-loader生成文件。我当时看到一个特别严重的问题:有一张背景图,原图2MB的jpg,被url-loader直接转成了base64塞进了css文件里,css体积直接爆掉,首屏css加载被硬生生拖慢了一秒多。

这个问题的根源是limit配置太大。项目里原来是这么配的:

{ loader: 'url-loader', options: { limit: 1024 * 100, // 100KB }, }

100KB以下全部转base64。看着挺合理,但没考虑到图片的base64体积会膨胀约33%,而且css里base64没法被浏览器按需加载,他会跟随css的加载全量解析。

我最终的策略是:

  • limit: 4096,4KB以下转base64,小了没意义,大了拖累css。
  • 4KB以上的走文件输出,用name: 'img/[name].[contenthash:8].[ext]'。
  • 大图(超过200KB的)顾顾不上懒加载的业务场景,就配合file-loader加一个publicPath,让图片走CDN地址。

还有一类静态资源容易漏:favicon和manifest.json。这类文件如果放在public静态目录里,webpack不会处理它们,生产环境需要手动复制到dist。我用copy-webpack-plugin统一处理:

new CopyWebpackPlugin([ { from: './public/favicon.ico', to: './' }, { from: './public/manifest.json', to: './' }, ]),

这个不起眼,但漏掉的话,上线后favicon 404,域名标签页上就是个打叉图标,很掉档次。

5. 优化效果与性能验证

5.1 优化前后的核心指标对比

数据不会骗人,这个项目优化前后的核心指标如下:

指标优化前优化后提升比例
冷启动构建时间84s22s约74%
二次构建时间(无改动)51s9s约82%
首屏未压缩JS体积约4MB约1.9MB约52%
gzip后体积1.2MB580KB约52%
白屏时间(模拟器)约3.2s约0.9s约72%
最大chunk体积1.1MB约320KB约71%

需要说明的是,构建时间这个指标受机器性能影响很大,不同机器之间横向对比没有意义。关键是同一台机器、同一个项目的纵向对比,优化的效果是非常明显的。

有一个容易被忽略的指标是chunk数量。优化前整个产物只有一个大bundle,加上图片和css,总共就七八个文件;优化后拆出了vendor、common、各页面入口等,加起来有15个文件左右。文件多了,但总请求数是合理的。如果拆得太多,我要在构建日志里留意有没有warning提示某个chunk体积超过了推荐限制。

5.2 验证方法与监控工具

推荐两个可以直接抄的工具,用来量化优化效果。

第一个是webpack-bundle-analyzer。在devDependencies里装好后,在webpack.prod.js里临时加上:

const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; // 构建时用环境变量控制,不想看的时候不用改代码 if (process.env.ANALYZE) { plugins.push(new BundleAnalyzerPlugin()); }

跑ANALYZE=true npm run build,构建完会自动打开一个可视化面板,能直观看到每个模块的体积占比。echarts、lodash这种大块头一目了然。我就是靠这张图定位到lodash和moment的体积问题的。

第二个是性能监控。改完之后我用Lighthouse在模拟器上跑了一遍,重点看三个指标:FCP(首次内容绘制)、LCP(最大内容绘制)、TBT(总阻塞时间)。优化后FCP从3.2s降到了1.1s,LCP从3.8s降到1.5s,TBT从760ms降到180ms。对教育类落地页来说,这个表现已经能进绿区了。

这里还想分享一个从后端同事那里学到的经验:打包后的体积监控最好固化成一个CI检查。我在.gitlab-ci.yml里加了一步,构建完了检查gzip后超过1MB就fail,防止后面的人加个依赖就把体积又拉回去。这个算是个长期保障,效果比口头约定好得多。

6. 常见问题与排查手册

6.1 典型问题速查

整个配置和优化过程中,遇到的最典型问题,我整理成一个速查表:

现象可能原因解决方向
构建报“Module not found: Can't resolve 'xxx'”依赖包本身缺文件,或resolve配置没覆盖到检查resolve.extensions和resolve.mainFields
开发环境正常,生产环境JS报错devtool sourcemap类型差异大,或生产模式下压缩代码改动先在mode: 'none'下构建,定位真实报错行
CSS正常但样式错乱css-loader和style-loader顺序不对,或MiniCssExtractPlugin配置未生效检查loader的use数组顺序,从右往左执行
打包体积莫名暴涨某个库被重复打包,或动态import没生效用webpack-bundle-analyzer检查重复模块
HMR不生效,改vue组件整页刷新入口文件少了module.hot.accept(),或vue-loader版本不兼容检查入口模块热更新代码,确认VueLoaderPlugin已注册
图片全部变成base64,css体积爆炸url-loader的limit太大调小limit至4096,大图走file-loader输出
生产环境资源404publicPath配错检查CDN路径与output.publicPath的拼接规则
二次构建时间和首次几乎一样缓存没有生效,或hard-source和部分loader不兼容确认cache相关插件列表,必要时清缓存重试

6.2 排查思路与方法技巧

再说几条真正有用的排查经验。

第一条,用webpack --profile --json输出构建过程JSON,然后再解析这个JSON文件来分析各模块耗时。这个比看构建日志的秒数直观得多:

webpack --profile --json > build-stats.json node analyze-stats.js

analyze-stats.js脚本的思路是读取build-stats.json里的modules数组,按build-time字段排序,找出耗时最长的前20个模块。我靠这个找到了几个构建时间超1秒的老组件,原因是有个大json文件被直接import了,webpack每次构建都要序列化转换,提升很快。

第二条,先开一个最小的复现项目。遇到webpack诡异问题,最忌讳在大型项目里直接试各种配置改动,因为项目里模块太多,错误信息往往会被吞掉或者被其他插件干扰。我当时遇到一个sass-loader和node-sass的版本冲突问题,直接在大项目里改各种版本,越改越乱。后来起了一个最小的webpack项目,只放一个.scss文件,一分钟就复现了,三分钟就定位到了是node-sass4.x绑定Node版本的问题。

第三条,留意webpack日志的warning。大多数warning都能直接指出问题,比如Chunk.entrypoints里提示npm包体积过大,Conflicting order提示模块引入顺序可能被抽包打乱。这些warning不像error那么显眼,但往往是后面线上bug的隐患。我处理过一个Conflicting order的warning,当时没管,结果上线后某个页面偶发样式错乱,排查了很久才回来处理它。

第四条,善用webpack-merge的覆盖顺序。多个配置文件合并的时候,数组类型的配置是追加而非覆盖,这是一个非常容易踩的坑。比如webpack.base.js里的module.rules是数组,webpack.prod.js里如果再push一条loader规则,它不会替换掉base里的,而是追加在后面。这样会导致loader执行两次或者规则冲突。解决办法是:

const { merge } = require('webpack-merge'); module.exports = merge({ customizeArray: (a, b, key) => { if (key === 'module.rules') { return b; // 用prod的rules整体覆盖base的 } return undefined; }, })(baseConfig, prodConfig);

这个坑排查了好几小时,说出来都是泪。

6.3 关于缓存失效的深层原因

最后单独写一下缓存失效的坑。build后文件的contenthash变了,这是个让人头疼的问题。有次我什么都没改,只是重新部署一遍,发现很多文件的hash全变了,等于所有用户都要重新下载一遍全部静态资源,缓存策略失效了。

排查后发现原因是构建机器时间不同。我们用的字段是[contenthash:8],但在Webpack 4里,这个hash的实际自定义计算逻辑在某些情况下会依赖模块的构建顺序。如果两个chunk文件在同一秒内被构建出来,而module的id是递增的,一旦新增或删除了一个入口,后面所有模块的id都会往后挪一个,contenthash就会全变。

解决办法有两个:

第一个是开启optimization.moduleIds: 'hashed',让模块id基于模块路径生成,而不是简单的递增数字:

optimization: { moduleIds: 'hashed', },

第二个是给文件名加上chunkhash而不是contenthash。虽然contenthash适合细粒度缓存,但在处理“入口新增导致所有文件hash全变”这个问题上,chunkhash会更稳定。实践中我最终保留了一个混合策略——JS文件用chunkhash:8,CSS用contenthash:8,这样改动模块时,兄弟模块的hash不会全部失效。

还有一个更隐蔽的问题:第三方依赖升级导致hash变化。npm包升级后,虽然你没改业务代码,但vendor里的内容变了,vendor的hash肯定要变。这个没办法规避,但可以通过一个手段保障业务代码的hash稳定——就是上面说的强制把第三方依赖放到一个固定vendors缓存组里。

最后,分享一个个人强烈推荐的习惯:把webpack.base.js、webpack.dev.js、webpack.prod.js的配置写好后,一定不要直接跑生产构建,先在本地用webpack --mode production跑一次,确认输出日志里没有warning再上。这能省很多后续的问题排查时间。

整篇写下来,核心就一句话:webpack配置没有银弹,所有优化都建立在理解项目实际瓶颈之上。构建慢就去找慢的模块,体积大就去看大的依赖,缓存失效就去查hash策略。把工具用到位,问题基本都是可以量化、可以解决的。这套“新东方”项目的配置方案如果你也在改造老项目,照着参考一下应该能省不少试错的时间,改配置的时候多留一份心眼,每个参数改动前想清楚它解决什么问题、会引入什么副作用,你会少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询