原文连接:https://zhuanlan.zhihu.com/p/...css
Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks. — Micro Frontends
TL;DRhtml
想跳过技术细节直接看怎么实践的同窗能够拖到文章底部,直接看最后一节。前端
目前社区有不少关于微前端架构的介绍,但大多停留在概念介绍的阶段。而本文会就某一个具体的类型场景,着重介绍微前端架构能够带来什么价值以及具体实践过程当中须要关注的技术决策,并辅以具体代码,从而能真正意义上帮助你构建一个生产可用的微前端架构系统。react
而对于微前端的概念感兴趣或不熟悉的同窗,能够经过搜索引擎来获取更多信息,如 知乎上的相关内容, 本文再也不作过多介绍。webpack
两个月前 Twitter 曾爆发过关于微前端的“热烈”讨论,参与大佬众多(Dan、Larkin 等),对“事件”自己咱们今天不作过多评论(后面可能会写篇文章来回顾一下),有兴趣的同窗能够经过这篇文章了解一二。git
微前端架构具有如下几个核心价值:angularjs
微前端架构旨在解决单体应用在一个相对长的时间跨度下,因为参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤为常见。github
中后台应用因为其应用生命周期长(动辄 3+ 年)等特色,最后演变成一个巨石应用的几率每每高于其余类型的 web 应用。而从技术实现角度,微前端架构解决方案大概分为两类场景:web
本文将着重介绍单实例场景下的微前端架构实践方案(基于 single-spa),由于这个场景更贴近大部分中后台应用。npm
传统的云控制台应用,几乎都会面临业务快速发展以后,单体应用进化成巨石应用的问题。为了解决产品研发之间各类耦合的问题,大部分企业也都会有本身的解决方案。笔者于17年末,针对国内外几个著名的云产品控制台,作过这样一个技术调研:
产品 | 架构(截止 2017-12) | 实现技术 |
---|---|---|
google cloud | 纯 SPA | 主 portal angularjs,部分页面 angular(ng2)。 |
aws | 纯 MPA 架构 | 首页基于 angularjs。各系统独立域名。 |
七牛 | SPA & MPA 混合架构 | 入口 dashboard 及 我的中心模块为 spa,使用同一 portal 模块(AngularJs(1.5.10) + webpack)。其余模块自治,或使用不一样版本 portal,或使用其余技术栈。 |
又拍云 | 纯 SPA 架构 | 基于 angularjs 1.6.6 + ui-bootstrap。控制台内容较简单。 |
ucloud | 纯 SPA 架构 | angularjs 1.3.12 |
MPA 方案的优势在于 部署简单、各应用之间硬隔离,天生具有技术栈无关、独立开发、独立部署的特性。缺点则也很明显,应用之间切换会形成浏览器重刷,因为产品域名之间相互跳转,流程体验上会存在断点。
SPA 则天生具有体验上的优点,应用直接无刷新切换,能极大的保证多产品之间流程操做串联时的流程性。缺点则在于各应用技术栈之间是强耦合的。
那咱们有没有可能将 MPA 和 SPA 二者的优点结合起来,构建出一个相对完善的微前端架构方案呢?
jsconf china 2016 大会上,ucloud 的同窗分享了他们的基于 angularjs 的方案(单页应用“联邦制”实践),里面提到的 "联邦制" 概念很贴切,能够认为是早期的基于耦合技术栈的微前端架构实践。
能够发现,微前端架构的优点,正是 MPA 与 SPA 架构优点的合集。即保证应用具有独立开发权的同时,又有将它们整合到一块儿保证产品完整的流程体验的能力。
这样一套模式下,应用的架构就会变成:
Stitching layer 做为主框架的核心成员,充当调度者的角色,由它来决定在不一样的条件下激活不一样的子应用。所以主框架的定位则仅仅是:导航路由 + 资源加载框架。
而具体要实现这样一套架构,咱们须要解决如下几个技术问题:
咱们在一个实现了微前端内核的产品中,正常访问一个子应用的页面时,可能会有这样一个链路:
因为咱们的子应用都是 lazy load 的,当浏览器从新刷新时,主框架的资源会被从新加载,同时异步 load 子应用的静态资源,因为此时主应用的路由系统已经激活,但子应用的资源可能尚未彻底加载完毕,从而致使路由注册表里发现没有能匹配子应用 /subApp/123/detail
的规则,这时候就会致使跳 NotFound 页或者直接路由报错。
这个问题在全部 lazy load 方式加载子应用的方案中都会碰到,早些年前 angularjs 社区把这个问题统一称之为 Future State。
解决的思路也很简单,咱们须要设计这样一套路由机制:
主框架配置子应用的路由为 subApp: { url: '/subApp/**', entry: './subApp.js' }
,则当浏览器的地址为 /subApp/abc
时,框架须要先加载 entry 资源,待 entry 资源加载完毕,确保子应用的路由系统注册进主框架以后后,再去由子应用的路由系统接管 url change 事件。同时在子应用路由切出时,主框架须要触发相应的 destroy 事件,子应用在监听到该事件时,调用本身的卸载方法卸载应用,如 React 场景下 destroy = () => ReactDOM.unmountAtNode(container)
。
要实现这样一套机制,咱们能够本身去劫持 url change 事件从而实现本身的路由系统,也能够基于社区已有的 ui router library,尤为是 react-router 在 v4 以后实现了 Dynamic Routing 能力,咱们只须要复写一部分路由发现的逻辑便可。这里咱们推荐直接选择社区比较完善的相关实践 single-spa。
解决了路由问题后,主框架与子应用集成的方式,也会成为一个须要重点关注的技术决策。
微前端架构模式下,子应用打包的方式,基本分为两种:
方案 | 特色 |
---|---|
构建时 | 子应用经过 Package Registry (能够是 npm package,也能够是 git tags 等其余方式) 的方式,与主应用一块儿打包发布。 |
运行时 | 子应用本身构建打包,主应用运行时动态加载子应用资源。 |
二者的优缺点也很明显:
方案 | 优势 | 缺点 |
---|---|---|
构建时 | 主应用、子应用之间能够作打包优化,如依赖共享等 | 子应用与主应用之间产品工具链耦合。工具链也是技术栈的一部分。 子应用每次发布依赖主应用从新打包发布 |
运行时 | 主应用与子应用之间彻底解耦,子应用彻底技术栈无关 | 会多出一些运行时的复杂度和 overhead |
很显然,要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下咱们须要使用运行时加载子应用这种方案。
在肯定了运行时载入的方案后,另外一个须要决策的点是,咱们须要子应用提供什么形式的资源做为渲染入口?
JS Entry 的方式一般是子应用将资源打成一个 entry script,好比 single-spa 的 example 中的方式。但这个方案的限制也颇多,如要求子应用的全部资源打包到一个 js bundle 里,包括 css、图片等资源。除了打出来的包可能体积庞大以外的问题以外,资源的并行加载等特性也没法利用上。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 做为入口,主框架能够经过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 做为子节点塞到主框架的容器中。这样不只能够极大的减小主应用的接入成本,子应用的开发方式及打包方式基本上也不须要调整,并且能够自然的解决子应用之间样式隔离的问题(后面提到)。想象一下这样一个场景:
<!-- 子应用 index.html --> <script src="//unpkg/antd.min.js"></script> <body> <main id="root"></main> </body>
// 子应用入口 ReactDOM.render(<App/>, document.getElementById('root'))
若是是 JS Entry 方案,主框架须要在子应用加载以前构建好相应的容器节点(好比这里的 "#root" 节点),否则子应用加载时会由于找不到 container 报错。但问题在于,主应用并不能保证子应用使用的容器节点为某一特定标记元素。而 HTML Entry 的方案则自然能解决这一问题,保留子应用完整的环境上下文,从而确保子应用有良好的开发体验。
HTML Entry 方案下,主框架注册子应用的方式则变成:
framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})
本质上这里 HTML 充当的是应用静态资源表的角色,在某些场景下,咱们也能够将 HTML Entry 的方案优化成 Config Entry,从而减小一次请求,如:
framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})
总结一下:
App Entry | 优势 | 缺点 |
---|---|---|
HTML Entry | 1. 子应用开发、发布彻底独立 2. 子应用具有与独立应用开发时一致的开发体验 |
1. 多一次请求,子应用资源解析消耗转移到运行时 2. 主子应用不处于同一个构建环境,没法利用 bundler 的一些构建期的优化能力,如公共依赖抽取等 |
JS Entry | 主子应用使用同一个 bundler,能够方便作构建时优化 | 1. 子应用的发布须要主应用从新打包 2. 主应用需为每一个子应用预留一个容器节点,且该节点 id 需与子应用的容器 id 保持一致 3. 子应用各种资源须要一块儿打成一个 bundle,资源加载效率变低 |
微前端架构下,咱们须要获取到子应用暴露出的一些钩子引用,如 bootstrap、mount、unmout 等(参考 single-spa),从而能对接入应用有一个完整的生命周期控制。而因为子应用一般又有集成部署、独立部署两种模式同时支持的需求,使得咱们只能选择 umd 这种兼容性的模块格式打包咱们的子应用。如何在浏览器运行时获取远程脚本中导出的模块引用也是一个须要解决的问题。
一般咱们第一反应的解法,也是最简单的解法就是与子应用与主框架之间约定好一个全局变量,把导出的钩子引用挂载到这个全局变量上,而后主应用从这里面取生命周期函数。
这个方案很好用,可是最大的问题是,主应用与子应用之间存在一种强约定的打包协议。那咱们是否能找出一种松耦合的解决方案呢?
很简单,咱们只须要走 umd 包格式中的 global export 方式获取子应用的导出便可,大致的思路是经过给 window 变量打标记,记住每次最后添加的全局变量,这个变量通常就是应用 export 后挂载到 global 上的变量。实现方式能够参考 systemjs global import,这里再也不赘述。
微前端架构方案中有两个很是关键的问题,有没有解决这两个问题将直接标志你的方案是否真的生产可用。比较遗憾的是此前社区在这个问题上的处理都会不约而同选择”绕道“的方式,好比经过主子应用之间的一些默认约定去规避冲突。而今天咱们会尝试从纯技术角度,更智能的解决应用之间可能冲突的问题。
因为微前端场景下,不一样技术栈的子应用会被集成到同一个运行时中,因此咱们必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
针对 "Isolated Styles" 这个问题,若是不考虑浏览器兼容性,一般第一个浮现到咱们脑海里的方案会是 Web Components。基于 Web Components 的 Shadow DOM 能力,咱们能够将每一个子应用包裹到一个 Shadow DOM 中,保证其运行时的样式的绝对隔离。
但 Shadow DOM 方案在工程实践中会碰到一个常见问题,好比咱们这样去构建了一个在 Shadow DOM 里渲染的子应用:
const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'}); shadow.innerHTML = '<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">';
因为子应用的样式做用域仅在 shadow 元素下,那么一旦子应用中出现运行时越界跑到外面构建 DOM 的场景,一定会致使构建出来的 DOM 没法应用子应用的样式的状况。
好比 sub-app 里调用了 antd modal 组件,因为 modal 是动态挂载到 document.body 的,而因为 Shadow DOM 的特性 antd 的样式只会在 shadow 这个做用域下生效,结果就是弹出框没法应用到 antd 的样式。解决的办法是把 antd 样式上浮一层,丢到主文档里,但这么作意味着子应用的样式直接泄露到主文档了。gg...
社区一般的实践是经过约定 css 前缀的方式来避免样式冲突,即各个子应用使用特定的前缀来命名 class,或者直接基于 css module 方案写样式。对于一个全新的项目,这样固然是可行,可是一般微前端架构更多的目标是解决存量/遗产 应用的接入问题。很显然遗产应用一般是很难有动力作大幅改造的。
最主要的是,约定的方式有一个没法解决的问题,假如子应用中使用了三方的组件库,三方库在写入了大量的全局样式的同时又不支持定制化前缀?好比 a 应用引入了 antd 2.x,而 b 应用引入了 antd 3.x,两个版本的 antd 都写入了全局的 .menu class
,但又彼此不兼容怎么办?
解决方案其实很简单,咱们只须要在应用切出/卸载后,同时卸载掉其样式表便可,原理是浏览器会对全部的样式表的插入、移除作整个 CSSOM 的重构,从而达到 插入、卸载 样式的目的。这样即能保证,在一个时间点里,只有一个应用的样式表是生效的。
上文提到的 HTML Entry 方案则天生具有样式隔离的特性,由于应用卸载后会直接移除去 HTML 结构,从而自动移除了其样式表。
好比 HTML Entry 模式下,子应用加载完成的后的 DOM 结构可能长这样:
<html> <body> <main id="subApp"> // 子应用完整的 html 结构 <link rel="stylesheet" href="//alipay.com/subapp.css"> <div id="root">....</div> </main> </body> </html>
当子应用被替换或卸载时,subApp
节点的 innerHTML 也会被复写,//alipay.com/subapp.css
也就天然被移除样式也随之卸载了。
解决了样式隔离的问题后,有一个更关键的问题咱们尚未解决:如何确保各个子应用之间的全局变量不会互相干扰,从而保证每一个子应用之间的软隔离?
这个问题比样式隔离的问题更棘手,社区的广泛玩法是给一些全局反作用加各类前缀从而避免冲突。但其实咱们都明白,这种经过团队间的”口头“约定的方式每每低效且易碎,全部依赖人为约束的方案都很难避免因为人的疏忽致使的线上 bug。那么咱们是否有可能打造出一个好用的且彻底无约束的 JS 隔离方案呢?
针对 JS 隔离的问题,咱们首创了一个运行时的 JS 沙箱。简单画了个架构图:
即在应用的 bootstrap 及 mount 两个生命周期开始以前分别给全局状态打下快照,而后当应用切出/卸载时,将状态回滚至 bootstrap 开始以前的阶段,确保应用对全局状态的污染所有清零。而当应用二次进入时则再恢复至 mount 前的状态的,从而确保应用在 remount 时拥有跟第一次 mount 时一致的全局上下文。
固然沙箱里作的事情还远不止这些,其余的还包括一些对全局事件监听的劫持等,以确保应用在切出以后,对全局事件的监听能获得完整的卸载,同时也会在 remount 时从新监听这些全局事件,从而模拟出与应用独立运行时一致的沙箱环境。
自去年年末伊始,咱们便尝试基于微前端架构模式,构建出一套全链路的面向中后台场景的产品接入平台,目的是解决不一样产品之间集成困难、流程割裂的问题,但愿接入平台后的应用,不论使用哪一种技术栈,在运行时均可以经过自定义配置,实现不一样应用之间页面级别的自由组合,从而生成一个千人千面的个性化控制台。
目前这套平台已在蚂蚁生产环境运行半年多,同时接入了多个产品线的 40+ 应用、4+ 不一样类型的技术栈。过程当中针对大量微前端实践中的问题,咱们总结出了一套完整的解决方案:
在内部获得充分的技术验证和线上考验以后,咱们决定将这套解决方案开源出来!
https://github.com/umijs/qiankun
取名 qiankun,意为统一。咱们但愿经过 qiankun 这种技术手段,让你能很方便的将一个巨石应用改形成一个基于微前端架构的系统,而且再也不须要去关注各类过程当中的技术细节,作到真正的开箱即用和生产可用。
对于 umi 用户咱们也提供了配套的 qiankun 插件 @umijs/plugin-qiankun ,以便于 umi 应用能几乎零成本的接入 qiankun。
最后欢迎你们点赞使用提出宝贵的意见。👻
Maybe the most complete micro-frontends solution you ever met🧐.多是你见过的最完善的微前端架构解决方案。