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 # @xuqm/rn-xwebview
通用 React Native WebView 容器。`1.0.0` 只有三项正式入口: 通用 React Native WebView 容器。`1.1.0` 只有三项正式入口:
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。 - `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。
- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。 - `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。
@ -49,6 +49,8 @@ const page = openWebView({
page.reload() page.reload()
page.postMessage('hello') page.postMessage('hello')
page.suspend() // 临时展示宿主原生页面,WebView 实例和历史仍保留
page.resume()
page.close() page.close()
const result = await page.closed const result = await page.closed
``` ```
@ -73,7 +75,27 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
## 页面栈与系统返回 ## 页面栈与系统返回
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层 每次 `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 隐藏原生导航栏但保留状态栏 导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
时,Bridge 可以从页面上下文取得真实安全区高度。 时,Bridge 可以从页面上下文取得真实安全区高度。

查看文件

@ -1,6 +1,6 @@
{ {
"name": "@xuqm/rn-xwebview", "name": "@xuqm/rn-xwebview",
"version": "1.0.0", "version": "1.1.0",
"description": "XuqmGroup RN SDK — XWebView module", "description": "XuqmGroup RN SDK — XWebView module",
"license": "UNLICENSED", "license": "UNLICENSED",
"main": "src/index.ts", "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 { ImageBackground, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context' import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
import { import {
closeActivePage,
getXWebViewSnapshot, getXWebViewSnapshot,
mountXWebViewHost, mountXWebViewHost,
subscribeXWebViewRuntime, subscribeXWebViewRuntime,
@ -149,7 +155,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
const navigationBar = config.navigationBar ?? {} const navigationBar = config.navigationBar ?? {}
const backButton = { const backButton = {
color: navigationBar.backButton?.color ?? presentation.titleColor, color: navigationBar.backButton?.color ?? presentation.titleColor,
visible: navigationBar.backButton?.visible ?? true, visible: navigationBar.backButton?.visible ?? navigationState.canGoBack,
} }
const closeButton = { const closeButton = {
color: navigationBar.closeButton?.color ?? presentation.titleColor, color: navigationBar.closeButton?.color ?? presentation.titleColor,
@ -203,10 +209,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
{backButton.visible ? ( {backButton.visible ? (
<Pressable <Pressable
hitSlop={HIT_SLOP} hitSlop={HIT_SLOP}
onPress={() => { onPress={() => handle.goBack()}
if (navigationState.canGoBack) handle.goBack()
else handle.close('back')
}}
style={styles.navigationButton} style={styles.navigationButton}
> >
<IconBack color={backButton.color} size={24} /> <IconBack color={backButton.color} size={24} />
@ -299,14 +302,39 @@ export function XWebViewHost() {
) )
useEffect(() => mountXWebViewHost(), []) useEffect(() => mountXWebViewHost(), [])
const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null)
const handleRequestClose = useCallback(() => { const handleRequestClose = useCallback(() => {
const page = snapshot.pages.at(-1) const page = [...snapshot.pages].reverse().find(item => !item.suspended)
if (!page) return if (!page) return
if (page.navigationState.canGoBack) page.handle.goBack() if (page.navigationState.canGoBack) {
else closeActivePage('back') 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]) }, [snapshot.pages])
const visiblePages = snapshot.pages.filter(page => !page.suspended)
const activePageId = visiblePages.at(-1)?.id
return ( return (
<Modal <Modal
animationType="slide" animationType="slide"
@ -314,12 +342,12 @@ export function XWebViewHost() {
onRequestClose={handleRequestClose} onRequestClose={handleRequestClose}
presentationStyle="fullScreen" presentationStyle="fullScreen"
statusBarTranslucent statusBarTranslucent
visible={snapshot.pages.length > 0} visible={visiblePages.length > 0}
> >
<SafeAreaProvider> <SafeAreaProvider>
<View style={styles.host}> <View style={styles.host}>
{snapshot.pages.map((page, index) => ( {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> </View>
</SafeAreaProvider> </SafeAreaProvider>

查看文件

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

查看文件

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

查看文件

@ -54,3 +54,22 @@ test('dynamic navigation state belongs only to its page', () => {
second.close() second.close()
unmount() 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()
})