深入解析:uniapp单仓库多应用(SaaS 化)架构

深入解析:单仓库多应用(SaaS 化)架构如何解决多租户维护痛点

在现代 SaaS(Software as a Service)业务模式下,技术团队面临的最大挑战之一是如何高效地为成百上千个客户(租户)交付独立的小程序或应用。本项目采用了一种 "单仓库多应用"(Monorepo-like / Multi-Tenant) 的架构模式,通过极致的工程化手段,将维护成本从线性增长(O(N))降低为常数级(O(1))。

本文将从痛点分析核心架构设计关键代码实现 以及进阶的条件编译体系四个维度进行详细剖析。


一、 痛点分析:传统多租户开发的"泥潭"

假设你需要维护 50 个客户的小程序,每个客户都有独立的 AppID、接口地址,且部分客户有定制化页面需求。

1. 代码维护成本高 (Code Redundancy)

  • 传统做法:为每个客户开一个 Git 分支或独立仓库。
  • 后果:当核心业务(如"下单流程")发现一个 Bug 时,你需要在 50 个仓库中分别合并代码。这是一场灾难,极易漏改或改错。

2. 发版事故频发 (Human Error)

  • 传统做法 :打包前手动修改 manifest.json 中的 appid,手动切换 config.js 中的 API 环境地址。
  • 后果:很容易把"客户 A"的代码发到了"客户 B"的小程序上,或者把"测试环境"的配置发到了"生产环境"。

3. 包体积与性能瓶颈 (Bundle Size)

  • 传统做法 :将所有客户的功能都写在一个大包里,通过 if (clientId === 'A') 来判断显示什么。
  • 后果:所有客户都要下载包含其他 49 个客户定制逻辑的巨型包。小程序主包体积限制(2MB)很快就会爆表,且启动速度极慢。

二、 核心架构设计:配置驱动的动态构建

本项目的核心思想是:代码是共享的,差异是配置出来的。

1. 目录结构设计

我们将"变"与"不变"完全分离:

  • src/ (不变):存放所有核心业务逻辑、组件、页面。所有客户共用这一套代码。
  • scripts/setting/ (变) :存放每个租户的"身份证"。每个文件(如 wx0159...js)代表一个客户,包含其独有的配置信息。

2. 构建流程图

graph LR A[开发者] -->|选择配置 ID: wx0159...| B(构建脚本 scripts/build/index.js) B --> C{读取配置} C -->|1. 读取公共配置| D[scripts/build/baseConfig.ts] C -->|2. 读取租户配置| E[scripts/setting/wx/wx0159...js] B --> F[动态生成/覆写文件] F -->|合并页面| G[src/pages.json] F -->|替换 AppID| H[src/manifest.json] F -->|注入变量| I[package.json / process.env] F --> J[Vite 编译打包] J --> K[生成最终小程序]

三、 关键代码实现:如何"移花接木"

1. 动态生成 pages.json ------ 解决包体积与定制化

Uniapp 的 pages.json 通常是静态的。为了实现"按需打包",我们在构建阶段动态生成它。

  • 公共页面 :在 baseConfig.ts 中定义所有客户都有的页面(如首页、个人中心)。
  • 租户页面 :在租户配置(如 wx01596767907b93fe.js)中定义特有的分包。

构建脚本逻辑 (scripts/build/index.js):

javascript 复制代码
// 1. 读取公共配置
const { baseSubPackages, pages } = require('./baseConfig.ts');
// 2. 读取当前租户配置
const { config } = require(configFilePath);

// 3. 动态合并
// 只有当前客户需要的 subPackages 才会进入最终的 pages.json
pagesJson.subPackages = baseSubPackages.concat(config.subPackages || []);
pagesJson.pages = pages.concat(config.pages || []);

// 4. 写入文件系统(供 Uniapp 编译器使用)
fs.writeFileSync(pagesJsonPath, JSON.stringify(pagesJson, null, 2), 'utf8');

成效:客户 A 的小程序里绝对不会出现客户 B 的定制页面代码,包体积最小化。

2. 自动化身份注入 ------ 杜绝发版事故

为了防止人工修改 manifest.json 出错,我们通过脚本全自动替换。

构建脚本逻辑 (scripts/build/index.js):

javascript 复制代码
// 读取 manifest.json 原始内容
let updatedManifestContent = manifestJsonContent;
const platform = scriptConfig.env.UNI_PLATFORM;

