React 运行环境的隔离
在实现项目的时候遇到这么一种场景:用户可以在页面引入第三方的组件渲染页面。
如图中的蓝框部分是用户引入的第三方组件,而下面的控制器是自己实现的组件。里面的列表项组件拥有固定的 props,需要实现正常的 Todo List 的效果。这里有两个最大的难题,一个是动态引入第三方组件库,这里暂且不提。另一个是将蓝框部分与应用的其余部分运行环境隔离。因为第三方组件的安全性是不能保证的,如果直接在应用环境中运行,会影响用户的安全。另一个原因是第三方组件样式的实现方式是不确定的,可能会和主应用的样式相互干扰产生问题。如果仅仅是样式问题,可以使用 Shadow DOM 解决,但是这里就需要使用 iframe 了。
function TodoList() {
const [text, setText] = useState('');
const handleSubmit = () => {
// TODO
};
return (
<div className="TodoList">
<h1>
Todo List <span>A simple React Todo List App</span>
</h1>
<iframe
src="/iframe"
sandbox="allow-forms allow-modals allow-popups allow-presentation allow-scripts"
/>
<form className="NewTodoForm" onSubmit={handleSubmit}>
<label htmlFor="task">New todo</label>
<input
value={text}
onChange={e => setText(e.target.value)}
id="task"
type="text"
name="task"
placeholder="New Todo"
/>
<button>Add Todo</button>
</form>
</div>
);
}
这里一定要限制 iframe 的 sandbox 属性,尤其是不能有 allow-same-origin
, allow-top-navigation
这样的属性,会隐藏严重的安全问题。然后再实现剩下的部分:
// iframe 部分
function IFrame() {
const Component = useThirdPartyComponent();
const props = useThirdPartyProps();
return <Component {...props} />;
}
// 入口文件
function App() {
return (
<React.Suspense fallback={loading}>
<Switch>
<Route
exact={true}
path="/"
component={React.lazy(() => import('./TodoList'))}
/>
<Route
exact={true}
path="/iframe"
component={React.lazy(() => import('./IFrame'))}
/>
</Switch>
</React.Suspense>
);
}
这里使用 Suspense 简单地进行了代码的分割,另外使用 useThirdPartyComponent
代表抽象的获取第三方组件的过程,这里先不展开讨论它的细节。接下来需要实现的是 TodoList
中 handleSubmit
函数(添加一条 task)和 IFrame
的 useThirdPartyProps
(获取 todolist 的属性)。
然后是经典的写 Redux 代码环节(好久没写过原生的 Redux 了,正经人是不会写的)。假设这是一个常规版的 todolist,那它的代码是很好写的(去网上抄一份就完了)。
// actions
const addTodo = text => ({
type: 'ADD_TODO',
id: Date.now(),
text,
});
// reducer
const todosReducer = (state = [], action) => {
switch (action.type) {
case 'ADD_TODO':
return [
...state,
{
id: action.id,
text: action.text,
},
];
default:
return state;
};
};
然后组件里面的这两个函数就很好写了
function TodoList() {
// ...
const dispatch = useDispatch();
const handleSubmit = () => {
dispatch(addTodo(text));
setText('');
};
// ...
}
function IFrame() {
const Component = useThirdPartyComponent();
const props = useSelector(state => state);
return <Component tasks={props} />;
}
如果在 iframe 之间也可以像这样写代码就更好了,无需改动代码,写的时候不需要有其它的成本,而且还可以兼容更上层的 Redux 封装。经过一番搜索找到了这么一个库 redux-state-sync。它实现了 Redux 的一个 middleware,可以同步多个 Redux 之间的状态,于是果断 yarn add redux-state-sync
。运行之后果不其然地失败了 ╮(╯▽╰)╭,不管怎么添加,里面的列表一点反应都没有。只能看看是怎么实现的了。
它的实现原理其实很简单,初始化时从其他实例获取 state 值,当有一个实例获取到 action 时将 action 转发给其它实例,由于 Redux 的性质,最终得到的 state 总是一致的。注意需要将 middleware 放在最后,防止使用 thunk 等其它 middleware 的时候转发异步的 action 导致问题。这个 middleware 使用 BroadcastChannel 发送消息,而 BroadcastChannel 只能支持发送消息到同源的页面,而我们的 iframe 关闭了 allow-same-origin
,所以没有办法收到消息。
这样的话就只能用无敌的 window.postMessage
想想办法了。主页面和 iframe 直接使用 postMessage
发送消息是不需要同源的,可以使用 postMessage
模拟一个 BroadcastChannel。因为主页面和 iframe 不是完全对称的,一个主页面里可能有多个 iframe,iframe 之间也需要发送消息,为了简化代码的复杂度,统一使用主页面发送消息给自己和所有的 iframe 页面。需要实现 channel 的 postMessage
和 onmessage
。思路比较简单就直接贴出
太长了,点击展开实现代码
// 这里用到的事件类型有两种
// frame-broadcast-request 是原始事件,用于主页面的转发,只有主页面需要处理
// frame-broadcast 是转发生成的事件,收到事件的页面需要处理
class FrameBroadcastChannel {
isTop: boolean;
name: string;
handlers: {
[type: string]: MessageHandler[];
} = {
message: [],
messageerror: [],
};
onMessage: null | MessageHandler = null;
mainHandler: {
[type: string]: MessageHandler;
};
constructor(name: string) {
this.isTop = typeof window === 'undefined' || window === window.parent;
this.name = name;
this.onMessage = null;
// 过滤出需要的事件,转到 handlers 里面的回调函数执行
this.mainHandler = {
message: e => {
if (e.data.type !== 'frame-broadcast' || e.data.channel !== this.name) {
return;
}
this.handlers.message.forEach(handler => handler(e.data.message));
},
messageerror: e => {
if (e.data.type !== 'frame-broadcast' || e.data.channel !== this.name) {
return;
}
this.handlers.messageerror.forEach(handler => handler(e.data.message));
},
};
for (const type of ['message', 'messageerror'] as const) {
window.addEventListener(type, this.mainHandler[type]);
}
// 如果是主页面就转发消息到所有 iframe 页面
this.broadcastHandler = this.broadcastHandler.bind(this);
if (this.isTop) {
window.addEventListener('message', this.broadcastHandler);
}
}
broadcastHandler(e: MessageEvent) {
if (
e.data.type !== 'frame-broadcast-request' ||
e.data.channel !== this.name
) {
return;
}
const data = {
type: 'frame-broadcast',
channel: this.name,
message: e.data.message,
};
// 遍历发送事件,这里只考虑了两层,如果有需要可以递归
window.postMessage(data, '*');
for (const frame of Array.from(window.frames)) {
frame.postMessage(data, '*');
}
}
postMessage(message: any) {
// 使用主页面发送事件
// 这里只考虑了两层,如果有需要可以使用
// let current = window;
// while (current !== current.parent) {
// current = current.parent
// }
// 获取最顶层
window.parent.postMessage(
{
type: 'frame-broadcast-request',
channel: this.name,
message,
},
'*',
);
}
close() {
this.handlers = {};
this.onMessage = null;
for (const type of ['message', 'messageerror'] as const) {
window.removeEventListener(type, this.mainHandler[type]);
}
if (this.isTop) {
window.removeEventListener('message', this.broadcastHandler);
}
}
set onmessage(v: MessageHandler) {
this.removeEventListener('message', v);
if (typeof v === 'function') {
this.addEventListener('message', v);
this.onMessage = v;
} else {
this.onMessage = null;
}
}
addEventListener(type: string, handler: MessageHandler) {
this.handlers[type].push(handler);
}
removeEventListener(type: string, handler: MessageHandler) {
this.handlers[type] = this.handlers[type].filter(x => x !== handler);
}
}
最后使用 FrameBroadcastChannel
代替原本的 BroadcastChannel
就可以了。