一种统一技术栈的微前端方案

发布于 | 分类于 前端/前端工程

目前参与的前端项目,把业务拆成了多个分别开发、构建和部署的工程,再在浏览器里组合成完整的应用。这套方案主要面向业务持续增长、多个工程共同维护的大型前端项目:各部分有自己的迭代节奏,同时需要统一的产品体验,以及可以持续复用的公共技术能力。

拆开之后,用户的操作仍然会跨越这些项目。比如从订单列表进入账号详情,再打开权限编辑弹窗,几个页面和组件可能由不同项目提供,却需要使用相同的账号上下文、主题和交互规则。

工程拆开之后,还有公共能力怎样更新的问题。一个请求模块或基础组件的修复,如果需要各个业务项目逐一升级依赖、重新构建和部署,公共改动的落地就会涉及多个项目的发布安排。

这篇围绕这些场景整理这套架构:哪些东西需要统一,运行时共享如何减少公共能力更新时的发包和重复构建,项目之间怎样保留独立开发和发布的空间,以及浏览器最终如何把它们加载起来。原工程相关的模块名称、业务场景、资源地址和代码均作通用示意;另有一个配套 Demo,用来展开工程拆分、版本选择和运行时组合的具体实现。

架构总览 ​

先从用户打开页面的过程看。浏览器访问宿主入口,读取当前环境的模块清单,再加载业务模块及其公共依赖。宿主拿到模块导出后,组织初始化、公共容器和路由,最终渲染页面。

运行时组合的微前端架构总览

图的左边是分别维护的工程,中间是部署环境中的资源和版本清单,右边是浏览器中的装配过程。统一工具链在各项目中执行,生成模块入口、内部 chunk 和样式;部署流程保存这些产物,再通过 manifest 记录环境选择的模块版本和入口。

宿主自身也有构建产物,用来提供 HTML、环境配置和加载适配。这里把模块标识解析、异步加载和导出交付统称为模块加载层。它连接部署配置与浏览器中的模块机制,具体实现可以采用不同的模块形式。

公共依赖也参与这次装配。核心库、基础组件、请求与国际化等技术模块,以及公共业务能力由相应模块提供,页面项目消费这些能力,再组织自己的路由与页面。它们在同一个运行环境中配合,版本和入口需要能够对应起来。

图中按职责归纳工程与模块,一个工程可以输出多个入口。哪些内容一起构建、哪些产物可以分别部署,需要看实际发布配置,逻辑上的模块分层并不直接等于仓库和部署单元的划分。

配套 Demo 的技术骨架 ​

为了把这些关系放到一份可以阅读的代码里,我整理了一个配套 Demo。它使用 pnpm workspace 和 Turborepo 管理多个项目,按下面的职责组织源码:

text
apps/
  host/               宿主入口、公共容器与路由
  orders/             订单列表与详情
  reports/            读取共享状态的报表
  legacy/             保留另一种模块格式的兼容应用
packages/
  vendor/             React 与渲染器的公共入口
  shared/             基础组件、Context 与公共 Hook
  customers/          客户选择与查询能力
core/
  contracts/          模块清单与页面入口的类型约定
  runtime/            模块、样式加载和应用契约检查
  tooling/            项目发现、Vite 构建与本地开发服务
  cli/                开发、构建、保存版本和切换环境
  resource-server/    本地版本资源与环境清单服务

这里选择订单和报表,是为了观察跨项目组合后的行为。宿主在组件树外层挂载 DemoProvider,订单页、报表页和客户选择器通过公共库的 useDemo() 读取同一个 Context。订单状态与客户筛选保存在 Provider 中,路由切换时继续保留,刷新页面后恢复样例数据。

这也给共享运行时提供了一个具体检查点:在订单页完成一笔订单,再切到报表页查看统计;在一个页面切换客户,再到另一个页面查看筛选结果。它们能否延续,取决于消费方是否加载同一份公共模块,以及页面是否仍在同一个 Provider 下。

Demo 中的公共 UI 只包含页面标题、标签和统计卡片等少量组件,客户库则演示业务组件与 Hook 的组合。大型项目中的完整组件库、主题和业务服务,可以沿着这些位置继续组织。

设计决策:先确定共同维护的部分 ​

一套产品需要一致的基础交互 ​

大型前端项目按业务拆开之后,用户仍然在使用同一套产品。这里的架构选择,需要同时支持多个工程分别迭代,以及用户在这些模块间连续操作的体验。

