ARTICLE DETAIL

资讯详情

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

Spotify开源微前端框架Madeira实践复盘与避坑指南

Spotify开源微前端框架Madeira实践复盘与避坑指南 看到标题点进来的开发者先确认下你想要的到底是哪个 Madeira如果脑海里浮现的是葡萄牙火山岛风光或者那杯带焦糖味的强化葡萄酒那你可能走错片场了。我今天要聊的 Madeira是 Spotify 开源的那套用于构建微前端的 UI 框架。很多做前端架构的人听过这个名字但真正上手推过的人不多。原因也好理解它在国内社区的热度远不如 qiankun文档也没有很多花哨的 Demo再加上“Madeira”这个词本身太容易和旅游、酒类内容混在一起搜索结果一多反而没人仔细翻它。这篇就当我在团队落地 Madeira 之后的一份复盘笔记包含我是怎么理解它、怎么把模块搭起来、以及踩过的几个比较典型的坑。适合正在做技术选型的前端负责人、想了解微前端框架差异的架构师以及第一次接手 Madeira 工程的一线开发。1. 为什么我会在团队里选 Madeira整体设计思路拆解1.1 微前端的老问题与 Madeira 的解药微前端要解决的问题本质上是组织问题不是技术问题。一个团队人多了以后代码仓库迟早会失控发布权限互相牵制、团队之间排队等版本、线上问题说不清是哪一次的改动引起的。所谓微前端就是让每个团队对自己负责的业务域有“从代码到上线”的完整话语权而宿主应用只提供一个容器。但市面上大多数微前端方案做的是“应用级集成”。qiankun 的核心是把一个个独立应用拉进同一个页面它解决的是沙箱、加载、通信这类底层问题至于“每个团队暴露什么接口、别人怎么接你的页面”基本靠团队自觉。而 Madeira 的思路不太一样它强调的不是“应用”而是“模块”。一个模块暴露的不是整站而是页面、组件、菜单项、扩展点这些小粒度的单元由宿主环境统一编排。我当初选 Madeira 一个重要因素是 Spotify 内部几十个团队都在用这套逻辑不是实验室项目。他们的做法是把产品界面拆分成很多独立模块每个模块可以由不同团队、不同技术栈、不同发布节奏去维护壳子只负责发现和装配。这正好对上了我们当时的痛点三个前端团队共用一个主仓库每次发版都要排期改一行文案都得走整套流水线。1.2 核心名词对齐页面、组件、菜单项、扩展、依赖第一次看 Madeira 文档的人容易被一堆抽象词绕晕。我按自己的理解给你理一遍它把所有 UI 能力抽象成这么几类抽象作用什么时候用页面对应一个可路由的视图一个 URL 需要渲染什么内容新建独立业务页、落地页、活动页组件可复用的 UI 片段能在页面或其他组件里被引用头部信息栏、商品卡片、表单区块菜单项往宿主或某个区域的导航里插入入口给某业务增加菜单链接、快捷入口扩展向宿主或其他模块暴露数据、操作、状态钩子跨模块传递配置、触发联动、定制文案依赖声明需要共享的运行时库或数据源让不同模块共用同一套 React 版本或公共 API 封装这套名词对齐以后团队之间的“接口”就变得很明确。你说“我要在你的页面上放个入口”不需要对方给你开什么后端配置也不需要改壳的代码只要对方模块支持菜单项扩展你发布一个模块声明一菜单项就行。对比之下传统组件库方式是把所有组件沉淀在一个仓库别人想用你的东西要等版本、要装包、要去文档里翻 props集成成本完全不在一个量级。1.3 为什么“模块”比“应用”更省心很多人会问我把“页面”部署成一个独立应用然后用路由跳转不行吗也可以但那样每个“页面应用”都自带一套完整基础设施公共依赖重复加载UI 之间切换像跳站一样。Madeira 把模块设计成运行时可以被宿主进程“按需拉取”的片段它可以和宿主共享依赖也可以自己加载独有的依赖可以在同屏内多个模块同时工作也可以交给路由懒加载。说白了模块是一个介于“大应用”和“组件库”之间的形态。它比应用轻比组件自治又不像源码级 npm 包那样强依赖构建链路。这个思路给了我一个启发在做架构设计的时候不要为了“微前端”三个字而去拆应用。正确的切入点是把现有能力按“模块域”来划分谁的业务谁提供页面谁需要谁的页面就往壳里注册一个page节点。边界清晰以后发布的颗粒度、灰度影响面、回滚成本都会同步降下来。2. 工程搭建与节点注册动手前必懂的核心细节2.1 初始化一个 Madeira 模块我这边 Node 环境是 18包管理器用的 pnpm。其实 npm 也行但 pnpm 对 monorepo 场景更友好后面几个模块一起联调的时候方便。官方文档更推荐的方式是直接拉它仓库里的 template 目录来用。命令行脚手架这些年变动过好几次有的版本叫create-madeira-module有的版本直接复制模板我建议以 GitHub 仓库当前的 README 为准别硬背命令。我的做法是把模板工程 clone 下来去掉 git 历史再重新 init这样既能拿到最新的工程配置又不会因为脚手架版本落后产生额外排错成本。初始化以后目录大概是这个样子my-madeira-module/ ├── src/ │ ├── page/ │ │ └── index.js │ ├── component/ │ │ └── index.js │ ├── extension/ │ │ └── index.js │ ├── dependency/ │ │ └── index.js │ └── index.js ├── package.json ├── webpack.config.js └── tsconfig.jsonsrc/index.js是模块的统一入口也是构建时唯一要暴露给宿主的东西。为什么这个入口这么重要因为 Madeira 的加载机制是“入口导出描述宿主拿到描述以后再决定怎么渲染”它不需要你预先告诉宿主你要注册几个页面。这种数据驱动的方式让模块之间非常松耦合。2.2 节点注册页面、组件、菜单项的长相我以页面注册为例写一段通用形态的伪代码API 名称可能随版本有出入但思路不会变// src/page/index.js export function registerPages(context) { return context.page.register({ id: travel.demo.page, route: /demo, title: 示例页面, load: () import(./view), // 需要时再加载对首屏友好 }); }有几个关键点我重点说一下id一定要全局唯一。微前端里最怕 id 撞车。我们内部定的规范是团队名.业务域.页面名比如booking.list.page。一旦上线后两个人用了同一个 id宿主就不知道到底该渲染哪个会静默选择其中一个排查起来非常费劲。load是函数不是对象。这个细节决定了你能不能做代码分割。写成() import(./view)以后页面代码才会被 webpack 拆成独立 chunk按需加载。有人图省事直接在入口里静态 import刚开始模块少没事模块一多首屏直接炸。route的匹配规则必须是稳定约定。不同模块各自定义路由最后汇总到壳里重复路由必须有一方退让。这个可以在团队文档里规定一个路由前缀比如/travel/*归旅行业务/account/*归账号业务。组件的注册逻辑也类似但组件的粒度更小通常还要暴露“在哪个区块渲染”的信息而不是route。菜单项则更简单通常只需要id、label、href以及“挂在哪个菜单容器”的标识。2.3 构建产物与宿主识别机制模块构建完以后产出物不是传统意义的“整站静态文件”而是一堆 JS chunk 和一个模块描述文件。描述文件里会写清楚这个模块有哪些页面、哪些组件、哪些依赖、入口 JS 的 URL 是多少。宿主加载模块时第一步就是拿到这个描述文件把它当作模块的“说明书”。所以你会看到文档里反复强调“托管静态资源必须支持 HTTPS 和跨域”。如果模块和宿主不在同一个域名下而你又不给静态服务器配 CORS那么宿主进程去异步加载模块脚本时必然被浏览器拦截页面就会白屏。我们团队第一次联调就栽在这个上后面我还会详细说。另外构建出来的 chunk 文件建议全部带上内容哈希。原因很简单宿主是按描述文件里的 URL 去拉脚本的如果你覆盖发布但没有更新 URL浏览器的缓存策略可能让用户拿到旧代码。有了内容哈希文件名一变天然就绕开了缓存问题。3. 多模块协作依赖、扩展与本地联调的实操路径3.1 共享依赖怎么声明怎么避免被双份库拖垮这是微前端项目里绕不开的“版本魔咒”。两个模块都用 React如果各自打进自己的 bundle最后页面里会有两份 React。这不仅仅是体积问题最直接的翻车现场是模块 A 用了一份 React模块 B 用的另一份B 通过 props 传递对象给 A 的组件A 内部用isValidElement判断失败或者 Hooks 状态在组件切换时丢失。Madeira 提供依赖声明机制目的就是为了解决这类共享。模块可以在描述文件里声明“我需要一个叫 react 的依赖版本区间写^17或^18”宿主持有依赖表加载模块的时候把匹配版本注入进去模块里再配合构建工具的 externals 配置把 react 标记为“不打包外部提供”。实际操作层面我们做了几件事在package.json里把 React 放到peerDependencies而不是dependencies。这样 npm 不会默认帮你装一份。webpack 配置里加上 externals 规则构建时跳过 React。宿主侧维护一个依赖版本中心所有模块共享的 runtime 都由宿主的依赖加载器管理。这样改完后整个页面的 React 实例是同一个。排查“是不是同一个 React”这种问题你甚至可以在两个模块的代码里各自打印Symbol.for(react.transitional.tracing)之类的全局标识来对照不过那是下策上策是规矩立好任何模块不要绕过依赖声明偷偷打包共享库。3.2 扩展点跨模块能力打通的正规入口扩展是 Madeira 里比较有意思的一个抽象。它可以理解为“能力钩子”。比如 A 模块定义了一个扩展叫travel:before-book它允许其他模块在用户下单前干点事情校验优惠券、更新埋点、弹个确认框。B 模块只需要向这个扩展注册一个回调A 模块在对应时机调用即可。这和传统的事件总线有什么区别事件总线的问题是“事件名”和“数据结构”全靠口头约定时间一长没有任何约束谁都能乱发、谁都能乱接出了问题只能靠人力去查。Madeira 的扩展因为有模块描述文件的存在扩展点本身是可以被收集和校验的宿主能知道哪些模块依赖哪个扩展、哪些扩展被哪些模块注册了。相当于把口头约定变成了结构化配置。我们实际使用中会把扩展分类成两类一类是“数据扩展”只提供数据给消费方比如提供用户可选的支付方式列表另一类是“行为扩展”消费方注册一个函数宿主在某个时机调用。建议你在设计扩展时先问自己真的有必要做跨模块调用吗如果两个模块属于同一个团队直接在源码层写好函数调用更省心。扩展的价值在于跨团队边界而不是把内部逻辑也拆得七零八落。3.3 多模块平行开发的联调工作流几个团队并行开发时最大的障碍是壳要跑起来模块也要跑起来怎么让壳加载到本地最新代码Madeira 的做法比较优雅因为模块本身不要求部署到一个统一 gateway你只要让壳的模块 URL 配置指向一个本地开发服务器地址即可。我们的开发流程一般是这样的启动壳工程本地起一个代理服务接收模块注册请求。每个模块开发者在本地起webpack-dev-server开启 HTTPS 和跨域端口各自约定比如3001、3002。壳的配置里维护一份“开发环境模块地址表”本地联调时把某个模块的 URL 替换成https://localhost:3001/manifest.json。改动自动热更新壳里马上能看到效果。这套流程跑顺以后团队之间基本不需要“等联调”。只要模块描述文件约定清楚了A 团队完全可以本地挂 B 团队的最新代码或者挂 B 团队在测试环境构建好的产物。谁挂了谁负责因为整个壳只关心“你给的 URL 能不能拉到一份合法的模块描述”。4. 踩坑记录常见问题与排查技巧实录4.1 页面一直白屏Console 只有一个 CORS 报错我们刚把第一个模块接到壳里时页面白屏打开 DevTools 只有一行跨域报错。当时第一反应是“改服务器配置加响应头”结果折腾半天发现根本不是服务器没配 CORS而是我们加载模块的入口 URL 写的是http但壳页面是https浏览器把混合内容的脚本请求直接拦了。所以排查白屏问题第一步永远不是看代码逻辑而是确认 URL 协议、端口、跨域头这三件事。更稳妥的办法是让静态资源服务器直接返回这几个响应头Access-Control-Allow-Origin: * Access-Control-Allow-Headers: Content-Type Access-Control-Allow-Methods: GET, OPTIONS如果生产环境不允许全开至少要把模块宿主的域名加到白名单里。我后来给自己定了一条铁律后端静态资源的所有配置必须先跑一遍 curl确认响应头没问题再丢给前端联调。还有一个比较隐蔽的白屏原因是描述文件里的 JS 地址写成了相对路径。一个模块可能挂在 CDN 的/madeira/module-a/目录下描述文件里的入口地址如果写成./index.js在嵌套路径下解析很容易出错。规范做法是在构建配置里强制使用绝对路径或者统一注入一个基于import.meta.url拼接的完整地址。4.2 版本错配模块之间互相打架另一个高频问题出现在 React 版本混装。我们有段时间一个模块用的是 React 17模块 B 用的是 18结果模块 B 里一切正常模块 A 里的弹窗却一直报“Cannot read properties of undefined”。那个报错提示非常像代码 bug怎么查都查不出来最后才发现是模块 A 和应用里某段被 B 加载出来的代码共享了同一个全局状态但因为 React 实例不同跨模块传递的 React 元素在 A 内部直接失效。解决这个问题的核心思路就是前面说的共享依赖机制。但光有依赖机制还不够真正落地时要对共享库“版本统一”这件事下狠心能统一就统一不能统一就固化成两个独立实例绝对不要让“一半共享一半不共享”的状态出现。我们最后是把 React 统一到同一个主版本然后通过构建工具把 React 所有相关依赖全部 external 掉再让宿主的 dependency loader 统一注入。排查这类问题的标准动作是查看浏览器 network 面板找到所有 React 相关的 JS 请求看是不是存在两个不同版本的文件。在模块代码里临时打印 React 的版本号和宿主里拿到的做对比。入口 HTML 里全局挂一个 debug 开关把依赖加载信息输出到 console能直接看到每个模块的依赖版本表。4.3 构建产物膨胀首屏加载被拖垮还有一次是上线后首屏性能告警。当时我们的系统里有将近十个模块每个模块都把自己依赖的工具库打进去了。虽然单个模块不大但首屏要加载的模块一多重复的公共代码全堆积在启动路径上。优化手段其实就是常规的几板斧但放在微前端环境下优先级不一样模块级代码分割是性价比最高的一步。让每个页面节点都用动态import()而不是把所有页面组件打包在一个入口 chunk 里。公共依赖抽离。React、ReactDOM、Router、状态管理这类库交给宿主统一加载模块侧 external。模块打包产物做预加载策略。有些模块只在特定路由用到可以让路由匹配后再触发模块加载而不是壳一启动就把所有模块描述文件里的 chunk 全部拉下来。实测下来前两项做完我们首屏的 JS 体积降了大约 40%。这个数据谈不上多惊艳但已经足够说明问题微前端架构下性能题其实比的还是“依赖管理”和“按需加载”做得干不干净。4.4 其他几个容易忽略的细节模块描述文件的更新策略。生产环境中壳一般会缓存模块描述文件模块更新后要么引入新的哈希文件名要么设置合理的心跳刷新时间否则你发布了一个新版本用户却还是被缓存按在旧版本上。ID 命名冲突的灾难现场。两个团队各自起了home.page上线后宿主默认用后注册的那个先注册的页面消失。这类问题没有银弹只能靠代码审查和自动化校验在 CI 里扫描描述文件发现重复 ID 直接让流水线变红。多模块环境变量不一致。有的模块打包引用process.env.NODE_ENV有的引用自定义变量生产构建时变量缺失可能导致模块加载后行为异常。建议所有模块统一用同一份环境变量模板缺失字段构建直接报错别留到运行时。5. 选型建议与一点个人体会如果你正在评估要不要上 Madeira我给你几个判断标准。团队规模至少在 3~5 个前端小组以上且有明确的业务域边界这时候微前端拆分才划算。如果你团队总共就三四个人一个单体应用完全能转得过来强行拆模块只会增加构建复杂度、运维成本和沟通成本。现有系统如果是老项目渐进式改造的意愿要足够强。Madeira 这类框架最大的优点恰恰是允许你“先接一个页面试试水”不用一次性重构整个系统。你完全可以保留现有主应用作为壳把一个独立页面迁到某个模块下跑通流程后再逐步扩大边界。技术栈方面如果你的团队现在用的是 React/Vue 这类主流栈切到 Madeira 的难度不会太高。模块的构建逻辑本质上是标准的 webpack 配置内部框架本身没有发明太多“语法糖”文档读一遍概念就能上手。但需要注意一点模块化以后“模块之间应该遵守的统一规范“”比“用什么框架”重要得多。我见过一个反面案例模块 A 用axios模块 B 用fetch模块 C 自己封了一套请求库最后联调时第三方组件之间传数据字段结构五花八门变成了一场灾难。我个人实际用过之后有个很深的体会微前端框架好不好用六成取决于壳和模块之间的协议设计四成取决于团队有没有把“接口纪律”当回事。Madeira 给了一套很清晰的结构化抽象但它不会替你把团队协作规则定义好。比如 ID 命名规范、路由前缀规划、扩展点命名风格、共享依赖的版本策略这些如果你不提前立好规矩架构再先进也会在执行中崩盘。最后分享一个小技巧在复杂业务上线之前可以留出一个只有两个模块的测试环境专门用来打乱依赖版本、模拟模块覆盖发布、强制让不同团队各自发布一次模块。这个环境的价值不是测功能而是测“当一切配合都不完美时壳还能不能优雅地降级”。我踩过很多坑以后才意识到微前端真正考验的不是“顺利时有多快”而是“出问题时你是否能在十分钟内定位到是哪个模块、哪个版本、哪个依赖出了问题”。把这一点想清楚你再用 Madeira会比单纯追技术热点踏实很多。
返回列表