Files
frontend-miniapp/src/services/WechatMiniProgramBridgeService.ts
lyf 8b3e3f8d55
Some checks failed
CI / verify (push) Has been cancelled
修复小程序讲解页面返回回退
2026-07-28 10:40:25 +08:00

195 lines
7.5 KiB
TypeScript

import { isEmbeddedInWechatMiniProgram } from '@/utils/hostEnvironment'
import {
buildWechatOpenLocationPageUrl,
validateWechatOpenLocationTarget,
type WechatOpenLocationTarget
} from '@/utils/wechatOpenLocationProtocol'
interface MiniProgramBridge {
getEnv?: (callback: (result: { miniprogram?: boolean }) => void) => void
navigateTo?: (options: { url: string; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void
navigateBack?: (options: { delta: number; success?: () => void; fail?: (error: { errMsg?: string }) => void }) => void
postMessage?: (options: { data: { type: string } }) => void
}
export type WechatMiniProgramBridgeResult =
| { status: 'opened' }
| { status: 'not-embedded' }
| { status: 'failed'; reason: 'invalid-target' | 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'page-not-found' | 'timeout' }
export type WechatMiniProgramReturnResult =
| { status: 'returned' }
| { status: 'not-embedded' }
| { status: 'failed'; reason: 'bridge-unavailable' | 'not-in-mini-program' | 'navigation-failed' | 'timeout' }
const SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.3.2.js'
export const MINI_PROGRAM_RETURN_MESSAGE = 'museum-guide:return'
const BRIDGE_WAIT_TIMEOUT = 2500
const BRIDGE_POLL_INTERVAL = 50
const GET_ENV_TIMEOUT = 1500
const NAVIGATION_TIMEOUT = 3000
let pendingNavigation: Promise<WechatMiniProgramBridgeResult> | null = null
let pendingReturn: Promise<WechatMiniProgramReturnResult> | null = null
let sdkLoadPromise: Promise<void> | null = null
const getBridge = (): MiniProgramBridge | null => {
if (typeof window === 'undefined') return null
return (window as Window & { wx?: { miniProgram?: MiniProgramBridge } }).wx?.miniProgram || null
}
const wait = (duration: number) => new Promise<void>((resolve) => setTimeout(resolve, duration))
const loadWechatJsSdk = () => {
if (typeof document === 'undefined' || getBridge()) return Promise.resolve()
if (sdkLoadPromise) return sdkLoadPromise
sdkLoadPromise = new Promise<void>((resolve) => {
const existing = document.querySelector<HTMLScriptElement>('script[data-wechat-js-sdk="true"]')
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => resolve(), { once: true })
return
}
const script = document.createElement('script')
script.src = SDK_URL
script.async = true
script.dataset.wechatJsSdk = 'true'
script.onload = () => resolve()
script.onerror = () => { script.remove(); resolve() }
document.head.appendChild(script)
}).finally(() => { sdkLoadPromise = null })
return sdkLoadPromise
}
const waitForBridge = async (): Promise<MiniProgramBridge | null> => {
const immediate = getBridge()
if (immediate) return immediate
void loadWechatJsSdk()
const deadline = Date.now() + BRIDGE_WAIT_TIMEOUT
while (Date.now() < deadline) {
await wait(BRIDGE_POLL_INTERVAL)
const bridge = getBridge()
if (bridge) return bridge
}
return null
}
const isMiniProgramWebView = (bridge: MiniProgramBridge) => new Promise<boolean>((resolve) => {
if (!bridge.getEnv) return resolve(isEmbeddedInWechatMiniProgram())
let settled = false
const finish = (value: boolean) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(value)
}
const timeout = setTimeout(() => finish(isEmbeddedInWechatMiniProgram()), GET_ENV_TIMEOUT)
try {
bridge.getEnv((result) => finish(Boolean(result?.miniprogram)))
} catch (error) {
console.error('微信小程序环境识别失败:', error)
finish(false)
}
})
const navigateToHostLocationPage = (bridge: MiniProgramBridge, target: WechatOpenLocationTarget) => new Promise<WechatMiniProgramBridgeResult>((resolve) => {
if (!bridge.navigateTo) return resolve({ status: 'failed', reason: 'bridge-unavailable' })
let settled = false
const finish = (result: WechatMiniProgramBridgeResult) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
const timeout = setTimeout(() => finish({ status: 'failed', reason: 'timeout' }), NAVIGATION_TIMEOUT)
try {
bridge.navigateTo({
url: buildWechatOpenLocationPageUrl(target),
success: () => finish({ status: 'opened' }),
fail: (error) => {
console.error('跳转宿主地图页失败:', error)
finish({ status: 'failed', reason: /page.*not found|not found.*page/i.test(error?.errMsg || '') ? 'page-not-found' : 'navigation-failed' })
}
})
} catch (error) {
console.error('调用微信小程序导航桥接失败:', error)
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
const requestHostReturnFallback = (bridge: MiniProgramBridge) => {
if (!bridge.postMessage) return false
try {
bridge.postMessage({ data: { type: MINI_PROGRAM_RETURN_MESSAGE } })
return true
} catch (error) {
console.error('请求宿主小程序安全返回失败:', error)
return false
}
}
const navigateBackToHost = (bridge: MiniProgramBridge) => new Promise<WechatMiniProgramReturnResult>((resolve) => {
if (!bridge.navigateBack) return resolve({ status: 'failed', reason: 'bridge-unavailable' })
let settled = false
const finish = (result: WechatMiniProgramReturnResult) => {
if (settled) return
settled = true
clearTimeout(timeout)
resolve(result)
}
const timeout = setTimeout(() => finish({ status: 'failed', reason: 'timeout' }), NAVIGATION_TIMEOUT)
try {
bridge.navigateBack({
delta: 1,
success: () => finish({ status: 'returned' }),
fail: (error) => {
console.error('返回宿主小程序失败:', error)
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
} catch (error) {
console.error('调用宿主小程序返回桥接失败:', error)
finish({ status: 'failed', reason: 'navigation-failed' })
}
})
const openOnce = async (target: WechatOpenLocationTarget): Promise<WechatMiniProgramBridgeResult> => {
const validation = validateWechatOpenLocationTarget(target)
if (!validation.ok) return { status: 'failed', reason: 'invalid-target' }
if (!isEmbeddedInWechatMiniProgram()) return { status: 'not-embedded' }
const bridge = await waitForBridge()
if (!bridge) return { status: 'failed', reason: 'bridge-unavailable' }
if (!await isMiniProgramWebView(bridge)) return { status: 'failed', reason: 'not-in-mini-program' }
return navigateToHostLocationPage(bridge, validation.value)
}
export const openWechatMiniProgramLocation = (target: WechatOpenLocationTarget) => {
if (pendingNavigation) return pendingNavigation
pendingNavigation = openOnce(target).finally(() => { pendingNavigation = null })
return pendingNavigation
}
const returnOnce = async (): Promise<WechatMiniProgramReturnResult> => {
if (!isEmbeddedInWechatMiniProgram()) return { status: 'not-embedded' }
const bridge = await waitForBridge()
if (!bridge) return { status: 'failed', reason: 'bridge-unavailable' }
if (!await isMiniProgramWebView(bridge)) return { status: 'failed', reason: 'not-in-mini-program' }
const result = await navigateBackToHost(bridge)
if (result.status === 'returned') return result
// 宿主页可能由分享或冷启动直接打开,页面栈没有上一页时改由宿主执行安全回退。
if (requestHostReturnFallback(bridge)) return { status: 'returned' }
return result
}
export const returnToWechatMiniProgram = () => {
if (pendingReturn) return pendingReturn
pendingReturn = returnOnce().finally(() => { pendingReturn = null })
return pendingReturn
}