以前面的流程为例,订单页与账号详情可以分别开发,但日期格式、表单报错、弹窗确认和关闭等行为,需要有共同约定。如果每个项目都选择自己的组件库,再逐页调整样式和行为,后续组件升级时还要继续维护这些差异。

因此,这套方案把基础组件库和设计配置放在公共层。颜色、字号、间距与主题由统一配置承接,表单、表格、弹窗等公共交互通过组件复用,业务页面在这些能力上组合。

组件库的版本也要处于共同维护的基线中。只统一库名,各项目长期停留在不同版本,组件 API、主题配置和默认交互仍然可能分化。公共组件的修复最终是否生效,还要看消费方实际加载了哪个版本。

这些组件承接跨模块的基础一致性,页面如何组织流程、何时反馈错误,仍由相应的产品设计和业务实现决定。

公共组件和技术模块共享运行时 ​

除了 UI 组件,各个工程也会使用请求封装、国际化、主题、消息连接等技术模块。比如公共请求层的错误处理方式需要调整,或者语言与主题的配置需要统一维护,把这些机制集中到公共模块,可以减少各项目分别实现和持续同步的工作。

公共业务能力会直接以组件和 Hook 的形式进入页面。一个账号选择器可以出现在不同项目的表单、抽屉或弹窗里,并使用宿主的主题、语言和账号上下文。这种复用方式对运行环境也有要求。

以 React 为例,同一条渲染链路中,组件与渲染器使用的 React 导入需要解析到同一个模块导出对象,否则可能出现 Hook 调用错误,见官方说明。两个项目即使声明了相同版本,各自打包出一份 React,也不能仅凭版本号判断这条渲染链路能够正常工作。

Context 也有类似的对象身份要求。提供方和消费方需要引用同一个 Context 对象,见 useContext 文档。主题和路由上下文对应的模块如果被重复打包,需要检查它们是否仍在共享同一份对象,以及 Provider 是否位于正确的组件树中。

所以,这里统一的是依赖版本、构建时的外部依赖规则,以及实际运行时的解析结果。减少重复下载是其中一个作用,跨项目组件能够正确组合也依赖这些约定。

这会限制业务项目对核心依赖的自由选择。公共层升级时,需要一起检查组件 API、主题和消费方的兼容性;相应地,业务项目可以持续复用同一套组件、工具和业务能力。独立开发与发布的空间,也是在这个公共契约范围内安排的。

公共能力更新时,减少逐个项目的发包与构建 ​

把公共代码抽成 npm 包,是多项目复用的一种方式。假设一个公共请求包要修复错误处理逻辑,采用构建时打包依赖的方式,通常需要先发布新包,再由各个业务项目升级依赖、重新构建和部署。即使消费方的业务源码没有变化,也要重新生成包含这次修复的产物。

当使用它的项目越来越多,公共能力又需要持续迭代时,这条链路就会反复发生。各项目还可能处在不同的发布周期,同一个修复何时覆盖到所有页面,需要继续跟踪消费方的升级情况。

运行时共享把公共能力作为单独的模块产物提供。业务项目构建时保留对它的引用,浏览器加载时再取得环境清单选择的实现。在接口和行为保持兼容的前提下,可以只构建、部署公共模块并更新对应版本记录;未变化的消费方产物可以继续使用,无须为了带入这次实现修复逐个重建。

对仅影响运行时实现的兼容修复,可以通过模块产物直接发布,减少为了传播这次修复而进行的 npm 发包、消费方依赖升级和重复构建。公共模块本身仍然需要生成新版本并部署,开发时使用的类型声明或依赖包也可能继续通过 npm 提供。业务项目需要调用新增 API,或者公共接口发生不兼容变化时,仍然要修改、验证并发布相应消费方。

因此,这套方案更适合公共能力被多个工程共同使用、需要持续更新,同时又希望各业务分别推进的场景。如果共享范围很小、更新也很少,额外维护模块清单、运行时契约和联调环境的成本,也需要一起考虑。

按业务拆分,保留必要的组合位置 ​

统一基础层之后,还要决定什么值得抽成公共模块。

比如订单页使用账号选择器和权限判断,页面负责把这些能力组合成当前流程;账号模块可以维护账号选择和展示的规则,但不应反向依赖订单页的路由和布局。这样订单页变化时,影响范围比较容易沿着依赖关系检查。

同一个共享业务库可以提供账号选择、权限编辑等多个入口。它们可以一起维护和发布,同时分别按需加载,无须为每个组件都建立独立应用。

