跨页面通信:从小程序 webview 到 Flutter webview 的一次踩坑笔记

最近做了个需求,三个 H5 页面 A、B、C 分属不同项目,用户从 A 进 B、再进 C,在 C 里做完操作后,希望 A 能拿到结果。听起来简单,真动手才发现,"C 通知 A"这件事能不能成、会不会闪,完全取决于这三个页面跑在什么容器里。

同一套 H5,放进小程序 webview 和放进 Flutter webview,结论是反的。这篇就把两边都讲透。

先想清楚一件事:A 还活着吗

所谓"C 发通知,A 能监听到",前提就一个:当用户停在 C 的时候,A 当初注册的那个监听器还在不在。

监听器活着,通知就能送达;监听器随页面销毁了,事件总线再花哨也是空转。所以别一上来就纠结用哪个通信库,先搞清楚容器对页面栈的处理方式。

这也是小程序和 Flutter 分道扬镳的地方。

小程序 webview:H5 之间其实是"聋的"

我那三个页面的地址结构大致长这样:

https://<同一域名>/<项目A>/.../index.html        ← A
https://<同一域名>/<项目B>/.../index.html#/...    ← B
https://<同一域名>/<项目C>/.../index.html#/...    ← C

域名相同,同源。但同源不代表能直接通信,这是第一个坑。

每个 <web-view> 在小程序里是一个独立的原生页面,里面的 H5 是各自隔离的浏览器上下文。A 的 JS、B 的 JS、C 的 JS 互相看不见对方的内存。你在 A 里 eventBus.on(...),C 里 eventBus.emit(...),那是两个压根不相干的 eventBus 实例。喊破喉咙 A 也听不到。

要让消息穿过去,得绕道小程序原生层:

C 的 H5 ──postMessage──▶ 小程序原生 ──▶ A

问题来了,web-viewpostMessage 不是实时的。平台会把消息攒着,等到后退、组件销毁、分享、复制链接这几个时机才一次性丢给小程序的 bindmessage。也就是说 C 调 postMessage 的那一刻,小程序根本没收到,得等 C 真的退出去才收到。realtime 通信在这里是奢望。

那 C→A 怎么办?老老实实用 localStorage 兜底。三个页面同源,localStorage 是共享的:

// C:写入,然后跳回 A
localStorage.setItem('resultFromC', JSON.stringify({ data: 123, ts: Date.now() }))
location.href = 'https://<同一域名>/<项目A>/.../index.html'

A 这边不能等通知,只能自己回来时主动读。这里藏着第二个坑——读的时机决定了会不会闪。

如果你在 onMounted 或者 window.onload 里读,那时候组件已经拿默认值渲染过一遍了,你再 setData,画面就跳一下。正确做法是把读取提到首次渲染之前,让组件一次成型:

// React:惰性初始化,函数只在首渲染前跑一次
const [result, setResult] = useState(() => {
  const raw = localStorage.getItem('resultFromC')
  if (!raw) return defaultValue
  localStorage.removeItem('resultFromC')
  return JSON.parse(raw).data
})

localStorage 是同步 API,读它不花时间,所以完全来得及塞进初始 state。这样小程序这条线也能做到不闪,只是它本质上是"A 重新加载后读了一份缓存",而不是真的收到了 C 的通知。

Flutter webview:A 一直没死,所以它真能听到

换到 Flutter,故事完全不一样,而且舒服很多。

我们的做法是全局通信。A、B、C 三个 H5 各自跑在一个 webview 里,但事件总线在原生那一层,是个全局单例。链路是这样:

C 的 H5 ──JSBridge──▶ Flutter 原生(全局 EventBus) ──JSBridge──▶ A 的 H5 回调

关键在于路由栈。A→B→C 是一路 push 上去的,B 和 C 盖在 A 上面,但 A 这个路由和它的 webview 没有被销毁,还乖乖待在栈底。webview 的 JS 上下文活着,A 当初注册的那个回调就还在。

于是 C 一发消息,经过原生的全局总线,直接派发回 A 的 H5 回调。A 在自己被 C 盖住、用户根本看不见的时候,就已经悄悄把数据更新好了。

等用户从 C 一路退回 A,A 早就是最新状态了。没有"先显示旧数据、再刷新成新数据"的过程,自然不闪。

这正是它比小程序优雅的地方:小程序得靠"退出时回传 + 重新加载时读缓存",A 经历了一次销毁重建;Flutter 是 A 全程在线,更新发生在它被遮住的那段时间,回来直接就是对的。

// A 的 H5:注册一次,整个生命周期都有效
bridge.on('resultFromC', (payload) => {
  // 此刻 A 被 C 盖着,但回调照常执行,数据已更新
  store.update(payload)
})
// C 的 H5:直接发,原生总线实时转发,不用等退出
bridge.emit('resultFromC', { data: 123 })

要注意的是,A 收到通知时它在后台。回调里只管更新数据,别去碰那些依赖可见性的逻辑(比如弹个 toast、播个动画),那些等 A 重新可见了再处理,不然用户回来会看到一串迟到的反应。

两边对照着看

小程序 webviewFlutter webview
H5 能否直接事件总线互通不行,各自隔离不行,但原生全局总线能转发
原生转发是否实时不实时,postMessage 攒到退出才触发实时
A 在栈底是否存活存活,但 H5 拿不到实时消息存活,且能实时收到
C→A 的可行方案localStorage 同源回传 + A 激活时读全局总线直接通知 A
会不会闪读取时机不对就会,提到初始化里可避免不会,A 被遮住时已更新好

一点收尾的想法

同一份 H5,命运是容器给的。小程序把 webview 当成相对封闭的盒子,跨页面通信处处要绕原生、还卡着 postMessage 的时机;Flutter 把 webview 当成普通组件挂在树上,全局总线想怎么传怎么传,A 也乐得一直在线等消息。

如果你的需求是"C 做完事 A 实时更新且不能闪",Flutter 这套全局通信几乎是为它量身定的。换到小程序,就别硬追实时了,老老实实同源 localStorage、A 回来时同步读,体验一样能做到不闪,只是心里要清楚——那是缓存读取,不是真的通知到了。