作者:刘显安(码怪)

任何新技术、新产品都是有肯定实用场景的,它可能在当下很风行,但它不肯定在任何时候都是最优解。

前言

最近几年微前端很火,火到有时候我的项目外面用到了iframe还要偷偷摸摸地藏起来惟恐被他人晓得了,因为放心被人质疑:你为什么不必微前端计划?直到最近笔者接手一个我的项目,须要将现有的一个零碎整体嵌入到另外一个零碎(一共20多个页面),在被微前端坑了几次之后,回过头发现,iframe真香!

qiankun的作者有一篇《Why Not Iframe》 介绍了iframe的优缺点(不过作者还有一篇《你可能并不需要微前端》给微前端降降火),诚然iframe的确存在很多毛病,然而在抉择一个计划的时候还是要具体场景具体分析,它可能在当下很风行,但它不肯定在任何时候都是最优解:iframe的这些毛病对我来说是否可能承受?它的毛病是否有其它办法能够补救?应用它到底是利大于弊还是弊大于利?咱们须要在优缺点之间找到一个均衡。

优缺点剖析

iframe适宜的场景

因为iframe的一些限度,局部场景并不适宜用iframe,比方像上面这种iframe只占据页面两头局部区域,因为父页面曾经有一个滚动条了,为了避免出现双滚动条,只能动静计算iframe的内容高度赋值给iframe,使得iframe高度齐全撑满,但这样带来的问题是弹窗很难解决,如果居中的话个别弹窗都绝对的是iframe内容高度而不是屏幕高度,从而导致弹窗可能看不见,如果固定弹窗top又会导致弹窗追随页面滚动,而且稍有不慎iframe内容高度计算有一点点偏差就会呈现双滚动条。

所以:

  • 如果页面自身比较简单,是一个没有弹窗、浮层、高度也是固定的纯信息展现页的话,用iframe个别没什么问题;
  • 如果页面是蕴含弹窗、信息提醒、或者高度不是固定的话,须要看iframe是否占据了全副的内容区域,如果是像下图这种经典的导航+菜单+内容构造、并且整个内容区域都是iframe,那么能够放心大胆地尝试iframe,否则,须要慎重考虑计划选型。

为什么肯定要满足“iframe占据全部内容区域”这个条件呢?能够设想一下上面这种场景,滚动条呈现在页面两头应该大部分人都无奈承受:

实战:A零碎接入B零碎

满足“iframe占据全部内容区域”条件的场景,iframe的几个毛病都比拟好解决。上面通过一个理论案例来具体介绍将一个线上在运行的零碎接入到另外一个零碎的全过程。以笔者前段时间刚实现的ACP(全称Alibaba.com Pay,阿里巴巴国内站旗下一站式寰球收款平台,下称A零碎)接入生意贷(下称B零碎)为例,已知:

  • ACP和生意贷都是MPA页面;
  • ACP零碎在此之前没有接入其余零碎的先例,生意贷是第一个;
  • 生意贷作为被接入零碎,本次须要接入的一共有20多个页面,且服务端蕴含大量业务逻辑以及跳转管制,有些页面想看看长什么样子都十分艰难,须要在Node层mock大量接口;
  • 接入时须要做性能删减,局部接口入参须要调整;
  • 生意贷除了接入到ACP零碎中,之前还接入过AMES零碎,本次接入须要兼容这部分历史逻辑;

咱们心愿的成果:

假如咱们新增一个页面 /fin/base.html?entry=xxx 作为咱们A零碎承接B零碎的地址,A零碎有相似如下代码:

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记录”,也就是点击后退或后退按钮(history.forward()history.back())能够让iframe页面也后退后退,然而地址栏无任何变动。

所以精确来说后退后退无需咱们做任何解决,咱们要做的就是让浏览器地址栏同步更新即可。

如果要禁用浏览器的上述默认行为,个别只能在iframe跳转时告诉父页面更新整个<iframe />DOM节点。

URL的同步更新

让URL同步更新须要解决2个问题,一个是什么时候去触发更新的动作,一个是URL更新的法则,即父页面的URL地址(A零碎)与iframe的URL地址(B零碎)映射关系的保护。