原有工程还区分了模块内部入口与对外入口:前者集中模块能力,后者选择允许跨项目使用的部分。跨模块引用、导出方式和循环依赖可以通过脚本检查;一个能力应该归属于哪个业务模块,仍然要结合规则由谁维护、哪些地方使用来判断。

这类边界会影响后续的独立性。如果一个公共模块逐渐依赖了某个页面的状态和初始化顺序,即使它仍然可以单独构建,使用时也会带上那个页面的条件。

开发与部署:让项目分别推进 ​

本地只启动需要修改的项目 ​

以订单和报表两个项目为例,它们分别维护页面源码、构建入口和配置,共用同一套 CLI、外部依赖映射与样式处理规则。放在 monorepo 里,也可以保留这些不同的构建入口。

开发订单页面时,可以启动本地订单项目,把它接入已有环境的宿主,公共依赖和报表模块继续使用环境中的版本。涉及共享业务库的修改时,再启动对应库的开发服务,一起联调。

这里依赖的是模块标识与资源地址的映射。消费方使用稳定的模块标识,开发工具把其中一部分指向本地地址。下面只表示覆盖关系,具体注入方式由工具适配:

json
{
  "orderPages": "http://localhost:4100/index.js",
  "sharedBusiness": "http://localhost:4101/index.js"
}

这让本地开发可以缩小到本次修改的范围,但仍然需要一个能提供宿主、公共运行时和配置的验证环境。多个本地项目联调时,CLI 可以编排它们的开发服务,再统一注入模块地址。

原有工程的工具链还提供了嵌入式开发模式:保留各项目独立的模块产物和增量编译,关闭子项目自身的 HMR 注入,修改后刷新宿主查看结果。这个模式下,需要重新加载页面来验证更新。

联调时还要跟踪入口之后的请求。入口指向本地,内部 chunk 和样式却仍然可能走其他地址。Webpack 的 publicPath 会影响内部资源路径,见官方说明。模块库没有独立 HTML 来引用 CSS 时,构建工具还要为抽离的样式补充加载逻辑。

配套 Demo 把这个过程放进 CLI。比如已有本地资源服务提供完整环境时,可以只启动订单项目:

sh
pnpm micro dev orders --env local --server http://127.0.0.1:4400

开发服务读取这个环境的清单,再用当前项目的产物覆盖相应模块记录,其他模块继续从资源服务加载。它只监听选中项目的源码,修改后重新构建并手动刷新;环境不可用时,则补齐当前项目及宿主所需的本地产物。

先保存资源,再让环境选择它 ​

业务项目完成构建后,会得到入口、chunk 和样式等一组文件。部署流程先把这些产物保存到目标环境中属于该项目的目录,再登记模块版本与入口信息:

text
完成项目构建
  → 部署这一版本的入口、chunk 和样式
  → 更新环境 manifest 中的模块记录
  → 后续页面加载时选择这个版本

资源文件已经存在,与环境开始使用这个版本,是两个状态。顺序需要保证,否则宿主可能先读到新入口,却请求不到对应的文件。

假设订单项目的更新保持现有公共契约,发布时可以更新它自己的资源与记录,报表项目继续使用已有产物。公共技术模块的兼容更新也沿用这条发布路径:部署该模块并调整清单,后续页面加载时取得新的实现,未变化的业务项目继续使用原有构建结果。

涉及公共依赖和导出变化时,则需要检查版本组合。比如页面开始调用账号模块新增的 getAccountLabel,环境却仍然选择旧账号模块,文件可能全部请求成功,页面仍会在调用方法时出错。manifest 能选择版本,兼容性需要由公共契约和相应验证保证。

新增模块如果还要修改宿主的发现规则或初始化约定,也需要更新相应发布单元。各项目能否分别部署,要结合这些条件判断。

回退版本同样需要旧入口引用的 chunk 和样式仍然可用,并正确更新清单与资源缓存。已经打开的页面还可能持有原来的模块实例,调整清单影响的是后续加载过程。

Demo 中,每个项目的 package.json 用 micro 配置声明模块标识、源码入口、输出格式和样式。统一工具链调用 Vite/Rollup,构建到各自的 dist/,并生成 build-manifest.json,记录入口、样式、实际跨包依赖以及文件摘要。公共模块标识被保留为 external,订单页面的产物中仍能看到对公共库的引用。

这里有两份清单:项目构建清单描述这次生成了什么,环境 manifest 则描述当前选择哪些已保存版本。Node 资源服务校验文件和摘要后保存整份产物,同版本内容不可覆盖;CLI 再单独更新环境选择。例如,先将订单项目版本改为 1.1.0,再执行:

