关于微前端:微前端解决方案

9次阅读

共计 8907 个字符,预计需要花费 23 分钟才能阅读完成。

前言

随着技术的倒退,前端利用承载的内容也日益简单,基于此而产生的各种问题也应运而生,从 MPAMulti-Page Application,多页利用)到 SPA(Single-Page Application,单页利用),尽管解决了切换体验的提早问题,但也带来了首次加载工夫长,以及工程爆炸增长后带来的巨石利用(Monolithic)问题;对于MPA 来说,其部署简略,各利用之间人造硬隔离,并且具备技术栈无关、独立开发、独立部署等特点。要是可能将这两方的特点联合起来,会不会给用户和开发带来更好的用户体验?至此,在借鉴了微服务理念下,微前端便应运而生。

目前社区有很多对于微前端架构的介绍,但大多停留在概念介绍的阶段。而本文会就某一个具体的类型场景,着重介绍微前端架构能够 带来什么价值 以及 具体实际过程中须要关注的技术决策 ,并辅以具体代码,从而能真正意义上帮忙你构建一个 生产可用 的微前端架构零碎。

什么是微前端?

微前端是一种相似于微服务的架构,它将微服务的理念利用于浏览器端,行将单页面前端利用由繁多的单体利用转变为把多个小型前端利用聚合为一的利用。各个前端利用还能够独立开发、独立部署。

微前端的价值

微前端架构具备以下几个外围价值:

  • 技术栈无关 主框架不限度接入利用的技术栈,子利用具备齐全自主权
  • 独立开发、独立部署 子利用仓库独立,前后端可独立开发,部署实现后主框架主动实现同步更新
  • 独立运行时 每个子利用之间状态隔离,运行时状态不共享

微前端架构旨在解决单体利用在一个绝对长的时间跨度下,因为参加的人员、团队的增多、变迁,从一个一般利用演变成一个巨石利用 (Frontend Monolith) 后,随之而来的利用不可保护的问题。这类问题在企业级 Web 利用中尤其常见。

针对中后盾利用的解决方案

中后盾利用因为其利用生命周期长 (动辄 3+ 年) 等特点,最初演变成一个巨石利用的概率往往高于其余类型的 web 利用。这次要带来了 技术栈落后 编译部署慢 两个问题。而从技术实现角度,微前端架构解决方案大略分为以下几类场景:

  • 前端容器化:iframe能无效地将另一个网页 / 单页面利用嵌入到以后页面中,两个页面间的 CSSJavaScript是互相隔离的。iframe相当于创立了一个全新的独立的宿主环境,相似于沙箱隔离,它意味着前端利用之间能够互相独立运行。如果咱们做一个利用平台,会在零碎中集成第三方零碎,或多个不同部门团队下的零碎,将 iframe 作为容器来包容其余前端利用,显然这仍然是一个十分靠谱的计划。
  • 微组件:借助于 Web Components 技术,开发者能够创立可重用的定制元素,来构建跨框架的前端利用。通常应用 Web Components 来做子利用封装,子利用更像是一个业务组件而不是利用。真正在我的项目上应用 Web Components 技术,离当初的咱们还有些间隔,可是联合 Web Components 来构建前端利用,是一种面向未来演进的架构。
  • 微利用:通过软件工程的形式,在部署构建环境中,把多个独立的利用组合成一个单体利用。
  • 微模块:开发一个新的构建零碎,将局部业务性能构建成一个独立的 chunk 代码,应用时只须要近程加载即可。

微前端架构

微前端架构 很好的借鉴了 SPA 无刷新的特点,在 SPA 之上引入新的分层实现利用切换的性能:

微前端架构实际中的问题

能够发现,微前端架构的劣势,正是 MPA SPA 架构劣势的合集。即保障利用具备独立开发权的同时,又有将它们整合到一起保障产品残缺的流程体验的能力。

