爲 iframe 正名,你可能並不需要微前端

任何新技術、新產品都是有一定適用場景的,它可能在當下很流行,但它不一定在任何時候都是最優解。

前言

最近幾年微前端很火,火到有時候項目裏面用到了 iframe 還要偷偷摸摸地藏起來生怕被別人知道了,因爲擔心被人質疑:你爲什麼不用微前端方案?直到最近筆者接手一個項目,需要將現有的一個系統整體嵌入到另外一個系統(一共 20 多個頁面),在被微前端坑了幾次之後,回過頭髮現,iframe 真香!

qiankun 的作者有一篇《Why Not Iframe》[1] 介紹了 iframe 的優缺點(不過作者還有一篇《你可能並不需要微前端》[2] 給微前端降降火),誠然 iframe 確實存在很多缺點,但是在選擇一個方案的時候還是要具體場景具體分析,它可能在當下很流行,但它不一定在任何時候都是最優解:iframe 的這些缺點對我來說是否能夠接受?它的缺點是否有其它方法可以彌補?使用它到底是利大於弊還是弊大於利?我們需要在優缺點之間找到一個平衡。

優缺點分析

iframe 適合的場景

由於 iframe 的一些限制,部分場景並不適合用 iframe,比如像下面這種 iframe 只佔據頁面中間部分區域,由於父頁面已經有一個滾動條了,爲了避免出現雙滾動條,只能動態計算 iframe 的內容高度賦值給 iframe,使得 iframe 高度完全撐滿,但這樣帶來的問題是彈窗很難處理,如果居中的話一般彈窗都相對的是 iframe 內容高度而不是屏幕高度,從而導致彈窗可能看不見,如果固定彈窗 top 又會導致彈窗跟隨頁面滾動,而且稍有不慎 iframe 內容高度計算有一點點偏差就會出現雙滾動條。

所以:

爲什麼一定要滿足 “iframe 佔據全部內容區域” 這個條件呢?可以想象一下下面這種場景,滾動條出現在頁面中間應該大部分人都無法接受:

實戰:A 系統接入 B 系統

滿足 “iframe 佔據全部內容區域” 條件的場景,iframe 的幾個缺點都比較好解決。下面通過一個實際案例來詳細介紹將一個線上在運行的系統接入到另外一個系統的全過程。以筆者前段時間剛完成的 ACP(全稱 Alibaba.com Pay,阿里巴巴國際站旗下一站式全球收款平臺,下稱 A 系統)接入生意貸(下稱 B 系統)爲例,已知:

我們希望的效果:

假設我們新增一個頁面 /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 種情況:

什麼時候更新 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 幾乎無異,而且菜單和導航本來就是無刷新的,頁面跳轉沒有割裂感:

結語

上述方案有幾個沒有提到的點:

在第一次摸索方案時可能需要花費一些時間,但是在熟悉之後,如果後續還有類似把 B 系統接入 A 系統的需求,在沒有特殊情況且順利的前提下可能花費 1-2 天時間即可完成,最重要的是大部分工作都是全局生效的,不會隨着頁面的增多而導致工作量增加,測試迴歸的成本也非常低,只需要驗證所有頁面跳轉、展示等是否正常,功能本身一般不會有太大問題,而如果是微前端方案的話需要從頭到尾全部仔仔細細測試一遍,開發和測試的成本都不可估量。

參考資料

[1]

《Why Not Iframe》: https://www.yuque.com/kuitos/gky7yw/gesexv?spm=ata.21736010.0.0.25c06df01VID5V

[2]

《你可能並不需要微前端》: https://zhuanlan.zhihu.com/p/391248835

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Sp2hlxUcY45-v9nlIhEOow