sh
pnpm micro build orders
pnpm micro publish orders
pnpm micro activate orders 1.1.0

publish 只保存已有构建产物,activate 才切换环境。公共库的兼容修复也可以使用同样的路径,只构建和保存公共库的新版本,保留业务项目原来的版本选择。环境服务会检查所需模块是否存在;导出 API 和行为是否兼容,仍需要另外验证。

运行时加载:从环境清单到页面 ​

模块形式:ESM import 与 AMD ​

前面的工程拆分、版本选择和宿主装配,可以通过不同的模块形式实现。原有工程使用 AMD,配套 Demo 同时提供 ESM 和 AMD 加载路径,下面分别说明它们承担加载的方式。

如果构建产物是浏览器可以执行的 ESM,宿主可以从环境 manifest 取得入口 URL,再通过动态 import() 加载。成功后得到模块命名空间对象,具体见 import() 文档。假设入口约定导出 getRoutes,调用过程可以写成:

js
// 示意:manifest 已由宿主读取,模块入口公开 getRoutes
const pages = await import(manifest.orderPages.entry);
const routes = pages.getRoutes();
// 交给宿主的路由适配逻辑处理

入口 URL 只解决入口定位。产物中保留的 react 等裸模块标识,还需要通过 import map 或构建时的地址改写解析。例如,宿主可以在相关模块加载之前提供这样的映射:

html
<script type="importmap">
{
  "imports": {
    "react": "/shared/react/index.js",
    "react/jsx-runtime": "/shared/react/jsx-runtime.js",
    "react-dom/client": "/shared/react-dom/client.js"
  }
}
</script>

这些地址需要对应浏览器可执行且彼此兼容的模块。import map负责标识到 URL 的映射,宿主可以把环境清单的选择转换成映射或加载参数。映射要在相关模块加载前建立,已经解析的标识不能靠后加一份映射任意替换。原生模块还涉及 CORS 和 JavaScript MIME 类型等资源服务要求,见模块使用说明。

原有工程的构建工具输出 AMD 模块,通过 define 声明依赖和导出,再由宿主注入的 loader 读取清单、解析地址和请求脚本。对应的简化调用如下:

js
define('orderPages', ['sharedCore'], function (core) {
  // 页面实现使用 core 提供的公共依赖,这里省略
  return { getRoutes: () => [] };
});

window.require(['orderPages'], function (pages) {
  const routes = pages.getRoutes();
  // 交给宿主的路由适配逻辑处理
});

AMD 规范约定在 factory 执行前解析依赖,再按依赖数组的顺序传入参数。原有工程的工具链扩展了公共运行时的属性映射,loader 源码也增加了对异步 factory 的等待;后者属于当前实现的扩展,规范没有规定自动等待 factory 返回的 Promise。

这部分实现此前在在webpack中基于chunk加载external外部依赖和AMD异步模块配合webpack代码拆分实现业务模块按需加载中有更详细的展开。

配套 Demo 使用 Vite/Rollup 为大部分模块分别生成 ESM 和 AMD 产物。ESM 路径在加载宿主前,根据 manifest 安装 import map;AMD 路径使用 RequireJS 的 package 配置,把模块入口和内部相对 chunk 对应到各自目录。兼容应用只输出 AMD,在 ESM 路径下,运行时先把已加载的 React 与公共库命名空间登记为它的依赖,使两种格式的页面仍能引用同一份运行时对象。这个桥接只覆盖显式声明的共享依赖,Demo 没有实现原工程的异步 factory 扩展。

两种方式最终都要把约定的导出交给宿主。切换模块形式时,需要一起调整构建输出、依赖解析、异步就绪语义和资源服务配置;页面入口与宿主之间的契约可以继续保持。

先找到入口,再收集页面 ​

模块形式确定之后,浏览器还需要知道当前环境应该使用哪些入口。为便于阅读,可以把 manifest 中的版本选择结果展开成下面的形式。字段和目录是示意,实际实现还包含版本分组与地址计算:

json
{
  "sharedCore": {
    "version": "2.4.1",
    "entry": "/modules/sharedCore/2.4.1/index.a12b.js"
  },
  "orderPages": {
    "version": "1.8.0",
    "entry": "/modules/orderPages/1.8.0/index.c34d.js"
  },
  "reportPages": {
    "version": "3.2.2",
    "entry": "/modules/reportPages/3.2.2/index.e56f.js"
  }
}