Stitching layer 作为主框架的核心成员,充当调度者的角色,由它来决定在不同的条件下激活不同的子利用。因而主框架的定位则仅仅是:导航路由 + 资源加载框架

而具体要实现这样一套架构,咱们须要解决以下几个技术问题:

路由零碎及 Future State

咱们在一个实现了微前端内核的产品中,失常拜访一个子利用的页面时,可能会有这样一个链路:

此时浏览器的地址可能是 https://app.alipay.com/subApp/123/detail,设想一下,此时咱们手动刷新一下浏览器,会产生什么状况?

因为咱们的子利用都是 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-routerv4 之后实现了 Dynamic Routing 能力,咱们只须要复写一部分路由发现的逻辑即可。这里咱们举荐间接抉择社区比较完善的相干实际 single-spa。

App Entry

解决了路由问题后,主框架与子利用集成的形式,也会成为一个须要重点关注的技术决策。

子利用载入形式

Monorepo

应用 single-spa 的最简略办法是领有一个蕴含所有代码的仓库。通常,您只有一个 package.json, 一个的webpack 配置,产生一个包,它在一个 html 文件中通过 “ 标签援用。

NPM 包

创立一个父利用,npm装置每个 single-spa 利用。每个子利用在一个独自的代码仓库中,负责每次更新时公布一个新版本。当 single-spa 利用产生更改时,根应用程序应该重新安装、从新构建和重新部署。

动静加载模块

子利用本人构建打包,主利用运行时动静加载子利用资源。

计划 长处 毛病
Monorepo 1、最容易部署
2、繁多版本控制
1、对于每个独自的我的项目来说,一个 Webpack 配置和 package.json 意味着的灵活性和自由度有余。
2、当你的我的项目越来越大时,打包速度越来越慢。
3、构建和部署都是捆绑在一起的,这要求固定的发版打算,而不能长期公布
NPM 1、npm装置对于开发中更相熟,易于搭建
2、独立的npm 包意味着,每个利用在公布到 npm 仓库之前能够别离打包
1、父利用必须重新安装子利用来从新构建或部署
动静加载模块 主利用与子利用之间齐全解耦,子利用能够采纳任何技术栈 会多出一些运行时的复杂度和overhead

很显然,要实现真正的技术栈无关跟独立部署两个外围指标,大部分场景下咱们须要应用运行时加载子利用这种计划。

JS Entry vs HTML Entry

在确定了运行时载入的计划后,另一个须要决策的点是,咱们须要子利用提供什么模式的资源作为渲染入口?

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']})

总结一下:

近程加载

微前端架构下,咱们须要获取到子利用暴露出的一些钩子援用,如 bootstrapmountunmount 等(参考 single-spa),从而能对接入利用有一个残缺的生命周期管制。而因为子利用通常又有集成部署、独立部署两种模式同时反对的需要,使得咱们只能抉择 umd 这种兼容性的模块格局打包咱们的子利用。如何在浏览器运行时获取近程脚本中导出的模块援用也是一个须要解决的问题。

通常咱们第一反馈的解法,也是 最简略的解法就是与子利用与主框架之间约定好一个全局变量,把导出的钩子援用挂载到这个全局变量上,而后主利用从这外面取生命周期函数

这个计划很好用,然而最大的问题是,主利用与子利用之间存在一种强约定的打包协定。那咱们是否能找出一种松耦合的解决方案呢?

很简略,咱们只须要走 umd 包格局中的 global export 形式获取子利用的导出即可,大体的思路是通过给 window 变量打标记,记住每次最初增加的全局变量,这个变量个别就是利用 export 后挂载到 global 上的变量。实现形式能够参考 systemjs global import,这里不再赘述。

个别子利用构建后会生成很多个 chunk,主利用怎么晓得要加载的子利用有哪些 chunk 呢?又如何将它们一一加载到主利用中呢?

