更多请关注微前端专题 https://codeteenager.github.io/Micro-Frontends/
微前端官网:https://micro-frontends.org/
问题:如何实现多个应用之间的资源共享?
之前比较多的处理方式是npm包形式抽离和引用,比如多个应用项目之间,可能有某业务逻辑模块或其他是可复用的,便抽离出来以npm包的形式进行管理和使用。但这样却带来了以下几个问题:
这些问题让我们意识到,扩展前端开发规模以便多个团队可以同时开发一个大型且复杂的产品是一个重要但又棘手的难题。因此,早在2016年,微前端概念诞生了。
“微前端”一词最早于 2016 年底在 ThoughtWorks Technology Radar 中提出,它将后端的微服务概念扩展到了前端世界。微服务是服务端提出的一个有界上下文、松耦合的架构模式,具体是将应用的服务端拆分成更小的微服务,这些微服务都能独立运行,采用轻量级的通信方式(比如 HTTP )。
微前端概念的提出可以借助下面的 Web 应用架构模式演变图来理解。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fpWJhCJG-1677042958816)(/application/foundation/1.png)]
最原始的架构模式是单体 Web 应用,整个应用由一个团队来负责开发。
随着技术的发展,开发职责开始细分,一个项目的负责团队会分化成前端团队和后端团队,即出现了前后端分离的架构方式。
随着项目变得越来越复杂,先感受到压力的是后端,于是微服务的架构模式开始出现。
随着前端运行环境进一步提升,Web 应用的发展趋势越来越倾向于富应用,即在浏览器端集成更多的功能,前端层的代码量以及业务逻辑也开始快速增长,从而变得越来越难以维护。于是引入了微服务的架构思想,将网站或 Web 应用按照业务拆分成粒度更小的微应用,由独立的团队负责开发。
从图上可以看出,微前端、微服务这些架构模式的演变趋势就是不断地将逻辑进行拆分,从而降低项目复杂度,提升可维护性和可复用性。
所以说微前端是一种类似于微服务的架构,是一种由独立交付的多个前端应用组成整体的架构风格,将前端应用分解成一些更小、更简单的能够独立开发、测试、部署的应用,而在用户看来仍然是内聚的单个产品。有一个基座应用(主应用),来管理各个子应用的加载和卸载。
从上面的演变过程可以看出,微前端架构比较适合大型的 Web 应用,常见的有以下 3 种形式。
微前端架构按集成微应用的位置不同,主要可以分为 2 类:
服务端集成常用的方式是通过反向代理,在服务端进行路由转发,即通过路径匹配将不同请求转发到对应的微应用。这种架构方式实现起来比较容易,改造的工作量也比较小,因为只是将不同的 Web 应用拼凑在一起,严格地说并不能算是一个完整的 Web 应用。当用户从一个微应用跳转到另一个微应用时,往往需要刷新页面重新加载资源。
这种代理转发的方式和直接跳转到对应的 Web 应用相比具有一个优势,那就是不同应用之间的通信问题变得简单了,因为在同一个域下,所以可以共享 localstorage、cookie 这些数据。譬如每个微应用都需要身份认证信息 token,那么只需要登录后将 token 信息写入 localstorage,后续所有的微应用就都可以使用了,不必再重新登录或者使用其他方式传递登录信息。
浏览器集成也称运行时集成,常见的方式有以下 3 种。
这也是一种非常热门的集成方式,代表性的框架有 single-spa 以及基于它修改的乾坤。
对比npm包方式抽离,让我们意识到更新流程和效率的重要性,微前端由于是多个子应用的聚合,如果多个业务应用依赖同一个服务应用的功能模块,只需要更新服务应用,其他业务应用就可以立马更新,从而缩短了更新流程和节约了更新成本。
迁移是一项非常耗时且艰难的任务,比如有一个管理系统使用AngularJS开发维护已经有三年时间,但是随着时间的推移和团队成员的变更,无论从开发成本还是用人需求上,AngularJS已经不能满足要求,于是团队想要更新技术栈,想在其他框架中实现新的需求,但是现有项目怎么办?直接迁移是不可能的,在新的框架中完全重写也不太现实。
使用微前端架构就可以解决问题,在保留原有项目的同时,可以完全使用新的框架开发新的需求,然后再使用微前端架构将旧的项目和新的项目进行整合,这样既可以使产品得到更好的用户体验,也可以使团队成员在技术上得到进步,产品开发成本也降到的最低。
在目前的单页应用架构中,使用组件构建用户界面,应用中的每个组件或功能开发完成或者bug修复完成后,每次都需要对整个产品重新进行构建和发布,任务耗时操作上也比较繁琐。
在使用了微前端架构后,可以将不能的功能模块拆分成独立的应用,此时功能模块就可以单独构建单独发布了,构建时间也会变得非常快,应用发布后不需要更改其他内容应用就会自动更新,这意味着你可以进行频繁的构建发布操作了。
因为微前端架构与框架无关,当一个应用由多个团队进行开发时,每个团队都可以使用自己擅长的技术栈进行开发,也就是它允许适当的让团队决策使用哪种技术,从而使团队协作变得不再僵硬。
多个微应用如何进行组合?
在微前端架构中,除了存在多个微应用以外,还存在一个容器应用,每个微应用都需要被注册到容
器应用中。
微前端中的每个应用在浏览器中都是一个独立的 JavaScript 模块,通过模块化的方式被容器应用启
动和运行。
使用模块化的方式运行应用可以防止不同的微应用在同时运行时发生冲突。
在微应用中如何实现路由?
在微前端架构中,当路由发生变化时,容器应用首先会拦截路由的变化,根据路由匹配微前端应
用,当匹配到微应用以后,再启动微应用路由,匹配具体的页面组件。
微应用与微应用之间如何实现状态共享?
在微应用中可以通过发布订阅模式实现状态共享,比如使用 RxJS。
微应用与微应用之间如何实现框架和库的共享?
通过 import-map 和 webpack 中的 externals 属性。
其中,目前值得关注的是去中心模式中的EMP微前端方案,既可以实现跨技术栈调用,又可以在相同技术栈的应用间深度定制共享资源。
微前端不是框架、不是工具/库,而是一套架构体系,它包括若干库、工具、中心化治理平台以及相关配套设施。
微前端包括 3 部分:
微前端具体要解决好的 10 个问题:
qiankun技术圆桌中有一篇关于微前端Why Not Iframe的思考,主要有以下几点:
因为这些原因,最终大家都舍弃了 iframe 方案。
MDN Web Components由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。
和
元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。但是兼容性很差,查看can i use WebComponents。
spa 单页应用时代,我们的页面只有 index.html 这一个 html 文件,并且这个文件里面只有一个内容标签
,用来充当其他内容的容器,而其他的内容都是通过 js 生成的。也就是说,我们只要拿到了子项目的容器 和生成内容的 js,插入到主项目,就可以呈现出子项目的内容。<link href=/css/app.c8c4d97c.css rel=stylesheet>
<div id=app></div>
<script src=/js/chunk-vendors.164d8230.js> </script>
<script src=/js/app.6a6f1dda.js> </script>
我们只需要拿到子项目的上面四个标签,插入到主项目的 HTML 中,就可以在父项目中展现出子项目。
这里有个问题,由于子项目的内容标签是动态生成的,其中的 img/video/audio 等资源文件和按需加载的路由页面 js/css 都是相对路径,在子项目的 index.html 里面,可以正确请求,而在主项目的 index.html 里面,则不能。
举个例子,假设我们主项目的网址是 www.baidu.com
,子项目的网址是 www.taobao.com
,在子项目的 index.html 里面有一张图片 ,那么这张图片的完整地址是
www.taobao.com/logo.jpg
,现在将这个图片的 img 标签生成到了父项目的 index.html,那么图片请求的地址是 www.baidu.com/logo.jpg
,很显然,父项目服务器上并没有这张图。
解决思路:
通常做法是动态修改 webpack 打包的 publicPath,然后就可以自动注入前缀给这些资源。
single-spa 是一个微前端框架,基本原理如上,在上述呈现子项目的基础上,还新增了 bootstrap 、 mount 、 unmount 等生命周期。
相对于 iframe,single-spa 让父子项目属于同一个 document,这样做既有好处,也有坏处。好处就是数据/文件都可以共享,公共插件共享,子项目加载就更快了,缺点是带来了 js/css 污染。
single-spa 上手并不简单,也不能开箱即用,开发部署更是需要修改大量的 webpack 配置,对子项目的改造也非常多。
运行时框架主要做了以下这些事:
App Entry作为子应用的加载入口,微前端框架根据注册的子应用,通过给定的 url,加载约定格式的子应用入口,并挂载到给定位置,目前业内有两种entry: JS Entry 和 Html Entry。
说明 | 优点 | 缺点 | |
---|---|---|---|
html | html作为子应用入口 | 解耦更彻底,子应用不依赖于主应用DOM,子应用独立开发,独立部署 | 多了一次对html的请求,解析有性能损耗,无法做构建时优化 |
js | js作为子应用入口 | 便于做构建时优化 | 依赖主应用提供挂载节点,打包产物体积膨胀,资源无法并行加载 |
Js Entry 的缺点是:
而Html Entry只需要指定子应用的 html 入口即可,微前端框架在加载 html 字符串后,从中提取出 css、js 资源,运行子应用时,安装样式、执行脚本,运行脚本中提供的生命周期钩子。因此优点也很明显:
JS Entry 的方式通常是子应用将资源打成一个 entry script,要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。像single-spa通常是结合SystemJS来实现,在single-spa框架中,基座会检测浏览器url的变化,在变化时往往通过SystemJS的import映射,来加载不同的子应用js。
Import maps这个功能是Chrome 89才支持的。它是对import的一个映射处理,让你控制在js中使用import时,到底从哪个url获取这些库。
比如通常我们会在js中,以下面这种方式引入模块:
import moment from "moment"
正常情况下肯定是node_modules中引入,但是现在我们在html中加入下面的代码:
<script type="importmap">
{
"imports": {
"moment": "/moment/src/moment.js"
}
}
</script>
这里/moment/src/moment.js这个地址换成一个cdn资源也是可以的。最终达到的效果就是:
import moment from "/moment/src/moment.js"
有了Import maps,import的语法就可以直接在浏览器中使用,而不再需要webpack来帮我们进行处理,不需要从node_modules中去加载库。
Import maps甚至还有一个兜底的玩法:
"imports": {
"jquery": [
"https://某CDN/jquery.min.js",
"/node_modules/jquery/dist/jquery.js"
]
}
当cdn无效时,再从本地库中获取内容。
尽管Import maps非常强大,但是毕竟浏览器兼容性还并不是很好,所以就有了我们的polifill方案:SystemJS
SystemJs是一个通用的模块加载器,有属于自己的模块化规范。他能在浏览器和node环境上动态加载模块,微前端的核心就是加载子应用,因此将子应用打包成模块,在浏览器中通过SystemJs来加载模块。SystemJS可兼容到IE11,但是它对于插件版本要求非常严格,而且变化非常大,兼容性也不是特别好,使用体验也不是很好,所以目前实践中用的非常少。它同样支持import映射,但是它的语法稍有不同:
<script src="system.js"></script>
<script type="systemjs-importmap">
{
"imports": {
"lodash": "https://unpkg.com/[email protected]/lodash.js"
}
}
</script>
在浏览器中引入system.js后,会去解析type为systemjs-importmap的script下的import映射。
HTML Entry 是由 import-html-entry 库实现的,这个库主要做了这些事情:
)的方式插入到 entry html 中。通过 http 请求加载指定地址的首屏内容即 html 页面,然后解析这个 html 模版得到 template, scripts , entry, styles。
{
template: 经过处理的脚本,link、script 标签都被注释掉了,
scripts: [脚本的http地址 或者 { async: true, src: xx } 或者 代码块],
styles: [样式的http地址],
entry: 入口脚本的地址,要不是标有 entry 的 script 的 src,要不就是最后一个 script 标签的 src
}
然后远程加载 styles 中的样式内容,将 template 模版中注释掉的 link 标签替换为相应的 style 元素。
然后向外暴露一个 Promise 对象
{
// template 是 link 替换为 style 后的 template
template: embedHTML,
// 静态资源地址
assetPublicPath,
// 获取外部脚本,最终得到所有脚本的代码内容
getExternalScripts: () => getExternalScripts(scripts, fetch),
// 获取外部样式文件的内容
getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
// 脚本执行器,让 JS 代码(scripts)在指定 上下文 中运行
execScripts: (proxy, strictGlobal) => {
if (!scripts.length) {
return Promise.resolve();
}
return execScripts(entry, scripts, proxy, { fetch, strictGlobal });
}
}
在 import-html-entry 库处理完之后,基座在需要的加载子应用时候将这个 html 放到对应的 DOM 容器节点,并执行 script list,即完成子应用的加载。
了解更多
子应用注册的时候,提供子应用激活规则 (路由字符串 或 函数)。因此,监听 hashchange 和 popstate 事件,在事件回调函数中,根据注册的子应用激活规则,卸载/激活子应用。
以 Vue-Router 的 history 模式为例,在切换路由时,通常会做三件重要事情:执行一连串的 hook 函数、更新url、router-view 更新,其中更新 url,就是通过 pushState/replaceState 的形式实现的。因此重写并增强 history.pushState 和 history.replaceState 方法,在执行它们的时候,可以拿到执行前、执行后的 url,对比是否有变化,如果有,根据注册的子应用激活规则,卸载/激活子应用。
以single-spa为例:
// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
if (typeof listenerFn === "function") {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
}
return originalRemoveEventListener.apply(this, arguments);
};
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
以上主要是增加了hashchange、popstate两个监听,监听url的变化。同时重写pushState以及replaceState方法,在方法中调用原有方法后执行如何处理子应用的逻辑监听hashchange及popstate事件,事件触发后执行如何处理子应用的逻辑。
single-spa的一个关键点就是生命周期,子应用生命周期包含bootstrap,mount,unmount三个回调函数。主应用在管理子应用的时候,通过子应用暴露的生命周期函数来实现子应用的启动和卸载。
子应用和基座的隔离主要有两点:
目前相对来说使用最多的样式隔离机制
BEM:Block Element Module命名约束
模块:.Block
模块多单词: .Header-Block
模块_状态: .Block_Modifier
模块__子元素: .Block__Element
模块__子元素_状态: .Block__Element_Modifier
CSS Modules:
代码中的每一个类名都是引入对象的一个属性,通过这种方式,即可在使用时明确指定所引用的 css 样式。并且 CSS Modules 在打包的时候会自动将类名转换成 hash 值,完全杜绝 css 类名冲突的问题;
使用JS写CSS,也是目前比较主流的方案,完全不需要些单独的 css 文件,所有的 css 代码全部放在组件内部,以实现 css 的模块化,但对于历史代码不好处理
使用postcss,在全局对所有class添加统一的前缀,但是在编译时处理,会增加编译时间;
js 隔离的核心是在基座和子应用中使用不同的上下文 (global env),从而达成基座和子应用之间 js 运行互不影响。
简单来说,就是给子应用单独的 window,避免对基座的 window 造成污染。
qiankun在js隔离上,同样提供了3种方案,分别是:
iframe 标签可以创造一个独立的浏览器级别的运行环境,该环境与主环境隔离,并有自己的 window 上下文;在通信机制上,也可以利用 postMessage 等 API 与宿主环境进行通信。具体来说,在执行 JavaScript 代码上,有以下要求:
当运行中发生错误时,需要对其进行捕获,这里主要监听了error和unhandledrejection两个错误事件。
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);