业务引用 orderPages 这样的稳定标识,环境清单选择版本和入口,加载适配结合静态资源根地址等配置得到实际 URL。原有工程的资源服务维护版本数据,并生成浏览器可读取的脚本;加载适配在缺少版本信息时先读取清单,再继续模块请求。

这份环境清单选择多个项目的版本和入口。单次构建也可能生成自己的 JS、CSS 资源清单,例如预览流程使用的产物清单,两者的范围不同。模块之间的依赖由产物中的引用表达,项目内部 chunk 还有构建工具自己的映射,无须把所有源码关系都手工放进环境 manifest。

页面模块加载后,宿主收集它们的初始化扩展,调用 getRoutes 合并路由,再放进公共布局和服务容器。账号、权限、语言等应用级准备工作也在这条启动流程中组织。

入口加载与具体页面实现的加载可以分开。启动时先取得路由和初始化信息,页面组件继续通过动态导入延后获取。分析首屏请求时,需要区分当前请求的是哪一层入口。

Demo 把页面入口约定收窄为 contractVersion 和 getRoutes()。宿主通过 ModuleLoader 加载清单中 applications 指定的入口,检查契约后收集路由;路由里的 load() 再动态导入具体页面。单个入口注册失败时,宿主记录错误并继续处理其他应用,页面加载与渲染错误则交给页面错误边界展示。

页面代码还依赖公共能力 ​

公共依赖的共享需要落实到构建产物。原有工程的工具链通过 Webpack 的 externals 排除指定依赖,再由消费环境提供实现,见官方文档。构建时保留什么引用,运行时就需要提供能够正确解析的入口与导出。

按需加载又多了一层关系。比如一个低频详情页使用某个共享业务能力,如果这个依赖被提前收集到整个项目入口,详情页尚未打开时,它也可能已经被请求。

原有工程的工具链通过 Babel 插件记录部分源码外部依赖,再由 Webpack 插件结合 chunk 中的文件,把需要的公共模块连接到相应加载流程。涉及这些构建缓存时,增删导入和 chunk 划分发生变化,依赖数据也需要同步更新。

模块入口已下载、导出已经可用,与业务初始化结束也需要分开。共享业务库可能继续加载自己的实现,账号或语言服务也可能仍在准备数据。每一步完成后允许调用什么,应由对应契约明确。

加载失败则要沿调用链传递。原有工程的启动适配会记录单个页面模块的加载错误,过滤失败模块后继续装配,其对应功能仍然不可用。公共依赖或应用初始化失败时,还需要各自的处理,不能仅根据页面入口的局部处理判断整个应用都能继续运行。

页面切走以后,模块可能仍然存在 ​

这些模块共用一个 JavaScript 环境。路由切换可以卸载页面组件,模块导出和公共服务却可能继续留在缓存中,模块加载机制不会自动清理业务状态、事件监听和订阅。

比如账号选择弹窗的临时数据可以随关闭结束,跨页面的账号上下文则由公共服务维护。账号或应用上下文变化时,旧请求、缓存和订阅如何处理,要按它们各自的生命周期安排。

样式也需要检查组合后的行为。原有工程的构建插件通过 Babel 与 PostCSS 为相关 DOM 和选择器添加作用域属性,基础组件和主题则维护公共视觉规则。全局样式和挂载到其他容器的浮层,仍然要检查实际作用范围。

Demo 的样式使用公共类名和业务前缀,没有接入原工程的自动作用域插件。它的版本服务也只面向本地演示,使用单进程文件存储,尚未提供账号鉴权、多实例写入和资源回收。公共模块在已打开页面中继续使用缓存,更新环境清单后需要重新加载页面,才能按新选择启动。

小结 ​

这套方案主要面向多个工程共同维护的大型前端项目。业务需要分别迭代,页面之间又要保持一致的产品体验,并持续复用公共组件和技术能力,因此依赖版本、运行时实例与页面入口契约需要共同维护。

在这些约定下,各项目可以分别开发和保存版本,再通过环境清单组合。公共模块的兼容修复可以独立更新,减少消费方为带入同一份改动而反复发包和构建;涉及接口变化时,仍然需要协调消费方升级。模块形式可以选择 ESM 或 AMD,版本组合、资源保留和状态生命周期都需要继续处理。

配套 Demo 把这些关系放进了具体代码。增加一个页面或更新一个公共能力时,可以沿着它实际走过的路径看:本地接入了哪个环境,发布后由清单选中了哪个版本,页面拿到什么导出,离开之后又留下哪些状态。这样检查,也更容易确定一次独立发布需要验证的范围。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。