咱们的实现思路,就是让子项目应用 stats-webpack-plugin 插件,每次打包后都输入一个 只蕴含重要信息的 manifest.json 文件,用 create-react-app 搭建的 react 利用中 webpack 配置默认应用 webpack-manifest-plugin 生成资源清单。父我的项目先 ajax 申请 这个json 文件,从中读取出须要加载的 js 目录,而后同步加载。

webpack-manifest-plugin插件生成的资源清单asset-manifest.json

{
  "files": {
    "main.js": "/index.js",
    "main.js.map": "/index.js.map",
    "static/js/1.97da22d3.chunk.js": "/static/js/1.97da22d3.chunk.js",
    "static/js/1.97da22d3.chunk.js.map": "/static/js/1.97da22d3.chunk.js.map",
    "static/css/2.8e475c3e.chunk.css": "/static/css/2.8e475c3e.chunk.css",
    "static/js/2.67d7628e.chunk.js": "/static/js/2.67d7628e.chunk.js",
    "static/css/2.8e475c3e.chunk.css.map": "/static/css/2.8e475c3e.chunk.css.map",
    "static/js/2.67d7628e.chunk.js.map": "/static/js/2.67d7628e.chunk.js.map",
    "static/css/3.5b52ba8f.chunk.css": "/static/css/3.5b52ba8f.chunk.css",
    "static/js/3.0e198e04.chunk.js": "/static/js/3.0e198e04.chunk.js",
    "static/css/3.5b52ba8f.chunk.css.map": "/static/css/3.5b52ba8f.chunk.css.map",
    "static/js/3.0e198e04.chunk.js.map": "/static/js/3.0e198e04.chunk.js.map",
    "index.html": "/index.html"
  },
  "entrypoints": ["index.js"]
}

利用隔离

微前端架构计划中有两个十分要害的问题,有没有解决这两个问题将间接标记你的计划是否真的生产可用。比拟遗憾的是此前社区在这个问题上的解决都会不谋而合抉择”绕道“的形式,比方通过奴才利用之间的一些默认约定去躲避抵触。而明天咱们会尝试从纯技术角度,更智能的解决利用之间可能抵触的问题。

款式隔离

因为微前端场景下,不同技术栈的子利用会被集成到同一个运行时中,所以咱们必须在框架层确保各个子利用之间不会呈现款式相互烦扰的问题。

Shadow DOM?

针对 "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 Module? BEM?

社区通常的实际是通过约定 css 前缀的形式来防止款式抵触,即各个子利用应用特定的前缀来命名 class,或者间接基于 css module 计划写款式。对于一个全新的我的项目,这样当然是可行,然而通常微前端架构更多的指标是解决存量 / 遗产 利用的接入问题。很显然遗产利用通常是很难有能源做大幅革新的。

最次要的是,约定的形式有一个无奈解决的问题,如果子利用中应用了三方的组件库,三方库在写入了大量的全局款式的同时又不反对定制化前缀?比方 a 利用引入了 antd 2.x,而 b 利用引入了 antd 3.x,两个版本的 antd 都写入了全局的 .menu class,但又彼此不兼容怎么办?

Dynamic Stylesheet !

决计划其实很简略,咱们只须要在利用切出 / 卸载后,同时卸载掉其样式表即可,原理是浏览器会对所有的样式表的插入、移除做整个 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 也就天然被移除款式也随之卸载了。

JS 隔离

解决了款式隔离的问题后,有一个更要害的问题咱们还没有解决:如何确保各个子利用之间的全局变量不会相互烦扰,从而保障每个子利用之间的软隔离?

这个问题比款式隔离的问题更辣手,社区的广泛玩法是给一些全局副作用加各种前缀从而防止抵触。但其实咱们都明确,这种通过团队间的”口头“约定的形式往往低效且易碎,所有依赖人为束缚的计划都很难防止因为人的忽略导致的线上 bug。那么咱们是否有可能打造出一个好用的且齐全无约束的 JS 隔离计划呢?

