接入移动壳返回栈事件

移动壳声明 host.events 和 navigation.canGoBack 能力

Expo WebView 导航状态变化时向 H5 注入返回栈事件

H5 native_app transport 支持订阅 HostBridge 事件

补充事件订阅测试、移动壳能力测试和配置守卫

更新宿主壳方案和团队共享决策记录
This commit is contained in:
2026-06-17 22:42:44 +08:00
parent a87f3dcc82
commit 6f19e1c3ba
9 changed files with 125 additions and 6 deletions

View File

@@ -14,6 +14,7 @@ import {
canUseTauriHostBridge,
requestNativeAppHostBridge,
resetNativeAppHostBridgeForTest,
subscribeNativeAppHostBridgeEvent,
} from './nativeAppHostBridge';
afterEach(() => {
@@ -139,4 +140,43 @@ describe('nativeAppHostBridge', () => {
await assertion;
});
test('订阅 React Native WebView 注入的宿主事件', () => {
window.ReactNativeWebView = {
postMessage: vi.fn(),
};
const listener = vi.fn();
const unsubscribe = subscribeNativeAppHostBridgeEvent<{
canGoBack: boolean;
}>('navigation.canGoBack', listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
}),
);
unsubscribe();
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
event: 'navigation.canGoBack',
payload: {
canGoBack: false,
},
}),
}),
);
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith({ canGoBack: true });
});
});

View File

@@ -2,6 +2,7 @@ import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeError,
type HostBridgeEvent,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
@@ -31,6 +32,7 @@ type PendingNativeRequest = {
};
const pendingNativeRequests = new Map<string, PendingNativeRequest>();
const nativeEventListeners = new Map<string, Set<(payload: unknown) => void>>();
let nativeBridgeListenerInstalled = false;
let nextNativeRequestSequence = 0;
@@ -77,6 +79,19 @@ function isHostBridgeResponse(value: unknown): value is HostBridgeResponse {
);
}
function isHostBridgeEvent(value: unknown): value is HostBridgeEvent {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<HostBridgeEvent>;
return (
candidate.bridge === HOST_BRIDGE_PROTOCOL &&
candidate.version === HOST_BRIDGE_VERSION &&
typeof candidate.event === 'string'
);
}
function parseNativeMessage(data: unknown) {
if (typeof data === 'string') {
try {
@@ -106,6 +121,17 @@ function settleNativeResponse(response: HostBridgeResponse) {
pending.reject(createHostBridgeError(response.error));
}
function dispatchNativeEvent(event: HostBridgeEvent) {
const listeners = nativeEventListeners.get(event.event);
if (!listeners) {
return;
}
for (const listener of listeners) {
listener(event.payload);
}
}
function ensureNativeBridgeListener() {
const nativeWindow = resolveNativeWindow();
if (!nativeWindow || nativeBridgeListenerInstalled) {
@@ -116,6 +142,10 @@ function ensureNativeBridgeListener() {
const value = parseNativeMessage(event.data);
if (isHostBridgeResponse(value)) {
settleNativeResponse(value);
return;
}
if (isHostBridgeEvent(value)) {
dispatchNativeEvent(value);
}
});
@@ -139,6 +169,24 @@ export function canUseNativeAppHostBridge() {
return canUseReactNativeHostBridge() || canUseTauriHostBridge();
}
export function subscribeNativeAppHostBridgeEvent<Payload = unknown>(
eventName: string,
listener: (payload: Payload | undefined) => void,
) {
ensureNativeBridgeListener();
const listeners = nativeEventListeners.get(eventName) ?? new Set();
listeners.add(listener as (payload: unknown) => void);
nativeEventListeners.set(eventName, listeners);
return () => {
listeners.delete(listener as (payload: unknown) => void);
if (listeners.size === 0) {
nativeEventListeners.delete(eventName);
}
};
}
export async function requestNativeAppHostBridge<Result = unknown>(
method: HostBridgeMethod,
payload?: unknown,
@@ -213,5 +261,6 @@ export function resetNativeAppHostBridgeForTest() {
clearTimeout(pending.timeoutId);
}
pendingNativeRequests.clear();
nativeEventListeners.clear();
nextNativeRequestSequence = 0;
}