uniappx实现app壳子,可直接拿来用

UniApp WebView 完整封装:实现 H5 与 App 双向通信、返回键处理及异常重试

标签:uni-app web-view 混合开发 双向通信 App端开发

在 UniApp 混合开发场景中,web-view 组件是实现 App 嵌入 H5 页面的核心载体,但其原生提供的能力较为基础,在实际项目中往往需要解决双向通信、WebView 实例获取失败、返回键冲突、加载异常重试 等一系列问题。本文将基于 Vue 3 + Setup 语法,完整封装一个高可用的 web-view 组件,覆盖上述核心场景,代码可直接复制落地到项目中。

一、组件核心功能概述

本次封装的 web-view 组件具备以下核心能力,满足大部分混合开发需求:

  1. 屏蔽不同环境差异(APP-PLUS/H5),统一处理逻辑
  2. 实现 App 与 H5 之间的双向消息通信,支持消息格式兼容
  3. 提供 WebView 实例自动重试获取机制,解决实例初始化时机问题
  4. 统一处理页面返回、应用退出逻辑,兼容 Android/iOS 平台差异
  5. 支持自定义扩展消息(更新导航栏标题、页面跳转)
  6. 完善的事件监听清理机制,防止内存泄漏
  7. 页面加载失败提示、返回键监听器注册/销毁闭环

二、完整组件代码(可直接复制)

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. 快速使用步骤

  1. 将上述组件代码复制到 UniApp 项目中(如:components/WebViewPage/WebViewPage.vue
  2. src 赋值 H5 页面地址(如:src.ref = 'https://xxx.com/h5/test'
  3. H5 页面按照上述适配代码改造,引入 UniApp SDK 并定义对应全局函数
  4. 运行到 App 端(Android/iOS)或 H5 端,即可实现双向通信

2. 常见踩坑点

  1. WebView 实例获取失败 :确保 web-view 组件的 id 与代码中 plus.webview.getWebviewById('wv1') 的 ID 一致,避免拼写错误
  2. 双向通信无响应 :H5 页面必须等待 UniAppJSBridgeReady 事件触发后再发送消息,且需引入正确的 UniApp H5 SDK
  3. 返回键无响应 :确保 WebView 页面加载完成后才注册返回键监听器(本次封装已在 onWebviewLoaded 中注册)
  4. iOS 无法退出应用:iOS 系统限制,无法直接将应用移至后台,封装中已做降级处理(提示+退出)
  5. 跨域问题:H5 页面与 App 通信无跨域限制,但 H5 页面自身的接口请求需处理跨域问题

3. 可扩展方向

  1. 增加 WebView 页面加载进度条显示
  2. 增加离线缓存、刷新重试功能
  3. 扩展更多自定义消息类型(如:上传图片、获取设备信息)
  4. 增加小程序环境的适配逻辑
  5. 封装成全局组件,支持通过 props 配置更多参数

五、总结

本次封装的 web-view 组件解决了 UniApp 混合开发中的核心痛点,具备高可用、易扩展、跨平台、无内存泄漏的特点,可直接落地到生产项目中。核心亮点如下:

  1. 自动重试的 WebView 实例获取机制,提高实例获取成功率
  2. 标准化的双向通信流程,兼容多种消息格式和运行环境
  3. 闭环的生命周期管理,防止内存泄漏和重复执行
  4. 可扩展的自定义消息逻辑,满足不同业务需求
  5. 详细的日志提示,便于问题排查和调试

如果在使用过程中遇到问题,可通过组件中的日志信息定位问题,也可根据业务需求扩展对应的功能模块。

总结

  1. 该组件解决了 UniApp 混合开发中 WebView 实例获取、双向通信、返回键冲突等核心痛点,支持 APP/H5 跨环境兼容。
  2. 核心亮点是自动重试的实例获取机制、闭环的生命周期管理(防止内存泄漏)、标准化的双向通信流程。
  3. 使用时需注意 H5 端引入 UniApp SDK 并定义对应全局函数,组件 ID 保持一致,避免跨域和初始化时机问题。
相关推荐
~央千澈~5 小时前
优雅草正版授权系统 - 优雅草科技开源2月20日正式发布
python·vue·php·授权验证系统
Roc.Chang18 小时前
Vite 启动报错:listen EACCES: permission denied 0.0.0.0:80 解决方案
linux·前端·vue·vite
PD我是你的真爱粉1 天前
Vite 项目搭建与Pinia状态管理
前端框架·vue
麦麦大数据1 天前
F071_vue+flask基于YOLOv8的实时目标检测与追踪系统
vue.js·yolo·目标检测·flask·vue·视频检测
lyyl啊辉2 天前
1. Vue3简介
vue.js·vue
lyyl啊辉2 天前
4. Vue-Router机制
vue
FindYou.4 天前
基于mdEditor实现数据的存储和回显(导出pdf&表情包&目录)
javascript·vue
PD我是你的真爱粉6 天前
Vue3核心语法回顾与Composition深入
前端框架·vue
code袁6 天前
基于Springboot+Vue的家教小程序的设计与实现
vue.js·spring boot·小程序·vue·家教小程序
是梦终空7 天前
计算机毕业设计266—基于Springboot+Vue3的共享单车管理系统(源代码+数据库)
数据库·spring boot·vue·课程设计·计算机毕业设计·源代码·共享单车系统