针对 JS 隔离的问题,咱们独创了一个运行时的 JS 沙箱。简略画了个架构图:

即在利用的 bootstrapmount 两个生命周期开始之前别离给全局状态打下快照,而后当利用切出 / 卸载时,将状态回滚至 bootstrap 开始之前的阶段,确保利用对全局状态的净化全副清零。而当利用二次进入时则再复原至 mount 前的状态的,从而确保利用在 remount 时领有跟第一次 mount 时统一的全局上下文。

快照能够了解为暂存 mount 前的 window 对象,mount后会产生一个新的 window 对象,当 umount 后且回到暂存的 window 对象

当然沙箱里做的事件还远不止这些,其余的还包含一些对全局事件监听的劫持等,以确保利用在切出之后,对全局事件的监听能失去残缺的卸载,同时也会在 remount 时从新监听这些全局事件,从而模拟出与利用独立运行时统一的沙箱环境。

初始加载主利用的脚本时可能全局监听了某些事件,比方 window.resize,当umount 后且回到暂存的 window 对象须要从新监听一边

qiankun VS single-spa

上述形容的问题是 qiankun 框架在蚂蚁落地产生的,qiankun是基于 single-spa 开发的,那它与 single-spa 又有哪些区别呢?

qiankun

在乾坤的角度,微前端就是“微利用加载器”,它次要解决的是:如何 平安 疾速 的把多个扩散的我的项目集中起来的问题,这从乾坤本身提点便可看出:

所有这些个性都是服务于“微利用加载器”这个定位。

single-spa

在 single-spa 的角度,微前端就是“微模块加载器”,它次要解决的是:如何实现前端的“微服务化”,从而让利用、组件、逻辑都成为可共享的微服务,这从 single-spa 对于微前端的概述中能够看出:

single-spa 看来微前端有三种类型:微利用、微组件、微模块,实际上 single-spa 要求它们都以 SystemJS 的模式打包,换句话说它们实质上都是 微模块

SystemJS是一个运行时加载模块的工具,是现阶段下 (浏览器尚未正式反对importMap) 原生 ES Module 的齐全替代品。SystemJS动静加载的模块必须是 SystemJS 模块或者 UMD 模块。

qiankun 与 single-spa 区别?

乾坤基于 single-spa,增强了微利用集成能力,却摈弃了微模块的能力。所以,它们的区别就是 微服务的粒度 ,乾坤的所能服务的粒度是利用级别,而single-spa 则是模块级别。它们都能将前端进行拆分,只是拆分的粒度不同罢了。

  1. 微利用加载器:“微”的粒度是利用,也就是HTML,它只能做到利用级别的分享
  2. 微模块加载器:“微”的粒度是模块,也就是 JS 模块,它能做到模块级别的分享

为什么要这么做呢?咱们要想分明这两个框架呈现的背景:

qiankun:阿里外部有大量年久失修的我的项目,业务侧急需工具去把他们疾速、平安的集成到一起。在这个角度,乾坤基本没有做模块联邦的需要,它的需要仅仅是如何疾速、平安的把我的项目集成起来。所以乾坤是想做一个微前端工具。

single-spa:学习后端的微服务,实现前端的微服务化,让利用、组件以及逻辑都成为可共享的微服务,实现真正意义上的微前端。所以 single-spa 是想做一个game-changer

这里我还整顿了一个图不便了解:

总结

迁徙微前端次要思考三个点:子利用加载、款式隔离、脚本隔离。上文剖析后别离给出了一些可行性计划:SystemJS动静加载脚本、Dynamic Stylesheet、快照。

参考文章

可能是你见过最欠缺的微前端解决方案

架构设计:微前端架构

几种微前端计划探索

实用于既有大型 MPA 我的项目的“微前端”计划

再谈微前端

基于 Single-SPA 的微前端架构

正文完
 0