构建支持多租户的SaaS前端应用,核心目标是让同一份代码在不同租户访问时呈现独立的数据与配置,同时避免租户间信息泄露。前端虽不直接存储业务主数据,但路由状态、接口鉴权、本地缓存都可能成为租户污染的突破口。只有从入口路由到请求层做全链路租户绑定,才能算真正支持多租户。

一、多租户前端的三种隔离模式
在动手写代码前,要先确定租户识别方式。业界常见做法有子域名隔离、路径前缀隔离和请求头隔离。子域名方式例如 clientA.app.com 与 clientB.app.com,后端容易做网关分发,但本地开发需改hosts;路径前缀方式例如 app.com/clientA/home,对部署友好,前端路由改动稍大;请求头隔离则是在每次请求带 x-tenant-id,前端改动最小但刷新页面时容易丢上下文。
对于多数中小团队,路径前缀加请求头双保险最稳妥。用户在登录页选择租户或系统根据域名自动映射租户ID,之后所有路由跳转自动补全前缀,所有接口自动塞入租户头。这样即使有人手动改URL,后端也会因头信息不匹配而拒绝,安全性更高。
1.1 子域名与路径方案对比
| 方案 | 改造成本 | 隔离强度 | 适用场景 |
|---|---|---|---|
| 子域名 | 中(需网关) | 高 | 大客户定制版 |
| 路径前缀 | 低(改路由) | 中 | 标准SaaS |
| 请求头 | 极低 | 低 | 内部系统 |
上表列出常见取舍。若产品需给不同企业完全独立的访问入口,子域名更专业;若只是同一平台下多公司账号,路径前缀够用。我们下文以路径前缀为主展开。
二、路由层的租户绑定
使用 Vue Router 或 React Router 时,可设计一个基础布局,将所有业务路由嵌套在 /:tenant 动态段下。这样组件内可通过 useParams 或 this.$route.params.tenant 拿到当前租户,不用全局变量。下面是一个 Vue 路由配置示例。
import Vue from 'vue'
import Router from 'vue-router'
import TenantLayout from './layouts/TenantLayout.vue'
import Dashboard from './views/Dashboard.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
routes: [
{
path: '/:tenant',
component: TenantLayout,
children: [
{ path: 'dashboard', name: 'dashboard', component: Dashboard }
]
}
]
})
在 TenantLayout 中,我们需要监听 tenant 参数变化,一旦切换就清空旧租户的状态。很多人忽略这点,导致 Vuex 里还留着上一租户的用户列表。正确做法是在 beforeRouteUpdate 钩子里提交清空 action,并重新拉取租户配置。
export default {
beforeRouteUpdate(to, from, next) {
if (to.params.tenant !== from.params.tenant) {
this.$store.dispatch('clearTenantData')
this.$store.dispatch('loadTenantConfig', to.params.tenant)
}
next()
}
}
2.1 动态租户主题的加载
不同租户常要求不同 logo 与配色。可在 loadTenantConfig 里请求 /api/tenant/config,返回primaryColor等字段,再用一段脚本动态修改 CSS 变量。这样无需打包多套样式,也能实现视觉隔离。
function applyTheme(config) {
const root = document.documentElement
root.style.setProperty('--primary', config.primaryColor)
}
注意主题变量要写在 :root 作用域,且切换租户时必须重置,否则会出现 A 租户的蓝色按钮在 B 租户页面闪现的尴尬。本地存储的主题也应当按租户ID做 key 后缀,例如 theme_clientA。
三、请求层的租户拦截
光有路由不够,每个接口都必须声明自己属于哪个租户。用 axios 拦截器统一处理,比在业务代码里手写头信息可靠得多。以下示例展示如何在请求发出前注入租户ID。
import axios from 'axios'
import store from './store'
const service = axios.create({ baseURL: '/api' })
service.interceptors.request.use(config => {
const tenant = store.state.currentTenant
if (tenant) {
config.headers['x-tenant-id'] = tenant
}
return config
})
export default service
这里从 Vuex 取 currentTenant,它是在路由布局里设好的。如果取不到,说明用户未进租户空间,应跳登录页。响应拦截器也要处理 403 租户无权错误,避免越权数据渲染。
3.1 避免缓存跨租户
浏览器默认按 URL 缓存 GET 请求。若 clientA 和 clientB 都请求 /api/user/list,且只靠头区分租户,缓存会返回错误数据。解决办法是给 axios 的 GET 请求加上租户查询参数,或自定义缓存 key 包含 tenant。
service.interceptors.request.use(config => {
if (config.method === 'get' && store.state.currentTenant) {
config.params = { ...config.params, _t: store.state.currentTenant }
}
return config
})
这样不同租户的列表请求 URL 不同,缓存自然隔离。虽多一个无用参数,但换来了数据安全,性价比极高。
四、本地存储与权限控制
localStorage 是另一个重灾区。若把 token 存为固定 key,切换租户登录会覆盖前者。应按租户分桶:token_clientA、token_clientB。下面是封装的存储工具。
function getToken(tenant) {
return localStorage.getItem('token_' + tenant)
}
function setToken(tenant, token) {
localStorage.setItem('token_' + tenant, token)
}
权限方面,前端菜单应根据租户配置接口返回的 permissions 数组动态生成,而不是写死。管理员租户可见后台,普通租户只看到工作台。每次路由跳转前用全局守卫校验,无权限则重定向。
router.beforeEach((to, from, next) => {
const tenant = to.params.tenant
const allowed = store.getters.hasPermission(tenant, to.name)
if (allowed) next()
else next('/' + tenant + '/403')
})
以上守卫依赖后端返回的真实权限,前端只做体验层拦截,真正安全仍由后端保证。但完整的前后端租户对齐,才能让 SaaS 产品安心卖给多家客户。
五、总结与实践建议
支持多租户的前端不是加个判断那么简单,而是路由、请求、存储、主题四维一体。建议新项目从第一天就用路径前缀路由,老项目可先上请求头拦截器兜底,再逐步迁移。测试阶段务必写两个租户互相越权访问的用例,很多漏洞在手工测试时不易发现,自动化脚本一跑就露馅。
当团队规模扩大,可考虑将租户逻辑抽成独立 npm 包,供所有前端应用复用。那时构建多租户不再是难题,而只是标准配置。
multi_tenantSAAS_frontendtenant_isolation修改时间:2026-08-03 04:30:18