保障URL同步更新性能失常须要满足这3种状况:

  • case1: 页面刷新,iframe可能加载正确页面;
  • case2: 页面跳转,浏览器地址栏可能正确更新;
  • case3: 点击浏览器的后退或后退,地址栏和iframe都可能同步变动;

什么时候更新URL地址

首先想到的必定是在iframe加载完发送一个告诉给父页面,父页面通过history.replaceState去更新URL。

为什么不是history.pushState呢?因为后面提到过,浏览器默认会产生一条历史记录,咱们只须要更新地址即可,如果用pushState会产生2条记录。

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会实时更新,浏览器的后退后退性能也失常。

为什么须要同时保留跳转前和跳转后呢?因为如果只保留跳转前,只能满足后面的case1和case2,case3无奈满足,也就是点击后退按钮只有iframe会后退,URL地址不会更新。

丑化URL地址

简略的应用/fin/base.html?entry=xxx这样的通用地址尽管能用,然而不太好看,而且很容易被人看进去是iframe实现的,比拟没有诚意,所以如果被接入零碎的页面数量在可枚举范畴内,倡议给每个地址保护一个新的短地址。

首先,新增一个SPA页面/fin/*.html,和后面的/fin/base.html指向同一个页面,而后保护一个URL地址的映射,相似这样:

// 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种场景:

  1. 持续以后iframe关上,须要暗藏原零碎的所有layout;
  2. 以后父页面关上第三方页面,不须要任何layout;
  3. 新开标签关上第三方页面(如支付宝页面),不须要做非凡解决;
  4. 新开标签关上宿主页面,须要把原零碎layout替换成新layout;

为此,先定义好一个beforeRedirect办法,因为新标签关上有target="_blank"window.open等形式,父页面关上有target="_parent"window.parent.location.href等形式,为了更好的对立封装,咱们把非凡状况的跳转对立在beforeRedirect解决好,并约定只有有返回值的状况才须要后续持续解决跳转:

// 保护一个须要做非凡解决的第三方页面列表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为例,只有重写 ctx.redirect 办法即可。

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.location.href,批量替换成(window.location2 || window.location).href,而后再全局搜寻location.href,批量替换成(window.location2 || window.location).href(思考一下为什么肯定是这个程序呢)。

另外须要留神,有些跳转可能是写在npm包外面的,这种状况只能npm也跟着替换一下了,并没有其它更好方法。

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页面没有上报 iframeDOMContentLoaded 事件导致loading不隐没:

// 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,须要留神辨别。

弹窗居中问题

以后场景下弹窗集体感觉并不需要解决,因为菜单的宽度无限,不认真看的话甚至都没留神到弹窗没有居中:

如果非要解决的话也不麻烦,笼罩一下原来页面弹窗的款式,当蕴含hideLayout参数时,让弹窗的地位别离向左挪动menuWidth/2、向上挪动navbarHeight/2即可(遮罩地位不能动、也动不了)。

增加了marginLeft=-120pxmarginTop=-30px 后的弹窗成果:

最终成果

其实不难看出,最终成果和SPA简直无异,而且菜单和导航原本就是无刷新的,页面跳转没有割裂感:

结语

上述计划有几个没有提到的点:

  • 计划成立的前提是建设在2个零碎共用一套用户体系,否则须要对2个零碎的登录体系进行买通,个别包含账号绑定、A零碎默认免登B零碎,等等,这须要肯定额定的工作量;
  • 参数的透传与删除,例如我心愿除了hideLayout参数之外其它URL参数全副在父子页面之间透传;
  • 埋点,数据上报的时候须要减少一个额定参数来标识流量来自另外一个零碎;

在第一次摸索计划时可能须要破费一些工夫,然而在相熟之后,如果后续还有相似把B零碎接入A零碎的需要,在没有非凡状况且顺利的前提下可能破费1-2天工夫即可实现,最重要的是大部分工作都是全局失效的,不会随着页面的增多而导致工作量减少,测试回归的老本也非常低,只须要验证所有页面跳转、展现等是否失常,性能自身个别不会有太大问题,而如果是微前端计划的话须要从头到尾全副仔仔细细测试一遍,开发和测试的老本都不可估量。