平时做的需求,都是前后端联调,可能有时候多一个客户端联调。但还有一些需求,需要前端与前端联调——iframe内嵌,一些很复杂的页面可能会选择直接内嵌、还有现在很火的微前端其中一种实现方式也是iframe,最后页面也基本少不了两个前端页面的通信了。前端和前端联调的时候,比起和后端联调的时候,需要做的更多。因为前端和前端联调不仅是数据层面上,还有页面状态的信息传递。下面我们来探讨一套前端联调通信的方案
技术选择
1. hashchange事件
页面监听hashchange事件,然后父页面改变哈希,子页面读取哈希来实现通信。但是这有一个问题,如果传递的信息过多,那就会导致url很长,而且维护起来也麻烦。更严重的问题是,如果页面本身有利用哈希的逻辑,将会无解
2. storage
虽然可以解决,但导致storage数据冗余,而且还需要及时清除多余数据。一般情况下不用,更适合多个tab通信
3. postmessage
这个应该是最稳定的方案,也不会带来额外的副作用,也不用担心数据量多少。加上一些鉴权校验逻辑,就比较完善了
设计思路
我们选择postmessage方案,那么需要考虑的设计思路有:
1. 需要鉴权,否则有安全性问题(host校验、data 传入一些flag来校验)
2. 使用的时候,像request http请求一样的无差别体验,只是底层换成前端通信
3. 支持promise的调用方式
4. 支持参数和数据的预处理、后处理
5. 容易扩展
实现细节
发&收
假设当前在子页面,发出请求的时候:
window.parent && window.parent.postMessage({ api: 'getUserInfo', payload: { id: 1 }}, '*');
收请求的处理:
window.IFRAME_APIS = { getUserInfo({ id }) { // 通过id拉用户信息,返回 // 怎么返回呢,在子页面再定义一个handleGetUserInfoSucc方法 iframeElement.postMessage({ api: 'handleGetUserInfoSucc', payload: { name: 'lhyt', age: 23 } }) }}window.addEventListener('message', ({ data }) => { try { console.log('recive data', data); window.IFRAME_APIS[data.api](data.payload); } catch (e) { console.error(e); }});
子页面请求父页面,获取数据后,父页面再调一下子页面的处理成功的方法。当然,子页面的addEventListener也是一模一样的代码,而且IFRAME_APIS里面要提前准备好handleGetUserInfoSucc
的方法
鉴权
addEventListener需要一些鉴权,否则有安全风险。最简单有效的方法,加一个准入名单校验即可
const FR_ALLOW_LIST = ['sourceA', 'sourceB']window.addEventListener('message', ({ data }) => { if (!data || typeof data !== 'object') { return; } if (FR_ALLOW_LIST.includes(data.fr)) { try { console.log('recive data', data); window.IFRAME_APIS[data.api](data.payload); } catch (e) { console.error(e); } } else { throw Error('unknown fr!') }});
后续我们可以和其他前端约定一些来源值fr来校验是否可以访问这些api
支持promise的方式
我们也看见了,子页面发请求的时候,父页面返回成功还要子页面提前再准备一个方法,这样子很麻烦。很明显是需要一个promise的then处理,就像平时使用request/axios/fetch
一样。需要解决的问题:
postMessage只能传可被结构化克隆算法序列化的数据,其中就不包含函数
promise的resolve和reject函数不能直接传过去,需要用另一种方式来间接调用
// 子页面// 存放resolve、rejectconst resolvers = {};const rejecters = {};window.IFRAME_APIS = {// 准备好处理promise的函数 resolvePromise({ payload, resolve }) { if (resolvers[resolve]) { resolvers[resolve](payload || {}); } delete resolvers[resolve]; delete rejecters[resolve]; }, }// 子页面请求父页面function requestParent({ api, payload }) { return new Promise((resolve, reject) => { const rand = Math.random().toString(36).slice(2); window.parent.postMessage({ api, payload: { ...payload, resolve: rand, reject: rand, } }, '*'); resolvers[rand] = resolve; rejecters[rand] = reject; })}
父页面要实现一个告诉子页面执行resolve的函数
function sendResponse(payload) { iframe.contentWindow.postMessage( { payload: { resolve: payload.resolve, payload }, fr: 'sourceA', api: 'resolvePromise', }, '*' );}
这个过程就是,子页面发请求给父页面的时候,顺便带上key传过去,自己维护key和resolve/reject
映射。父页面调用子页面的resolvePromise
来间接执行resolve/reject
。这样子下来,所有的promise类型调用的请求都可以用这种方式来完成,举个?
// 子页面requestParent({ api: 'a', payload: { fr: 'sourceA', a: 1, b: '2' } }).then(console.log)// 父页面window.IFRAME_APIS = {// 在里面准备好处理promise的函数sendResponse a(payload) { sendResponse({ resolve: payload.resolve, msg: 'succ' }) }, }
预处理 & 后处理
有时候需要上游加上一些统一处理的逻辑,以免每一个请求的地方都做一次特殊处理。对于后处理也是,对格式进行一次全局适配
const prefix = { a(params) { params.b = 2; return params }, b(params) { // loading的时候不请求 if (params.loading) { return false } return params }}const afterfix = { a(data) { return { ...data, msg: 'afterfix success' } }}function requestParent({ api, payload }) { // 预处理 if (prefix[api]) { payload = prefix[api](payload) } // 不请求 if (!payload) { return Promise.resolve({}) } return new Promise((resolve, reject) => { const rand = Math.random().toString(36).slice(2); window.parent.postMessage({ api, payload: { ...payload, resolve: rand, reject: rand, } }, '*'); resolvers[rand] = data => { // 后处理在这里 if (afterfix[api]) { data = afterfix[api](data) } return resolve(data) }; rejecters[rand] = reject; })}
有一些不需要promise,是单向调用的,额外写一个不是promise调用的函数即可,或者加一个参数来控制。还有promise调用方式可以加一个超时处理,改成正常请求和一个定时器来
Promise.race
。这些都是小问题,可酌情修改
可扩展
不一定所有的请求都要提前放IFRAME_APIS
里面的,有一些有组件内置依赖的要在组件内部写,还有一些是可能不需要这个请求了要删掉。所以需要一个扩展iframe-api
的函数和一个删除的函数,以及辅助数据的维护
const ext = {}function injectIframeApi(api, fn, injectExt) { function remove() { delete window.IFRAME_APIS[api]; } // 这个是扩展辅助数据,em,有时候的确是需要一些额外辅助数据 injectExt(ext); // 可以理解为,fn传null就是仅仅更新ext if (fn === null) { return remove; } if (window.IFRAME_APIS[api]) { return remove; } window.IFRAME_APIS[api] = fn; return remove;}
加上了ext机制,请求的时候可能会用到,所以需要加上
function requestParent({ api, payload }) {
// 预处理
if (prefix[api]) {-- payload = prefix[api](payload)++ payload = prefix[api](payload, ext)
}
// 不请求
if (!payload) {
return Promise.resolve({})
}
return new Promise((resolve, reject) => {
const rand = Math.random().toString(36).slice(2);
window.parent.postMessage({
api, payload: {
...payload,
resolve: rand,
reject: rand,
}
}, '*');
resolvers[rand] = data => {
// 后处理在这里
if (afterfix[api]) {-- data = afterfix[api](data)++ data = afterfix[api](data, ext)
}
return resolve(data)
};
rejecters[rand] = reject;
})
}
window.addEventListener('message', ({ data }) => {
try {
console.log('recive data', data);-- window.IFRAME_APIS[data.api](data.payload);++ window.IFRAME_APIS[data.api](data.payload, ext);
} catch (e) {
console.error(e);
}
});
使用的时候,比如在一个组件里面:
window.IFRAME_APIS = { a(params, ext) { if (ext.loading) { return false } retuan params }}function C({ loading }) { useEffect(() => { // 请求a的时候,需要看看loading的值 injectIframeApi('a', null, ext => { ext.loading = loading }) }, [loading]) // 组件特有的请求函数,不用的时候就可以不要他了 useEffect(() => { const remove = injectIframeApi('someapi', data => { console.log(data, 'this is iframe api data') }) return remove }, []) return }
最后
这样,就可以和普通request的使用方式一模一样了,而且也支持各种处理和扩展,是一个和发起http请求的方式一模一样的无差别体验。当然,根据自己情况酌情修改更舒服哦,比如一些人喜欢node的error放第一个参数的callback风格、一些人喜欢axios风格的、一些人喜欢面向对象的风格,这些都可以围着这个思路来酌情修改,最合适自己为好b站全灰了,但我一下把它弄回来了——css 滤镜
1年多职业生涯中最玄乎的线上问题
前端与前端联调的姿势
[js算法]手把手带你从leetcode原题——【两数相加】到大数相加
内功修炼之lodash——Object系列
那个前端写的页面好酷——大量的粒子(元素)的动效实现)
内功修炼之lodash—— clone&cloneDeep(一定有你遗漏的js基础知识