关于微前端:为iframe正名你可能并不需要微前端

35次阅读

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

作者:刘显安(码怪)

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

前言

最近几年微前端很火,火到有时候我的项目外面用到了 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 天工夫即可实现,最重要的是大部分工作都是全局失效的,不会随着页面的增多而导致工作量减少,测试回归的老本也非常低,只须要验证所有页面跳转、展现等是否失常,性能自身个别不会有太大问题,而如果是微前端计划的话须要从头到尾全副仔仔细细测试一遍,开发和测试的老本都不可估量。

正文完
 0