跨域通信实现实时预览

需求背景

项目中存在一个复杂的代练服务模块,其服务详情页由运营人员手动配置。

由于涉及的功能繁多,如选项配置、联动逻辑、页面文案和折扣信息等,我们为其专门开发了一个配置工具。

此前的操作流程是:运营人员在工具中完成配置后,手动保存并跳转至预览页面查看效果。

这种方式无法实时反馈修改结果,操作流程冗长,严重影响了配置效率。

实现目标

为了提升运营人员的工作效率,我们希望实现配置实时预览功能,具体目标包括:

  • 配置工具与预览页面之间可实时通信
  • 无需刷新页面即可看到最新配置效果
  • 预览页面应与正式页面保持隔离,避免互相干扰

方案选型

要实现实时通信,有以下几种方案:

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 的触发频率,避免了高频操作下的性能问题。

同时,我们也考虑了用户体验的问题。

通过按钮切换预览方式,让用户可以根据自己的需求选择合适的预览方式。


跨域通信实现实时预览
http://www.inksha.com/archives/kua-yu-tong-xin-shi-xian-shi-shi-yu-lan
作者
inksha
发布于
2025年04月17日
许可协议