微前端原理与iframe 项目实践
一、背景
在讲微前端之前,首先了解下前端开发模式的发展历程,在最早的时候,前端的开发是耦合在服务端,主要的工作其实就是提供一个界面模板,交互也不多,实际的数据还是在服务端渲染的时候提供的。
大概在2010年,界面越来越复杂,调试、部署、都要依赖于后端,如果还是以模板的形式开发效率太低了。于是就提出了前后端分离的模式开发,这个时期也是单页应用开始火起来的时期。
到了2014左右,后端开始了微服务模式的开发,这也为微前端提供的思路。随着前端承担的东西越来越多,不断的迭代后,原本简单的单页应用,已经变成了一个巨石应用,不管是代码量还是页面量都非常庞大,一个单页应用由多个团队一起来维护。同时巨石应用还受到了非常大的约束,比如新技术的更新、打包速度等等问题。
因此,在2019年左右,借鉴微服务的思想,提出了微前端的开发模式,也就是将一个大型的单页应用,以业务域为粒度,拆分为多个子应用,最后通过微前端的技术,整合成一个完整的单页应用,同时,每个子应用也能够享有独立应用开发一致的体验。
二、微前端简介
微前端概念
微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。Micro Frontends
微前端(Micro Frontends)是一种前端架构模式,借鉴了微服务的架构理念,将一个庞大的前端应用拆分为多个独立灵活的小型应用,每个前端应用都可以独立开发、独立运行、独立部署,再将这些小型应用联合为一个完整的应用。
微前端的目标是使前端开发更加容易、可维护和可扩展,并且能够实现团队之间的协作。
微前端的特点
- 技术栈无关 主框架不限制接入应用的技术栈,子应用可自主选择技术栈
- 独立开发/部署 子应用仓库独立,单独部署,互不依赖
- 增量升级 当一个应用庞大之后,技术升级或重构相当麻烦,而微应用具备渐进式升级的特性
- 独立运行 子应用之间运行时互不依赖,有独立的状态管理
- 提升效率 微应用可以很好拆分项目,提升协作效率
- 可维护性 微前端可以更容易地进行维护和测试,因为它们具有清晰的界限和独立的代码库
劣势
- 增加了系统复杂度 需要对系统进行拆分,将单体应用拆分成多个独立的微前端应用。这种拆分可能导致系统整体变得更加复杂,因为需要处理跨应用之间的通信和集成问题
- 需要依赖额外的工具和技术 例如模块加载器、应用容器等,这些工具和技术需要额外的学习和维护成本,也可能会导致一些性能问题
- 安全性问题 由于微前端应用是独立的,它们之间可能存在安全隐患。例如,如果某个微前端应用存在漏洞,攻击者可能会利用这个漏洞来攻击整个系统
- 兼容性问题 由于微前端应用是独立的,它们之间可能存在兼容性问题。例如,某个微前端应用可能使用了一些不兼容的依赖库,这可能会导致整个系统出现问题
- 开发团队需要有一定的技术水平 实现微前端需要开发团队有一定的技术水平,包括对模块化、代码复用、应用集成等方面有深入的了解。如果团队缺乏这方面的技能,可能会导致微前端实现出现问题
三、微前端的技术实现
3.1 微前端的基础架构
微前端架构基本需要实现三个部分:
- 主应用接入子应用,包括子应用的注册、路由的处理、应用的加载和路由的切换。
- 主应用加载子应用,这部分之所以重要,是因为接入的方式决定了是否可以更高效的解耦。
- 子应用的容器,这是子应用加载之后面临的问题,包含了JS沙箱、样式隔离和消息机制。
3.2 微前端的主要技术问题
1) 构建时组合 VS 运行时组合
主框架与子应用集成的方式
微前端架构模式下,子应用打包的方式,基本分为两种:
组合方式 | 说明 | 优点 | 缺点 |
---|---|---|---|
构建时 | 子应用与主应用一起打包发布 | 构建的时候可以做打包优化,如依赖共享等 | 主子应用构建方案、工具耦合,必须一起发布,不够灵活 |
运行时 | 子应用自己构建打包,主应用运行时动态加载子应用资源 | 主子应用完全解耦,完全技术栈无关 | 有运行时损耗,多出一些运行时的复杂度 |
要实现真正的技术栈无关跟独立部署两个核心目标,大部分场景下需要使用运行时加载子应用这种方案。
2)JS Entry VS HTML Entry
子应用提供什么形式的资源作为渲染入口?
JS Entry 的方式通常是子应用将资源打成一个 entry script。但这个方案的限制也颇多,如要求子应用的所有资源打包到一个 js bundle 里,包括 css、图片等资源。
HTML Entry 则更加灵活,直接将子应用打出来 HTML 作为入口,主框架可以通过 fetch html 的方式获取子应用的静态资源,同时将 HTML document 作为子节点塞到主框架的容器中。
App Entry | 优点 | 缺点 |
---|---|---|
HTML Entry | 1.子应用开发、发布完全独立 2.子应用具备与独立应用开发时一致的开发体验 | 1.多一次请求,子应用资源解析消耗转移到运行时 2.主子应用不处于同一个构建环境,无法利用bundle的一些构建期的优化能力,如公共依赖抽取等 |
JS Entry | 主子应用使用同一个bundle,可以方便做构建时优化 | 1.子应用的发布需要主应用重新打包 2.主应用需为每个子应用预留一个容器节点,且该节点id需与子应用的容器id保持一致 3.子应用各类资源需要一起打成一个bundle,资源加载效率变低 |
3)样式隔离
由于微前端场景下,不同技术栈的子应用会被集成到同一个运行时中,所以必须在框架层确保各个子应用之间不会出现样式互相干扰的问题。
“甲之蜜糖,乙之砒霜“,每个方案都有着不同的优势与劣势。
- BEM (Block Element Module)规范命名约束
- CSS Modules 构建时生成各自的作用域
- CSS in JS 使用 JS 语言写 CSS
- Shadow DOM 沙箱隔离
- experimentalStyleIsolation 给所有的样式选择器前面都加了当前挂载容器
- Dynamic Stylesheet 动态样式表
- postcss 增加命名空间
方案 | 说明 | 优点 | 缺点 |
---|---|---|---|
BEM | 不同项目用不同的前缀/命名规则避开冲突 | 简单 | 依赖约定,这也是耦合的一种,容易出现纰漏 |
CSS Modules | 通过编译生成不冲突的选择器名 | 可靠易用,避免人工约束 | 只能在构建期使用,依赖预处理器与打包工具 |
CSS in JS | CSS和JS编码在一起最终生成不冲突的选择器 | 基本彻底避免冲突 | 运行时开销,缺失完整的CSS能力 |
4)JS隔离
一个子应用从加载到运行,再到卸载,有可能会对全局产生一些污染。这些污染包括但不限于:添加 / 删除 / 修改全局变量、绑定全局事件、修改原生方法或对象等。而所有这些子应用造成的影响都可能引起潜在的全局冲突。为此,需要在加载和卸载一个应用的同时,尽可能消除这种影响。目前,主要有两种隔离方式,一种是快照沙箱、另外一种是代理沙箱。
- 快照沙箱的核心思想就是在应用挂载(mount方法)的时候记录快照,在应用卸载(unmount)的时候依据快照恢复环境。
实现的思路是直接用 window diff,把当前的环境和原来的环境做一个比较,跑两次循环(创建快照和恢复快照),然后把两个环境做一次比较,最后去全量的恢复回原来的环境。
- 代理沙箱的核心思想是让子应用里面的环境和外面的环境完全隔离。每个应用对应一个环境,比如应用A对应环境A,应用B对应环境B,同时两者之间的环境和全局环境也互不干扰。
实现思路是主要利用 ES6 的 Proxy 能力。通过劫持window,可以劫持到子应用对全局环境的一些修改,当子应用往window上挂载、删除、修改的时候,把操作记录下来,当恢复全局环境时,反向执行之前的操作。
四、微前端方案
实现方式 | 基本思想 | 优点 | 不足 | 代表方案 |
---|---|---|---|---|
路由分发 | 1.前端框架公共路由方案,映射到不同应用 2.服务器反向代理路由到不同应用 | 1. 维护、开发成本低;2.适应一些关联方不多、业务场景不发展的情况; | 不足:1.独立应用的硬聚合,有比较明显的割裂感 | -- |
前端容器化 | iframe 可以创建一个全新的独立的宿主环境,这意味着前端应用之间可以相互独立运行,仅需要做好应用之间的管理、通信即可 | 1. 比较简单,无需过多改造,开发成本低; 2.完美隔离,JS、CSS 都是独立的运行环境; 3. 不限制使用,页面上可以放多个 iframe 来组合业务 | 1. 无法保持路由状态,刷新后 iframe url 状态丢失(这点也不是完全不能解决,可以将路由作为参数拼接在链接后,刷新时去参数进行页面跳转); 2. 全局上下文完全隔离,应用之间通信困难(比如iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果); 3. iframe 中的弹窗无法突破其本身,无法覆盖全局; 4. 加载慢,每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程 | Iframe |
前端微服务化 | 前端微服务化,是微服务架构在前端的实施,每个前端应用都是完全独立(技术栈、开发、部署、构建独立)、自主运行的,最后通过模块化的方式组合出完成的应用 | 1. 应用间通信简单,全局注入; 2. html entry 的方式引入子应用,相比 js entry 极大的降低了应用改造的成本; 3. 完备的js、css 沙箱方案,确保微应用之间的样式/全局变量/事件互相不干扰; 4. 具备静态资源预加载能力,加速微应用打开速度 | 1. 适配成本比较高,webpack工程化、生命周期、静态资源路径、路由等都要做一系列的适配工作; 2. css 沙箱采用严格隔离会有各种问题,js 沙箱在某些场景下执行性能下降严重; 3. 基于路由匹配,无法同时激活多个子应用,也不支持子应用保活; 4. 无法支持 vite 等 ESM 脚本运行 | Single-SPA、qiankun |
应用组件化 | Web Components 是一套不同的技术,允许开发者创建可重用的定制元素(它们的功能封装在代码之外)并且在 Web 应用中使用它们,其中html imports 被废弃,可以直接加载js即可 | 1. 使用 类WebComponent 加载子应用相比 single-spa 这种注册监听方案更加优雅a; 2. 组件式的 api 更加符合使用习惯,支持子应用保活; 3. 降低子应用改造的成本,提供静态资源预加载能力; 4. 基于CustomElement和样式隔离、js隔离来实现微应用的加载,所以子应用无需改动就可以接入 | 1. 类 webcomponent css 沙箱依然无法绝对的隔离; 2. 支持 vite 运行,但必须使用 plugin 改造子应用,且 js 代码没办法做沙箱隔离; 3. 对于不支持 webcompnent 的浏览器没有做降级处理,兼容性不够 | micro-app |
微件化 | 微前端下的微件化是指,每个业务团队编写自己的业务代码,并将编译好的代码部署到指定的服务器上,运行时只需要加载指定的代码即可 | 1. webpack 联邦编译可以保证所有子应用依赖解耦; 2. 应用间去中心化的调用、共享模块; 3. 模块远程 ts 支持 | 1. 需要相关规范约束各Widget; 2. 没有有效的 css 沙箱和 js 沙箱,需要靠用户自觉; 3. 子应用保活、多应用激活无法实现; 4. 主、子应用的路由可能发生冲突 | EMP |
五、iframe 项目实践
5.1 iframe的概念以及作用
iframe(内联框架)是HTML标签,也是一个内联元素,作用是文档中的文档,或者浮动的框架(FRAME),iframe 元素会创建包含另外一个HTML文档的内联框架(即行内框架) 。
简言之,iframe可以在当前页面嵌入其他页面
5.2 优缺点
优点:
- 内容隔离:可以在同一页面中加载并显示来自不同源的内容,而不会影响主页面的功能
- 异步加载:可以异步加载iframe中的内容,不会阻塞主页面的加载
- 独立滚动:iframe内的内容可以独立滚动,不影响主页面的滚动
- 可以实现复杂的布局和组件,如广告、小工具、第三方插件等
缺点:
- 性能问题:每个iframe都会创建一个新的窗口环境,会消耗更多的内存和CPU资源
- SEO问题:搜索引擎可能无法正确索引iframe中的内容
- 跨域问题:当iframe嵌入网页与主页面不同源,会受到浏览器的安全限制,使用postMessage API需要避免发送敏感信息,或者接收来自不可信源的消息
- 历史记录问题:iframe的导航可能不会更新浏览器的历史记录,可能会影响用户的导航体验
5.3 主要属性
属性 | 值 | 描述 |
---|---|---|
src | URL | 规定在 iframe 中显示的文档的 URL |
class | classname | 规定 iframe 的类名 |
id | id | 规定 iframe 的特定id |
style | style_definition | 规定 iframe 的行内样式 |
title | text | 规定 iframe 的额外信息(可在工具提示中显示) |
width | pixels/percentage | 规定 iframe 的宽度 |
height | pixels/percentage | 规定 iframe 的高度 |
5.4 父子页面通信
5.4.1 单向通信(父传子)
URL传参: 可以在iframe的src属性中使用URL参数,将父页面的数据传递到iframe嵌入的页面。
<iframe src="http://new-iframe-url?param1=value1¶m2=value2"></iframe>
5.4.2 双向通信
父页面和子页面(即iframe内的页面)的通信机制,分为两种
(1)同源的父子页面通信:
如果父页面和子页面同源,可以直接通过JavaScript访问对方的DOM。这是因为同源策略允许同源的文档之间进行完全的交互。
父页面可以通过iframe元素的contentWindow属性获取子页面的window对象,然后直接调用其函数或访问其变量。同样,子页面也可以通过window.parent获取父页面的window对象。
父页面访问子页面:
// 获取iframe元素
const iframe = document.getElementById('myIframe');
// 获取子页面的window对象
const childWindow = iframe.contentWindow;
// 调用子页面的函数
childWindow.myFunction();
// 访问子页面的变量
console.log(childWindow.myVariable);
// 修改子页面的DOM
childWindow.document.getElementById('myElement').innerText = 'hhhh';
子页面访问父页面:
// 获取父页面的window对象
var parentWindow = window.parent;
// 调用父页面的函数
parentWindow.myFunction();
// 访问父页面的变量
console.log(parentWindow.myVariable);
// 修改父页面的DOM
parentWindow.document.getElementById('myElement').innerText = 'hhhh';
(2)不同源的父子页面通信:
如果父页面和子页面不同源,则不能直接通过JavaScript访问对方的DOM。但可以通过HTML5的postMessage API进行跨源通信。
具体来说,一个页面可以通过postMessage方法向另一个页面发送消息,然后另一个页面通过监听message事件来接收这个消息。
父页面发送消息到子页面:
var iframe = document.getElementById('myIframe');
iframe.contentWindow.postMessage('Hello', 'https://example.com');
子页面接收来自父页面的消息:
window.addEventListener('message', function(event) {
if (event.origin !== 'https://parent.com') return;
console.log('received message:', event.data);
});
5.5 项目中遇到的问题
问题描述
背景:页面初始化时,子应用iframe要从主应用获取cookie,实现免登
问题实现步骤:清除主应用的cookie,刷新页面,点击加载子应用,此时没获取到cookie,接口报鉴权错误
问题原因
1)异步获取 cookie:cookie 是通过 postMessage 从父页面异步获取的,在发送 HTTP 请求时,cookie可能尚未获取或存储在 sessionStorage 中
2)立即发送请求:在页面组件的 useEffect 钩子中,当组件挂载时立即发送请求,而不等待 cookie 的获取和存储,导致请求发出时缺少 cookie,造成请求失败
cookie交互流程
修复方案
为了确保 token 在 HTTP 请求发送之前已经成功获取并存储,需要实现以下步骤:
1)等待 token 被存储:在 httpMethod 中添加一个辅助函数,用于轮询 sessionStorage,直到 token 被存储
2)在请求之前检查并等待 token:在每个 HTTP 请求方法中,在请求实际发送之前,先调用辅助函数等待 token 被存储
具体实现
修改 api.js文件
1)创建一个waitForToken函数用于等待cookie中的 token
每隔 100ms 检查一次 sessionStorage 中是否存在 Access-Token,并返回一个 Promise。若存在 token ,调用 resolve(token) 方法将 Promise 标记为成功,并返回 token,否则等待 200 毫秒,再次检查token是否存在
const waitForToken = (timeout = 20000) => {
return new Promise((resolve) => {
const startTime = Date.now();
const checkToken = () => {
const token = window.sessionStorage.getItem("Access-Token");
if (token) {
resolve(token); //找到 token,通过 resolve 通知任务成功完成
} else if (Date.now() - startTime >= timeout) {
resolve(null); // 超时后返回 null
} else {
setTimeout(checkToken, 200); //如果没找到,每200ms检查一次
}
};
checkToken();
});
};
2)修改httpMethod
在每个请求方法里调用 waitForToken, 确保在发送请求之前获取到 token,并在获取到 token 后将其从 sessionStorage 中取出并添加到请求头中
const httpMethod = {
get: async (url, params) => {
const token = await waitForToken(); //获取token
return axios
.get(api + url, {
params: params,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
postJson: async (url, data, options = {}) => {
const token = await waitForToken(); //获取token
return axios
.post(api + url, data, {
...options,
headers: Object.assign(
headers("json"),
{ "Access-Token": JSON.parse(token) },
options.headers || {}
),
})
.then(dataError, errorHandle);
},
}
六、总结
微前端能否做到利大于弊,具体取决于实际情况和条件。对于小型、高度协作的团队和相对简单的产品来说,微前端的优势相比代价来说就很不明显了;而对于大型、功能众多的产品和许多较独立的团队来说,微前端的好处就会更突出。
工程就是权衡的艺术,而微前端提供了另一个可以做权衡的维度。
学习资料:
来源:juejin.cn/post/7435928578585264138