做前端这些年,我越来越觉得webpack像一门"很熟又不熟"的手艺。天天在用,打包、热更新、上线都靠它,可一旦遇到性能问题或者特殊需求,很多人第一反应是去网上复制一段配置,而不是自己动手查清楚每一行在干什么。这个系列做到第13期,我把主题定为"013-webpack:新东方"——不是那个培训机构,而是我给自己定的一条规矩:新,是重新认识;东方,代表不盲从别人给的配方,把webpack配置和打包优化这件事的主动权拿回自己手里。
这篇文章不打算讲高深原理,也不搞逐行源码分析。我想做的是把从一个真实项目出发,从零手动搭建一套webpack配置、做体积分析和构建提速、处理开发环境和生产环境的差异,以及最后折腾出的那一堆坑,完整地分享出来。里面所有配置都是实测跑过、验证过的,按照这篇的思路走,你至少能有一套属于自己的、可解释每个选项来由的webpack配置。
如果你现在处于用脚手架一把梭、配置全靠复制的阶段,这篇文章应该能帮你过渡到"我清楚自己的构建系统每一环在干嘛"的状态。
1. 为什么放着脚手架不用,非要自己手动写配置
1.1 脚手架的黑盒:省事的代价
先说说大多数人日常的状态。创建一个新项目,一把create-react-app或者vue-cli敲下去,没几分钟一个能跑的前端项目就起来了。webpack在里面扮演的角色被完全封装起来,隐藏在那几千行复杂配置里。你根本不需要知道entry在哪、output怎么生成、loader为什么长那个样子,开发服务器就自动跑起来了。
这种省事当然好,但它有一个隐性代价:当默认配置不够用的时候,你不知道去哪找需要改的那一行。我见过很多做了一两年前端的朋友,项目要求部署到子路径下,出现所有静态资源404的问题,第一反应是去改publicPath,可改完路径又不对,最后只能在publicPath、base标签、nginx的location之间反复试错。这种"试出来能跑"和"想清楚为什么这样写"之间,隔着一条不小的认知鸿沟。
老实说,脚手架不存在好不好之说,它只是把决策封装起来了。如果你只做简单的单页应用,CRA那套默认配置是够用的。但一旦遇到多入口、CDN路径、细粒度分包、构建时间过长这些问题,配置的主动权就很重要了。如果再拿不到主动权,就只能做一件很尴尬的事:eject。
1.2 从eject到失控:我的一次真实翻车经历
eject就是把脚手架封装的webpack配置全部弹出来,变成项目里可改的源码。我还在用CRA的时候,有个项目需要做多页面、还要针对不同的后端环境切换API域名,复杂一点就超出默认配置能力了。于是我很自然地在命令行敲了yarn eject。
确实,配置被弹出来了几百行,我也能改任何地方了。但问题在于:那是webpack团队生成的庞大配置,各种env判断、几十个loader组合串在一起,根目录一个几十行的webpack配置文件,加上内部的eval加载流程,踩错一个地方就得在配置迷宫里绕很久。那个项目后面每次构建出问题,查错成本都特别高。
那次经历之后我彻底想明白一件事:自己写的配置哪怕丑一点,也比看不懂的复杂配置强一万倍。因为你清楚每一行的来由,出问题时有明确的排查方向。这也是我写下"013-webpack:新东方"的初衷——把配置从外包团队手里接回来,变成自己能掌控的东西。
1.3 新东方的态度:配置的主动权要自己拿着
"新东方"里面的"东方",在我这里不是一个地理概念,而是一种态度:不拿着大洋彼岸的工具模板当圣旨,而是自己去验证、去推演,建立自己的配置方法论。
很多人在学习webpack时有个误区:一上来就背配置项,entry、output、module.rules、plugins,死记硬背,结果还是不会用。我的经验是反过来——先明确你想解决什么,再有针对性地去查某个配置项该怎么写。配置是写给构建流程看的"说明书",不是为了炫技,也不是为了好看。了解每一种loader为什么存在、每个优化项解决什么问题,比记住几百个配置项更有价值。
关于这个"自己掌控配置"的态度,后面所有章节都是从实际操作展开的:先搭一个能跑的骨架,再做优化,再处理环境差异,最后聊聊我踩过的坑。你会发现,一旦主动权在自己手里,webpack从一个"黑盒工具"变成了一件"随手可调的家伙事"。
2. 从零搭配置骨架:入口到产物的完整链路
在开始之前,先交代一下选型。下面的示例我都用webpack 5 + webpack-cli 4来做。
2.1 版本选型:为什么直接上webpack 5
webpack 4曾经很流行,社区里有大量基于4的配置文章。但如果你现在新起项目,我的建议是直接用5。原因很直接:
- webpack 5内置了
cache持久化缓存,第二次构建速度提升非常明显,这在webpack 4里只能靠第三方插件实现。 - 内置了资源模块(
asset、asset/resource),不再需要单独装file-loader和url-loader。 - 模块联邦(
Module Federation)是5才有的能力,微前端场景下极其实用。 - webpack 5在Tree Shaking和副作用处理上做得更彻底,配合
"sideEffects": false效果更好。
版本上不要用5刚发布的早期小版本,我当时用的webpack@5.88.2,已经比较稳定。webpack-cli是命令行工具,用来执行webpack命令,装一个最新版就行。这两个装好了,构建的基本能力就有了。
2.2 entry与output:多入口项目的基本盘
拿一个典型的多页面项目举例:src目录下有login和dashboard两个页面,各自有独立的JS入口,还共享一些公共代码。我的入口配置是:
module.exports = { entry: { login: './src/login/index.js', dashboard: './src/dashboard/index.js' }, output: { filename: '[name].[contenthash:8].js', path: path.resolve(__dirname, 'dist'), clean: true } };entry里的键名就是output.filename里的[name],这个映射关系我是用了很久才自然记住的。多入口的意义在于,不同页面的代码可以独立加载和缓存,避免用户打开登录页还要下载整包后台代码。
output里有三个关键点:
[contenthash:8]:文件指纹,根据文件内容生成哈希,内容没变哈希就不变,这样浏览器能继续用缓存。path:产物目录,必须用绝对路径。clean: true:每次构建前清空dist目录,这个配置是webpack 5新增的,以前要靠CleanWebpackPlugin。
在这里踩过一个很基础的坑:path一开始我写过path.join(__dirname, '../dist'),后来把项目目录结构调整过,配置没改,构建产物就跑到预想外的地方去了。从此之后我养成了先确认__dirname的定位习惯,在配置里用console.log输出当前路径确认再往下写。
2.3 loader的串联逻辑:让不同类型文件各就各位
webpack默认只认识JS,其他类型的文件都需要loader来"翻译"。关键是理解loader的执行顺序:同一个rule里的loader从右往左、从下往上执行,像流水线一样,先处理格式转换,再做开发适配。
我常用的loader组合是这样的:
module: { rules: [ { test: /\.jsx?$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-env', '@babel/preset-react'] } } }, { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: /\.(png|jpg|jpeg|gif|svg)$/i, type: 'asset', parser: { dataUrlCondition: { maxSize: 8 * 1024 } } } ] }解释一下每个部分的逻辑:
babel-loader是用来做JS语法转换的。比如你用ES6+语法,要兼容旧浏览器,就需要把代码转成ES5。exclude: /node_modules/是性能关键,第三方包基本都已经转译过,没必要再走一遍babel,否则构建时间会成倍增加。css-loader负责解析CSS里的@import和url(),如果你写background: url('./img.png'),css-loader会把相对路径解析到打包体系里。style-loader则负责把解析好的CSS通过<style>标签注入页面。顺序必须是['style-loader', 'css-loader'],因为先由css-loader处理CSS语法,再由style-loader把结果插入DOM。- 图片资源就交给
type: 'asset',这是webpack 5的资源模块。maxSize: 8 * 1024的含义是:小于8KB的图片自动转成data URI,减少HTTP请求;大于8KB的则输出为独立的文件。8KB这个值可以根据项目情况调整,但阈值太低会请求爆炸,太高会导致HTML体积膨胀。
这里有个我后来才想明白的细节:loader看似是"一个文件交给一个loader",实际是每个文件都要经过这条规则链里的所有loader,只是每个loader只处理自己能识别的内容。把loader想成流水线上的工人,就很好理解为什么顺序那么重要了。CSS如果先过style-loader再过css-loader,CSS语法还没解析就被插入页面,结果必然是报错或者样式不生效。
2.4 plugin与mode:补齐Webpack不做的事
loader解决的是"文件类型识别",plugin解决的是"构建流程中那些额外要做的事"。最常见的需求是自动生成HTML、拷贝静态资源、压缩产物。
基础配置里我至少会放这两个plugin:
const HtmlWebpackPlugin = require('html-webpack-plugin'); module.exports = { mode: 'development', plugins: [ new HtmlWebpackPlugin({ template: './public/index.html', filename: 'index.html' }) ] };HtmlWebpackPlugin的作用是:自动生成一个HTML文件,并把构建出来的JS和CSS以<script>和<link>标签的形式注入进去。没有它,你得手动在HTML里写死dist/js/login.xxxx.js,一旦哈希变了就得手工改,那日子就没法过了。
再聊聊mode。mode有三个取值:development、production、none。它不只是个开关,会直接影响webpack的默认行为:
development:开启process.env.NODE_ENV为development,不压缩代码,带完整的错误提示。production:自动启用TerserPlugin做代码压缩,并开启一系列生产环境优化。none:不预设任何优化,像一张白纸。
我踩过的教训是用development模式构建产物交到测试环境,结果线上报错一大堆"未捕获的语法错误",排查半天发现压缩根本没开。后来我养成了规矩:发布前必须用production模式过一遍构建,本地看产物大小和报错,确认无异常再发。
3. 打包优化不是玄学:从体积分析到构建提速
配置骨架跑通以后,接下来进入这个系列的重头戏:优化。很多人一提到webpack优化就想到那几板斧,但每板斧应该什么时候用、参数怎么调、为什么有效,就说不清楚了。这里我按"先诊断、再拆包、后提速"的顺序来。
3.1 先看体检报告:bundle分析器的使用与解读
优化不应该是瞎猜。我每次都先看一份"体检报告",也就是webpack-bundle-analyzer生成的产物依赖分析图。它会以交互式树状图展示每个chunk里包含哪些模块、各自占多大体积、模块之间的依赖关系长什么样。
安装和启动方式:
npm install -D webpack-bundle-analyzer在webpack配置里加上:
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); module.exports = { plugins: [ process.env.ANALYZE === 'true' && new BundleAnalyzerPlugin() ].filter(Boolean) };通过环境变量控制是否启用,这样日常构建不会每次弹浏览器,只在需要的时候带上--env ANALYZE=true。
看分析报告时我主要关注三件事:
- 有没有一个特别巨大的模块被多次引用?比如
moment.js体积本身就不小,如果它还带了完整的语言包,那就得考虑按需引入或者换dayjs。 - 公共依赖是否被正确抽离?如果多个入口重复打了同样的依赖,说明
splitChunks还没生效。 - 是否存在循环引用导致的模块重复?这个问题比较隐蔽,但分析图上会清晰显示两个chunk互相引用。
有一次项目首屏体积极大,一查发现图表库echarts被完整塞进了主入口,而我只在一个弹窗里用到折线图。后来改成按需引入之后,主包体积直接掉了大半。这就是报告的价值——它用直观的方式告诉你,体积都花在哪了。
3.2 代码分割:splitChunks的取舍逻辑
代码分割是打包优化里最值得花时间琢磨的部分。webpack 5的splitChunks取代了旧版的CommonsChunkPlugin,核心逻辑是:把共享的、体积较大的模块从业务代码里抽出来,单独成一个chunk,利用浏览器缓存避免重复下载。
下面是我在项目里用的一个相对理性的配置:
optimization: { splitChunks: { chunks: 'all', minSize: 20000, maxInitialRequests: 30, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10, chunks: 'all' }, commons: { name: 'commons', minChunks: 2, priority: 5, reuseExistingChunk: true } } } }这个配置有几个容易理解偏的点:
chunks: 'all'表示对同步和异步加载的模块都做分割。如果把异步模块排除在外,动态import的代码就永远不会被单独抽取。minSize: 20000的意思是,只有超过20KB的模块才考虑拆分。太小就拆,会搞出很多碎文件,HTTP请求变多,反而得不偿失。cacheGroups里priority越大优先级越高。vendor专门处理node_modules里的第三方依赖,commons处理在多个入口之间被引用两次以上的业务模块。
我把name写成固定字符而不是[name]的原因是:给chunk固定的名字能保证长期构建哈希稳定,不然每次依赖位置变化,chunk文件名就跟着变,缓存命中率会受影响。
有一件很多人忽略的事:splitChunks不是配置完就万事大吉,分割粒度要配合业务模块体积来看。如果你的项目只有几十KB,硬拆出七八个chunk,效果适得其反。所以做代码分割前,先回答一个问题:拆出来的包是否真的会被独立加载、独立缓存?
3.3 Tree Shaking的生效条件与副作用声明
Tree Shaking是我以前最容易"以为会了、实际没生效"的一项。它听起来玄乎,核心原理就一句话:通过ES Module的静态语法分析,把import了但没被用到的代码在打包时删除掉。
它要真正生效,至少满足三个条件:
{ "name": "my-app", "sideEffects": false }第一,必须是ES Module语法。require和module.exports是CommonJS,动态导入无法静态分析,Tree Shaking就不生效。所以你在业务代码里应该优先用import/export。
第二,在package.json里声明"sideEffects": false,告诉webpack这个包导出时没有副作用,可以安全地删除未使用的导出。如果你的代码里有调用全局变量、修改外部状态的逻辑,那就不能盲目设false,更稳妥的做法是写成:
"sideEffects": [ "./src/styles/**/*.css", "*.css" ]把CSS保留下来,因为CSS被引入时有真正的副作用——将样式注入页面。
第三,webpack的生产模式下压缩器(TerserPlugin)需要参与配合,因为Tree Shaking只负责标记未使用的代码"可删除",真正删除动作由压缩器完成。
我在实现国际化功能时遇到过这类情况:某个工具库里导出了十几个函数,我只用到其中一个,可打包后体积纹丝不动。排查半天,发现那个包的主入口是CommonJS版的index.js,Tree Shaking完全插不上手。换了一个有ES Module入口的版本后,体积立刻降下来。选择依赖时,留意包是否提供module字段指向ESM版本,对打包体积的影响比想象中更大。
3.4 构建提速三板斧:缓存、并行与持久化
优化不光要看产物体积,构建时间同样是体验的一部分。在项目变大之后,改一行代码要等十几秒,这种体验很磨人。
我的提速组合是这三件事:
第一,开启持久化缓存。webpack 5的cache可以直接写到磁盘,构建完成的数据二次构建直接复用:
module.exports = { cache: { type: 'filesystem', buildDependencies: { config: [__filename] } } };这个配置的效果非常直观。我实测过一个中等体量的项目,冷构建20多秒,加了缓存之后热构建直接降到3秒左右。buildDependencies的作用是,当配置文件本身变化时自动失效缓存,防止缓存错乱。
第二,用thread-loader做多进程打包。它的原理是把耗时的loader(主要是babel-loader)放到worker池里并行运行:
{ test: /\.jsx?$/, exclude: /node_modules/, use: [ { loader: 'thread-loader', options: { workers: 2 } }, { loader: 'babel-loader', options: { cacheDirectory: true } } ] }这里有个必须注意的事:thread-loader必须放在use数组的第一个,因为loader是从右往左执行的,它要最先起线程去接后面的任务。另外,thread-loader只对耗时操作有意义。项目很小或者loader本身很快时,起多线程反而有线程通信的开销,实测下来可能更慢。要不要用,看项目体量判断。
第三,配置好babel-loader自带的cacheDirectory: true,给babel加一层文件缓存。配合前面的thread-loader,整体构建速度能再上一个台阶。
构建提速还有一个思路容易被忽略:减少loader的处理范围。比如exclude: /node_modules/就是典型的"不做无用功",确保babel-loader不会去处理已经编译过的第三方包。看似简单,实际对构建速度影响非常大。
4. 开发与生产环境:一份配置如何优雅分身
真实项目里最烦的是开发环境和生产环境对同一份配置的要求不一样——开发要快、要详细报错、要有热更新;生产要压缩、要指纹、要体积最小。我从一开始就把配置拆成了三份:公共配置、开发配置、生产配置。这里说的都是我在拆完之后验证过的细节。
4.1 devServer:本地开发的那些开关
开发环境的核心设施是devServer,它提供本地服务器、热更新和代理能力。我用的是webpack-dev-server,配置示例:
module.exports = { devServer: { static: { directory: path.join(__dirname, 'dist') }, port: 3000, hot: true, historyApiFallback: true, proxy: { '/api': { target: 'http://localhost:8080', pathRewrite: { '^/api': '' } } } } };几个关键选项的实际作用:
hot: true:开启模块热替换(HMR),改动一个模块只重新加载那个模块,不再整页刷新,开发体验差别非常大。historyApiFallback: true:当路由是history模式时,所有不存在的URL都回退到index.html,保证前端路由刷新不404。proxy:开发时解决跨域问题。前端请求/api/login,devServer会把请求转发到http://localhost:8080/login。注意pathRewrite的作用是转换URL路径,我遇到过明明配了代理却始终404的情况,就是忘了加pathRewrite。
一个真实的坑:hot和liveReload是两回事。hot: true是模块热替换,liveReload是整页刷新。如果两者同时开启,会产生奇怪的冲突,有时候代码改了页面不刷新,有时候刷新了状态丢失。我的做法是开发环境只开hot,关闭liveReload。
4.2 source map策略:要调试体验还是要线上安全
source map是定位线上问题的利器,但它也是双刃剑——把源码原样暴露给所有能打开DevTools的人。所以开发和生产的策略我完全是两种。
开发环境我用:
devtool: 'eval-cheap-module-source-map'这个选择是在调试体验和编译速度之间做的平衡。eval速度最快,cheap只定位到行不定位到列,module则保留原始TS/JS源码的映射关系,断点时能正确显示源码而不是编译后的代码。
生产环境我用:
devtool: 'hidden-source-map'hidden-source-map会把source map文件生成出来,但不在产物JS末尾加注释引用。这样外人拿到部署的JS文件,看不到源码,但当线上报错时,你能拿着这份map文件在本地还原出错位置。如果确定不需要在线还原或者团队有前端监控系统,生产环境可以直接设devtool: false,完全移除source map,体积最小。
选择source map生成策略时,我建议用一个表来对照选择:
| 场景 | devtool | 理由 |
|---|---|---|
| 本地开发调试 | eval-cheap-module-source-map | 编译快,断点能看原始源码 |
| 生产环境且需要排查 | hidden-source-map | 源码不暴露,但有报错还原能力 |
| 生产环境且监控完备 | false | 体积最小,构建最快 |
4.3 环境变量注入:一套代码对接多套环境
前端的代码要对接开发、测试、预发、生产多套环境,每套环境的API地址不一样。最原始的做法是打包前手动改代码,这属于灾难级别的操作。合理做法是把环境变量编译时注入。
我用DefinePlugin比较多,它是webpack自带的,用法是在配置里定义运行时可访问的全局常量:
const webpack = require('webpack'); module.exports = (env) => { const apiBaseUrl = env.production ? 'https://api.example.com' : 'https://staging.example.com'; return { plugins: [ new webpack.DefinePlugin({ 'process.env.API_BASE_URL': JSON.stringify(apiBaseUrl) }) ] }; };要注意JSON.stringify不能省。DefinePlugin的替换是文本级别的,如果直接写字符串不带引号,process.env.API_BASE_URL会变成一个变量引用,运行时会直接报"变量未定义"。
实际项目里也可以结合.env文件,或者用cross-env设环境变量,再把process.env按需暴露给前端。核心思路是一样的:构建时决定环境,运行时只读常量。绝不要让前端代码在运行时动态探测环境,那样不仅慢,还可能暴露内部配置。
4.4 产物hash策略:contenthash与缓存命中率
hash策略直接影响浏览器缓存的表现。webpack提供三种hash:
hash:整次构建生成一个hash,任何文件改动所有产物全变。缓存命中率最低。chunkhash:每个chunk一个hash,基于chunk内容生成。contenthash:基于单个文件的内容生成,粒度最细,缓存命中率最高。
我的产出文件名一直都是:
output: { filename: '[name].[contenthash:8].js' }这个contenthash:8是取哈希的前8位,够唯一且不会太长。
这里有个过去踩坑的经验:如果只对业务文件加contenthash,不对第三方库做处理,那么业务代码每次改动,vendor里的库可能也跟着变。原因在于splitChunks自动拆出来的vendor chunk里包含了webpack的runtime代码,而runtime是和某些运行时信息绑定的,内容变了hash就变。解法是在optimization里单独拆一个runtime chunk:
optimization: { runtimeChunk: 'single' }这样webpack的runtime代码独立成一个chunk,vendor的hash就只取决于vendor内容本身。这个配置后文还会展开讲,因为它直接关系到"为什么每次发布vendor都变了"这个经典问题。
5. 那些年我在webpack上踩过的坑
前面的章节侧重"怎么做",这一章聊聊"做错之后怎么排查"。我挑四个印象最深、也是社区里高频出现的问题,按真实的排查链路还原当时的情况。
5.1 publicPath配错之后:资源全部404
这是新手期踩的第一个大坑,现象是构建成功但页面打开一堆404,尤其CSS和JS资源。当时项目要部署到服务器的一个子路径下,而不是根路径,我没处理publicPath,资源请求地址自然全部错了。
publicPath决定的是运行时,浏览器以什么路径去加载这些静态资源。它和output.path是两个维度:output.path决定资源在磁盘的物理位置,publicPath决定浏览器请求资源时的URL前缀。最常见的组合是:
output: { path: path.resolve(__dirname, 'dist'), publicPath: '/static/' }意思是构建产物物理放到dist目录,然后在浏览器里通过example.com/static/xxx.js加载。如果部署在子路径下,就要改成:
publicPath: '/your-sub-path/'还有一种省心办法:用相对路径。设publicPath: './'会让浏览器根据当前页面路径去拼资源地址。但这在路由多级跳转时容易出问题,不是最优解。真正稳妥的做还是明确完整的部署路径,前后端配合确认一次。
排查这种问题时,最快的办法是打开浏览器DevTools的Network面板,看失败资源的具体URL和当前页面URL,自己脑补一遍"浏览器拿到这个URL后会去请求什么",很快就能定位是publicPath还是nginx配置的问题。
5.2 CSS顺序"随机"变化:loader顺序与抽取时机
这个问题的现象很诡异:样式表里的某些规则有时候生效有时候不生效,刷新几次表现还不一样。一开始我以为是CSS优先级问题,排查了很久才发现是打包后CSS的加载顺序变了。
根源在两个方面。一个是loader顺序。CSS文件经过css-loader解析后,style-loader会按JS模块的依赖顺序插入<style>标签。如果两个入口JS的CSS依赖顺序不稳定,页面注入的CSS顺序也会跟着不稳定。
另一个是抽取时机。用MiniCssExtractPlugin把CSS从JS里抽成独立文件时,插件会按chunk的依赖关系合并CSS文件,如果公共部分和页面部分的拆分不合理,加载顺序看起来就很奇怪。
我最终的解决路径分三步:
- 统一用
MiniCssExtractPlugin,并确认所有CSS都走同一个loader链,避免有的走style-loader有的走抽取插件。 - 检查
splitChunks的cacheGroups,确保公共CSS不会同时被多个chunk重复引用并产生多种顺序。 - 在入口JS里按依赖关系明确import顺序,让CSS的依赖关系可控。
CSS顺序问题是最难排查的类型之一,因为报错不会太明显,表现出来只是"样式时灵时不灵"。现在想想,所有loader配置都应该在项目开始时就定好,避免混用两种CSS注入策略。
5.3 每次发布vendor都变:runtimeChunk的重要性
这个问题的典型现象是:业务代码只改了一行字,发布后vendors.js的哈希也变了。如果vendors是长缓存核心,这个现象意味着CDN缓存的命中率一直在被浪费。
我前面提过runtimeChunk: 'single',就是专门解决这个的。深入解释一下为什么:
webpack打包后,模块的加载逻辑由一段runtime代码维护。默认情况这段runtime会被打进最后生成的chunk里(比如vendors),而runtime里包含了模块ID、chunkID这些容易变的信息。你改了业务代码,模块的排列可能变化,runtime跟着变,vendor的内容也跟着变,hash就没法稳定了。
配置加上后面:
optimization: { runtimeChunk: 'single' }webpack会把runtime单独抽出来,比如生成一个runtime.js,vendor的内容不再包含runtime,于是vendor的哈希只由node_modules里那几个库决定。这个方案对长期迭代的项目价值非常大,我是在经历了几次"一改代码、CDN缓存全部失效"的教训后才把它变成标准配置的。
5.4 动态import报错:变量路径的webpack约束
这个坑是在做按需加载组件时踩的。我原本想根据用户权限动态加载不同模块,代码写成了这样:
const importComponent = (name) => import(`./components/${name}`)结果运行到该加载的时候,报错提示找不到模块,而且构建时能把components下所有文件都打进一个巨大的上下文chunk里。
原因很明确:webpack的静态分析要求动态import的路径必须是可解析的。它会在编译时扫描可能的模块,并生成一个映射表。如果模板字符串里的变量是全动态的,webpack没法确定它到底引用哪些文件,只能把整个匹配目录都打包进来。
解决方法是缩小变量范围,把动态部分限制到具体文件级别的后缀:
const importComponent = (name) => import(`./components/${name}.js`)这样webpack能限定扫描目录是./components/,只会打包这个目录下以.js结尾的文件,既保证了可解析性,也不会把整个目录无脑纳入。
对于更复杂的映射,直接维护一张模块路径表会更清晰和安全。这类按需加载的坑在业务量上来后非常常见,记住核心原则:webpack需要看到足够的静态信息来确定你import的是哪些模块。
最后分享一点个人体会
webpack这套东西,归根结底不是靠背书学会的,而是靠一次次"为什么这样写"和"为什么报了那个错"折腾出来的。我自己从脚手架的一键构建,走到手动配置并且能解释清楚每一项的来由,花了挺长一段时间,中间踩过的坑、看过的报错信息,现在回想起来都变成了经验沉淀。
如果你也打算从零配置一套webpack,我的建议是:先把自己项目的真实需求列出来——多入口还是单入口?需要兼容哪些浏览器?部署在什么路径?有没有多环境?然后照着这份清单去写配置,写完一项就打一个勾。遇到报错,先读全错误信息,再动手,别急着复制线上的配置。
配置这种东西,最大的技巧就是把它变成自己的,而不是别人给的。