SaaS 后台的 DSL 改造(番外):前端工程化踩坑实录——手动 HMR、UnoCSS 战争与测试选型

5 分钟阅读

正篇五篇讲的是架构决策,这篇讲工程实现里的坑。BFF 项目里的前端工程化和纯前端项目不一样:页面最终要由 Koa 渲染,构建、热更新、模板、测试全都得围着这个前提。这篇把这套自建工程化的关键实现和踩过的坑摊开,和主线剧情无关,独立阅读即可。

照例,代码按真实实现精简过。

1. 为什么不用现成的 dev server

项目的前端是 Vue 多页应用,但页面 HTML 不是静态文件,是 Koa 用 nunjucks 渲染的——渲染时要注入 projKey、当前环境这些信息(当然这不是唯一实现,其实也可以通过接口获取当前域名的projectKey 信息再注入 我们只是当时选择了一个比较简单的实现方式 其实这里甚至可以不注入 单纯从用户的请求域名里去提取域名查询对应的 project 信息 我们只是为了减少一些无必要的查询):

typescript
// view controller: 页面模板是构建产物里的 .nj 文件
await ctx.render(`dist/entry.${ctx.params.page}`, {
  projKey: ctx.query.projKey,
  env: app.appEnv.getEnv(),
});

所以我们选择webpack 的构建能力以中间件的形式嵌进这个体系里。

2. 手动 HMR 四件套

实现不复杂,express 加两个中间件:

typescript
const compiler = webpack(devConfig);
const app = express();

app.use(
  devMiddleware(compiler, {
    publicPath: devConfig.output.publicPath,
    // 哪些文件要落地到磁盘
    writeToDisk: (filePath) => filePath.endsWith(".nj"),
    headers: { "Access-Control-Allow-Origin": "*" /* ... */ }, // 跨端口 要开 CORS
  }),
);

app.use(hotMiddleware(compiler, { path: "/__webpack_hmr" }));
app.listen(3001);

四个关键细节:

entry 注入热更新客户端。 每个入口前面拼上 webpack-hot-middleware/client,带上完整的 HMR 服务地址。多页应用的每个 entry 都要注,遍历 entry 对象统一处理。

writeToDisk 只落模板。 devMiddleware 默认把产物放内存文件系统,速度快,但 Koa 的 nunjucks 渲染读的是真实磁盘。所以只让 .nj 模板文件写盘,JS/CSS 留在内存里。

publicPath 带完整 origin。 Koa 在 3345 端口,dev server 在 3001 端口,页面里引用 JS 必须写绝对地址 http://localhost:3001/js/xxx.js,跨端口所以 devMiddleware 要配 CORS 头。

配置合并用 mergeWithRules。 base 和 dev/prod 的 module.rules 按 test 匹配、use 整体替换,不用默认的数组合并——默认合并会把 dev 的 loader 追加进 base 的 use 数组,两条规则串行执行,结果南辕北辙。

3. 多页 entry 的自动化

多页应用的入口维护是个经典的重复劳动:加个页面要加 entry、加 HtmlWebpackPlugin,一处都不能漏。这里的解法和第二篇的 loader 是同一个思想——目录即注册表:

typescript
const pages = globSync("./app/pages/**/entry.*.ts").map((file) => ({
  entryName: path.basename(file, ".ts"),
  path: path.resolve(file),
  plugin: new HtmlWebpackPlugin({
    template: "./app/templates/entry.nj",
    filename: `./app/public/dist/${entryName}.nj`, // 输出成 nunjucks 模板
    chunks: [entryName],
  }),
}));

entry: Object.fromEntries(pages.map((p) => [p.entryName, p.path]));

约定 entry.*.ts 就是页面入口,glob 扫出来,webpack entry 和 HtmlWebpackPlugin 一对一生成。加页面就是加文件,构建配置一行不动。产物直接输出成 .nj 模板,正好接上第 1 节 Koa 渲染的那一步。

4. 踩坑:UnoCSS 和 thread-loader 的战争

