feat(xwebview): refine web navigation lifecycle

这个提交包含在:
XuqmGroup 2026-07-27 15:11:08 +08:00
父节点 9a1d351468
当前提交 3a80ad5dd5
共有 6 个文件被更改,包括 107 次插入15 次删除

查看文件

@ -1,6 +1,6 @@
# @xuqm/rn-xwebview
通用 React Native WebView 容器。`1.0.0` 只有三项正式入口:
通用 React Native WebView 容器。`1.1.0` 只有三项正式入口:
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。
- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。
@ -49,6 +49,8 @@ const page = openWebView({
page.reload()
page.postMessage('hello')
page.suspend() // 临时展示宿主原生页面,WebView 实例和历史仍保留
page.resume()
page.close()
const result = await page.closed
```
@ -73,7 +75,27 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
## 页面栈与系统返回
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层
页面及其导航状态。Android 系统返回先执行网页历史返回,没有历史才关闭当前页。
页面及其导航状态。默认导航栏的左返回按钮只在存在网页历史时显示,且只执行网页后退;
右侧关闭按钮用于明确关闭当前页。
Android 系统返回先执行网页历史返回,没有历史时默认关闭当前页。需要防止误退出的宿主
可以启用双击关闭,并自行决定首次按键的提示样式:
```tsx
const page = openWebView({
behavior: {
hardwareBack: {
closeMode: 'doublePress',
doublePressIntervalMs: 1_000,
onFirstClosePress: () => showToast('再按一次退出当前页面'),
},
},
source: { uri: 'https://example.com' },
})
```
宿主需要短暂展示原生页面时调用 `page.suspend()`,原生页面结束后调用
`page.resume()`;暂停不等于关闭,不触发 `closed`,也不创建第二份 WebView。
导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
时,Bridge 可以从页面上下文取得真实安全区高度。

查看文件

@ -1,6 +1,6 @@
{
"name": "@xuqm/rn-xwebview",
"version": "1.0.0",
"version": "1.1.0",
"description": "XuqmGroup RN SDK — XWebView module",
"license": "UNLICENSED",
"main": "src/index.ts",

查看文件

@ -1,11 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { ImageBackground, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
import {
closeActivePage,
getXWebViewSnapshot,
mountXWebViewHost,
subscribeXWebViewRuntime,
@ -149,7 +155,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
const navigationBar = config.navigationBar ?? {}
const backButton = {
color: navigationBar.backButton?.color ?? presentation.titleColor,
visible: navigationBar.backButton?.visible ?? true,
visible: navigationBar.backButton?.visible ?? navigationState.canGoBack,
}
const closeButton = {
color: navigationBar.closeButton?.color ?? presentation.titleColor,
@ -203,10 +209,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
{backButton.visible ? (
<Pressable
hitSlop={HIT_SLOP}
onPress={() => {
if (navigationState.canGoBack) handle.goBack()
else handle.close('back')
}}
onPress={() => handle.goBack()}
style={styles.navigationButton}
>
<IconBack color={backButton.color} size={24} />
@ -299,14 +302,39 @@ export function XWebViewHost() {
)
useEffect(() => mountXWebViewHost(), [])
const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null)
const handleRequestClose = useCallback(() => {
const page = snapshot.pages.at(-1)
const page = [...snapshot.pages].reverse().find(item => !item.suspended)
if (!page) return
if (page.navigationState.canGoBack) page.handle.goBack()
else closeActivePage('back')
if (page.navigationState.canGoBack) {
pendingHardwareClose.current = null
page.handle.goBack()
return
}
const hardwareBack = page.config.behavior?.hardwareBack
if (hardwareBack?.closeMode !== 'doublePress') {
pendingHardwareClose.current = null
page.handle.close('back')
return
}
const now = Date.now()
const interval = Math.max(250, hardwareBack.doublePressIntervalMs ?? 1_000)
const pending = pendingHardwareClose.current
if (pending?.pageId === page.id && now - pending.at <= interval) {
pendingHardwareClose.current = null
page.handle.close('back')
return
}
pendingHardwareClose.current = { at: now, pageId: page.id }
hardwareBack.onFirstClosePress?.()
}, [snapshot.pages])
const visiblePages = snapshot.pages.filter(page => !page.suspended)
const activePageId = visiblePages.at(-1)?.id
return (
<Modal
animationType="slide"
@ -314,12 +342,12 @@ export function XWebViewHost() {
onRequestClose={handleRequestClose}
presentationStyle="fullScreen"
statusBarTranslucent
visible={snapshot.pages.length > 0}
visible={visiblePages.length > 0}
>
<SafeAreaProvider>
<View style={styles.host}>
{snapshot.pages.map((page, index) => (
<HostPage key={page.id} active={index === snapshot.pages.length - 1} page={page} />
<HostPage key={page.id} active={page.id === activePageId} page={page} />
))}
</View>
</SafeAreaProvider>

查看文件

@ -24,6 +24,7 @@ export type XWebViewPage = {
id: string
navigationState: XWebViewNavigationState
presentation: XWebViewPresentation
suspended: boolean
}
type InternalPage = XWebViewPage & {
@ -110,6 +111,8 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle {
goForward: () => controllerCall(id, controller => controller.goForward()),
postMessage: message => controllerCall(id, controller => controller.postMessage(message)),
reload: () => controllerCall(id, controller => controller.reload()),
resume: () => setPageSuspended(id, false),
suspend: () => setPageSuspended(id, true),
}
const page: InternalPage = {
@ -124,6 +127,7 @@ export function openWebView(config: XWebViewConfig): XWebViewPageHandle {
},
presentation: defaultPresentation(config),
resolveClosed,
suspended: false,
timeout: null,
}
@ -218,6 +222,13 @@ export function updatePageWindow(pageId: string, patch: XWebViewWindowPatch): vo
emit()
}
export function setPageSuspended(pageId: string, suspended: boolean): void {
const page = pageById(pageId)
if (!page || page.suspended === suspended) return
page.suspended = suspended
emit()
}
export function isActivePage(pageId: string): boolean {
return activePage()?.id === pageId
}

查看文件

@ -41,6 +41,12 @@ export type XWebViewPageHandle = XWebViewController & {
readonly closed: Promise<XWebViewPageClosedResult>
readonly id: string
close(reason?: XWebViewPageCloseReason | string, data?: unknown): void
/**
* WebView
* 宿 resume
*/
suspend(): void
resume(): void
}
export type XWebViewNavigationBackground = {
@ -203,6 +209,12 @@ export type XWebViewBehaviorConfig = {
injectedJavaScript?: string
injectedJavaScriptBeforeContentLoaded?: string
mixedContent?: 'never' | 'compatibility' | 'always'
hardwareBack?: {
/** 默认立即关闭;doublePress 表示在指定时间内再次按返回键才关闭。 */
closeMode?: 'immediate' | 'doublePress'
doublePressIntervalMs?: number
onFirstClosePress?: () => void
}
onBridgeError?: (error: string) => void
onClose?: (result: XWebViewPageClosedResult) => void
onControllerReady?: (controller: XWebViewController | null) => void

查看文件

@ -54,3 +54,22 @@ test('dynamic navigation state belongs only to its page', () => {
second.close()
unmount()
})
test('suspend and resume preserve the same page and handle', () => {
resetXWebViewRuntimeForTests()
const unmount = mountXWebViewHost()
const page = openWebView({
source: { uri: 'https://suspend.test' },
})
page.suspend()
assert.equal(getXWebViewSnapshot().pages[0].suspended, true)
assert.equal(getXWebViewSnapshot().pages[0].handle, page)
page.resume()
assert.equal(getXWebViewSnapshot().pages[0].suspended, false)
assert.equal(getXWebViewSnapshot().pages[0].handle, page)
page.close()
unmount()
})