UniApp WebView 完整封装:实现 H5 与 App 双向通信、返回键处理及异常重试
标签:
uni-appweb-view混合开发双向通信App端开发
在 UniApp 混合开发场景中,web-view 组件是实现 App 嵌入 H5 页面的核心载体,但其原生提供的能力较为基础,在实际项目中往往需要解决双向通信、WebView 实例获取失败、返回键冲突、加载异常重试 等一系列问题。本文将基于 Vue 3 + Setup 语法,完整封装一个高可用的 web-view 组件,覆盖上述核心场景,代码可直接复制落地到项目中。
一、组件核心功能概述
本次封装的 web-view 组件具备以下核心能力,满足大部分混合开发需求:
- 屏蔽不同环境差异(APP-PLUS/H5),统一处理逻辑
- 实现 App 与 H5 之间的双向消息通信,支持消息格式兼容
- 提供 WebView 实例自动重试获取机制,解决实例初始化时机问题
- 统一处理页面返回、应用退出逻辑,兼容 Android/iOS 平台差异
- 支持自定义扩展消息(更新导航栏标题、页面跳转)
- 完善的事件监听清理机制,防止内存泄漏
- 页面加载失败提示、返回键监听器注册/销毁闭环
二、完整组件代码(可直接复制)
vue
<template>
<web-view
ref="viewRef"
id="wv1"
:src="src"
:verticalScrollBarAccess="false"
:horizontalScrollBarAccess="false"
@message="onWebviewMessage"
@onPostMessage="onWebviewMessage"
@error="handleWebViewError"
@load="onWebviewLoaded"
/>
</template>
<route lang="json">{
"layout": "default",
"index": 0,
"type": "home",
"name": "webViewApp",
"style": {
"navigationBarTitleText": "app"
}
}</route>
<script setup>
import { ref, onMounted, onUnmounted, getCurrentInstance } from 'vue'
// ==================== 常量定义(统一管理,便于维护扩展) ====================
const MESSAGE_TYPES = {
NAVIGATE_BACK: 'navigateBack',
EXIT_APP: 'exitApp',
CUSTOM: 'custom',
BACK_BUTTON_PRESSED: 'backButtonPressed'
}
const CUSTOM_ACTIONS = {
UPDATE_TITLE: 'updateTitle',
NAVIGATE_TO: 'navigateTo'
}
const RETRY_CONFIG = {
DELAY: 300, // 重试延迟时间(ms)
MAX_RETRIES: 3 // 最大重试次数
}
// ==================== 响应式状态 ====================
const viewRef = ref(null) // web-view 组件引用
const src = ref('') // H5 页面地址(可自行赋值,如:https://xxx.com/h5/page)
const webviewInstance = ref(null) // 缓存 WebView 实例
const retryCount = ref(0) // 实例获取重试计数器
// 存储返回键监听器引用,用于组件卸载时清理
const backButtonHandler = ref(null)
// ==================== WebView 实例管理(核心:解决实例获取失败问题) ====================
/**
* 获取 WebView 实例(APP-PLUS 环境专属)
* @returns {Promise<plus.webview.WebviewObject|null>} WebView 实例
*/
const getWebviewInstance = () => {
return new Promise((resolve, reject) => {
// #ifdef APP-PLUS
const instance = getCurrentInstance()
if (!instance?.proxy) {
console.warn('当前组件实例不可用')
resolve(null)
return
}
const fetchWebview = () => {
try {
// 方式1:从当前页面作用域获取子 WebView 实例
const appWebview = instance.proxy.$scope?.$getAppWebview()
if (!appWebview) {
console.warn('无法获取当前页面的 AppWebview 实例')
resolve(null)
return
}
const children = appWebview.children()
if (children?.length > 0) {
webviewInstance.value = children[0]
console.log('WebView 实例获取成功(方式:子组件实例)')
resolve(webviewInstance.value)
return
}
// 方式2:通过 web-view 组件的 ID 直接获取(备用方案)
const webview = plus.webview.getWebviewById('wv1')
if (webview) {
webviewInstance.value = webview
console.log('WebView 实例获取成功(方式:组件ID查询)')
resolve(webviewInstance.value)
} else {
handleWebviewNotFound(resolve)
}
} catch (error) {
console.error('获取 WebView 实例异常:', error)
handleWebviewNotFound(resolve)
}
}
// 延迟执行,避免页面初始化未完成导致获取失败
setTimeout(fetchWebview, RETRY_CONFIG.DELAY)
// #endif
// 非 APP 环境直接返回 null
// #ifndef APP-PLUS
resolve(null)
// #endif
})
}
/**
* 处理 WebView 实例未找到的情况(自动重试机制)
* @param {Function} resolve Promise 解析函数
*/
const handleWebviewNotFound = (resolve) => {
retryCount.value++
if (retryCount.value < RETRY_CONFIG.MAX_RETRIES) {
console.warn(`未找到 WebView 实例,将在${RETRY_CONFIG.DELAY}ms后重试 (${retryCount.value}/${RETRY_CONFIG.MAX_RETRIES})`)
// 递归调用获取实例,达到重试效果
setTimeout(() => getWebviewInstance().then(resolve), RETRY_CONFIG.DELAY)
} else {
console.warn('达到最大重试次数,放弃获取 WebView 实例')
resolve(null)
}
}
/**
* 获取当前页面的根 WebView 实例
* @returns {plus.webview.WebviewObject|null} 页面根 WebView 实例
*/
const getCurrentWebview = () => {
// #ifdef APP-PLUS
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
return currentPage?.$getAppWebview() || null
// #endif
return null
}
// ==================== 消息处理(双向通信:接收 H5 消息) ====================
/**
* 接收 H5 页面发送的消息(统一处理 message/onPostMessage 事件)
* @param {Object} event 消息事件对象
*/
const onWebviewMessage = (event) => {
try {
const data = extractMessageData(event)
console.log('收到 H5 消息:', JSON.stringify(data))
const { type, message, action, title, url } = data || {}
// 消息分发:根据不同消息类型处理对应逻辑
switch (type) {
case MESSAGE_TYPES.NAVIGATE_BACK:
handleNavigateBack()
break
case MESSAGE_TYPES.EXIT_APP:
handleExitApp(message)
break
case MESSAGE_TYPES.CUSTOM:
handleCustomMessage(action, data)
break
case MESSAGE_TYPES.BACK_BUTTON_PRESSED:
handleBackButtonPressed()
break
default:
console.warn('未知的 WebView 消息类型:', type)
}
} catch (error) {
console.error('处理 WebView 消息失败:', error)
}
}
/**
* 提取消息数据(兼容不同环境的消息格式差异)
* @param {Object} event 消息事件对象
* @returns {Object|null} 标准化后的消息数据
*/
const extractMessageData = (event) => {
if (!event) return null
// 格式1:UniApp 原生 web-view message 事件格式
if (event.detail?.data) {
return event.detail.data[0] || event.detail
}
// 格式2:自定义 postMessage 事件格式
if (event.detail) {
return event.detail
}
// 格式3:H5 环境 window.postMessage 格式
if (event.data) {
return event.data
}
return null
}
// ==================== 导航与退出逻辑(兼容多平台) ====================
/**
* 处理返回键按下事件(向 H5 发送返回通知)
*/
const handleBackButtonPressed = () => {
sendMessageToWebview({
type: MESSAGE_TYPES.BACK_BUTTON_PRESSED
})
sendMessageToWebview({
type: MESSAGE_TYPES.NAVIGATE_BACK
})
}
/**
* 处理 H5 发起的返回操作
*/
const handleNavigateBack = () => {
// #ifdef APP-PLUS
const pages = getCurrentPages()
// 页面栈长度 > 1 时,返回上一页;否则,触发退出应用
if (pages.length > 1) {
uni.navigateBack()
} else {
handleExitApp('确定要退出应用吗?')
}
// #endif
// #ifdef H5
// H5 环境直接调用浏览器历史返回
history.back()
// #endif
}
/**
* 处理退出应用请求(带确认弹窗)
* @param {string} message 确认弹窗提示内容
*/
const handleExitApp = (message = '确定要退出应用吗?') => {
const exitHandler = (res) => {
if (res.confirm) {
// #ifdef APP-PLUS
moveTaskToBack()
// #endif
// #ifdef H5
// 浏览器环境无法直接关闭窗口,仅做日志提示
console.log('浏览器环境,无法直接退出应用')
// #endif
}
}
uni.showModal({
title: '提示',
content: message,
showCancel: true,
confirmText: '确定',
cancelText: '取消',
success: exitHandler,
fail: (error) => {
console.error('显示退出确认框失败:', error)
}
})
}
/**
* 将应用移至后台(不直接退出,兼容 Android/iOS 差异)
*/
const moveTaskToBack = () => {
// #ifdef APP-PLUS
try {
if (plus?.android) {
// Android 支持将应用移至后台
const main = plus.android.runtimeMainActivity()
main?.moveTaskToBack(false)
} else if (plus?.ios) {
// iOS 不支持直接移至后台,给出提示
plus.nativeUI.toast('应用将保持运行')
} else {
// 降级方案:直接退出应用
plus.runtime.quit()
}
} catch (error) {
console.error('移至后台失败,尝试退出应用:', error)
plus?.runtime?.quit()
}
// #endif
}
// ==================== 自定义消息处理(可扩展) ====================
/**
* 处理自定义消息(扩展业务逻辑)
* @param {string} action 自定义操作类型
* @param {Object} data 消息数据
*/
const handleCustomMessage = (action, data) => {
if (!action) {
console.warn('自定义消息缺少 action 参数')
return
}
switch (action) {
case CUSTOM_ACTIONS.UPDATE_TITLE:
updatePageTitle(data)
break
case CUSTOM_ACTIONS.NAVIGATE_TO:
navigateToPage(data)
break
default:
console.warn('未知的自定义消息操作:', action)
}
}
/**
* 更新页面导航栏标题
* @param {Object} data 消息数据(包含 title 字段)
*/
const updatePageTitle = (data) => {
const { title } = data || {}
if (!title) {
console.warn('标题更新失败:缺少 title 参数')
return
}
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
// 更新页面元信息 + 导航栏标题
if (currentPage?.$page) {
currentPage.$page.meta.navigationBarTitleText = title
uni.setNavigationBarTitle({ title })
}
}
/**
* 跳转到 UniApp 内部指定页面
* @param {Object} data 消息数据(包含 url 字段)
*/
const navigateToPage = (data) => {
const { url } = data || {}
if (!url) {
console.warn('页面跳转失败:缺少 url 参数')
return
}
uni.navigateTo({
url,
success: () => {
console.log('页面跳转成功:', url)
},
fail: (error) => {
console.error('页面跳转失败:', error)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
}
// ==================== 消息发送(双向通信:向 H5 发送消息) ====================
/**
* 向 H5 页面发送消息(兼容 APP/H5 环境)
* @param {Object} message 要发送的消息数据
*/
const sendMessageToWebview = async (message) => {
if (!message) {
console.warn('发送消息失败:消息内容为空')
return
}
// #ifdef APP-PLUS
try {
// 确保 WebView 实例已初始化(未初始化则自动获取)
if (!webviewInstance.value) {
await getWebviewInstance()
}
if (webviewInstance.value) {
// 执行 H5 页面中的全局函数,实现消息传递
const script = `typeof handleUniAppMessage === 'function' && handleUniAppMessage(${JSON.stringify(message)});`
webviewInstance.value.evalJS(script, (result) => {
console.log('消息发送成功:', message)
})
} else {
console.warn('WebView 实例未初始化,无法发送消息')
}
} catch (error) {
console.error('向 H5 发送消息失败:', error)
}
// #endif
// #ifdef H5
// H5 环境使用 window.postMessage 传递消息
window.postMessage(
{
type: 'uniAppMessage',
data: message
},
'*' // 生产环境建议指定具体域名,提高安全性
)
// #endif
}
// ==================== 事件处理(加载成功/失败) ====================
/**
* WebView 页面加载完成回调
*/
const onWebviewLoaded = () => {
console.log('WebView 加载完成')
// #ifdef APP-PLUS
// 加载完成后注册返回键监听器,避免提前注册导致冲突
registerBackButtonHandler()
// #endif
}
/**
* WebView 页面加载失败回调
* @param {Object} event 错误事件对象
*/
const handleWebViewError = (event) => {
console.error('WebView 加载失败:', event)
uni.showToast({
title: '页面加载失败',
icon: 'none'
})
}
// ==================== 返回键管理(防止多次注册/内存泄漏) ====================
/**
* 注册 App 返回键监听器(APP-PLUS 环境)
*/
const registerBackButtonHandler = () => {
// #ifdef APP-PLUS
if (backButtonHandler.value) {
console.warn('返回键监听器已存在,跳过重复注册')
return
}
// 定义返回键处理逻辑
backButtonHandler.value = () => {
handleBackButtonPressed()
}
// 注册系统返回键事件
plus.key.addEventListener('backbutton', backButtonHandler.value, false)
console.log('返回键监听器注册成功')
// #endif
}
/**
* 移除 App 返回键监听器(组件卸载时调用)
*/
const unregisterBackButtonHandler = () => {
// #ifdef APP-PLUS
if (backButtonHandler.value) {
plus.key.removeEventListener('backbutton', backButtonHandler.value, false)
backButtonHandler.value = null
console.log('返回键监听器已移除')
}
// #endif
}
// ==================== 生命周期管理(闭环:防止内存泄漏) ====================
/**
* 应用启动初始化(注册全局消息监听)
*/
const handleAppLaunch = () => {
// #ifdef APP-PLUS || H5
uni.$on('onWebviewMessage', onWebviewMessage)
console.log('WebView 全局消息监听器已注册')
// #endif
}
/**
* 页面返回事件拦截
* @param {Object} e 返回事件对象
* @returns {boolean} 是否阻止默认返回行为
*/
const handleBackPress = (e) => {
console.log('页面返回事件触发:', e)
sendMessageToWebview({
type: MESSAGE_TYPES.BACK_BUTTON_PRESSED
})
return false // 不阻止默认返回行为,可根据业务调整
}
// 组件挂载:初始化监听器
onMounted(() => {
handleAppLaunch()
})
// 组件卸载:清理所有监听器和实例
onUnmounted(() => {
// 移除全局消息监听
// #ifdef APP-PLUS || H5
uni.$off('onWebviewMessage', onWebviewMessage)
console.log('WebView 全局消息监听器已移除')
// #endif
// 移除返回键监听器
unregisterBackButtonHandler()
})
// UniApp 生命周期钩子(兼容页面级逻辑)
onBackPress(handleBackPress)
</script>
<style scoped lang="scss">
// 让 web-view 占满整个页面
web-view {
width: 100%;
height: 100vh;
display: block;
}
</style>
三、关键模块解析
1. WebView 实例获取与重试机制
这是 App 端混合开发的核心痛点之一:web-view 组件初始化需要时间,若在页面挂载后立即获取实例,大概率会失败。
本次封装的解决方案:
- 提供两种实例获取方式:先从页面作用域获取子组件实例,失败后通过组件 ID 查询,提高获取成功率
- 加入延迟执行:首次获取延迟 300ms,避免页面初始化未完成
- 加入自动重试机制:最多重试 3 次,每次间隔 300ms,失败后给出明确日志提示
- 缓存实例:获取成功后缓存到
webviewInstance,避免重复获取
2. 双向通信实现
(1)App 接收 H5 消息
- 绑定
web-view的@message和@onPostMessage两个事件(兼容不同 UniApp 版本差异) - 封装
extractMessageData方法,兼容多种消息格式,提取标准化数据 - 使用
switch语句分发消息,便于扩展新的消息类型
(2)App 向 H5 发送消息
- App 端(APP-PLUS):通过
webviewInstance.evalJS()执行 H5 页面的全局函数handleUniAppMessage - H5 端(兼容 H5 环境):通过
window.postMessage传递消息 - 发送前检查 WebView 实例状态,未初始化则自动触发获取
(3)H5 端适配代码(关键)
H5 页面需要定义全局函数 handleUniAppMessage 来接收 App 发送的消息,同时通过 uni.postMessage 向 App 发送消息(需引入 UniApp 的 H5 端 SDK):
typescript
<script type="text/javascript" src="https://gitcode.com/dcloud/uni-app/tree/dev/dist/uni.webview.1.5.6.js"></script>
typescript
<!--
* @Author: LGX 1478856829@qq.com
* @Date: 2025-12-09 20:35:12
* @LastEditors: LGX
* @LastEditTime: 2026-01-06 17:41:02
* @Description: 描述
-->
<template>
<view class="container">
<!-- <wd-navbar title="Webview通信示例" left-arrow @click-left="handleClickLeft" safeAreaInsetTop placeholder/> -->
<view class="content">
<wd-cell-group>
<wd-cell title="返回上一页" clickable @click="handleNavigateBack" />
<wd-cell title="退出应用" clickable @click="handleExitApp" />
<wd-cell title="更新标题" clickable @click="handleUpdateTitle" />
<wd-cell title="页面跳转" clickable @click="handleNavigateTo" />
</wd-cell-group>
<view class="button-group">
<wd-button block @click="handleCustomMessage">发送自定义消息</wd-button>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import useWebviewBridge from '@/hooks/useWebviewBridge';
const { navigateBack, exitApp, updateTitle, navigateTo, sendCustom } = useWebviewBridge();
const handleClickLeft = () => {
navigateBack();
};
const handleNavigateBack = () => {
navigateBack();
};
const handleExitApp = () => {
exitApp('您确定要退出应用吗?');
};
const handleUpdateTitle = () => {
const randomTitles = ['新标题', '通信示例', 'H5与App通信', 'Webview Bridge'];
const randomTitle = randomTitles[Math.floor(Math.random() * randomTitles.length)];
updateTitle(`${randomTitle}-${new Date().getTime()}`);
};
const handleNavigateTo = () => {
// 示例跳转,实际使用时请替换为真实路径
navigateTo('/pages/home/index');
};
const handleCustomMessage = () => {
sendCustom('testAction', {
message: '这是一条自定义消息',
timestamp: new Date().getTime()
});
};
</script>
<route lang="json">
{
"layout": "default",
"name": "chemicals",
"style": {
"navigationBarTitleText": "天气"
}
}
</route>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background-color: #f5f5f5;
}
.content {
padding: 20rpx;
}
.button-group {
margin-top: 40rpx;
padding: 0 20rpx;
}
</style>
(4)H5 端适配代码Hooks(webviewBridge关键)
typescript
/**
* WebView H5 端通信桥接工具
* 配合 App 端壳子使用,实现双向通信
*/
import router from "@/router";
// ==================== 类型定义 ====================
export enum MessageType {
NAVIGATE_BACK = 'navigateBack',
EXIT_APP = 'exitApp',
CUSTOM = 'custom',
BACK_BUTTON_PRESSED = 'backButtonPressed'
}
export interface MessageData {
type: MessageType;
message?: string;
action?: string;
title?: string;
url?: string;
[key: string]: any;
}
// 底部菜单页面路径列表(请根据实际路由配置调整)
const TAB_BAR_PAGES = [
'pages/home/index',
'pages/case/index',
'pages/my/index',
'pages/supervise/home',
'pages/supervise/my',
'pages/supervise/unitInfo',
'pages/supervise/stationInfo',
'pages/security/index',
'pages/securityWork/index',
'pages/securityMap/index',
'pages/securityMy/index'
];
// ==================== 环境判断 ====================
/**
* 判断当前是否在 UniApp WebView 环境中
*/
const isUniAppWebView = (): boolean => {
return typeof uni !== 'undefined' && (uni as any).webView;
};
/**
* 判断当前是否在开发浏览器环境中(用于调试)
*/
const isBrowserEnv = (): boolean => {
return typeof window !== 'undefined' && window.parent !== window;
};
// ==================== 消息发送 ====================
/**
* 发送消息到 App
* @param data 消息数据
*/
export function sendMessageToApp(data: MessageData): void {
const payload = { data };
// 1. 尝试使用 UniApp WebView 标准接口
if (isUniAppWebView()) {
// 确保 UniAppJSBridge 准备就绪
if ((window as any).UniAppJSBridge) {
(uni as any).webView.postMessage(payload);
console.log('[H5 -> App] 消息已发送:', data);
} else {
// 如果 Bridge 未就绪,等待就绪后再发送
document.addEventListener('UniAppJSBridgeReady', () => {
(uni as any).webView.postMessage(payload);
console.log('[H5 -> App] Bridge就绪,消息已发送:', data);
});
}
return;
}
// 2. 浏览器调试环境 (iframe postMessage)
if (isBrowserEnv()) {
window.parent.postMessage(payload, '*');
console.log('[H5 -> Debug] 浏览器调试消息:', data);
return;
}
console.warn('[H5] 当前环境既不支持 uni.webView 也不在 iframe 中,无法发送消息:', data);
}
/**
* 请求返回上一页 (含底部菜单逻辑)
* @param router Vue Router 实例 (可选,传入可更精确判断路由)
*/
// export function requestNavigateBack(router?: any): void {
// console.log('[H5] 请求返回上一页');
// // 1. 获取当前页面路径
// let currentPath = '';
// // 优先使用 router 获取路径 (Vue 环境)
// if (router && router.currentRoute && router.currentRoute.value) {
// currentPath = router.currentRoute.value.path;
// } else {
// // 降级方案:使用 window.location
// currentPath = window.location.hash.replace(/\?.*/, '') || window.location.pathname;
// // 去除开头的 # 或 /
// currentPath = currentPath.replace(/^#?\/?/, '');
// }
// console.log(`[H5] 当前路径: ${currentPath}`);
// // 2. 检查当前页面是否在底部菜单列表中
// const tabIndex = TAB_BAR_PAGES.findIndex(path => currentPath.includes(path));
// if (tabIndex !== -1) {
// // === 场景 A:当前是底部菜单页面 ===
// console.log(`[H5] 检测到当前是底部菜单页 (索引: ${tabIndex})`);
// if (tabIndex > 0) {
// // 如果不是第一个菜单,跳转到前一个菜单
// const prevPath = TAB_BAR_PAGES[tabIndex - 1];
// console.log(`[H5] 切换到上一个菜单: ${prevPath}`);
// if (router) {
// // 使用 replace 替换当前历史记录,防止用户点击返回时又回到当前页
// router.replace(`/${prevPath}`);
// } else {
// // 非 Router 环境,直接修改 hash
// window.location.replace(`#/${prevPath}`);
// }
// } else {
// // 如果是第一个菜单(通常是首页),根据需求处理
// // 方案1:保持在当前页(推荐)
// console.log('[H5] 已经是第一个菜单页,保持不动');
// // 方案2:如果没有历史记录,尝试通知 App 退出 (如果需要退出逻辑,取消下面注释)
// /*
// if (window.history.length <= 1) {
// sendMessageToApp({ type: MessageType.EXIT_APP });
// } else {
// window.history.back();
// }
// */
// }
// } else {
// // === 场景 B:不是底部菜单页面,执行正常返回逻辑 ===
// console.log('[H5] 非菜单页,执行正常返回');
// // 检查 H5 历史栈 (注意:在 SPA 中 history.length 可能较大,这里简单判断)
// if (window.history.length > 1) {
// window.history.back();
// console.log('[H5] 执行 history.back()');
// } else {
// // H5 已经是根页面,通知 App 退出或返回
// sendMessageToApp({
// type: MessageType.NAVIGATE_BACK
// });
// }
// }
// }
/**
* 请求返回上一页
* 如果 H5 有历史记录则后退,否则通知 App 退出
*/
export function requestNavigateBack(): void {
console.log('[H5] 请求返回上一页');
const pages = getCurrentPages(); // 获取页面栈数组
const currentPage = pages[pages.length - 1]; // 最后一个元素为当前页
console.log(pages ,"currentPage", currentPage.route);
// 检查 H5 历史栈
if (window.history.length > 1) {
console.log(window.history,'window.history' );
// router.back({
// delta:1,
// animationType:'pop-out',
// });
// uni.navigateBack({
// delta:pages.length
// });
// H5 自身还有页面,执行后退
window.history.back();
console.log('[H5] 执行 history.back()');
} else {
// H5 已经是根页面,通知 App 退出或返回
// sendMessageToApp({
// type: MessageType.NAVIGATE_BACK
// });
}
}
/**
* 请求退出应用
* @param message 确认消息
*/
export function requestExitApp(message: string = '确定要退出应用吗?'): void {
sendMessageToApp({
type: MessageType.EXIT_APP,
message
});
}
/**
* 发送自定义消息
* @param action 自定义操作
* @param data 附加数据
*/
export function sendCustomMessage(action: string, data: any = {}): void {
sendMessageToApp({
type: MessageType.CUSTOM,
action,
...data
});
}
// ==================== 消息接收 ====================
/**
* 处理来自 App 的消息 (通过 evalJS 调用全局函数)
* @param message 消息对象
*/
function handleAppMessageInternal(message: MessageData): void {
console.log('[App -> H5] 收到消息:', message);
if (!message) return;
const { type, action } = message;
try {
switch (type) {
case MessageType.BACK_BUTTON_PRESSED:
handleBackButtonPressed();
break;
case MessageType.CUSTOM:
handleCustomAppMessage(action, message);
break;
default:
console.log('[App -> H5] 未处理的 App 消息类型:', type);
}
} catch (error) {
console.error('[App -> H5] 处理消息出错:', error);
}
}
/**
* 处理物理返回键按下事件
* 逻辑:先尝试 H5 返回,如果 H5 无法返回,则通知 App 退出
*/
function handleBackButtonPressed(): void {
console.log('[H5] 物理返回键被按下');
// 逻辑:通知 H5 业务层按键被按下(可选,如果业务层有特殊监听)
// 然后执行返回逻辑
requestNavigateBack();
}
/**
* 处理自定义 App 消息
* @param action 操作类型
* @param data 消息数据
*/
function handleCustomAppMessage(action?: string, data?: MessageData): void {
switch (action) {
case 'updateTitle':
if (data?.title) {
document.title = data.title;
// 尝试设置 UniApp 导航栏标题
if (typeof uni !== 'undefined') {
uni.setNavigationBarTitle({ title: data.title });
}
}
break;
case 'refresh':
window.location.reload();
break;
default:
console.log('[App -> H5] 未知的自定义操作:', action, data);
}
}
// ==================== 初始化与全局注册 ====================
/**
* 初始化桥接
*/
export function initWebViewBridge(): void {
// 1. 注册全局函数供 App 端 evalJS 调用
if (typeof window !== 'undefined') {
(window as any).handleUniAppMessage = (message: any) => {
handleAppMessageInternal(message);
};
console.log('[H5] 全局消息监听器已注册 (window.handleUniAppMessage)');
}
// 2. 监听浏览器 postMessage (用于非 UniApp 环境调试或特殊情况)
if (typeof window !== 'undefined') {
window.addEventListener('message', (event: MessageEvent) => {
// 安全检查:在生产环境中应限制 origin
// if (event.origin !== 'your-app-domain') return;
if (event.data && typeof event.data === 'object' && event.data.data) {
handleAppMessageInternal(event.data.data);
}
});
}
// 3. (可选) 兼容旧版 uni.$on 监听方式,虽然 webview 中通常不需要
if (typeof uni !== 'undefined' && (uni as any).$on) {
(uni as any).$on('uniAppMessage', (data: any) => {
handleAppMessageInternal(data);
});
}
}
// 自动执行初始化
initWebViewBridge();
// ==================== 声明补充 ====================
declare global {
interface Window {
handleUniAppMessage?: (message: any) => void;
UniAppJSBridge?: any;
uni?: any;
}
}
3. 生命周期闭环与内存泄漏防护
混合开发中,若监听器未及时清理,容易导致内存泄漏和重复执行问题,本次封装的防护措施:
- 组件挂载时(
onMounted)注册全局消息监听器和返回键监听器 - 组件卸载时(
onUnmounted)移除所有监听器,清空实例缓存 - 返回键监听器使用引用缓存,避免多次注册
- 全局事件使用
uni.$on/uni.$off成对使用,确保监听闭环
4. 跨平台兼容处理
通过 UniApp 的条件编译 (#ifdef/#ifndef),区分不同运行环境:
- APP-PLUS 环境:处理 WebView 实例、返回键、应用后台逻辑
- H5 环境:处理浏览器历史返回、
window.postMessage通信 - 小程序环境:默认返回 null,不执行额外逻辑(可自行扩展)
四、使用说明与踩坑指南
1. 快速使用步骤
- 将上述组件代码复制到 UniApp 项目中(如:
components/WebViewPage/WebViewPage.vue) - 给
src赋值 H5 页面地址(如:src.ref = 'https://xxx.com/h5/test') - H5 页面按照上述适配代码改造,引入 UniApp SDK 并定义对应全局函数
- 运行到 App 端(Android/iOS)或 H5 端,即可实现双向通信
2. 常见踩坑点
- WebView 实例获取失败 :确保
web-view组件的id与代码中plus.webview.getWebviewById('wv1')的 ID 一致,避免拼写错误 - 双向通信无响应 :H5 页面必须等待
UniAppJSBridgeReady事件触发后再发送消息,且需引入正确的 UniApp H5 SDK - 返回键无响应 :确保 WebView 页面加载完成后才注册返回键监听器(本次封装已在
onWebviewLoaded中注册) - iOS 无法退出应用:iOS 系统限制,无法直接将应用移至后台,封装中已做降级处理(提示+退出)
- 跨域问题:H5 页面与 App 通信无跨域限制,但 H5 页面自身的接口请求需处理跨域问题
3. 可扩展方向
- 增加 WebView 页面加载进度条显示
- 增加离线缓存、刷新重试功能
- 扩展更多自定义消息类型(如:上传图片、获取设备信息)
- 增加小程序环境的适配逻辑
- 封装成全局组件,支持通过 props 配置更多参数
五、总结
本次封装的 web-view 组件解决了 UniApp 混合开发中的核心痛点,具备高可用、易扩展、跨平台、无内存泄漏的特点,可直接落地到生产项目中。核心亮点如下:
- 自动重试的 WebView 实例获取机制,提高实例获取成功率
- 标准化的双向通信流程,兼容多种消息格式和运行环境
- 闭环的生命周期管理,防止内存泄漏和重复执行
- 可扩展的自定义消息逻辑,满足不同业务需求
- 详细的日志提示,便于问题排查和调试
如果在使用过程中遇到问题,可通过组件中的日志信息定位问题,也可根据业务需求扩展对应的功能模块。
总结
- 该组件解决了 UniApp 混合开发中 WebView 实例获取、双向通信、返回键冲突等核心痛点,支持 APP/H5 跨环境兼容。
- 核心亮点是自动重试的实例获取机制、闭环的生命周期管理(防止内存泄漏)、标准化的双向通信流程。
- 使用时需注意 H5 端引入 UniApp SDK 并定义对应全局函数,组件 ID 保持一致,避免跨域和初始化时机问题。