为iframe正名,你可能并不需要微前端
当前位置:点晴教程→知识管理交流
→『 技术文档交流 』
作者:刘显安(码怪)
前言最近几年微前端很火,火到有时候项目里面用到了iframe还要偷偷摸摸地藏起来生怕被别人知道了,因为担心被人质疑:你为什么不用微前端方案?直到最近笔者接手一个项目,需要将现有的一个系统整体嵌入到另外一个系统(一共20多个页面),在被微前端坑了几次之后,回过头发现,iframe真香! qiankun的作者有一篇《Why Not Iframe》 介绍了iframe的优缺点(不过作者还有一篇《你可能并不需要微前端》给微前端降降火),诚然iframe确实存在很多缺点,但是在选择一个方案的时候还是要具体场景具体分析,它可能在当下很流行,但它不一定在任何时候都是最优解:iframe的这些缺点对我来说是否能够接受?它的缺点是否有其它方法可以弥补?使用它到底是利大于弊还是弊大于利?我们需要在优缺点之间找到一个平衡。 优缺点分析iframe适合的场景由于iframe的一些限制,部分场景并不适合用iframe,比如像下面这种iframe只占据页面中间部分区域,由于父页面已经有一个滚动条了,为了避免出现双滚动条,只能动态计算iframe的内容高度赋值给iframe,使得iframe高度完全撑满,但这样带来的问题是弹窗很难处理,如果居中的话一般弹窗都相对的是iframe内容高度而不是屏幕高度,从而导致弹窗可能看不见,如果固定弹窗top又会导致弹窗跟随页面滚动,而且稍有不慎iframe内容高度计算有一点点偏差就会出现双滚动条。 所以:
为什么一定要满足“iframe占据全部内容区域”这个条件呢?可以想象一下下面这种场景,滚动条出现在页面中间应该大部分人都无法接受: 实战:A系统接入B系统满足“iframe占据全部内容区域”条件的场景,iframe的几个缺点都比较好解决。下面通过一个实际案例来详细介绍将一个线上在运行的系统接入到另外一个系统的全过程。以笔者前段时间刚完成的ACP(全称Alibaba.com Pay,阿里巴巴国际站旗下一站式全球收款平台,下称A系统)接入生意贷(下称B系统)为例,已知:
我们希望的效果: 假设我们新增一个页面 class App extends React.Component { state = { currentEntry: decodeURIComponent(iutil.getParam('entry') || '') || '', }; render() { return <div> <iframe id="microFrontIframe" src={this.state.currentEntry}/> </div>; } } 隐藏原系统导航菜单因为是接入到另外一个系统,所以需要将原系统的菜单和导航等都通过一个类似“hideLayout”的参数去隐藏。 前进后退处理需要特别注意的是,iframe页面内部的跳转虽然不会让浏览器地址栏发生变化,但是却会产生一个看不见的“history记录”,也就是点击前进或后退按钮( 所以准确来说前进后退无需我们做任何处理,我们要做的就是让浏览器地址栏同步更新即可。
URL的同步更新让URL同步更新需要处理2个问题,一个是什么时候去触发更新的动作,一个是URL更新的规律,即父页面的URL地址(A系统)与iframe的URL地址(B系统)映射关系的维护。 保证URL同步更新功能正常需要满足这3种情况:
什么时候更新URL地址首先想到的肯定是在iframe加载完发送一个通知给父页面,父页面通过
B系统: <script> var postMessage = function(type, data) { if (window.parent !== window) { window.parent.postMessage({ type: type, data: data, }, '*'); } } // 为了让URL地址尽早地更新,这段代码需要尽可能前置,例如可以直接放在document.head中 postMessage('afterHistoryChange', { url: location.href }); </script> A系统: window.addEventListener('message', e => { const { data, type } = e.data || {}; if (type === 'afterHistoryChange' && data?.url) { // 这里先采用一个兜底的URL承接任意地址 const entry = `/fin/base.html?entry=${encodeURIComponent(data.url)}`; // 地址不一样才需要更新 if (location.pathname + location.search !== entry) { window.history.replaceState(null, '', entry); } } }); 优化URL的更新速度按照上面的方法实现后可以发现,URL虽然可以更新但是速度有点慢,点击跳转后一般需要等待7-800毫秒地址栏才会更新,有点美中不足。可以把地址栏的更新在“跳转后”基础之上再加一个“跳转前”。为此我们必须有一个全局的beforeRedirect钩子,先不考虑它的具体实现: B系统: function beforeRedirect(href) { postMessage('beforeHistoryChange', { url: href }); } A系统: window.addEventListener('message', e => { const { data, type } = e.data || {}; if ((type === 'beforeHistoryChange' || type === 'afterHistoryChange') && data?.url) { // 这里先采用一个兜底的URL承接任意地址 const entry = `/fin/base.html?entry=${encodeURIComponent(data.url)}`; // 地址不一样才需要更新 if (location.pathname + location.search !== entry) { window.history.replaceState(null, '', entry); } } }); 加上上述代码之后,点击iframe中的跳转链接,URL会实时更新,浏览器的前进后退功能也正常。
美化URL地址简单的使用 首先,新增一个SPA页面 // A系统地址到B系统地址映射 const entryMap = { '/fin/home.html': 'https://fs.alibaba.com/xxx/home.htm?hideLayout=1', '/fin/apply.html': 'https://fs.alibaba.com/xxx/apply?hideLayout=1', '/fin/failed.html': 'https://fs.aibaba.com/xxx/failed?hideLayout=1', // 省略 }; const iframeMap = {}; // 同时再维护一个子页面 -> 父页面URL映射 for (const entry in entryMap) { iframeMap[entryMap[entry].split('?')[0]] = entry; } class App extends React.Component { state = { currentEntry: decodeURIComponent(iutil.getParam('entry') || '') || entryMap[location.pathname] || '', }; render() { return <div> <iframe id="microFrontIframe" src={this.state.currentEntry}/> </div>; } } 同时完善一下更新URL地址部分: // base.html继续用作兜底 let entry = `/fin/base.html?entry=${encodeURIComponent(data.url)}`; const [path, search] = data.url.split('?'); if (iframeMap[path]) { entry = `${iframeMap[path]}?${search || ''}`; } // 地址不一样才需要更新 if (location.pathname + location.search !== entry) { window.history.replaceState(null, '', entry); }
全局跳转拦截为什么一定要做全局跳转拦截呢?一个因为我们需要把hideLayout参数一直透传下去,否则就会点着点着突然出现下面这种双菜单的情况: 另一个是有些页面在被嵌入前是当前页面打开的,但是被嵌入后不能继续在当前iframe打开,比如支付宝付款这种第三方页面,想象一下下面这种情况会不会觉得很怪?所以这类页面一定要做特殊处理让它跳出去而不是当前页面打开。 URL跳转可以分为服务端跳转和浏览器跳转,浏览器跳转又包括A标签跳转、location.href跳转、window.open跳转、historyAPI跳转等; 而根据是否新标签打开又可以分为以下4种场景:
为此,先定义好一个 // 维护一个需要做特殊处理的第三方页面列表 const thirdPageList = [ 'https://service.alibaba.com/', 'https://sale.alibaba.com/xxx/', 'https://alipay.com/xxx/', // ... ]; /** * 封装统一的跳转拦截钩子,处理参数透传和一些特殊情况 * @param {*} href 要跳转的地址,允许传入相对路径 * @param {*} isNewTab 是否要新标签打开 * @param {*} isParentOpen 是否要在父页面打开 * @returns 返回处理好的跳转地址,如果没有返回值则表示不需要继续处理跳转 */ function beforeRedirect(href, isNewTab) { if (!href) { return; } // 传过来的href可能是相对路径,为了做统一判断需要转成绝对路径 if (href.indexOf('http') !== 0) { var a = document.createElement('a'); a.href = href; href = a.href; } // 如果命中白名单 if (thirdPageList.some(item => href.indexOf(item) === 0)) { if (isNewTab) { // _rawOpen参见后面 window.open 拦截 window._rawOpen(href); } else { // 第三方页面如果不是新标签打开就一定是父页面打开 window.parent.location.href = href; } return; } // 需要从当前URL继续往下透传的参数 var params = ['hideLayout', 'tracelog']; for (var i = 0; i < params.length; i++) { var value = getParam(params[i], location.href); if (value) { href = setParam(params[i], value, href); } } if (isNewTab) { let entry = `/fin/base.html?entry=${encodeURIComponent(href)}`; const [path, search] = href.split('?'); if (iframeMap[path]) { entry = `${iframeMap[path]}?${search || ''}`; } href = `https://payment.alibaba.com${entry}`; window._rawOpen(href); return; } // 如果是以iframe方式嵌入,向父页面发送通知 postMessage('beforeHistoryChange', { url: href }); return href; } 服务端跳转拦截服务端主要是对301或302重定向跳转进行拦截,以Egg为例,只要重写 A标签跳转拦截document.addEventListener('click', function (e) { var target = e.target || {}; // A标签可能包含子元素,点击目标可能不是A标签本身,这里只简单判断2层 if (target.tagName === 'A' || (target.parentNode && target.parentNode.tagName === 'A')) { target = target.tagName === 'A' ? target : target.parentNode; var href = target.href; // 不处理没有配置href或者指向JS代码的A标签 if (!href || href.indexOf('javascript') === 0) { return; } var newHref = beforeRedirect(href, target.target === '_blank'); // 没有返回值一般是已经处理了跳转,需要禁用当前A标签的跳转 if (!newHref) { target.target = '_self'; target.href = 'javascript:;'; } else if (newHref !== href) { target.href = newHref; } } }, true); location.href拦截location.href拦截至今是一个困扰前端界的难题,这里只能采用一个折中的方法: // 由于 location.href 无法重写,只能实现一个 location2.href = '' if (Object.defineProperty) { window.location2 = {}; Object.defineProperty(window.location2, 'href', { get: function() { return location.href; }, set: function(href) { var newHref = beforeRedirect(href); if (newHref) { location.href = newHref; } }, }); } 因为我们不仅实现了location.href的写,location.href的读也一起实现了,所以可以放心大胆的进行全局替换。找到对应前端工程,首先全局搜索
window.open拦截var tempOpenName = '_rawOpen'; if (!window[tempOpenName]) { window[tempOpenName] = window.open; window.open = function(url, name, features) { url = beforeRedirect(url, true); if (url) { window[tempOpenName](url, name, features); } } } history.pushState拦截var tempName = '_rawPushState'; if (!window.history[tempName]) { window.history[tempName] = window.history.pushState; window.history.pushState = function(state, title, url) { url = beforeRedirect(url); if (url) { window.history[tempName](state, title, url); } } } history.replaceState拦截var tempName = '_rawReplaceState'; if (!window.history[tempName]) { window.history[tempName] = window.history.replaceState; window.history.replaceState = function(state, title, url) { url = beforeRedirect(url); if (url) { window.history[tempName](state, title, url); } } } 全局loading处理完成上述步骤后,基本上已经看不出来是iframe了,但是跳转的时候中间有短暂的白屏会有一点顿挫感,体验不算很流畅,这时候可以给iframe加一个全局的loading,开始跳转前显示,页面加载完再隐藏: B系统: document.addEventListener('DOMContentLoaded', function (e) { postMessage('iframeDOMContentLoaded', { url: location.href }); }); A系统: window.addEventListener('message', (e) => { const { data, type } = e.data || {}; // iframe 加载完毕 if (type === 'iframeDOMContentLoaded') { this.setState({loading: false}); } if (type === 'beforeHistoryChange') { // 此时页面并没有立即跳转,需要再稍微等待一下再显示loading setTimeout(() => this.setState({loading: true}), 100); } }); 除此之外还需要利用iframe自带的onload加一个兜底,防止iframe页面没有上报 // iframe自带的onload做兜底 iframeOnLoad = () => { this.setState({loading: false}); } render() { return <div> <Loading visible={this.state.loading} tip="正在加载..." inline={false}> <iframe id="microFrontIframe" src={this.state.currentEntry} onLoad={this.iframeOnLoad}/> </Loading> </div>; } 还需要注意,当新标签页打开页面时并不需要显示loading,需要注意区分。 弹窗居中问题当前场景下弹窗个人觉得并不需要处理,因为菜单的宽度有限,不仔细看的话甚至都没注意到弹窗没有居中: 如果非要处理的话也不麻烦,覆盖一下原来页面弹窗的样式,当包含 添加了 最终效果其实不难看出,最终效果和SPA几乎无异,而且菜单和导航本来就是无刷新的,页面跳转没有割裂感 结语上述方案有几个没有提到的点:
在第一次摸索方案时可能需要花费一些时间,但是在熟悉之后,如果后续还有类似把B系统接入A系统的需求,在没有特殊情况且顺利的前提下可能花费1-2天时间即可完成,最重要的是大部分工作都是全局生效的,不会随着页面的增多而导致工作量增加,测试回归的成本也非常低,只需要验证所有页面跳转、展示等是否正常,功能本身一般不会有太大问题,而如果是微前端方案的话需要从头到尾全部仔仔细细测试一遍,开发和测试的成本都不可估量。 ———————————————————— https://juejin.cn/post/7185070739064619068 该文章在 2023/5/30 10:34:46 编辑过 |
关键字查询
相关文章
正在查询... |