拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue多客户配置自动化:运行时配置与构建流水线实战

Vue多客户配置自动化:运行时配置与构建流水线实战

上一篇文章我们把多客户配置的目录结构和基础思路定下来了,今天把话接上,聊一聊真正核心的部分:怎么让 Vue 项目在多个客户、多套环境之间切换的时候,不再靠“人肉改代码”。这篇我会把拆解思路、运行时配置模型、自动生成入口、构建流水线接入全讲清楚,也会附上我自己踩过的坑。内容基于 Vue 3 + Vite,但 Vue 2 + webpack 的思路完全一致,迁移成本不高。

多客户配置这件事,表面上看着就是“把 API 域名换成客户的”,实际上往里走一步就会发现问题远不止 API 域名:每个客户的 Logo、主题色、备案号、功能开关、菜单权限、甚至某些页面组件都不一样。如果这些全部靠环境变量硬编码,很快你就会被客户数量乘以环境数量给吞掉。我看过不少团队,配置维护靠一个共享 Excel,上线靠微信群喊“谁改过 customerA 的配置”,最后发布错了客户,回滚还要折腾半天。自动化方案要解决的,根本不是“能不能自动”,而是把变化的边界划清楚,让变化只在该变化的地方发生。

1. 先把“多客户配置”这件事拆透

1.1 客户配置到底都隔些什么?先盘一单

动手写方案之前,我习惯先给配置项分个类,因为不同类别的配置,生命周期和注入时机完全不一样。我把实际项目里的客户配置归纳成四类,你拿这个清单去套自己的项目基本是够用的:

类别典型字段变化频率注入时机
品牌与展示类站点标题、Logo、主题色、备案号、版权文案低,基本定下来就不动运行期注入
接口与基础服务类API Base URL、WebSocket 地址、文件服务地址、埋点 ID中,环境切换必改运行期注入
功能开关与权限类是否开启报表、是否启用 IM、菜单可见性、角色权限高,业务运营经常调运行期注入
构建与环境类publicPath、CDN 域名、是否开启 sourcemap极低,改了就要重新构建构建期注入

看到没有,前两类是“随站点走”的,后两类里构建与环境是“随代码走”的。很多团队把四类全塞进.env文件,然后每个客户复制一份.env.customerA,每次构建传--mode customerA。这个做法临时能用,但有一个致命问题:只要改了 API 域名或者开关,就必须重新执行一次构建。客户 A 有 3 个环境,客户 B 有 3 个环境,改一个 Logo 要等六次流水线跑完,资源浪费还是小事情,关键是发布窗口拉长,出错概率成倍增加。

所以我在设计这套方案时,第一原则就是:凡是运行期能变的,绝不放到构建期去定死。

1.2 构建期变量与运行期配置的边界,这是新旧方案的唯一分水岭

理解这个边界是整套方案的钥匙。构建期变量,比如VITE_PUBLIC_PATH、VITE_CDN,它们在vite build执行的时候就会被静态替换进产物,打包结束后这些值已经焊死在 JS 里面了,改也只能重新构建。运行期配置则相反,它是页面加载后从某个外部文件读取的,只要换掉外部文件,不用动产物,刷新页面就是新配置。

打个比方:构建期变量是房子的地基和承重墙,浇筑完了就定型;运行期配置是软装和智能家居设置,换个用户入住,直接调设置就行。你不可能为了换一套沙发改地基。

确定了这个边界后,方案的核心骨架就很清晰了:构建期只保留那些真正和打包路径相关的变量,其余客户差异项全部抽到一个运行时站点配置对象里。这个对象里存的是一份普通 JSON,不参与打包、不参与编译,由浏览器在页面加载时同步读取。这样一来,“产物”和“站点数据”就彻底解耦了,你可以做到一份 Vue 构建产物对应任意数量的客户站点。

这一步想通了,后面所有实现都是水到渠成的事,不用纠结“为什么不能直接在代码里 import 一个 config.ts”。因为一旦 import,配置就进了 bundle,你又回到了“改配置必须重新构建”的老路。

2. 一套可落地的配置矩阵:把“客户”和“环境”组合成二维坐标系

2.1 配置矩阵长什么样?

配置不是一堆散文件,它需要有一个干净的坐标体系。我常用的矩阵是两个维度:客户维度(tenant)× 环境维度(env)。客户是横轴,环境是纵轴,交叉点就是一个具体的配置文件。