项目原本给 vue-loader 和 babel-loader 前面挂了 thread-loader 做并行构建提速,接入 UnoCSS 之后样式开始出问题。查下来是已知冲突(unocss/unocss#3139):UnoCSS 的 webpack 集成依赖虚拟模块,thread-loader 把 loader 丢进 worker 线程跑,虚拟模块的解析在 worker 里就断了。

处理方式是认输:在受影响的 loader 上把 thread-loader 摘掉,代码里至今还留着注释掉的残骸:

typescript
{
  test: /\.vue$/,
  use: [
    // 'thread-loader',   // 与 UnoCSS 冲突 见 unocss#3139
    'vue-loader',
  ],
}

构建慢了一点,但样式是对的。这条的教训很朴素:构建提速是可牺牲的,构建正确性不是。

5. 顺手写了个 webpack 插件:pinia-auto-refs

pinia 的 store 解构会失去响应性,官方解法是 storeToRefs,但每次都要手写。社区有 pinia-auto-refs 的 babel 方案,我们不想为此引入额外的 babel 处理层,就自己写了个 webpack 插件,思路还是那套:扫目录、生成代码、文件变化自动重新生成。

typescript
export class PiniaAutoRefsWebpackPlugin {
  apply(compiler: Compiler) {
    // 每次构建产出后 重新扫描 store 目录 生成 helper 文件
    compiler.hooks.afterEmit.tapPromise(
      "PiniaAutoRefsWebpackPlugin",
      async () => {
        const ctx = await this.generateConfigFiles();
        // 内容没变就不写盘 避免触发无意义的二次编译
        if (oldCtx !== ctx) await fs.writeFile(this.options.outputFile, ctx);
      },
    );

    // 开发模式下额外监听 store 目录的增删
    if (isDev) {
      chokidar
        .watch(storeDir)
        .on("add", () => this.generateConfigFiles())
        .on("unlink", () => this.generateConfigFiles());
    }
  }
}

效果是使用侧只需要调用 useStore('config'),refs 自动解包。两个细节值得一提:写盘前比对内容,没变就不写;和第二篇的类型 watcher 一样只监听增删,原因也一样,形状取决于文件清单而非内容。

6. 测试:选型过程和签名注入的骚操作

测试框架对比了 jest、mocha、vitest 三家:

  • jest:dynamic import 时不允许超出测试配置规定的文件范围,GitHub 上有相似 issue 没解决,不浪费时间,放弃。
  • mocha:能跑,但要额外的第三方断言库协助,配置繁琐;watch mode 非常慢;异步测试需要很多额外的废话代码。和 vitest 比没有优势。
  • vitest:基本开箱即用,内置功能齐全,速度优势明显。

我们的框架启动依赖运行时扫目录 dynamic import,正好踩在 jest 那个限制上,所以第一条就直接把 jest 排除了,选型有时候不是选最好的,是先排除跑不起来的。

测试本身的写法是 supertest 打真实启动的 app,但每个请求都要带签名 header(第三篇的机制),手写就太蠢了。用 Proxy 在请求方法上统一注入:

typescript
beforeAll(async () => {
  await PuzzlesCore.start();
  const rawRequest = supertest(PuzzlesCore.server);

  request = new Proxy(rawRequest, {
    get(target, prop) {
      const result = Reflect.get(target, prop, rawRequest);
      // 只拦截 http 方法 其他属性原样透传
      if (!["get", "post", "put", "delete", "patch"].includes(prop))
        return result;
      return (...args) => injectSign(result(...args)); // 自动带签名和 proj_key
    },
  });
});

injectSign 负责算签名、带时间戳、带随机选中的 projKey。测试用例里 request.post(PRODUCT_MAIN).send(product) 就是一行,签名机制对用例作者完全透明,和第二篇 Proxy 自动 bind 是同一个套路:横切的事在底层拦截掉,使用侧保持干净。

小结

这篇是工程化的杂记:dev server 自己接是因为页面归 Koa 渲染、HMR 四件套的四个细节、多页 entry 用 glob 消灭注册、UnoCSS 和 thread-loader 冲突时砍提速保正确、pinia-auto-refs 插件写盘前先比对内容、vitest 是排除法选出来的、Proxy 注入签名让测试无感。单个看都是小问题,但是告诉我们一个道理:工程化没有银弹,全是这种具体问题具体分析的小决策。
这个系统设计是我们 3 年前的最初版本,我们现在的系统已经迁移到 vite + nestjs了, 也接入了一些 AI 的能力, 后续我们会继续更新我们的改造过程和最新的设计

相关文章
前后篇
OLDER →SaaS 后台的 DSL 改造(五):渲染引擎——一份 DSL 生成整个后台
评论