前端中台实践:基于 Vite + TypeScript 搭建可扩展的微前端工程体系
在很多团队里,所谓“前端中台”并不只是一个大仓库,或者一套组件库。它真正解决的问题是:多个业务团队并行开发时,如何既统一基础能力,又不把所有人绑死在一个超大单体应用里。
如果你们已经遇到下面这些现象,这篇文章会很对口:
- 主应用越来越大,发版一次像搬家
- 不同业务团队技术栈演进速度不同,互相掣肘
- 公共能力(鉴权、路由、埋点、主题、权限)重复造轮子
- 一个子模块改动,引发整站回归测试
- 想做前端中台,但最后做成了“另一个巨型单页应用”
这类问题,我一般不会先上“宏大架构图”,而是先回到一个更现实的目标:让业务域独立交付,让平台能力可插拔,让主应用只负责治理,不负责吞掉一切业务。
基于这个目标,Vite + TypeScript 是一个很实用的组合:
- Vite:开发启动快、构建体验好、适合多应用并行开发
- TypeScript:对跨团队接口边界特别友好,能把“约定”变成“可校验”
- 微前端:用工程隔离换取团队自治,再通过壳应用进行治理和集成
本文我会从“中台治理”这个角度来讲,不只讲怎么跑起来,还会讲为什么这样拆、怎么减少后期维护成本,以及一些我实际踩过的坑。
背景与问题
传统前端单体在中台场景下的几个典型问题
很多团队一开始做得都挺顺:一个 React 或 Vue 单页应用,大家往里加页面,加着加着就开始出问题:
-
代码边界模糊
- 用户中心、订单、营销、报表全塞进一个项目
- 公共模块与业务模块耦合严重
- 修改一个 shared 模块,影响全局
-
协作成本升高
- 多团队同时改主干,冲突频繁
- CI 时间越来越长
- 回归范围难以收敛
-
技术升级困难
- 某个业务想升级依赖,担心牵一发动全身
- 某模块需要独立部署,但项目结构不支持
-
中台能力难沉淀
- 权限、菜单、埋点、国际化、主题这些“平台能力”散落在业务中
- 复制代码比抽象能力更快,最终形成历史包袱
微前端不是银弹,但很适合“治理型前端中台”
微前端最适合的不是“为了潮流拆项目”,而是下面这类场景:
- 组织上有多个前端小组
- 业务域相对清晰
- 需要独立部署、灰度发布
- 需要统一接入权限、监控、埋点、导航等平台能力
如果只是一个 3 人小团队、一个简单后台系统,直接搞微前端大概率是过度设计。这个边界要先说清楚。
方案概览与取舍分析
这类工程体系通常有三层:
-
壳应用(Shell / Host)
- 提供导航、路由入口、鉴权、主题、监控、错误兜底
- 负责装载微应用
-
微应用(Micro Apps)
- 按业务域拆分,比如
user-app、order-app、report-app - 独立开发、独立构建、独立部署
- 按业务域拆分,比如
-
共享基础层(Shared / Platform)
- 类型定义
- 通信协议
- SDK(埋点、鉴权、请求封装)
- UI 规范与设计令牌
为什么这里选择 Vite + TypeScript
相比传统 Webpack 体系
- 本地开发体验更轻
- 启动多个微应用时,速度差异很明显
- 插件生态已经足够支撑工程化需求
相比“只做 Monorepo 不做微前端”
Monorepo 解决的是代码协同,微前端解决的是运行时集成与独立交付。两者不是替代关系,而是经常一起用。
相比 iframe 方案
iframe 隔离性确实强,但常见问题也明显:
- 路由、样式、通信体验比较重
- SEO、埋点、全局交互不够自然
- 用户感知常常不够“像一个系统”
如果你的系统偏后台管理且强调统一体验,通常会优先考虑 JS 运行时集成方案。
核心原理
本文用一个比较容易落地的模式来讲:Host 通过动态模块注册 + 统一协议装载子应用。
核心思想并不复杂:
- 主应用维护一份微应用清单
- 每个微应用暴露统一的入口协议:
mount/unmount - 主应用在路由切换时动态加载子应用
- 平台能力通过上下文或 SDK 注入给子应用
- 所有共享契约用 TypeScript 类型约束
总体架构图
flowchart LR
A[Host 壳应用] --> B[路由分发]
A --> C[权限/鉴权]
A --> D[监控/埋点]
A --> E[主题/布局]
B --> F[user-app]
B --> G[order-app]
B --> H[report-app]
F --> I[共享 SDK]
G --> I
H --> I
I --> J[类型契约]
微应用生命周期
每个微应用都遵守统一生命周期:
mount(container, props):挂载到指定容器unmount():卸载并清理资源
这样主应用就不用关心微应用内部是 React、Vue 还是其他实现,只关心它是否遵守协议。
sequenceDiagram
participant User as 用户
participant Host as Host壳应用
participant Registry as 微应用注册表
participant App as 子应用
User->>Host: 进入 /orders
Host->>Registry: 根据路由查找应用配置
Registry-->>Host: 返回 order-app 入口
Host->>App: 动态 import 入口模块
App-->>Host: 暴露 mount/unmount
Host->>App: mount(container, context)
App-->>User: 渲染订单页面
User->>Host: 切换路由
Host->>App: unmount()
类型契约是这套体系可维护的关键
很多微前端方案的问题,不是“跑不起来”,而是后面靠口头约定维持,久了就崩。
所以我非常建议把跨应用协议收敛成一份共享类型。
classDiagram
class MicroAppModule {
+mount(container: HTMLElement, props: MicroAppProps): Promise~void~
+unmount(): Promise~void~
}
class MicroAppProps {
+name: string
+basePath: string
+token: string
+emit(event: string, payload: unknown): void
+navigate(path: string): void
}
class AppMeta {
+name: string
+activeRule: string
+entry: string
}
AppMeta --> MicroAppModule
MicroAppModule --> MicroAppProps
这个思路听起来很朴素,但真正把它落实到 TypeScript 层,后面能少很多“对不上接口”的问题。
工程结构设计
这里给一个典型目录结构,兼顾独立部署和共享能力沉淀:
frontend-platform/
├── apps/
│ ├── host/
│ ├── user-app/
│ └── order-app/
├── packages/
│ ├── shared-types/
│ ├── shared-sdk/
│ └── ui-tokens/
├── pnpm-workspace.yaml
├── tsconfig.base.json
└── package.json
各层职责建议
apps/host
- 路由主入口
- 容器布局
- 应用注册表
- 权限拦截
- 全局异常捕获
apps/*-app
- 只处理自身业务域
- 不直接依赖别的业务 app
- 与平台交互走共享 SDK / 统一协议
packages/shared-types
- 微应用接口定义
- 菜单、用户信息、权限模型
- 事件总线类型
packages/shared-sdk
- 请求封装
- 埋点
- token 获取
- 平台事件通信
实战代码(可运行)
下面用一个最小可运行示例演示。为了减少篇幅,我使用原生 DOM + TypeScript + Vite 的方式,重点突出微前端装载逻辑。你也可以把子应用替换成 React/Vue 实现。
第一步:初始化 workspace
根目录 package.json
{
"name": "frontend-platform",
"private": true,
"packageManager": "[email protected]",
"scripts": {
"dev:host": "pnpm --filter host dev",
"dev:user": "pnpm --filter user-app dev",
"dev:order": "pnpm --filter order-app dev"
}
}
pnpm-workspace.yaml
packages:
- apps/*
- packages/*
根目录 tsconfig.base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@shared-types/*": ["packages/shared-types/src/*"],
"@shared-sdk/*": ["packages/shared-sdk/src/*"]
}
}
}
第二步:定义共享类型契约
packages/shared-types/src/micro-app.ts
export interface MicroAppProps {
name: string;
basePath: string;
token: string;
navigate: (path: string) => void;
emit: (event: string, payload: unknown) => void;
}
export interface MicroAppModule {
mount: (container: HTMLElement, props: MicroAppProps) => Promise<void> | void;
unmount: () => Promise<void> | void;
}
export interface AppMeta {
name: string;
activeRule: string;
entry: string;
}
第三步:实现 Host 壳应用
apps/host/src/app-registry.ts
import type { AppMeta } from "@shared-types/micro-app";
export const appRegistry: AppMeta[] = [
{
name: "user-app",
activeRule: "/users",
entry: "http://localhost:5174/src/main.ts"
},
{
name: "order-app",
activeRule: "/orders",
entry: "http://localhost:5175/src/main.ts"
}
];
apps/host/src/loader.ts
import type { AppMeta, MicroAppModule, MicroAppProps } from "@shared-types/micro-app";
let currentApp: MicroAppModule | null = null;
export async function loadMicroApp(
app: AppMeta,
container: HTMLElement,
props: MicroAppProps
) {
if (currentApp) {
await currentApp.unmount();
container.innerHTML = "";
}
const mod = (await import(/* @vite-ignore */ app.entry)) as MicroAppModule;
await mod.mount(container, props);
currentApp = mod;
}
apps/host/src/main.ts
import { appRegistry } from "./app-registry";
import { loadMicroApp } from "./loader";
import type { MicroAppProps } from "@shared-types/micro-app";
const app = document.querySelector<HTMLDivElement>("#app")!;
app.innerHTML = `
<div style="font-family: sans-serif;">
<h1>Host Shell</h1>
<nav style="display:flex; gap:12px; margin-bottom:16px;">
<a href="/users" data-link>用户中心</a>
<a href="/orders" data-link>订单中心</a>
</nav>
<div id="micro-container" style="border:1px solid #ddd; padding:16px;"></div>
</div>
`;
const container = document.querySelector<HTMLDivElement>("#micro-container")!;
const props: MicroAppProps = {
name: "host",
basePath: "/",
token: "mock-token",
navigate(path: string) {
history.pushState({}, "", path);
render();
},
emit(event, payload) {
console.log("[host event]", event, payload);
}
};
async function render() {
const path = window.location.pathname;
const appMeta = appRegistry.find((item) => path.startsWith(item.activeRule));
if (!appMeta) {
container.innerHTML = "<div>请选择一个子应用</div>";
return;
}
await loadMicroApp(appMeta, container, props);
}
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.matches("[data-link]")) {
e.preventDefault();
const href = target.getAttribute("href");
if (href) {
history.pushState({}, "", href);
render();
}
}
});
window.addEventListener("popstate", render);
render();
第四步:实现用户子应用
apps/user-app/src/main.ts
import type { MicroAppModule, MicroAppProps } from "@shared-types/micro-app";
let root: HTMLElement | null = null;
const app: MicroAppModule = {
mount(container: HTMLElement, props: MicroAppProps) {
root = document.createElement("div");
root.innerHTML = `
<section>
<h2>用户中心</h2>
<p>当前 token:${props.token}</p>
<button id="go-order">跳转订单中心</button>
</section>
`;
container.appendChild(root);
root.querySelector("#go-order")?.addEventListener("click", () => {
props.emit("user:navigate", { from: "user-app", to: "/orders" });
props.navigate("/orders");
});
},
unmount() {
if (root) {
root.remove();
root = null;
}
}
};
export const mount = app.mount;
export const unmount = app.unmount;
第五步:实现订单子应用
apps/order-app/src/main.ts
import type { MicroAppModule, MicroAppProps } from "@shared-types/micro-app";
let root: HTMLElement | null = null;
const app: MicroAppModule = {
mount(container: HTMLElement, props: MicroAppProps) {
root = document.createElement("div");
root.innerHTML = `
<section>
<h2>订单中心</h2>
<p>这里是独立部署的微应用</p>
<button id="report">发送埋点事件</button>
</section>
`;
container.appendChild(root);
root.querySelector("#report")?.addEventListener("click", () => {
props.emit("order:report", {
app: "order-app",
action: "click-report"
});
});
},
unmount() {
if (root) {
root.remove();
root = null;
}
}
};
export const mount = app.mount;
export const unmount = app.unmount;
第六步:Vite 配置
apps/host/vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5173
}
});
apps/user-app/vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5174,
cors: true
}
});
apps/order-app/vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
port: 5175,
cors: true
}
});
第七步:运行方式
pnpm install
pnpm dev:host
pnpm dev:user
pnpm dev:order
分别启动后,访问:
http://localhost:5173/users
你就能看到 Host 负责路由分发,子应用负责各自渲染的最小运行效果。
进一步扩展:从“能跑”走向“可扩展”
上面的 demo 只是最小闭环。真正的中台工程,还要继续补齐这些能力:
1. 应用注册中心
不要把所有微应用入口硬编码在前端仓库里。更合理的方式是:
- 由配置中心下发应用列表
- 支持灰度环境、地域环境、测试环境切换
- 配置签名校验,避免被篡改
2. 平台上下文注入
把这些能力收敛成标准上下文:
- 当前登录态
- 权限点
- 菜单信息
- 国际化配置
- 主题 tokens
- 统一请求实例
3. 事件通信边界
事件总线可以有,但不要变成“全局大喇叭”。
我的建议是:路由跳转、全局通知、埋点事件可以共享;核心业务数据不要跨应用直接传递。
4. 样式隔离策略
如果多个子应用共享页面容器,一定要提前确定隔离方案:
- CSS Modules
- BEM 约定
- Shadow DOM
- 设计令牌 + 原子类
如果什么都不做,后面出现“一个子应用按钮样式把另一个应用覆盖了”的情况,几乎是必然的。
常见坑与排查
这一部分我尽量写得接地气一点,因为这些问题我自己都遇到过。
1. 动态 import 远程模块失败
现象
Host 中执行动态加载时报错:
Failed to fetch dynamically imported moduleTypeError: error loading dynamically imported module
排查方向
检查子应用地址是否可访问
直接在浏览器访问:
http://localhost:5174/src/main.ts
如果打不开,优先看端口和启动状态。
检查 CORS
子应用是跨端口访问,开发时必须允许跨域。
export default defineConfig({
server: {
cors: true
}
});
检查路径是否是可被浏览器加载的 ESM 资源
有些同学会写成构建产物路径,但本地开发阶段其实拿不到目标文件。
2. 子应用切换后事件未释放,内存持续增长
现象
来回切换几次路由后:
- 点击一次按钮,触发多次回调
- 页面越来越卡
根因
unmount() 只删了 DOM,没有清理:
window事件监听- 定时器
- 全局订阅
- 请求轮询
建议
给每个微应用建立统一资源回收机制。
const disposers: Array<() => void> = [];
function addResizeListener() {
const handler = () => console.log("resize");
window.addEventListener("resize", handler);
disposers.push(() => window.removeEventListener("resize", handler));
}
export function cleanup() {
disposers.forEach((fn) => fn());
disposers.length = 0;
}
在 unmount() 里统一调用。
3. 路由冲突
现象
Host 和子应用都想控制 URL,导致:
- 页面空白
- 刷新后 404
- 子应用内部二级路由失效
处理思路
明确路由主权
- Host 负责一级路由分发
- 子应用负责自己域内二级路由
例如:
- Host:
/users、/orders user-app内部:/users/list、/users/detail/1
部署时处理 history fallback
Nginx 或网关层要支持前端路由回退,否则刷新深链接就容易 404。
4. 共享依赖版本不一致
现象
某个子应用升级后,运行时报奇怪问题:
- React hooks 异常
- 样式系统失效
- 类型正常但运行不正常
根因
共享库版本漂移。
建议
- Monorepo 下统一锁版本
- 关键基础库建立升级规范
- 核心共享包采用 semver 管理
- 不要让每个子应用随意升级平台级依赖
5. 本地开发时“看起来能跑”,上线后挂掉
常见原因
- 入口 URL 写死 localhost
- 子应用资源路径不是绝对路径
- CDN 缓存导致新旧版本混用
- Host 配置和实际部署环境不一致
经验建议
上线前至少验证:
- 测试环境配置中心是否生效
- 子应用静态资源 publicPath 是否正确
- 回滚机制是否可用
- Host 与子应用版本兼容矩阵是否明确
安全/性能最佳实践
微前端体系的复杂度,除了工程管理,还有安全和性能。这里给一组比较务实的建议。
安全最佳实践
1. 不要信任远程入口地址
如果微应用入口是动态下发的,必须做好约束:
- 白名单域名
- 配置签名
- HTTPS 强制
- 环境隔离
否则理论上存在加载恶意脚本的风险。
2. token 不要无脑下发给所有子应用
很多系统会把完整登录态直接透传给每个微应用,这很危险。更合理的方式:
- 只暴露必要权限信息
- 用平台 SDK 代替明文 token 直传
- 敏感操作统一走 Host 代理
3. 事件总线需要边界
事件名建议前缀化,例如:
"user:login"
"order:create"
"platform:theme-change"
同时限制可监听事件清单,避免任意广播。
4. XSS 防护
子应用来自不同团队,代码质量不一定一致。要统一要求:
- 不直接拼接不可信 HTML
- CSP 策略按需配置
- 富文本内容严格过滤
性能最佳实践
1. 子应用按需加载,不要全量预加载
微前端最容易犯的错误之一,就是为了“切换快”把所有子应用首屏都预取。结果:
- 首屏变慢
- 带宽浪费
- 用户只访问一个模块也要下载整套系统
建议按业务价值选择性预加载,比如只预取高频应用。
2. 公共依赖做合理共享,但不要过度共享
共享的目标是减少重复加载,不是制造耦合。适合共享的通常是:
- 基础框架
- 设计 tokens
- 平台 SDK
- 类型定义
不适合共享的通常是业务组件和临时工具库。
3. 保持子应用首屏可控
给每个子应用设定性能预算,例如:
- JS 体积上限
- 首屏渲染时间目标
- 关键接口数量上限
中台不是“统一管理一切”,而是“用治理手段建立边界”。
4. 缓存与版本策略配合
静态资源建议采用:
- 文件名 hash
- CDN 长缓存
- HTML / 配置短缓存
- Host 可感知子应用版本
这样可以兼顾缓存命中和快速发布。
一个更实用的治理建议:先统一协议,再统一框架
很多团队做中台时,第一反应是统一 React、统一组件库、统一目录规范。
这些当然重要,但我更建议优先级按下面排:
- 统一微应用生命周期协议
- 统一类型契约
- 统一鉴权/埋点/监控 SDK
- 统一发布与配置中心
- 最后再讨论是否统一框架
因为真正影响长期维护成本的,往往不是“是不是同一个框架”,而是应用间是否有稳定边界。
如果边界清晰,哪怕某个子应用做技术升级,也不会把整个平台拖下水。
验证清单:上线前至少确认这些事
这个清单非常建议保存下来给团队做联调验收。
工程层
- 每个子应用都有
mount/unmount - 子应用
unmount可完全释放资源 - 共享类型包已版本化
- Host 可降级处理子应用加载失败
运行层
- 路由切换正常
- 深链接刷新不 404
- 样式无明显污染
- 埋点、监控、错误上报可区分 app 名称
发布层
- Host 与子应用可独立部署
- 配置中心可动态切换入口
- 回滚路径清晰
- 版本兼容关系可追踪
安全层
- 远程入口有白名单
- 敏感信息未全量暴露
- 子应用加载失败有兜底页
- 关键事件通信有权限边界
总结
基于 Vite + TypeScript 搭建微前端工程体系,最核心的价值不在于“把一个项目拆成多个项目”,而在于:
- 让业务边界清晰
- 让平台能力沉淀
- 让团队协作更可控
- 让应用具备独立交付能力
如果你准备在前端中台场景落地,我建议按这个顺序推进:
- 先按业务域划分微应用边界
- 再定义
mount/unmount和共享类型契约 - 接着补齐 Host 的路由、鉴权、监控和错误兜底
- 最后再做配置中心、灰度发布、性能治理
最后再强调一个边界条件:微前端适合中大型、多人协作、需要独立交付的系统,不适合所有项目。如果业务简单、团队规模小,单体应用 + 良好模块化往往更划算。
真正成熟的前端中台,不是“拆得越细越高级”,而是能在复杂度、效率和治理之间找到平衡。这一点,比选哪套框架都更重要。