跨域通信实现实时预览
需求背景
项目中存在一个复杂的代练服务模块,其服务详情页由运营人员手动配置。
由于涉及的功能繁多,如选项配置、联动逻辑、页面文案和折扣信息等,我们为其专门开发了一个配置工具。
此前的操作流程是:运营人员在工具中完成配置后,手动保存并跳转至预览页面查看效果。
这种方式无法实时反馈修改结果,操作流程冗长,严重影响了配置效率。
实现目标
为了提升运营人员的工作效率,我们希望实现配置实时预览功能,具体目标包括:
- 配置工具与预览页面之间可实时通信
- 无需刷新页面即可看到最新配置效果
- 预览页面应与正式页面保持隔离,避免互相干扰
方案选型
要实现实时通信,有以下几种方案:
WebSocket
WebSocket
支持双向实时通信,但需要服务端配合实现。
如果将配置工具与预览页面间的通信建立在 WebSocket
之上,虽然可以满足需求,
但由于通信量较大,可能对服务器带来额外负担。因此不予采用。
轮询
轮询无需建立长连接,依靠定时请求获取数据。但同样需要服务端支持,且通信存在延迟,实时性差。
此外,需手动保存配置才能看到预览效果,违背了所见即所得的目标,也不在考虑之列。
postMessage
postMessage
是浏览器原生提供的跨窗口通信方式,能够在无需服务器支持的前提下实现跨域数据传递。
它可在不同来源的页面之间交换信息,非常适合我们这种工具页和预览页之间的交互场景。
虽然 postMessage 支持跨域通信,但也可能接收到来源不明的恶意消息。
因此在接收消息时,必须严格校验消息的 origin 和 source,确保只处理来自可信页面的消息,以防止潜在攻击。
综合以上,最终选用 postMessage
作为核心通信方案。
实现步骤
工具页为父页面,预览页为嵌套的子页面或新开窗口。我们分别在两个页面中实现通信逻辑。
预览页面(子页面)
预览页面的监听逻辑被抽离成独立组件,并通过环境变量控制其在构建阶段是否被启用,从而保证线上环境不受影响。
// Preview.jsx
export const ORIGIN_LOCALHOST = 'http://localhost'
export const ORIGIN_PRODUCTION = 'https://example.com'
function Preview({ updateData }) {
const handleMessage = (event) => {
//
// 这里需要对消息来源进行校验,避免恶意攻击
//
if (event.origin === ORIGIN_LOCALHOST || event.origin === ORIGIN_PRODUCTION) {
if (event.data.type === 'save' && event.data.payload) {
updateData(event.data.payload)
}
}
}
useEffect(() => {
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [updateData])
return null
}
const isProd = process.env.NODE_ENV === 'production'
export default isProd ? () => null : Preview
工具页面(父页面)
配置页面负责生成预览区域,包括嵌入 iframe
和新窗口两种方式。窗口对象通过 ref
传入,由组件内部统一管理。
import { useEffect, useRef, useState } from 'react'
export type Props = {
link: string
iframe: React.MutableRefObject<Window | null>
show?: boolean
}
const openInNewTab = (link: string, width = 1920, height = 1080) => {
const features = `width=${width},height=${height}`
const newWindow = window.open(link, '_blank', features)
return newWindow
}
export default function Preview({ link, iframe, show }: Props) {
const ref = useRef<HTMLIFrameElement>(null)
const tab = useRef<Window | null>(null)
const [openTab, updateOpenTab] = useState(false)
const [isFull, updateIsFull] = useState(false)
const openNewTab = () => {
updateOpenTab(true)
const newWindow = openInNewTab(link)
const timer = setInterval(() => {
if (newWindow?.closed) {
clearInterval(timer)
tab.current = null
updateOpenTab(false)
}
}, 1000)
tab.current = newWindow
}
useEffect(() => {
if (openTab) {
iframe.current = tab.current
}
else if (ref.current) {
iframe.current = ref.current.contentWindow
}
}, [openTab])
useEffect(() => {
if (openTab && tab.current) {
tab.current.location.href = link
}
}, [link])
if (!show) return null
return (
<div className={`preview-container ${isFull ? 'full-screen' : ''} `}>
<div className="preview-options-container">
<div>仅用于预览!</div>
<button type="button" onClick={openNewTab}>新窗口打开</button>
<button type="button" onClick={() => updateIsFull(!isFull)}>{isFull ? '退出' : ''}全屏</button>
</div>
<div className="preview-loading-container">
<div className="loader"></div>
</div>
{!openTab && <iframe title='预览页面' ref={ref} src={link}></iframe>}
</div>
)
}
向预览页面发送数据
为避免频繁的数据通信,这里使用了防抖函数对 postMessage
进行控制。
尽管 postMessage
是浏览器原生提供的 API,通信本身的开销较小,但在操作频繁的场景下,持续高频的数据更新可能会造成短时间内大量通信请求。
虽然浏览器能够承受这种负载,但频繁的消息触发仍可能导致页面性能下降,出现卡顿或抖动等问题。
因此,我们引入防抖机制来降低通信频率,在保证预览实时性的同时减轻性能负担。
这里设置了 50ms 的延迟,使得在操作时几乎不会产生感知延迟,同时能够有效控制通信频率,即便在高频操作下也不会对页面造成明显影响。
const postData = useCallback(debounce((data: Data) => {
try {
if (iframeRef.current) {
iframeRef.current?.postMessage({
type: 'save',
payload: data
}, new URL(link).origin)
}
}
catch (e) {}
}, 50), [link])
总结
通过以上步骤,我们实现了配置实时预览功能。
配置工具与预览页面之间可实时通信,无需刷新页面即可看到最新配置效果。
预览页面与正式页面保持隔离,避免互相干扰。
通过防抖函数控制 postMessage
的触发频率,避免了高频操作下的性能问题。
同时,我们也考虑了用户体验的问题。
通过按钮切换预览方式,让用户可以根据自己的需求选择合适的预览方式。