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 保持一致,避免跨域和初始化时机问题。
相关推荐
Agatha方艺璇10 小时前
前端开发技术复习笔记
vue·bootstrap·css3·html5·web
小葛要努力19 小时前
创建vue2项目
程序人生·vue
七仔啊19 小时前
基于海康门禁的人员计数系统
vue
步十人2 天前
【Vue3】前置知识简单概述(包括ES6核心语法,模块化ESM以及npm基础)
arcgis·npm·vue·es6
有梦想的程序星空2 天前
【环境配置】Vue3项目离线化本地部署echarts全攻略
前端·javascript·vue·echarts
向日的葵0063 天前
vue路由(二)
前端·javascript·vue.js·vue
小妖6664 天前
Hydration completed but contains mismatches
javascript·vue·vuepress
lianyinghhh4 天前
FlowGame 从零上手:开源 AI 工作流编排框架与 Vue 3 接入实战
python·低代码·开源·vue·rag·flowgame·ai工作流编排
蜡台4 天前
UniApp WebView 组件宽高设置与动态适配全方案
前端·javascript·uniapp·webview·iframe
爱编程的小金4 天前
告别手写分页逻辑:usePagination 从 50 行到 3 行
javascript·vue·前端分页·alova·usepagination