前端工程化设计与实现
完成 BFF 层的基础建设后,Mint 已经能够正常启动一个 Koa 服务。接下来需要解决的问题是:如何构建并向浏览器提供前端页面?
一个完整的页面交付流程可以拆分为两个部分:
- 使用 Webpack 将 Vue 页面构建为 HTML、JavaScript 和 CSS 等静态资源;
- 通过 BFF 层托管这些构建产物,并将页面返回给浏览器。
静态资源托管与 HTML 渲染
首先,需要为 Koa 应用注册静态资源中间件。Mint 分别托管框架内置的静态资源目录和业务项目的静态资源目录:
js
const path = require("path");
const { sep } = path;
// 托管框架内置的静态资源
app.use(require("koa-static")(path.join(__dirname, "public")));
// 托管业务项目的静态资源
app.use(require("koa-static")(path.join(app.baseDir, `${sep}app${sep}public`)));
为了支持服务端返回 HTML 页面,还需要注册模板渲染引擎:
js
app.use(
require("koa-nunjucks-2")({
ext: "html",
path: path.join(app.baseDir, `${sep}app${sep}public`),
functionName: "render",
writeResponse: true,
nunjucksConfig: {
trimBlocks: true,
nocache: true,
},
}),
);
注册完成后,koa-nunjucks-2 会在 Koa 上下文中挂载 render 方法。Controller 可以通过该方法渲染指定的 HTML 文件,并将结果返回给浏览器。
页面资源最终统一存放在 app/public 目录中。至此,BFF 已经具备静态资源托管和 HTML 页面渲染能力,接下来需要通过 Webpack 将 Vue 源代码构建到该目录。
约定页面入口
Mint 约定前端页面统一放在 app/pages 目录中。例如,创建一个名为 demo 的页面:
text
app/pages
└── demo
├── index.vue
└── entry.demo.js
index.vue 是页面的根组件:
vue
<template>
<div>你好</div>
</template>
<script setup></script>
<style scoped></style>
entry.demo.js 是该页面对应的构建入口。Mint 使用下面的文件命名约定识别入口:
text
entry.<页面名称>.js
Webpack 会递归扫描 pages 目录,找到所有符合规则的入口,并为其生成对应的 HTML 和 JavaScript 产物。新增页面时,只需要按照约定创建入口文件,不需要再手动修改 Webpack 的入口配置。
统一页面启动器
如果系统中存在大量页面,每个入口都重复创建 Vue 实例、注册插件和挂载根组件,会产生大量样板代码。
因此,Mint 在 pages 目录下提供了统一的 boot.js,负责创建 Vue 实例、注册 Store、初始化路由以及挂载应用:
js
import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import store from "$mStore/index";
import "ant-design-vue/dist/reset.css";
/**
* 启动 Vue 页面
*
* @param {object} App 页面根组件
* @param {object} options 启动配置
* @param {Array} options.routes 页面路由
* @param {Function} callback Vue 实例扩展回调
*/
export default function boot(App, options = {}, callback) {
const { routes = [] } = options;
const app = createApp(App);
app.use(store);
if (typeof callback === "function") {
callback(app);
}
if (routes.length > 0) {
const router = createRouter({
history: createWebHistory(),
routes,
});
app.use(router);
router.isReady().then(() => {
app.mount("#app");
});
return;
}
app.mount("#app");
}
页面入口只需要引入根组件并调用启动器:
js
import boot from "../boot";
import App from "./index.vue";
boot(App);
如果页面需要注册自定义插件,也可以通过回调访问 Vue 应用实例:
js
boot(App, {}, (app) => {
app.use(/* 业务插件 */);
});
这样可以将通用启动逻辑集中管理,同时保留业务页面的扩展能力。
Webpack 配置拆分
开发环境与生产环境的目标并不相同:
- 开发环境关注启动速度、增量编译和热更新;
- 生产环境关注资源体积、构建性能、缓存策略和代码安全。
如果把两套逻辑全部写在一个配置文件中,并通过大量环境判断进行区分,配置会逐渐变得难以维护。因此,Mint 将 Webpack 配置拆分为三部分:
text
webpack/config
├── webpack.base.js # 公共配置
├── webpack.dev.js # 开发环境配置
└── webpack.prod.js # 生产环境配置
公共配置负责入口扫描、文件解析、路径别名、代码分包和 HTML 生成;开发与生产配置则在此基础上分别覆盖不同的构建行为。
Webpack 公共配置
公共配置首先扫描框架和业务项目中的页面入口。每发现一个入口文件,构建系统会完成两项工作:
- 将入口添加到 Webpack 的
entry配置; - 创建一个
HtmlWebpackPlugin,为该入口生成 HTML 页面并自动注入对应资源。
核心实现如下:
js
const path = require("path");
const glob = require("glob");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { VueLoaderPlugin } = require("vue-loader");
const Components = require("unplugin-vue-components/webpack");
const { AntDesignVueResolver } = require("unplugin-vue-components/resolvers");
const { sep } = path;
const handleEntryFile = (filePath, entriesConfig, htmlPluginList) => {
const entryName = path.basename(filePath, ".js");
entriesConfig[entryName] = filePath;
htmlPluginList.push(
new HtmlWebpackPlugin({
template: "html模版路径,自定义",
filename: outputPage(entryName), // xxx/xxx/entry.xxx.html
chunks: [entryName],
}),
);
};
const pageEntries = {};
const htmlWebpackPluginList = [];
const mintFileList = glob.sync(mEntryPath);
mintFileList.forEach((filePath) => {
handleEntryFile(filePath, pageEntries, htmlWebpackPluginList);
});
const webpackBaseConfig = {
entry: pageEntries,
module: {
rules: [
{
test: /\.js$/i,
include: ["app/pages"],
use: "babel-loader",
},
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.less$/i,
use: ["style-loader", "css-loader", "less-loader"],
},
{
test: /\.vue$/i,
use: "vue-loader",
},
{
test: /\.(png|jpe?g|gif|webp)$/i,
type: "asset",
parser: {
dataUrlCondition: {
maxSize: 8 * 1024,
},
},
generator: {
filename: "static/images/[name].[contenthash:8][ext]",
},
},
{
test: /\.(woff|woff2|eot|ttf|otf|svg)$/i,
type: "asset/resource",
generator: {
filename: "static/fonts/[name].[contenthash:8][ext]",
},
},
],
},
resolve: {
extensions: [".js", ".vue", ".less", ".css"],
alias: {},
},
optimization: {
runtimeChunk: "single",
splitChunks: {
chunks: "all",
maxAsyncRequests: 10,
maxInitialRequests: 10,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
chunks: "initial",
priority: 20,
},
common: {
name: "common",
chunks: "all",
priority: 10,
minChunks: 2,
minSize: 20 * 1024,
reuseExistingChunk: true,
},
},
},
},
plugins: [
new VueLoaderPlugin(),
new webpack.ProvidePlugin({
Vue: "vue",
}),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: "true",
__VUE_PROD_DEVTOOLS__: "false",
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "false",
"process.env.APP_ENV": JSON.stringify(process.env.NODE_ENV),
}),
Components({
resolvers: [
AntDesignVueResolver({
importStyle: false,
resolveIcons: true,
}),
],
}),
...htmlWebpackPluginList,
],
};
module.exports = webpackBaseConfig;
自研开发环境热更新构建
开发环境主要需要解决三个问题:
- 文件变化后自动重新编译;
- 浏览器不刷新页面即可更新模块;
- 尽量减少磁盘 I/O,提高增量构建速度。
Webpack Dev Server 默认将构建产物保存在内存中,而 Mint 的开发链路有一项特殊要求:HTML 文件需要写入磁盘,供 BFF 的模板引擎读取;JavaScript、CSS 等资源则应继续保存在内存中,以减少磁盘 I/O 并提升增量构建速度。
因此,Mint 使用 Express、webpack-dev-middleware 和 webpack-hot-middleware 自建开发服务,以实现"HTML 写入磁盘、其他资源保留在内存、浏览器支持模块热更新"的混合构建模式。
期望达到的效果是:
- HTML 文件写入
app/public,由 BFF 层负责返回; - JavaScript、CSS 等构建资源保存在内存中;
- 文件变化后触发 Webpack 增量编译;
- 通过 HMR 通道将更新信息推送给浏览器;
- 浏览器通过 Webpack 热更新运行时代码替换变化的模块。
实现过程可以概括为四步:
webpack.dev.js继承webpack.base.js的公共配置;- 遍历页面入口,为每个业务入口注入
webpack-hot-middleware/client; - 抽取统一的
DEV_SERVER_CONFIG,确保 HMR 客户端与本地开发服务使用相同的主机、端口和通信路径; - 使用 Express 启动开发服务,并挂载 Webpack 编译中间件与热更新中间件。
开发环境配置如下:
js
// webpack/config/webpack.dev.js
const path = require("path");
const webpack = require("webpack");
const { mergeWithRules } = require("webpack-merge");
const webpackBaseConfig = require("./webpack.base");
const DEV_SERVER_CONFIG = {
HOST: "127.0.0.1",
PORT: 9003,
TIMEOUT: 20000,
HMR_PATH: "__webpack_hmr",
};
const { HOST, PORT, TIMEOUT, HMR_PATH } = DEV_SERVER_CONFIG;
const entryConfig = {};
Object.entries(webpackBaseConfig.entry).forEach(([entryName, entryPath]) => {
entryConfig[entryName] = [
entryPath,
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}&timeout=${TIMEOUT}&reload=true`,
];
});
const webpackDevConfig = mergeWithRules({
module: {
rules: {
test: "match",
use: "replace",
},
},
})(webpackBaseConfig, {
mode: "development",
entry: entryConfig,
output: {
path: path.resolve(process.cwd(), "./app/public/dist/dev"),
filename: "js/[name].bundle.js",
publicPath: `http://${HOST}:${PORT}/public/dist/dev/`,
chunkFilename: "js/async.[name].chunk.js",
clean: true,
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});
module.exports = {
webpackDevConfig,
DEV_SERVER_CONFIG,
};
启动开发服务
dev.js 负责创建 Webpack Compiler 并启动开发服务器:
js
// webpack/dev.js
const express = require("express");
const cors = require("cors");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const webpackHotMiddleware = require("webpack-hot-middleware");
const { webpackDevConfig, DEV_SERVER_CONFIG } = require("./config/webpack.dev");
function startDevServer() {
const app = express();
const compiler = webpack(webpackDevConfig);
app.use(cors());
app.use(
webpackDevMiddleware(compiler, {
publicPath: webpackDevConfig.output.publicPath,
writeToDisk(filePath) {
return /\.html$/.test(filePath);
},
}),
);
app.use(
webpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
}),
);
app.listen(DEV_SERVER_CONFIG.PORT, DEV_SERVER_CONFIG.HOST, () => {
const { HOST, PORT } = DEV_SERVER_CONFIG;
console.log(`Development server listening on ` + `http://${HOST}:${PORT}`);
});
}
module.exports = startDevServer;
if (require.main === module) {
startDevServer();
}
开发流程可以概括为:扫描页面入口、注入 HMR 客户端、启动增量编译、将 HTML 写入磁盘,并将其他资源保存在内存中。当文件发生变化后,浏览器会接收更新消息并替换变化的模块。
生产环境构建与优化
开发环境强调调试效率,而生产环境更加关注以下几个方面:
- 资源体积是否足够小;
- 构建速度是否足够快;
- 静态资源能否有效利用缓存;
- 是否会泄露源代码和调试信息;
- 长期未使用的旧资源能否及时清理。
针对这些问题,Mint 采用以下优化策略:
- 使用
TerserPlugin压缩 JavaScript,并移除生产环境中的调试日志; - 使用
CssMinimizerWebpackPlugin压缩 CSS; - 使用
MiniCssExtractPlugin将 CSS 提取为独立文件; - 使用
contenthash生成文件名,充分利用浏览器缓存; - 关闭生产 Source Map,降低源码泄露风险;
- 通过
output.clean在构建前清理历史产物; - 使用
include限制 Babel 的编译范围; - 对适合并行处理的 JavaScript 编译和资源压缩任务启用多进程。
需要注意的是,多进程本身也存在启动和通信成本,更适合代码量较大的项目。对于规模较小的项目,应当根据实际构建数据判断是否开启。
生产环境配置如下:
js
// webpack/config/webpack.prod.js
const path = require("path");
const os = require("os");
const { mergeWithRules } = require("webpack-merge");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const HtmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
const TerserPlugin = require("terser-webpack-plugin");
const webpackBaseConfig = require("./webpack.base");
module.exports = mergeWithRules({
module: {
rules: {
test: "match",
use: "replace",
},
},
})(webpackBaseConfig, {
mode: "production",
devtool: false,
output: {
path: path.resolve(process.cwd(), "./app/public/dist/prod"),
filename: "js/[name].[contenthash:8].bundle.js",
chunkFilename: "js/async.[name].[contenthash:8].chunk.js",
publicPath: "/dist/prod/",
clean: true,
},
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
{
test: /\.less$/i,
use: [MiniCssExtractPlugin.loader, "css-loader", "less-loader"],
},
{
test: /\.js$/i,
use: [
{
loader: "thread-loader",
options: {
workers: Math.max(os.cpus().length - 1, 1),
},
},
"babel-loader",
],
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: "css/[name].[contenthash:8].css",
chunkFilename: "css/async.[name].[contenthash:8].css",
}),
new HtmlWebpackInjectAttributesPlugin({
crossorigin: "anonymous",
}),
],
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
extractComments: false,
terserOptions: {
compress: {
drop_console: true,
},
},
}),
new CssMinimizerPlugin({
parallel: true,
}),
],
},
});
执行生产构建
webpack.prod.js 负责描述生产构建规则,prod.js 则负责创建 Webpack Compiler 并真正启动构建:
js
// webpack/prod.js
const webpack = require("webpack");
const webpackConfig = require("./config/webpack.prod");
function build() {
webpack(webpackConfig, (error, stats) => {
if (error) {
console.error(error);
process.exitCode = 1;
return;
}
console.log(
stats.toString({
colors: true,
modules: false,
children: false,
chunks: true,
chunkModules: false,
timing: true,
}),
);
if (stats.hasErrors()) {
process.exitCode = 1;
}
});
}
module.exports = build;
最终工程目录结构
完成公共配置、环境配置和执行入口后,Webpack 模块的目录结构如下:
text
webpack
├── config
│ ├── resolve-path.js
│ ├── webpack.base.js
│ ├── webpack.dev.js
│ └── webpack.prod.js
├── dev.js
└── prod.js
各文件的职责如下:
webpack.base.js:提供入口扫描、Loader、Plugin、路径别名和代码分包等公共配置;webpack.dev.js:提供开发模式、HMR 和开发环境产物配置;webpack.prod.js:提供代码压缩、CSS 提取、缓存和生产产物配置;dev.js:启动本地开发服务并接入 Webpack 中间件;prod.js:执行一次完整的生产环境构建;
配置文件负责描述"如何构建",执行入口负责决定"何时开始构建"。将两者分开后,工程结构更加清晰,也便于后续接入命令行工具和 CI/CD 流程。
本章小结
至此,Mint 已经形成了一套从页面开发到产物交付的完整工程化流程:
- 按照目录规范创建 Vue 页面和入口文件;
- 自动扫描并生成 Webpack 多页面入口;
- 通过统一的
boot.js初始化 Vue 应用; - 使用公共配置处理 Vue、JavaScript、CSS、Less、图片和字体;
- 在开发环境中提供内存编译和模块热更新;
- 在生产环境中完成代码压缩、CSS 提取、缓存控制和产物清理;
- 通过独立的开发与生产入口执行构建;
- 将最终产物交给 BFF 层统一托管。
这套工程化体系仍然遵循"约定优于配置"的设计原则。
普通业务只需要按照约定创建页面,无须了解底层 Webpack 配置;存在特殊需求时,也可以通过业务侧配置覆盖框架默认行为。框架负责处理入口发现、页面生成、环境差异和构建优化,业务开发者则可以将更多精力放在页面与业务逻辑本身。