跳转到内容
123xiao | 无名键客

《前端中台实践:基于 Vite + TypeScript 搭建可扩展的微前端工程体系》

字数: 0 阅读时长: 1 分钟

前端中台实践:基于 Vite + TypeScript 搭建可扩展的微前端工程体系

在很多团队里,所谓“前端中台”并不只是一个大仓库,或者一套组件库。它真正解决的问题是:多个业务团队并行开发时,如何既统一基础能力,又不把所有人绑死在一个超大单体应用里

如果你们已经遇到下面这些现象,这篇文章会很对口:

  • 主应用越来越大,发版一次像搬家
  • 不同业务团队技术栈演进速度不同,互相掣肘
  • 公共能力(鉴权、路由、埋点、主题、权限)重复造轮子
  • 一个子模块改动,引发整站回归测试
  • 想做前端中台,但最后做成了“另一个巨型单页应用”

这类问题,我一般不会先上“宏大架构图”,而是先回到一个更现实的目标:让业务域独立交付,让平台能力可插拔,让主应用只负责治理,不负责吞掉一切业务

基于这个目标,Vite + TypeScript 是一个很实用的组合:

  • Vite:开发启动快、构建体验好、适合多应用并行开发
  • TypeScript:对跨团队接口边界特别友好,能把“约定”变成“可校验”
  • 微前端:用工程隔离换取团队自治,再通过壳应用进行治理和集成

本文我会从“中台治理”这个角度来讲,不只讲怎么跑起来,还会讲为什么这样拆、怎么减少后期维护成本,以及一些我实际踩过的坑。


背景与问题

传统前端单体在中台场景下的几个典型问题

很多团队一开始做得都挺顺:一个 React 或 Vue 单页应用,大家往里加页面,加着加着就开始出问题:

  1. 代码边界模糊

    • 用户中心、订单、营销、报表全塞进一个项目
    • 公共模块与业务模块耦合严重
    • 修改一个 shared 模块,影响全局
  2. 协作成本升高

    • 多团队同时改主干,冲突频繁
    • CI 时间越来越长
    • 回归范围难以收敛
  3. 技术升级困难

    • 某个业务想升级依赖,担心牵一发动全身
    • 某模块需要独立部署,但项目结构不支持
  4. 中台能力难沉淀

    • 权限、菜单、埋点、国际化、主题这些“平台能力”散落在业务中
    • 复制代码比抽象能力更快,最终形成历史包袱

微前端不是银弹,但很适合“治理型前端中台”

微前端最适合的不是“为了潮流拆项目”,而是下面这类场景:

  • 组织上有多个前端小组
  • 业务域相对清晰
  • 需要独立部署、灰度发布
  • 需要统一接入权限、监控、埋点、导航等平台能力

如果只是一个 3 人小团队、一个简单后台系统,直接搞微前端大概率是过度设计。这个边界要先说清楚。


方案概览与取舍分析

这类工程体系通常有三层:

  1. 壳应用(Shell / Host)

    • 提供导航、路由入口、鉴权、主题、监控、错误兜底
    • 负责装载微应用
  2. 微应用(Micro Apps)

    • 按业务域拆分,比如 user-apporder-appreport-app
    • 独立开发、独立构建、独立部署
  3. 共享基础层(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": "pnpm@8.6.0",
  "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 module
  • TypeError: 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、统一组件库、统一目录规范。
这些当然重要,但我更建议优先级按下面排:

  1. 统一微应用生命周期协议
  2. 统一类型契约
  3. 统一鉴权/埋点/监控 SDK
  4. 统一发布与配置中心
  5. 最后再讨论是否统一框架

因为真正影响长期维护成本的,往往不是“是不是同一个框架”,而是应用间是否有稳定边界

如果边界清晰,哪怕某个子应用做技术升级,也不会把整个平台拖下水。


验证清单:上线前至少确认这些事

这个清单非常建议保存下来给团队做联调验收。

工程层

  • 每个子应用都有 mount/unmount
  • 子应用 unmount 可完全释放资源
  • 共享类型包已版本化
  • Host 可降级处理子应用加载失败

运行层

  • 路由切换正常
  • 深链接刷新不 404
  • 样式无明显污染
  • 埋点、监控、错误上报可区分 app 名称

发布层

  • Host 与子应用可独立部署
  • 配置中心可动态切换入口
  • 回滚路径清晰
  • 版本兼容关系可追踪

安全层

  • 远程入口有白名单
  • 敏感信息未全量暴露
  • 子应用加载失败有兜底页
  • 关键事件通信有权限边界

总结

基于 Vite + TypeScript 搭建微前端工程体系,最核心的价值不在于“把一个项目拆成多个项目”,而在于:

  • 让业务边界清晰
  • 让平台能力沉淀
  • 让团队协作更可控
  • 让应用具备独立交付能力

如果你准备在前端中台场景落地,我建议按这个顺序推进:

  1. 先按业务域划分微应用边界
  2. 再定义 mount/unmount 和共享类型契约
  3. 接着补齐 Host 的路由、鉴权、监控和错误兜底
  4. 最后再做配置中心、灰度发布、性能治理

最后再强调一个边界条件:微前端适合中大型、多人协作、需要独立交付的系统,不适合所有项目。如果业务简单、团队规模小,单体应用 + 良好模块化往往更划算。

真正成熟的前端中台,不是“拆得越细越高级”,而是能在复杂度、效率和治理之间找到平衡。这一点,比选哪套框架都更重要。


分享到:

上一篇
《Docker Compose 到 Kubernetes:中级团队的容器化应用迁移实战与避坑指南》
下一篇
《AI 智能体实战:基于大模型构建企业知识库问答系统的架构设计与落地指南》