从脚手架到构建注入:Vue 多租户「入驻」工程实践

写在前面

多租户常见两种形态:运行时按域名切租户 (单包多租户),或构建期选定租户 (一租户一产物)。pro-a 属于后者:通过 VITE_TENANT_ID 在构建/开发时选定租户,产物内只打进该租户的 config、静态资源与权限;不是同一套前端在浏览器里按域名动态切换百租户。

本文按入驻 → 构建 → 启动串联核心逻辑,并附关键代码位置,便于对照仓库阅读。


一、租户在仓库里长什么样

每个租户对应 src/tenant/<tenantId>/,常见结构:

路径 作用
config/index.js 应用信息、站点 SEO、外链、路由覆盖、cssVars 主题
assets/image/ Logo、支付图等
assets/js/permissions.js 功能开关(hasPermission
assets/public/ 协议 HTML 等,构建时复制到产物根目录

标准模板租户为 tenant-standardcreateOrg.js 会从这里复制图片、权限、public 等作为新租户基线。


二、入驻第一步:createOrg.js 脚手架

根目录执行 node createOrg.js,交互输入:tenantId、机构名、appId、开发端口、是否独立租户。

脚本主要做四件事:

  1. 创建 src/tenant/<tenantId>/ 目录树(assets/*config)。
  2. tenant-standard 复制图片、permissions.jsassets/public
  3. 生成 config/index.jsappInfositeoutUrlrouter.routescssVars)。
  4. 若选独立租户,把 tenantId 写入 build/tenant-domain-map.jsindependentTenants
92:116:d:/project/pro-a/createOrg.js 复制代码
  // 1. 创建租户目录结构
  const tenantRoot = path.join(__dirname, 'src', 'tenant', tenantId);
  createDirectory(path.join(tenantRoot, 'assets', 'css'));
  createDirectory(path.join(tenantRoot, 'assets', 'image'));
  createDirectory(path.join(tenantRoot, 'assets', 'js'));
  createDirectory(path.join(tenantRoot, 'assets', 'public'));
  createDirectory(path.join(tenantRoot, 'config'));
  
  // 2. 复制标准平台资源(无论是否独立租户,都复制基础资源)
  const standardTenantRoot = path.join(__dirname, 'src', 'tenant', 'tenant-standard');
  // ... 复制 image、permissions、public
166:186:d:/project/pro-a/createOrg.js 复制代码
  const domainMapPath = path.join(__dirname, 'build', 'tenant-domain-map.js');
  let domainMapContent = readFile(domainMapPath);
  if (isIndependent) {
    if (!domainMapContent.includes(`'${tenantId}'`)) {
      domainMapContent = domainMapContent.replace(
        /export const independentTenants = \[(.*?)\];/s,
        (match, tenants) => {
          const updatedTenants = tenants.trim() ? `${tenants.trim()}, '${tenantId}'` : `'${tenantId}'`;
          return `export const independentTenants = [${updatedTenants}];`;
        }
      );
      writeFile(domainMapPath, domainMapContent);
    }
  }

脚手架不会 自动改 package.json。README 要求手动增加类似脚本:

json 复制代码
"dev:tenant-a": "cross-env VITE_TENANT_ID=tenant-a vite --port 8090",
"build:tenant-a": "cross-env VITE_TENANT_ID=tenant-a vite build"

三、构建期:Vite 如何「锁定」一个租户

vite.config.js 读取 process.env.VITE_TENANT_ID(默认 platform),加载该租户 config/index.js 做 SEO 等;用 independentTenants 判断是否独立租户,并写入构建常量。

115:185:d:/project/live-pc-web/vite.config.js 复制代码
import { independentTenants } from './build/tenant-domain-map.js'
const isIndependent = independentTenants.includes(tenantId)
// ...
export default defineConfig({
  publicDir: path.resolve(__dirname, `src/tenant/${tenantId}/assets/public`),
  define: {
    __IS_INDEPENDENT_TENANT__: isIndependent,
    'import.meta.env.VITE_TENANT_ID': JSON.stringify(tenantId),
    'import.meta.env.VITE_IS_INDEPENDENT_TENANT': JSON.stringify(isIndependent),
  },

别名 @tenant 指向当前构建租户目录,业务里写 @tenant/assets/image/... 即该租户资源:

219:227:d:/project/live-pc-web/vite.config.js 复制代码
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@tenant': path.resolve(__dirname, `src/tenant/${tenantId}`),
      '@platform': path.resolve(__dirname, 'src/tenant/platform')
    },

构建插件会把租户 assets/public 再复制到 dist-* 根目录(如 agreement.html)。批量构建见 build/build-all-tenants.js:遍历 src/tenant 子目录,对每个租户执行 cross-env VITE_TENANT_ID=... vite build


四、运行时:谁决定「当前是哪个租户」

tenant-loader.js 从构建注入的 import.meta.env 读租户 ID 与是否独立;只加载本租户 config,缺失则抛错,不继承 platform 默认配置。

8:40:d:/project/live-pc-web/src/utils/tenant-loader.js 复制代码
export const getCurrentTenant = () => {
  if (import.meta.env.VITE_TENANT_ID) {
    return import.meta.env.VITE_TENANT_ID;
  }
  return 'platform';
};

export const isIndependent = () => {
  if (import.meta.env.VITE_IS_INDEPENDENT_TENANT) {
    return import.meta.env.VITE_IS_INDEPENDENT_TENANT === 'true';
  }
  const tenantId = getCurrentTenant();
  return independentTenants.includes(tenantId);
};

export const loadTenantConfig = async () => {
  const tenantId = getCurrentTenant();
  try {
    const tenantModule = await import(`../tenant/${tenantId}/config/index.js`);
    return tenantModule.default;
  } catch (e) {
    throw new Error(`租户${tenantId}配置缺失,无法启动`);
  }
};

useTenantStore 把配置放进 Pinia,组件可读 appInfositecssVars 等。


五、应用启动顺序(main.js

59:99:d:/project/live-pc-web/src/main.js 复制代码
async function initApp() {
  const app = createApp(App)
  const pinia = Pinia.createPinia();
  app.use(pinia);
  // ...
  const router = await initRouter();
  app.use(router);
  const tenantConfig = await initTenantConfig(app);
  app.config.globalProperties.tenantConfig = tenantConfig;
  setGlobalConfig(tenantConfig);
  sessionStorage.setItem('configId', tenantConfig?.appInfo?.appId || '');
  await initPermissions();
  mountGlobalMethods(app);
  app.mount('#app')
}

要点:先路由、再租户配置、再权限configIdappId)会进 sessionStorage,与店铺等接口上下文相关。

initTenantConfig 写 Pinia、设标题/描述/关键词,并按 cssVars 同步 Element Plus / Vant 主题变量。


六、路由合并:租户如何覆盖平台页

initRouter 以平台 constantRoutes 为底,用租户 router.routesname 覆盖 或追加无 name 的新路由;覆盖时合并 beforeEnter,避免租户换组件却丢掉平台守卫。

788:830:d:/project/live-pc-web/src/router/index.js 复制代码
        if (tenantConfig && tenantConfig.router && tenantConfig.router.routes) {
            const routeMap = new Map();
            routes.forEach(route => {
                if (route.name) {
                    routeMap.set(route.name, route);
                }
            });
            tenantConfig.router.routes.forEach(tenantRoute => {
                if (tenantRoute.name) {
                    const baseRoute = routeMap.get(tenantRoute.name);
                    if (baseRoute?.beforeEnter && !tenantRoute.beforeEnter) {
                        routeMap.set(tenantRoute.name, { ...tenantRoute, beforeEnter: baseRoute.beforeEnter });
                    } else if (baseRoute?.beforeEnter && tenantRoute.beforeEnter) {
                        const merged = [
                            ...normalizeBeforeEnter(baseRoute.beforeEnter),
                            ...normalizeBeforeEnter(tenantRoute.beforeEnter),
                        ];
                        routeMap.set(tenantRoute.name, { ...tenantRoute, beforeEnter: merged });
                    } else {
                        routeMap.set(tenantRoute.name, tenantRoute);
                    }
                } else {
                    routes.push(tenantRoute);
                }
            });
            routes = Array.from(routeMap.values());
        }

tenant-standard 示例:覆盖 downloadagreementagreementM 到标准协议/下载页。


七、权限与协议:白标边界

权限按租户动态 import permissions.js,用点路径判断,例如 hasPermission('order.showBIteMiniProgram')

27:41:d:/project/live-pc-web/src/utils/permission.js 复制代码
export const initPermissions = async () => {
  const tenantId = getCurrentTenant();
  try {
    const tenantPermissions = await import(`../tenant/${tenantId}/assets/js/permissions.js`);
    permissions = tenantPermissions.default || {};
  } catch (error) {
  permissions = {};
  }
  return permissions;
};

协议类静态页在 assets/public/(如 agreement.html),配合租户路由指向 service-standard 或租户专属 Vue 页;README 有 PC/移动协议文件清单。


八、独立租户 vs 非独立(本仓库含义)

维度 非独立 独立
标记 不在 independentTenants createOrg 写入列表
构建 VITE_IS_INDEPENDENT_TENANT 为 false 为 true
配置 仍从 tenant-standard 复制基线,再改 config/资源 同上,可更深定制路由与协议
与资金分账 无直接关系;分账/宝付在业务后端,非本前端入驻脚本

「独立」在这里主要是构建标识 + 工程隔离,不等于已配置自有微信/支付宝商户号。


九、端到端流程(串联)

flowchart TD A[node_createOrg] --> B[src_tenant_tenantId] B --> C[package_json_VITE_TENANT_ID] C --> D[vite_alias_public_define] D --> E[dist_tenant] C --> F[dev_server] F --> G[main_initRouter] G --> H[initTenantConfig] H --> I[initPermissions] I --> J[mount]

十、小结与边界

核心结论 :入驻 = createOrg 生成租户目录与配置 → VITE_TENANT_ID 构建注入 → @tenantpublicDir 绑定资源 → 启动时 loadTenantConfig + 路由按 name 合并 + 租户 permissions

本文未覆盖:后台开户、课程/订单库表隔离、宝付分账、支付商户进件------属业务与后端,需另文展开。

延伸阅读 :同系列可写支付收银台(wechat-jsapi-cashier)、订单 settleMode 与小程序跳转分账展示模式,并与资金侧「监管分账」区分命名。

相关推荐
卤蛋fg63 小时前
VxeTable 实现表尾合计行并支持数据实时统计
vue.js
杨大厨wd3 小时前
Vue3 业务组件封装别只会传 props:如何设计一个真正好用的组件
vue.js
前端那点事3 小时前
Vue3 script setup 语法糖最全教程!零基础吃透+项目落地+面试满分
前端·vue.js
卷帘依旧4 小时前
Vue 响应式原理:Object.defineProperty vs Proxy 深度对比
前端·vue.js
布局呆星4 小时前
Vue3 路由守卫详解:全局守卫、路由独享守卫、组件内守卫
前端·javascript·vue.js
小李子呢02114 小时前
前端八股Vue---ref操作 DOM 元素或组件,调用子组件方法
前端·javascript·vue.js
Yoram5 小时前
Vue3 响应性:跨上下文的传递、转换与作用域控制
前端·vue.js
qingy_20466 小时前
浏览器页面出现竖向滚动条的解决方案
前端·javascript·vue.js
前端小万6 小时前
用 AI 写了个 VSCode 摸鱼插件,从开发到上架全过程
vue.js