客户 \ 环境devtestprod
customerAsite.customerA.dev.jsonsite.customerA.test.jsonsite.customerA.prod.json
customerBsite.customerB.dev.jsonsite.customerB.test.jsonsite.customerB.prod.json
customerCsite.customerC.dev.jsonsite.customerC.test.jsonsite.customerC.prod.json

这个矩阵的好处是:任何一个配置问题,你都能第一时间定位到“哪个客户、哪个环境”的交叉点。我在实际项目里还会在 JSON 里加一个_meta字段,专门记录这个配置文件最后是谁改的、什么时候改的、对应前端产物版本是什么,排查线上问题的时候非常救命,不然两个客户共用一个产物,出了问题互相甩锅都不知道是谁动了配置。

文件命名规范我用的是小写加点的形式:site.{customer}.{env}.json。这个命名规则不是随便定的,因为后续自动化脚本要用正则从文件名里解析维度和值,命名越规律,脚本越简单,越不容易出错。你要是喜欢用customerA_prod.json也行,但全项目必须只有一个规范,文件名就是接口协议。

2.2 为什么不能只搞一个 “config-production.js”?

我见过有的团队说“我们也有多客户配置”,结果就是一个config/prod.js,里面写:

export default { customerA: { apiBase: 'https://a.example.com' }, customerB: { apiBase: 'https://b.example.com' } }

然后代码里到处config[customerName]。这个方案不是不能用,但它把两个问题混淆了:第一,它把所有客户的配置打进了同一个 bundle,客户 A 永远会下载到客户 B 的敏感信息;第二,它没有环境维度,测试环境联调的时候你根本没法模拟线上差异。

正确的做法是每个客户每个环境一个独立文件,构建物是“通用壳子”,加载哪个配置由站点入口决定。客户 A 和生产环境的关系,应该在“站点”这一层就确定下来,而不是在代码里动态认领。站点配置的下发,本质上是部署行为,不是代码行为。想清楚这句话,整套方案的架构就会很干净。

3. 核心实操:运行时站点配置 + 自动生成入口

3.1 目录结构设计

说再多理论不如直接看目录。我推荐把运行时配置放在 Vue 项目的public/config/目录下,注意是public,不是src。原因是public下的文件会被原样拷贝到构建产物的根目录,不会被 Vite 处理、不会被打包、不会有 hash 后缀,非常干净。

public/config/ ├── site.json # 默认配置,作为兜底 ├── site.customerA.dev.json ├── site.customerA.test.json ├── site.customerA.prod.json ├── site.customerB.dev.json ├── site.customerB.test.json └── site.customerB.prod.json

程序入口这边,index.html里会通过一段同步脚本去加载对应配置。为什么不用fetch异步拉?因为配置要在 Vue 应用实例化之前就位,如果用异步请求,首屏会出现一段没有站点标题、没有主题色的白屏,而且fetch跨域、超时的问题都要额外处理。同步script加载是浏览器原生能力,最简单也最可靠。

3.2 配置加载的时序和注入方式

先看index.html里的加载逻辑。这里我用了动态判断域名的方式,让一个构建产物能同时服务多个客户:

<!DOCTYPE html> <html lang="zh-CN"> <head> <script src="/config/runtime-loader.js"></script> <script> // 加载完 loader 后,同步阻塞直到站点配置写入 window var __site = window.__siteLoader && window.__siteLoader(); window.__SITE_CONFIG__ = __site || window.__DEFAULT_SITE__ || {}; document.title = window.__SITE_CONFIG__.title || '默认标题'; </script> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>

关键就在runtime-loader.js。它的职责是:根据当前域名判断是哪个客户,然后同步加载对应的配置文件。这是文件的核心逻辑:

// public/config/runtime-loader.js (function (win) { var hostMap = { 'localhost': 'site.customerA.dev.json', 'a.example.com': 'site.customerA.prod.json', 'b.example.com': 'site.customerB.prod.json' }; var fileName = hostMap[win.location.hostname] || 'site.json'; // 同步阻塞加载指定配置文件,避免首屏闪烁 document.write('<script src="/config/' + fileName + '"><\/script>'); // 暴露统一入口,把配置对象交出去 win.__siteLoader = function () { return win.__SITE_CONFIG_RAW__ || null; }; })(window);

对应地,每个site.customerA.prod.json我并不是直接放纯 JSON 文件,而是放一段赋值全局变量的 JS。这样避免了 JSON 文件的 MIME 类型问题,也保证同步加载后立刻有值:

// public/config/site.customerA.prod.json.js window.__SITE_CONFIG_RAW__ = { _meta: { customer: 'customerA', env: 'prod', version: '1.2.3', updatedAt: '2025-06-18T10:00:00+08:00' }, title: 'A 客户管理后台', logoUrl: '/assets/logo-a.png', apiBase: 'https://api.a.example.com', wsBase: 'wss://ws.a.example.com', theme: { primaryColor: '#1b6ef3', borderRadius: 6 }, features: { report: true, im: false, exportExcel: true } };

看到这里你可能会有疑问:site.customerA.prod.json.js这个名字挺别扭,为什么不干脆叫.js?我也不知道最开始谁定的规矩,但用.json.js这个后缀,最大的好处是 CI 脚本生成文件时,可以直接生成标准 JSON,再在前后各包一行赋值语句即可,Excel、后端同学给的配置模板也不用额外学新格式。这也是一个很实用的经验:生成文件时遵循“数据是 JSON、壳子是 JS”的约定,人和机器的理解成本都最低。

3.3 如何优雅地决定“当前是哪个客户”?

域名映射表维护起来很简单,但并非所有项目都有独立的客户域名。内部联调阶段,大家往往是用 IP 加端口访问的,这时候再维护 hostMap 就不太现实了。我建议把“决定客户身份”的逻辑做成一个可插拔的解析器,优先级从上到下:

  1. URL 上的显式参数,例如?__site=customerA,本地调试最方便;
  2. 域名映射表,线上域名自动匹配;
  3. 部署目录名,例如 Nginx 下/customerA/目录前缀;
  4. 兜底默认配置site.json,保证裸访问不至于 404。
