Mint 自研框架设计与实现:从重复开发走向配置驱动(三)

前端工程化设计与实现

完成 BFF 层的基础建设后,Mint 已经能够正常启动一个 Koa 服务。接下来需要解决的问题是:如何构建并向浏览器提供前端页面?

一个完整的页面交付流程可以拆分为两个部分:

  1. 使用 Webpack 将 Vue 页面构建为 HTML、JavaScript 和 CSS 等静态资源;
  2. 通过 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-middlewarewebpack-hot-middleware 自建开发服务,以实现"HTML 写入磁盘、其他资源保留在内存、浏览器支持模块热更新"的混合构建模式。

期望达到的效果是:

  • HTML 文件写入 app/public,由 BFF 层负责返回;
  • JavaScript、CSS 等构建资源保存在内存中;
  • 文件变化后触发 Webpack 增量编译;
  • 通过 HMR 通道将更新信息推送给浏览器;
  • 浏览器通过 Webpack 热更新运行时代码替换变化的模块。

实现过程可以概括为四步:

  1. webpack.dev.js 继承 webpack.base.js 的公共配置;
  2. 遍历页面入口,为每个业务入口注入 webpack-hot-middleware/client
  3. 抽取统一的 DEV_SERVER_CONFIG,确保 HMR 客户端与本地开发服务使用相同的主机、端口和通信路径;
  4. 使用 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 采用以下优化策略:

  1. 使用 TerserPlugin 压缩 JavaScript,并移除生产环境中的调试日志;
  2. 使用 CssMinimizerWebpackPlugin 压缩 CSS;
  3. 使用 MiniCssExtractPlugin 将 CSS 提取为独立文件;
  4. 使用 contenthash 生成文件名,充分利用浏览器缓存;
  5. 关闭生产 Source Map,降低源码泄露风险;
  6. 通过 output.clean 在构建前清理历史产物;
  7. 使用 include 限制 Babel 的编译范围;
  8. 对适合并行处理的 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 已经形成了一套从页面开发到产物交付的完整工程化流程:

  1. 按照目录规范创建 Vue 页面和入口文件;
  2. 自动扫描并生成 Webpack 多页面入口;
  3. 通过统一的 boot.js 初始化 Vue 应用;
  4. 使用公共配置处理 Vue、JavaScript、CSS、Less、图片和字体;
  5. 在开发环境中提供内存编译和模块热更新;
  6. 在生产环境中完成代码压缩、CSS 提取、缓存控制和产物清理;
  7. 通过独立的开发与生产入口执行构建;
  8. 将最终产物交给 BFF 层统一托管。

这套工程化体系仍然遵循"约定优于配置"的设计原则。

普通业务只需要按照约定创建页面,无须了解底层 Webpack 配置;存在特殊需求时,也可以通过业务侧配置覆盖框架默认行为。框架负责处理入口发现、页面生成、环境差异和构建优化,业务开发者则可以将更多精力放在页面与业务逻辑本身。

相关推荐
写代码的强哥4 小时前
TiDB 和 OceanBase 对比:架构师视角下的企业选型实战指南
数据库·云原生·架构
Conan在掘金4 小时前
ArkTS 进阶之道(1):为哈禁 any/unknown?从「编译期就拦」理解鸿蒙严格类型哲学
后端
JoyT4 小时前
Nuxt 3的核心设计与静态官网应用
前端·javascript·vue.js
程序员黑豆4 小时前
鸿蒙应用开发之@State 装饰器详解:从基本类型到 @Observed/@ObjectLink/@Track 嵌套监听
前端·harmonyos
杉氧5 小时前
Flutter 像素级还原实战:用 CustomPaint 与 Bezier 曲线手绘精致图针
android·前端·flutter
Patrick_Wilson5 小时前
为什么让程序主动崩溃反而更可靠?聊聊 supervisor 与 let it crash
后端·erlang·elixir
张忠琳5 小时前
【NVIDIA】NVIDIA k8s-device-plugin v0.19.3 配置API模块深度分析之二
云原生·容器·架构·kubernetes·nvidia
心念枕惊5 小时前
【Agent Harness】Gliding Horse 整体架构拼图:当 AI Agent 有了自己的操作系统
人工智能·架构
金融小白数据分析之路6 小时前
绍兴市镇街echarts 制作
前端·数据库·echarts