一、工程目标:用统一构建链连接页面与服务
elpis-core 前端工程化要解决的核心问题,是让业务页面拥有统一的入口、依赖处理、开发反馈和交付方式。开发者编写 Vue 组件,Webpack 将模块及资源转换为浏览器可执行的产物,Koa 再通过模板引擎把页面交付给用户。
当前项目采用多页应用结构:page1、page2 各有一个入口文件,各自产生一个页面模板。页面之间共享 Vue、Element Plus 等基础依赖,以及统一的启动函数。页面内部还预留了 Vue Router 的接入方式,可以在一个服务端页面内组织局部前端路由。
这种结构把三个阶段分开:构建期负责依赖分析与资源地址注入,请求期负责模板数据渲染,浏览器运行期负责组件交互。当前 Vue 组件通过 createApp() 在浏览器挂载,服务端渲染的是 HTML 页面壳及配置数据,并没有执行 Vue 组件的 SSR 渲染。
工程相关目录如下:
text
app/
├── pages/
│ ├── boot.js Vue 应用统一初始化
│ ├── page1/
│ │ ├── entry.page1.js page1 构建入口
│ │ └── page1.vue 页面组件
│ ├── page2/
│ │ ├── entry.page2.js
│ │ └── page2.vue
│ ├── common/curl.js 请求封装
│ ├── store/index.js Pinia 实例
│ └── assets/cusiom.css 公共样式,沿用源码拼写
├── view/entry.tpl Webpack 使用的模板源文件
├── webpack/
│ ├── dev.js Express 开发构建服务
│ ├── prod.js 一次性生产构建脚本
│ └── config/
│ ├── webpack.base.js 多页入口与公共构建规则
│ ├── webpack.dev.js 开发配置与 HMR
│ └── webpack.prod.js 生产资源与优化配置
├── public/
│ ├── static/ 直接提供给浏览器的公共资源
│ └── dist/ 构建生成的模板及资源
├── controller/view.js 调用 ctx.render
└── router/view.js 声明 /view/:page
二、多页入口与模板:把目录约定变成构建规则
2.1 自动发现页面入口
app/webpack/config/webpack.base.js 使用 glob 扫描 app/pages/**/entry.*.js,以去掉扩展名后的文件名作为入口名称。每个入口同时创建一个 HtmlWebpackPlugin 实例。以下为源码逻辑的等价简化:
javascript
const glob = require('glob');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const entries = {};
const htmlPlugins = [];
const pattern = path.resolve(process.cwd(), 'app/pages/**/entry.*.js');
for (const file of glob.sync(pattern)) {
const entryName = path.basename(file, '.js');
entries[entryName] = file;
htmlPlugins.push(new HtmlWebpackPlugin({
filename: path.resolve(
process.cwd(), 'app/public/dist', `${entryName}.tpl`
),
template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
chunks: [entryName]
}));
}
这段代码建立了一组稳定的映射关系:
| 入口文件 | Webpack 入口名 | 生成模板 | 页面地址 |
|---|---|---|---|
| pages/page1/entry.page1.js | entry.page1 | dist/entry.page1.tpl | /view/page1 |
| pages/page2/entry.page2.js | entry.page2 | dist/entry.page2.tpl | /view/page2 |
表中 pages、dist 分别位于 app 和 app/public 下。入口名称取自文件名,不包含父目录;不同目录中的入口文件仍应保持名称唯一。由于 glob 扫描在配置加载时执行,新增入口文件后需要重启构建进程,让新入口进入配置。
chunks: [entryName] 的含义是为模板选择这个入口对应的资源组。入口依赖的 runtime 和共享 chunk 仍会一起注入,不代表最终 HTML 中只有一个 script 标签。本次开发配置隔离编译生成的 page1 模板就包含 runtime、vendors、entry.page1 三个脚本,不包含 entry.page2 的入口脚本。
2.2 同一个模板经过两次处理
模板源文件 app/view/entry.tpl 提供页面骨架,核心内容可简化为:
html
<!DOCTYPE html>
<html>
<head>
<title>{{name}}</title>
<link href="/static/normalize.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<input id="env" value="{{env}}" style="display:none">
<input id="options" value="{{options}}" style="display:none">
</body>
</html>
构建期,HtmlWebpackPlugin 注入带哈希的资源地址,并生成 .tpl。其中 {{name}}、{{env}}、{{options}} 保留为模板占位符。请求期,Koa 的 ViewController 再执行:
javascript
// app/controller/view.js:当前分支源码节选
await ctx.render(`./dist/entry.${ctx.params.page}`, {
name: app.options?.name,
env: app.env.get(),
options: JSON.stringify(app.options)
});
Nunjucks 的根目录是 app/public,扩展名是 tpl,所以 /view/page1 最终读取 app/public/dist/entry.page1.tpl。这个分支已经把模板渲染路径接到了 Webpack 的 dist 产物。
text
app/view/entry.tpl
│ 构建期:注入 JS / CSS 地址
▼
app/public/dist/entry.page1.tpl
│ 请求期:填充 name / env / options
▼
浏览器收到 HTML
│ 运行期:加载脚本、读取配置、挂载 Vue
▼
可交互页面
原始模板还把隐藏输入框中的值赋给 window.env、window.options。这是一条从服务端配置到浏览器的传递通道,页面可读取应用级选项;进入 HTML 的配置应仅包含允许在浏览器中访问的字段。
三、基础配置:模块转换、路径解析与依赖注入
3.1 Loader 负责把资源转换成模块
公共规则集中在 webpack.base.js。Loader 数组通常从右向左执行,因此 Less 的转换顺序是先编译为 CSS,再处理 CSS 依赖,最后注入页面。
| 输入类型 | 当前基础处理链 | 作用 |
|---|---|---|
| .vue | vue-loader | 拆解单文件组件的模板、脚本与样式 |
| app/pages 内的 .js | babel-loader | 为业务脚本提供 Babel 转换入口 |
| .css | css-loader → style-loader | 处理样式依赖并注入 style 标签 |
| .less | less-loader → css-loader → style-loader | 编译 Less 并注入样式 |
| png、jpg、gif、svg 等匹配图片 | url-loader | 小资源内联,其余交由文件输出 |
| eot、ttf、woff、woff2 | file-loader | 输出字体资源并返回访问地址 |
这里箭头表示转换发生的先后顺序;实际配置中 CSS 写为 use: ['style-loader', 'css-loader']。
VueLoaderPlugin 配合 vue-loader,使基础规则也能应用于 .vue 文件拆出的脚本及样式。例如页面中的 <style lang="less" scoped> 会参与 Less 处理,scoped 则用于生成组件范围内的样式选择器。
图片规则设置 limit: 300,单位是字节,达到内联条件的小图片会转换为数据 URL。这个阈值很小,不能把它理解成 300 KB。模板里直接引用的 /static/logo.png 没有通过业务模块 import,因此不会自动进入这条 url-loader 处理链。
基础配置只为业务 JS 指定 babel-loader;Babel 实际做哪些转换还取决于 presets、plugins 和目标配置。当前生产 HappyPack 的 JS 配置显式指定 @babel/preset-env 与 @babel/plugin-transform-runtime,不能仅凭"使用了 babel-loader"就断言开发和生产具有完全一致的转译行为。
3.2 别名让业务代码围绕模块职责组织
javascript
// webpack.base.js:源码节选
resolve: {
extensions: ['.js', '.vue', '.less', '.css'],
alias: {
$pages: path.join(process.cwd(), 'app/pages'),
$common: path.join(process.cwd(), 'app/pages/common'),
$widgets: path.join(process.cwd(), 'app/pages/widgets'),
$store: path.join(process.cwd(), 'app/pages/store'),
$assets: path.join(process.cwd(), 'app/pages/assets'),
$utils: path.join(process.cwd(), 'app/utils')
}
}
因此入口可以写 import boot from '$pages/boot.js',组件可以写 import $curl from '$common/curl'。这里的别名在构建时解析,浏览器收到的是已编译模块,不需要认识 $pages。别名也不会创建目录,使用 $widgets 或 $utils 前仍要准备对应模块。
3.3 ProvidePlugin 与 DefinePlugin 的职责
基础配置通过 ProvidePlugin 配置 Vue、axios 和 lodash:
javascript
new webpack.ProvidePlugin({
Vue: 'vue',
axios: 'axios',
_: 'lodash'
});
它会在模块使用相应自由标识符时注入模块依赖,不会因为这份配置就自动创建 window.Vue、window.axios。当前 curl.js 本身显式引入 axios,模板又通过 CDN 引入 axios 和 js-md5;构建没有配置 externals,因此这些 CDN 标签并不会把 bundle 中对应的依赖排除出去。
DefinePlugin 负责构建期常量替换,例如 Vue 的功能开关。当前配置还将外部 process.env.NODE_ENV 写入 DefinePlugin,而开发和生产配置又分别指定 mode。若外部环境变量未设置或与 mode 不一致,可能出现定义冲突;本次未设置 NODE_ENV 的开发隔离编译就出现了该警告。执行构建时显式设置与 mode 相同的值,可以使两处定义一致。
四、统一 boot:让页面入口保持简单
当前每个页面入口只负责找到页面组件,并把它交给公共启动函数:
javascript
// app/pages/page1/entry.page1.js:现有实现
import boot from '$pages/boot.js';
import Page1 from './page1.vue';
boot(Page1);
app/pages/boot.js 集中引入 Element Plus、组件库 CSS、公共样式和 Pinia,并通过 createApp() 创建 Vue 应用。其主路径可概括为:
javascript
// 等价简化:展示当前无页面内路由时的启动路径
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import '$assets/cusiom.css';
import pinia from '$store';
export default (Page, { libs = [] } = {}) => {
const app = createApp(Page);
app.use(ElementPlus);
app.use(pinia);
libs.forEach(lib => app.use(lib));
app.mount('#root');
};
公共初始化的价值是让不同页面使用相同的 UI、状态管理和插件安装方式。app/pages/store/index.js 当前创建并导出 Pinia 实例,业务 Store 可以在此基础上扩展。不同 HTML 页面有各自的浏览器运行上下文,安装同一套 Pinia 初始化代码不会自动让它们共享运行时状态。
4.1 多页地址与页面内路由
/view/page1、/view/page2 由服务端路由控制,切换页面意味着请求另一个 HTML。boot 另外接收 routers 选项,计划使用 createWebHashHistory() 支持页面内部路由,例如 /view/page1#/detail。URL 的 hash 部分由浏览器处理,不会作为服务端的页面参数。
这个可选分支在当前源码里读取了 routers,却把未定义的 routes 直接传给 createRouter。实际 page1、page2 都只调用 boot(Page),没有进入该分支。接入页面内路由时,应使用下面的修正片段:
javascript
// 接入页面内路由时的修正示例
if (routers && routers.length) {
const router = createRouter({
history: createWebHashHistory(),
routes: routers
});
app.use(router);
router.isReady().then(() => app.mount('#root'));
} else {
app.mount('#root');
}
4.2 请求封装承接前后端协议
page1 在 onMounted 中调用 $common/curl,将 query 映射到 Axios 的 params,并把响应 data 赋给表格:
javascript
// app/pages/page1/page1.vue:源码节选
onMounted(async () => {
const res = await $curl({
url: '/api/project/list',
method: 'GET',
query: { proj_key: 'test' }
});
tableData.value = res.data;
});
curl.js 集中处理请求头、超时和 Element Plus 错误提示,并追加服务端所需的签名请求头。文章不展开其中的密钥值。当前成功结果按 { success, data, metaData } 返回;后端成功响应则使用 metadata,需要额外数据时应统一字段大小写。另外,函数参数中的 baseURL、responseType 目前没有写入最终 Axios 配置,不能把它们视为已生效的选项。
这层封装让页面关注数据如何展示,但调用方仍需判断结果。当前封装会把部分错误转换为已兑现的 Promise,不能只依靠 catch 处理所有业务失败。
五、开发环境:双服务协作与热更新
5.1 8080 提供页面,9090 提供编译资源
app/webpack/dev.js 使用 Express 承载 webpack-dev-middleware 和 webpack-hot-middleware。它与 Koa 业务服务分别运行:
text
浏览器打开 http://127.0.0.1:8080/view/page1
│
▼
Koa 渲染 dist/*.tpl
│
HTML 中注入 9090 资源地址
│
▼
http://127.0.0.1:9090/public/dist/dev/js/*.bundle.js
│
▼
Vue 页面运行
│ │
/api 请求 ───┘ └── HMR EventSource
▼ ▼
Koa 8080 Express 9090
/__webpack_hmr
脚本来自 9090,不会把页面的 origin 改成 9090。页面仍由 8080 的文档地址决定来源,所以相对地址 /api/project/list 会请求 Koa 8080。当前开发服务没有配置 API proxy,这条链路通过"业务页面来源与 API 同源"来连接。
在项目根目录中,可以分别启动:
bash
# 终端一:前端编译与开发资源服务
NODE_ENV=development npm run build:dev
# 终端二:Koa 业务服务
__ENV=local node index.js
以上是 macOS / Linux 写法。build:dev 实际执行 node --max-old-space-size=8192 ./app/webpack/dev.js;这里给 Node 设置的是堆内存上限,不表示构建一定占用 8 GB。后端直接设置 __ENV 是为了对齐当前 env.js 的实际读取名称。等待前端编译成功后访问 /view/page1,而不是把 9090 的根路径当作业务首页。
5.2 output.path 与 publicPath 分别解决什么
开发配置的关键字段如下:
javascript
// app/webpack/config/webpack.dev.js:源码节选
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: path.resolve(process.cwd(), 'app/public/dist/'),
publicPath: 'http://127.0.0.1:9090/public/dist/dev/',
globalObject: 'this'
}
output.path 是编译输出文件系统中的根目录;publicPath 是浏览器访问这些资源的 URL 前缀。开发 URL 中出现 /public/dist/dev/,并不意味着磁盘必须存在完全对应的 app/public/dist/dev 目录。dev middleware 会按照 publicPath 将请求映射到它管理的编译结果。
javascript
// app/webpack/dev.js:源码节选
app.use(devMiddleware(compiler, {
writeToDisk: filePath => filePath.endsWith('.tpl'),
publicPath: devConfig.output.publicPath,
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'
},
stats: { colors: true }
}));
这里最关键的是 writeToDisk:模板需要写到磁盘,因为 Koa 的 Nunjucks 会从磁盘读取;JS 等编译资源继续由开发中间件的内存文件系统提供。这样可以同时满足业务模板渲染和开发资源快速更新。代码最后的 express.static 提供磁盘静态文件兜底,正常编译资源主要由前面的 dev middleware 处理。
开发配置还使用 eval-cheap-module-source-map,方便定位到源模块;这类开发产物的大小不能直接当作生产下载体积。
5.3 HMR 的四个参与者
HMR 需要入口客户端、Webpack 热替换插件、服务端事件流及能够接受更新的模块共同配合:
text
修改 .vue / .js / .less
│
▼
Webpack 监听依赖变更并重新编译
│
▼
webpack-hot-middleware 发送构建事件
│ EventSource / SSE
▼
浏览器内的 HMR client 收到通知
│
▼
Webpack runtime 获取并应用更新
│
├── 可接受更新:替换受影响模块
└── 无法接受且开启 reload:尝试整页刷新
开发配置为每个页面入口追加了客户端:
javascript
// webpack.dev.js:等价简化
const origin = 'http://127.0.0.1:9090';
const query = new URLSearchParams({
path: origin + '/__webpack_hmr',
timeout: 20000,
reload: true,
quiet: true
});
baseConfig.entry[entryName] = [
baseConfig.entry[entryName],
'webpack-hot-middleware/client?' + query.toString()
];
这里使用完整的 HMR URL 很关键:页面来源是 8080,如果仅传 /__webpack_hmr,EventSource 会默认连接 8080,而实际事件流服务位于 9090。服务端通过 hotMiddleware(compiler, { path: '/__webpack_hmr', heartbeat: 2000 }) 与客户端对应。
HotModuleReplacementPlugin 负责生成更新所需能力,Vue Loader 提供组件层面的更新支持。具体能否保留页面状态取决于变更类型与模块是否接受更新,不能把所有修改都描述成"无刷新且状态完全保留"。此外,配置中的 127.0.0.1 指向浏览器所在设备,当前默认地址适合本机开发;跨设备访问时要同步调整资源与 HMR 地址。
六、生产构建:分包、样式提取与资源交付
生产构建脚本 app/webpack/prod.js 通过 Node API 调用 webpack(ProdConfig, callback),打印编译统计。配置由 merge.smart(baseConfig, productionConfig) 合并而来,复用入口、模板、别名和基础分包规则。
| 配置维度 | 开发 | 生产配置 |
|---|---|---|
| mode | development | production |
| 运行方式 | Express 常驻编译服务 | 一次性调用 Webpack |
| 浏览器资源前缀 | http://127.0.0.1:9090/public/dist/dev/ | /dist/prod |
| 资源输出目录 | app/public/dist,开发资源主要在内存 | app/public/dist/prod |
| 模板输出目录 | app/public/dist | app/public/dist |
| JS 文件名 | js/name_chunkhash:8.bundle.js | 相同命名规则 |
| Source Map | 显式启用开发 Source Map | 未启用 devtool,Terser sourceMap 为 false |
| 样式策略 | style-loader 注入页面 | 配置 MiniCssExtractPlugin 提取 CSS |
| 代码压缩 | 开发调试用途 | Terser 与 CSSMinimizer |
生产模板仍输出到 dist 根目录,是因为 HtmlWebpackPlugin 使用了绝对 filename,不随 output.path 自动移动到 prod。Koa 的静态根目录是 app/public,因此 /dist/prod/js/... 对应磁盘上的 app/public/dist/prod/js/...。部署时应同时交付 dist 根目录下的模板与 prod 资源,而不是只上传 JS 文件。
6.1 按变化频率组织资源
基础配置设置 runtimeChunk: { name: 'runtime' },把 Webpack 的模块加载运行时代码独立出来,并通过 splitChunks 建立缓存组:
| 缓存组 | 当前匹配规则 | 设计作用 |
|---|---|---|
| vendor → vendors | node_modules,priority 20,enforce true | 聚合第三方依赖 |
| common | 路径包含 src,至少 3 个 chunk 引用、达到 30000 字节 | 提取匹配目录内的共享模块 |
| page | app/pages,至少 2 个 chunk 引用、达到 30000 字节 | 提取满足条件的页面共享代码 |
较大的 priority 优先,而不是源码注释所写的"值越小越优先"。当前业务源码在 app/pages,common 组的 src 条件不能直接代表业务公共目录;page 组名也不表示"每一个页面都会独立生成一个 page chunk"。分组能否输出文件取决于模块匹配、复用关系、体积和最终优化结果。
本次开发配置隔离编译中,两页实际共享 vendors 和 runtime,各有 entry.page1、entry.page2,没有产生名为 common 或 page 的共享包。这个结果有助于理解:splitChunks 描述提取规则,不是提前保证固定数量的文件。
text
entry.page1.tpl ──▶ runtime + vendors + entry.page1
entry.page2.tpl ──▶ runtime + vendors + entry.page2
▲
同一构建内可复用资源
哈希文件名让资源内容变化时有机会得到新 URL,配合静态服务器的缓存策略,可以减少重复下载。配置使用 JS 的 chunkhash;CSS 插件只显式设置了 chunkFilename: 'css/[name]_[contenthash:8].css',这主要控制非入口 CSS chunk。若希望入口 CSS 也统一采用内容哈希,应另行显式设置 filename,不能把这一条 chunkFilename 当作所有 CSS 文件的命名规则。
6.2 生产优化的作用范围
生产配置包含 MiniCssExtractPlugin、CSSMinimizerPlugin、HappyPack 和 TerserWebpackPlugin。其意图分别是将样式提取为文件、压缩 CSS、并行执行指定 Loader、压缩 JS。Terser 开启 parallel,并设置 drop_console、drop_debugger。HappyPack 的线程数来自 CPU 数量;这属于并行处理策略,实际是否更快还取决于任务大小、进程通信和机器资源。
当前 performance.hints 为 false,所以配置中的入口 5 MB、资源 3 MB 阈值没有形成构建告警或失败门禁。生产脚本也只是打印 stats,没有显式用 stats.hasErrors() 设置非零退出码;因此不能只凭终端出现 building 或进程正常退出,就判断产物可交付。
6.3 合并配置必须看最终规则
当前生产配置展示了一个很典型的工程问题:追加生产 Loader,并不等于删除开发 Loader。实际加载合并配置后,JS 的 use 同时保留了 babel-loader 与 happypack/loader;基础 .css、.less 规则也仍然存在,同时又追加了一条 /\.(less|css)$/ 的提取规则。一个样式文件可能匹配多条规则,不能把配置意图直接描述成已经完成了干净的生产样式切换。
更容易控制的写法,是在统一的规则工厂里按环境选择 style-loader 或提取 Loader。下面是说明这一思路的替代设计示例,并非当前分支已经应用的配置:
javascript
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
function createStyleRules(isProd) {
const firstLoader = isProd
? MiniCssExtractPlugin.loader
: 'style-loader';
return [
{
test: /\.css$/,
use: [firstLoader, 'css-loader']
},
{
test: /\.less$/,
use: [firstLoader, 'css-loader', 'less-loader']
}
];
}
该设计应替换原有样式规则,而不是再追加一套;生产环境仍需保留 MiniCssExtractPlugin 实例。是否继续采用 HappyPack,也要结合最终 Loader 链与依赖兼容性决定。