// 正则替换 AppID
// 确保只替换当前平台的 appid,不影响其他配置
const platformRegex = new RegExp(`"${platform}":\\s*{[^}]*"appid":\\s*"[^"]*"`);
updatedManifestContent = updatedManifestContent.replace(platformRegex, match => match.replace(/"appid":\\s*"[^"]*"/, `"appid": "${configType}"`));

fs.writeFileSync(manifestJsonPath, updatedManifestContent, 'utf8');

成效:只要选对了配置 ID,打出来的包 AppID 绝对正确,无需人工 Check。

3. 环境变量透传 ------ 代码去敏感化

业务代码中(src/ 下)严禁出现任何硬编码的 URL 或 Key。所有环境相关变量都通过构建过程注入。

配置定义 (scripts/setting/wx/wx0159...js):

javascript 复制代码
env: {
  URL: 'https://mall.baoquanlvyou.com', // 接口地址
  MCODE: 'dwr1Op9C3C5q...',             // 商家编码
  SITE_CHANNEL_TYPE: 5
}

注入逻辑 (vite.config.ts + defineEnv.js): 构建脚本先将 env 对象注入到 Node 进程的 process.env 中,然后 vite.config.ts 通过 define 选项将其传递给前端代码。

typescript 复制代码
// vite.config.ts
export default defineConfig(() => {
  return {
    define: getDefineEnv() // 将 process.env.URL 等注入全局
    // ...
  };
});

业务代码使用 (src/utils/env.ts):

typescript 复制代码
export const getConfig = () => {
  return {
    // 这里的 process.env.URL 在编译时会被替换为字符串常量
    // @ts-ignore
    baseUrl: process.env.URL
    // ...
  };
};

四、 进阶架构:多层次条件编译与定向构建体系

在解决了基础的"配置隔离"后,我们通过 scripts/setting/ 配置文件与 Vite 的深度结合,构建了一套从构建脚本到业务代码、从路由层级到组件粒度 的全方位条件编译体系,解决 "千人千面" 的深度定制需求。

1. 核心能力:宏注入机制

我们不仅仅利用了 Uniapp 原生的条件编译(#ifdef),更通过自定义注入的宏(Macro),实现了基于租户身份的定向编译。

在每个租户的配置文件中(如 package.json 中的 pro-h5scripts/setting/wx/ 下的配置),我们定义了 define 字段:

json 复制代码
// package.json 示例
"uni-app": {
  "scripts": {
    "pro-h5": {
      "title": "商城",
      "env": {
        "UNI_PLATFORM": "h5",
        "SITE_CHANNEL_TYPE": 4
      },
      "define": {
        "PRO_H5": true  // <--- 自定义宏
      }
    }
  }
}

或者在租户独立配置中(如 wx00c9.js):

javascript 复制代码
define: {
  WX00C9: true; // <--- 针对该特定客户的宏
}

这些宏在构建时通过 defineEnv.js 被注入到全局变量中,使得业务代码可以像使用系统变量一样使用它们。

2. 场景实战:从路由到组件的定向控制

2.1 脚本路由级控制 (Script-Level)

package.jsonuni-app.scripts 中,我们定义了不同的启动命令(如 pro-h5)。这不仅决定了环境变量,还决定了Uniapp 的平台表现

  • 场景 :特定租户(如 WX00C9)需要在个人中心显示额外的功能入口,而其他租户不需要。

  • 实现

    typescript 复制代码
    // src/pages/mine/index.vue
    
    // #ifdef WX00C9
    <view class="menu-item" @click="goSpecialPage">
      <text>专属VIP通道</text>
    </view>
    // #endif
2.2 租户级定向编译 (Tenant-Level)

对于 50 个租户中,只有"客户 A"需要显示某个特殊按钮,或者只有"客户 B"需要隐藏某个模块。

  • 场景 :只有 wxb557... 这个客户需要默认加载某些数据,其他客户不需要。

  • 实现 :直接使用注入的 define 宏进行判断。

    typescript 复制代码
    // 伪代码示例
    if (process.env.WX1558) {
      // 只有 WX1558 这个客户会执行这段逻辑
      // 编译后,其他客户的代码中这段会被直接 Tree-Shaking 移除
      initSpecialData();
    }
2.3 页面与入口级控制 (Page-Level)

通过动态生成 pages.json,我们实现了物理级别的页面隔离

  • 场景:客户 A 需要"扫码点餐"功能,客户 B 只需要"内容展示"。
  • 实现
    • 客户 A 的配置 subPackages 包含 subPackageScanQrOrder
    • 客户 B 的配置 subPackages 为空。
    • 结果:客户 B 的小程序包中根本不包含点餐相关的页面文件和资源,包体积减少 50% 以上。

3. 多层次条件编译矩阵

通过 package.jsonscripts/setting/ 中的精细化配置,我们构建了一个多维度的条件编译矩阵

维度 控制粒度 实现方式 作用
平台级 H5 / 微信 / 支付宝 UNI_PLATFORM + #ifdef 抹平平台差异,调用原生 API
租户级 特定客户 (AppID) 自定义宏 (WX00C9) 实现"千人千面"的定制业务逻辑
路由级 页面 / 分包 动态 pages.json 按需打包,物理隔离业务模块
环境级 Dev / Prod process.env.URL 自动切换接口环境,杜绝事故

五、 总结

这套架构方案的本质是 "编译时多态" 。通过将多租户的差异前置到 构建阶段(Build Time) 处理,而不是留到 运行时(Runtime) 判断,我们实现了:

  1. 安全性:代码中无敏感配置,不同租户代码物理隔离(在产物层面)。
  2. 高性能:无冗余代码,包体积最小。
  3. 高效率:一套代码服务所有客户,Bug 修复一次,全员受益。
  4. 高灵活:从平台到租户,从页面到组件,全方位满足个性化定制需求。
相关推荐
怎么就重名了39 分钟前
Kivy的属性系统
java·前端·数据库
hxjhnct1 小时前
JavaScript Promise 的常用API
开发语言·前端·javascript
web小白成长日记1 小时前
前端让我明显感受到了信息闭塞的恐怖......
前端·javascript·css·react.js·前端框架·html
GIS之路2 小时前
GDAL 实现创建几何对象
前端
liulilittle2 小时前
CLANG 交叉编译
linux·服务器·开发语言·前端·c++
自信阿杜2 小时前
跨标签页数据同步完全指南:如何选择最优通信方案
前端·javascript
牛马1112 小时前
WidgetsFlutterBinding.ensureInitialized()在 Flutter Web 端启动流程的影响
java·前端·flutter
Captaincc2 小时前
2025: The year in LLMs
前端·vibecoding
指尖跳动的光3 小时前
Vue的nextTick()方法
前端·javascript·vue.js
码事漫谈3 小时前
可能,AI早都觉醒了
前端