function resolveSiteName() { var q = win.location.search.match(/[?&]__site=([^&]+)/); if (q) return q[1]; var pathMatch = win.location.pathname.match(/^\/([^/]+)\//); if (pathMatch) return pathMatch[1]; var domainMatch = hostMap[win.location.hostname]; return domainMatch || ''; }

这套解析逻辑放在 loader 里,前端主代码完全不用关心自己是谁。等 Vue 实例开始干活,它只需要读window.__SITE_CONFIG__就行。我在main.js里初始化 Pinia 的时候,会把配置对象一次性注入一个useSiteStore里,而不是在每个组件里都裸读全局变量。全局变量只作为“注入源”,业务代码永远通过 store 读配置,这样后续如果要改造成接口下发配置,只动 store 的初始化部分就够了。

关于 Vue 的组合式和选项式,顺带多说一句:千万不要在组件的setup里直接访问window.__SITE_CONFIG__,这会让你在每个组件里都产生一层未受控的依赖,还容易因为Object.freeze丢了响应式而一脸懵。正确做法是像依赖注入一样,在入口把配置变成provide提供出来,或者放进 Pinia 的 state,组件通过storeToRefs按需解构。我见过有人把整个配置对象放进 reactive 里然后发现所有字段都变了深响应式,性能崩了半秒钟,排查半天才发现是在配置对象里放了图片资源 URL,被 Vue 的响应式代理反复复制了一遍。

4. 构建与发布侧的自动化:把手工降为零

4.1 预处理器帮你生成 site 文件

配置文件的生成是我这套方案里自动化程度最高的环节。以前我们手动创建 JSON,一旦客户多了,漏一个字段、写错一个环境,线上就是事故。所以我自己写了一个 Node 脚本scripts/site-config.mjs,它从一份“配置源”读取数据,批量生成所有客户和所有环境的站点文件。

配置源我强烈推荐用.env加一份单独维护的config/tenants.json。.env负责放通用构建变量,例如VITE_PUBLIC_PATH、VITE_CDN_BASE;tenants.json负责放每个客户的差异化字段。举个例子:

// config/tenants.json { "customers": [ { "name": "customerA", "title": "A 客户管理后台", "apiBase": "https://api.a.example.com", "features": { "report": true } }, { "name": "customerB", "title": "B 客户运营平台", "apiBase": "https://api.b.example.com", "features": { "report": false, "im": true } } ], "envs": ["dev", "test", "prod"] }

脚本做的事情很机械但很治愈:遍历customers,再遍历envs,按环境拼上各自的 API 域名前缀,生成 JSON 内容,前面包一行window.__SITE_CONFIG_RAW__ =,后面补;,写到public/config/下。这样新增一个客户,只需要在tenants.json里加一行记录,然后跑npm run generate:site,全部站点配置自动更新。

// scripts/site-config.mjs import { writeFileSync, mkdirSync } from 'node:fs'; import { resolve } from 'node:path'; const tenants = JSON.parse(readFileSync('config/tenants.json', 'utf-8')); const outputDir = resolve('public/config'); mkdirSync(outputDir, { recursive: true }); for (const customer of tenants.customers) { for (const env of tenants.envs) { const config = { _meta: { customer: customer.name, env, version: process.env.npm_package_version || 'unknown', updatedAt: new Date().toISOString() }, title: customer.title, apiBase: customer.apiBase.replace('{ENV}', env), features: customer.features }; const content = `window.__SITE_CONFIG_RAW__ = ${JSON.stringify(config, null, 2)};\n`; writeFileSync( resolve(outputDir, `site.${customer.name}.${env}.json.js`), content, 'utf-8' ); } }

这里注意apiBase里我留了{ENV}占位符。因为同一个客户的apiBase在不同环境通常只有子域名前缀不同,比如api.a.dev.example.com、api.a.prod.example.com,占位符替换比每个组合手写完整 URL 更直观也更容易维护。这也是一个减少手工细节的好习惯。

4.2 接入流水线:一次构建,多处部署

配置文件就位后,构建自动化就分成两条路,我实际项目里两条都用过,看团队规模选择。

第一种是“一份公共产物,运行时选站点”。流水线只跑一次npm run build,产物上传到静态服务器或 CDN,然后客户域名对应的 Nginx 直接指到同一份产物,区别只在于配置目录是否同步替换。这种方式适合客户数量多、每个客户站点入口独立、产品形态几乎一致的情况。我经常在会场上强调:这套方案最大的好处是,客户 A 如果只想改个标题,不用等前端发布,直接改配置站点的 JSON 文件,刷新立刻生效。对于运营类后台,这个诉求特别多,省下来的沟通成本是肉眼可见的。

第二种是“按客户构建,参数交给 CI”。流水线每次部署时,通过 CI 变量指定CUSTOMER=customerA、ENV=prod,构建命令类似:

npm run generate:site -- --customer customerA --env prod npm run build

这种方式适合客户之间有较多独立逻辑、产物本来就要分开的场景。它仍然比“人肉改 .env”强在:所有差异项在tenants.json里统一管理,CI 只是把参数从配置源透传给脚本,没有人会再去手动改环境变量或代码。

我个人的建议是:如果你的客户数量不超过三个,用第二种;一旦超过五个,强烈建议往第一种靠。因为客户多了以后,维护“每个客户单独构建”本身就是一种负担,构建时间、存储空间、发布流程全是成本和风险点。而公共产物加运行时配置,才是真正匹配“很多客户、同一套系统、差异由数据驱动”这个业务模型的。

5. 常见问题与排查技巧实录

5.1 踩过的坑和翻车现场

我把平时支持同事时遇到的高频问题整理成一个速查表,每个问题背后都是一个真实的翻车现场:

现象原因解决办法
改了site.customerA.prod.json.js,线上不生效浏览器缓存或 CDN 缓存了旧的配置文件配置文件名加版本参数,如site.customerA.prod.json.js?v=20250618;或协调 CDN 缓存规则,设置较短的 max-age
首屏偶发白屏,控制台报配置 undefined异步加载配置,Vue 初始化早于配置就位确认加载方式是同步script,不要用fetch
页面 title 一直显示默认值document.title 在 SPA 路由切换后被覆盖在路由全局前置守卫里重新读取 store 里的 title 并赋值
客户 A 新增的字段,客户 B 的页面报错配置对象校验缺失,布局侧强依赖了某字段在 store 初始化时做一次 schema 校验,给缺失字段打默认值
配置里写了带/的 logo URL,却打不开publicPath 是相对路径,而站点配置在public/config/子目录下所有资源路径建议以绝对路径/开头,或运行时动态拼接 CDN 前缀
构建后public/config没出现在 dist 里Vite 配置了publicDir或者构建脚本清理了 dist检查 vite.config 的publicDir,不要自己乱清 dist

5.2 配置对象到底要不要 freeze?要,而且要彻底

很多人不知道 Object.freeze 这个细节的价值。运行时配置一旦被某个组件不小心this.siteConfig.apiBase = 'https://xxx'改掉,线上就出现一个“只有那个客户倒霉”的诡异 bug,排查成本极高。我在main.js里把配置写进 store 时,会做一层Object.freeze(window.__SITE_CONFIG__)。之后任何代码试图修改它,非严格模式下静默失败,严格模式下直接抛 TypeError,问题当场暴露,而不是等到线上某个功能不工作了才回头找。

这里有个反直觉的坑:如果你用ref()包住这个被 freeze 的对象,Vue 内部的响应式代理在改写时会出错,但你改的对象其实还是原来的普通对象,报错信息可能指向 Vue 内部,容易让人误判。所以我一般直接用一个普通reactive状态加上readonly方法包一层:

import { readonly, ref } from 'vue'; const siteConfig = readonly(window.__SITE_CONFIG__); const siteStore = { config: siteConfig, apiBase: siteConfig.apiBase };

配置只读,store 只负责向上读取,业务往下永远不应该改配置。这个设计原则写进团队规范,比在代码里写一百行注释有用。

5.3 动态路由和菜单权限的客户差异化处理

最后说说菜单权限和动态路由,这是多客户配置里最容易失控的部分。有的客户只显示三个菜单,有的客户要显示十个;有的客户有“数据导出”这个功能,有的完全没有。我见过最粗暴的做法是v-if="features.xxx"写满每个菜单项,客户多了以后页面模板像补丁一样脏。

我的做法是:把菜单和路由的映射做成“配置驱动”。tenants.json里每个客户维护一份menuRoutes数组,路由名称和组件路径都用字符串表示,页面加载后由前端根据这份配置动态注册路由。Vue Router 4 的动态路由 API 比较成熟,配合import.meta.glob实现组件的按需加载,既能满足“菜单随客户变”,又能保持代码统一:

// 伪代码:根据配置生成菜单和路由 const viewModules = import.meta.glob('/src/views/**/*.vue'); function buildRoutes(menuConfig) { return menuConfig.map((item) => ({ path: item.path, name: item.name, component: viewModules[`/src/views/${item.componentPath}.vue`] })); }

这里要特别提醒一句:动态路由的表象虽然灵活,但权限控制一定要有后端数据兜底,只靠前端隐藏菜单是不安全的。不过这是另一个话题了,今天不展开。如果在做多客户配置自动化,你至少要把“功能开关”和“菜单路由”两个维度分开:前者决定某个能力有没有,后者决定用户看到什么入口,两者混在一起,后期改一处就崩另一处。

5.4 本地调试时如何快速切换客户?

本地开发时,你不可能为了看客户 B 的样式去改hosts文件绑定域名。我这里有一个很喜欢的小技巧:让resolveSiteName优先解析 URL 参数?__site=customerB,然后 loader 里再把这个参数自动替换掉,不让它进入 Vue Router 的路由记录,否则每次刷新路由会多一个 query 黏在 URL 上,时间长了你会疯掉。

function cleanQuery() { var url = win.location.href; if (url.indexOf('__site=') > -1) { var cleanUrl = url.replace(/[?&]__site=[^&]+/, ''); history.replaceState(null, '', cleanUrl); } } cleanQuery();

这个“带参调试、进站清参”的处理方式,前端同事之间接力联调时特别方便:A 同事发给 B 一个链接http://localhost:5173/?__site=customerB,B 打开直接进入客户 B 的站点配置,不用任何手工改动。

自动生成站点配置之后,最好把npm run generate:site挂到predev和prebuild钩子里,保证本地开发和生产构建拿到的配置都是最新生成的。不要问“为什么我改了 tenants.json 跑起来没变化”,多半就是忘记先跑一遍生成脚本。

最后一个小经验

做完这套自动化之后,维护成本最大的其实不是代码,是tenants.json里的字段越来越多。所以我后来在客户配置里加了一个schemaVersion字段,每次字段结构调整都升版本,生成的配置里统一写一个版本号,自己写了一个简单的校验脚本跑在 CI 里。这样配置漏改、新增字段没补默认值都能第一时间发现。

如果你也是一个人维护十几个客户的 Vue 项目,我特别推荐把 3.1 到 4.1 的核心链路先搭出来,花费的时间一般不超过半天,但之后每一周你都会感谢这个半天的投入。如果是一个小团队,就让流水线把“配置差异”和“代码发布”彻底分开,你会发现发布时的心理压力小一个数量级。以上就是我在多客户配置自动化这条路上摸爬滚打后沉淀下来的完整方案与细节,希望对你手头的项目有帮助。

返回列表