最近把 elpis 的第二章学完了,这一章主要是基于 webpack5 完成前端工程化建设。刚开始看 webpack 配置的时候,我其实也有点散:entry、output、loader、plugin、splitChunks、dev middleware、hot middleware 都出现了,但每个概念好像都能单独讲,反而不容易串成一个整体。
学完之后我现在更愿意这样理解:第二章不是单纯教我怎么打包 JS,而是在把前端源码、开发环境、生产环境和第一章搭好的 Koa 服务端内核接起来。
第一章里,Koa 已经可以通过 /view/:page 渲染页面,也可以通过 controller、service、middleware 处理 API 请求。第二章要解决的问题是:页面源码不可能一直写成手工模板,Vue 组件、JS 模块、CSS、Less、图片、字体这些资源都需要被编译、组织和输出,最后还要让 Koa 能渲染,让浏览器能访问。
所以这一章的主线我现在会这样画:
rust
app/pages 页面源码
-> webpack 扫描入口
-> loader 编译 Vue / JS / CSS / Less / 图片
-> plugin 生成 .tpl 模板和优化产物
-> 开发环境内存构建和热更新
-> 生产环境输出 hash 静态资源
-> Koa 通过 ctx.render() 和 koa-static 承接产物
从多页面入口开始看
第二章里让我印象比较深的是,elpis 并没有把页面入口写死在 webpack 配置里,而是用了一个动态扫描:
ini
const entryList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryList).forEach(file => {
const entryName = path.basename(file, '.js');
pageEntries[entryName] = file;
});
这段代码的意思是:只要 app/pages 下面有符合 entry.*.js 规则的文件,它就会被当成一个页面入口。
比如当前项目里有:
bash
app/pages/page1/entry.page1.js
那 webpack 就会得到一个入口:
arduino
{
'entry.page1': '/项目根目录/app/pages/page1/entry.page1.js'
}
这个地方其实和第一章的 loader 有点像。第一章是扫描 controller、service、middleware,把文件系统里的模块挂到 app 上;第二章是扫描页面入口,把文件系统里的页面变成 webpack 的构建入口。它们背后的思路都是"约定优于配置"。
也正因为这里是动态入口,所以 elpis 更偏多页面工程化。
单页面应用通常是:
rust
一个入口
-> 一个 HTML
-> 前端路由切换页面
多页面应用更像是:
rust
多个入口
-> 多个 bundle
-> 多个 HTML/TPL
-> 服务端根据 URL 渲染不同页面
elpis 里访问 /view/page1 时,Koa 会渲染 dist/entry.page1.tpl。也就是说,webpack 生成的不是一个完全独立的 HTML 页面,而是 Koa 后面要继续渲染的模板。
这个点让我重新理解了 entry:它不只是"从哪个 JS 开始打包",在 elpis 这种项目里,它还决定了服务端可以渲染哪些前端页面。
为什么要生成 .tpl
普通前端项目里,HtmlWebpackPlugin 常常生成的是 index.html。但 elpis 里生成的是:
arduino
app/public/dist/entry.page1.tpl
配置大概是:
css
new HtmlWebpackPlugin({
filename: path.resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
template: path.resolve(process.cwd(), './app/view/entry.tpl'),
chunks: [entryName]
});
这里有三个关键点:
template是公共模板,也就是app/view/entry.tpl。filename是最终生成的页面模板位置。chunks控制当前模板里注入哪个入口对应的 JS/CSS 资源。
所以我现在会把这段理解成:
rust
webpack 读取公共模板
-> 根据当前入口注入构建后的资源
-> 生成 Koa 可以渲染的 .tpl 文件
然后第一章里的 viewController 会继续接住它:
bash
ctx.render(`dist/entry.${ctx.params.page}`, data);
这就是第二章和第一章真正连接起来的地方。
如果只看 webpack,这里像是在生成模板;如果把 Koa 一起看,这里其实是在为服务端渲染准备入口文件。这个理解对我挺重要,因为它让我不再把前端构建和服务端渲染看成两个孤立的东西。
loader 不是随便写的字符串
第二章里还出现了很多 loader,比如:
vue-loader
babel-loader
css-loader
style-loader
less-loader
url-loader
file-loader
刚开始我容易把它们理解成"webpack 配置里固定要写的东西"。后来想清楚一点以后,我觉得 loader 的本质是:把 webpack 默认不认识的资源,转换成 webpack 可以处理的模块。
比如 .vue 文件,浏览器和 webpack 默认都不能直接理解 Vue 单文件组件里的 <template>、<script>、<style>。所以需要 vue-loader 把它拆开并交给对应规则继续处理。
.js 文件需要 babel-loader,是因为项目可能写了比较新的 JS 语法,需要转换成目标浏览器能更稳定运行的代码。
.less 文件需要 less-loader、css-loader、style-loader 配合:
arduino
less-loader:把 Less 编译成 CSS
css-loader:让 CSS 可以被 JS 模块 import
style-loader:开发环境把 CSS 插入到页面 style 标签里
我也补了一个之前没想清楚的问题:配置里写 loader: 'vue-loader',webpack 到底去哪里找?
当前项目没有配置 resolveLoader,所以 webpack 会按默认 loader 解析规则,从项目根目录的 node_modules 里找 vue-loader 这个 npm 包。它不是去当前配置文件旁边找,也不是去 .vue 文件旁边找。
这一点让我区分开了两个概念:
arduino
resolve.alias:主要影响业务 import 怎么找模块
resolveLoader:才是影响 loader 自己怎么被查找
plugin 解决的是构建过程里的工程任务
如果说 loader 更像"资源转换器",plugin 更像"构建流程参与者"。
第二章里比较关键的 plugin 有:
VueLoaderPlugin
HtmlWebpackPlugin
ProvidePlugin
DefinePlugin
HotModuleReplacementPlugin
MiniCssExtractPlugin
CleanWebpackPlugin
CSSMinimizerPlugin
HtmlWebpackInjectAttributesPlugin
HappyPack
我现在不会把它们都背下来,而是按职责去理解。
VueLoaderPlugin 是为了让 .vue 文件里的不同块能正确进入 webpack 规则。
HtmlWebpackPlugin 是为了生成最终的 .tpl 模板,并注入当前入口对应的资源。
ProvidePlugin 是为了在代码里用到某些变量时自动引入模块,比如 Vue、axios、lodash。这个不是简单地"挂到 window",更准确地说是 webpack 在模块编译时帮我们自动补依赖。
DefinePlugin 是在编译阶段定义常量。当前项目里主要是 Vue3 相关的编译标识,比如是否启用 Options API、是否打开生产环境 devtools。
生产环境里的插件则更偏交付优化,比如清理旧产物、抽离 CSS、压缩 CSS、给资源注入 crossorigin 属性。
这一块我现在的理解是:loader 主要回答"这个文件怎么变成模块",plugin 主要回答"构建过程中还要做哪些额外的工程动作"。
分包策略不是为了炫技
老师在 MR 里特别提醒我,splitChunks 是分包策略的核心,要重点理解。
我现在对它的理解是:分包不是为了让 dist 目录里文件变多,而是为了把不同变化频率的代码拆开,提升缓存利用率。
elpis 里大概希望拆成这几类:
vendor:第三方依赖
common:多个入口共同引用的业务公共模块
entry.page1:页面自己的业务代码
runtime:webpack 运行时代码
第三方依赖一般不经常变,比如 Vue、axios、lodash。页面业务代码经常变。如果它们都打在一个 bundle 里,我改一行页面代码,整个 bundle 的 hash 都会变,浏览器缓存就失效了。
拆开以后,理想情况是:
shell
只改 page1 业务代码
-> entry.page1 的 hash 变化
-> vendor 仍然可以使用浏览器缓存
runtimeChunk: true 也和缓存有关。webpack 运行时代码会记录模块 ID、chunk 加载关系等信息。如果 runtime 混在业务 bundle 里,有时候依赖关系变化会牵连业务 bundle 的 hash。单独拆 runtime,可以减少这种牵连。
这里我之前也有一个误区:我以为配置了 common cacheGroup 就一定会生成 common.bundle.js。后来打包看产物才发现不是这样。
common chunk 必须真的有多个入口共同引用的业务模块,并且满足 webpack 的拆分条件。如果当前只有一个真实页面入口,或者公共模块没有实际内容,common 就不一定会生成。
这个点让我明白,webpack 配置只是规则,最终产物要看真实模块依赖图。
开发环境重点是快和稳
开发环境配置在:
bash
app/webpack/config/webpack.dev.js
app/webpack/dev.js
开发环境最明显的目标是提升开发体验,所以它做了几件事。
第一是打开 source map:
vbnet
devtool: 'eval-cheap-module-source-map'
source map 是为了把打包后的代码映射回源码。开发环境用这个配置,是因为它兼顾了调试能力和重新构建速度。它不是最精确的 source map,但对开发阶段来说比较实用。
第二是给每个入口注入 HMR client:
ini
baseConfig.entry[v] = [
baseConfig.entry[v],
`webpack-hot-middleware/client?...`
];
也就是说,浏览器端除了运行页面自己的入口代码,还会运行一段热更新客户端代码,用来接收更新消息。
第三是通过 Express 起了一个 webpack dev server:
less
const compiler = webpack(webpackConfig);
app.use(devMiddleware(compiler, {
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
publicPath: webpackConfig.output.publicPath
}));
app.use(hotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`
}));
这里我现在能区分开两件事:
webpack-dev-middleware:负责内存构建,并把最新 bundle 提供给浏览器访问
webpack-hot-middleware:负责把模块更新消息推给浏览器
开发环境不急着把所有产物写到磁盘,因为磁盘 IO 慢,而且开发时更看重重新构建速度。但 elpis 里又需要 Koa 渲染 .tpl,所以配置了:
javascript
writeToDisk: (filePath) => filePath.endsWith('.tpl')
也就是 JS/CSS 主要在内存里,模板文件落到磁盘,方便 Koa/Nunjucks 读取。
这个设计挺能体现"工程协作":不是一味追求内存构建,也不是全部写磁盘,而是按项目链路选择哪些产物必须落地。
热更新不是简单刷新页面
我以前听到热更新,会直接理解成"保存代码后页面自动变了"。但第二章看完后,我觉得应该把它拆成一条链。
在 elpis 里,热更新大概是:
rust
修改页面源码
-> webpack 监听到文件变化
-> compiler 重新编译受影响模块
-> dev middleware 提供新的内存产物
-> hot middleware 通知浏览器有更新
-> HMR client 接收更新消息
-> 浏览器尝试替换变化模块
-> 如果不能热替换,就 reload 兜底刷新
这里有三个角色比较关键:
bash
HotModuleReplacementPlugin:让 webpack 支持热替换
webpack-hot-middleware/client:浏览器端接收更新
webpack-hot-middleware:服务端推送更新消息
所以热更新不是某一个插件单独完成的,而是 webpack 编译器、dev server、浏览器 HMR client 一起配合。
这个理解以后在面试里也能讲,不会只说"我配置了 HMR"。
生产环境重点是交付质量
生产环境配置在:
bash
app/webpack/config/webpack.prod.js
app/webpack/prod.js
和开发环境相比,生产环境的重点不是调试方便,而是:
产物更小
缓存更稳定
资源路径正确
旧文件不残留
线上加载更可靠
所以生产环境做了这些事情:
bash
mode: production
输出到 app/public/dist/prod
文件名带 chunkhash/contenthash
构建前清理 app/public/dist
CSS 抽离成独立文件
JS/CSS 压缩
drop_console
splitChunks 分包
runtimeChunk 拆运行时代码
给资源加 crossorigin="anonymous"
这里的 hash 很重要。浏览器缓存资源时,文件名如果不变,浏览器可能继续用旧文件;文件名带 hash 后,内容变化会让文件名变化,浏览器就会重新请求。内容不变的 vendor 资源,文件名也可以保持稳定,从而继续利用缓存。
生产环境的 publicPath 也很关键。它不是磁盘路径,而是浏览器访问静态资源时使用的 URL 前缀。
比如磁盘上资源在:
arduino
app/public/dist/prod/js/entry.page1_xxx.bundle.js
Koa 静态资源托管的是:
arduino
app/public
所以浏览器访问时路径应该是:
bash
/dist/prod/js/entry.page1_xxx.bundle.js
这就是为什么我现在会把 output.path 和 output.publicPath 分开理解:
lua
output.path:文件写到磁盘哪里
output.publicPath:HTML 里引用资源时写什么 URL 前缀
CORS 配置是开发环境协作问题
开发 server 里还有一段 headers 配置:
css
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization'
}
我现在理解它主要是为了解决开发环境里 Koa 页面服务和 webpack dev server 不是同一个 origin 的问题。
页面可能是 Koa 服务渲染的,但 JS/CSS/HMR 资源来自:
ruby
http://127.0.0.1:9002/public/dist/dev/
如果浏览器认为这是跨源访问,一些资源请求、HMR 通道、source map、字体加载或者错误堆栈信息就可能受到限制。
所以开发环境里加 CORS header,是为了让 Koa 和 webpack dev server 可以顺畅协作。它不是这章最核心的配置,但能帮助我理解一个真实项目里"两个服务配合开发"的问题。
写完第二章后的感觉
第二章写完以后,我最大的变化是,不再把 webpack 只看成一个"打包工具"。
如果只是背配置,webpack 会变成一堆零散的字段:
lua
entry
output
loader
plugin
optimization
devtool
publicPath
但放回 elpis 项目里看,它们其实都在服务同一条链路:
让 Vue 页面源码变成 Koa 能渲染、浏览器能加载、开发时能热更新、生产时能缓存和压缩的工程产物。
第一章是把服务端内核搭起来,让请求能进入 Koa、经过中间件、匹配路由、进入 controller 和 service。第二章是把前端工程化接上来,让页面不再依赖手写模板,而是由 webpack 从源码构建出来。
我现在能更清楚地理解,为什么老师一直强调不要只学某一个工具。因为工具名字会变,webpack、Vite、Rspack 都只是具体方案,但背后的工程问题不会变:
源码如何组织
资源如何编译
环境如何区分
产物如何缓存
开发体验如何保证
服务端如何承接前端资源
这才是我第二章真正应该带走的东西。
后面进入第三章 Vue3 领域模型架构时,我觉得视角又要往前走一步:第二章解决的是"页面怎么构建出来",第三章要开始解决"页面内部怎么组织,怎么从一个具体页面抽象成可复用的领域模型和配置化能力"。