让 Vant 弹出层适配 Uniapp Webview 返回键

问题背景

UniApp Webview 中使用

Vant 组件库时,返回键的行为往往不符合用户预期:


  • Popup、Dialog
    、ActionSheet 等弹出层打开时,用户按下返回键会直接返回上一页,而不是关闭弹出层
  • 多层弹出层叠加时,无法按层级顺序依次关闭
  • Vant 内置的 closeOnPopstate 仅会在页面回退时自动关闭弹窗,而不会阻止页面回退

这导致用户体验与原生应用存在明显差距。

解决方案

@vu1e-spark/back-handler 提供了基于栈的返回键处理机制,可以与 Vant 组件无缝集成,让弹出层正确响应 UniApp 的返回键事件。

核心思路:将每个需要响应返回键的弹出层注册到全局栈中,按后进先出的顺序处理返回事件。

使用方式

1. 安装依赖

复制代码
npm install @vue-spark/back-handler

2. 初始化插件(UniApp 适配)

复制代码
// main.ts
import { BackHandler } from '@vue-spark/back-handler'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App)
  .use((app) => {
    const routerHistory = router.options.history

    let initialPosition = 0
    const hasRouteHistory = () => {
      // 当 vue-router 内部记录的位置不是初始位置时认为还存在历史记录
      return routerHistory.state.position !== initialPosition
    }

    router.isReady().then(() => {
      // 记录初始位置
      initialPosition = routerHistory.state.position as number

      router.afterEach(() => {
        // 每次页面变更后通知 uniapp 是否需要阻止返回键默认行为
        uni.postMessage({
          type: 'preventBackPress',
          data: hasRouteHistory(),
        })
      })
    })

    // 注册插件
    BackHandler.install(app, {
      // 每次增加栈记录时通知 uniapp 阻止返回键默认行为
      onPush() {
        uni.postMessage({
          type: 'preventBackPress',
          data: true,
        })
      },

      // 每次移除栈记录时通知 uniapp 是否阻止返回键默认行为
      onRemove() {
        uni.postMessage({
          type: 'preventBackPress',
          data: BackHandler.stack.length > 0 || hasRouteHistory(),
        })
      },

      // 栈为空时尝试页面回退
      fallback() {
        hasRouteHistory() && router.back()
      },

      // 这里绑定 uniapp webview 触发的 backbutton 事件
      bind(handler) {
        window.addEventListener('uni:backbutton', handler)
      },
    })
  })

  // 在这里注册 router
  .use(router)

  // 挂载应用
  .mount('#app')

通过扩展 Popup 的 setup 函数,让所有基于 Popup 的组件(ActionSheet、ShareSheet、Picker 等)都支持返回键关闭:

复制代码
import { useBackHandler } from '@vue-spark/back-handler'
import { Dialog, Popup } from 'vant'
import { callInterceptor } from 'vant/es/utils'
import { getCurrentInstance, watch } from 'vue'

const { setup } = Popup

// 变更 closeOnPopstate 默认值为 true
Popup.props.closeOnPopstate = {
  type: Boolean,
  default: true,
}

Popup.setup = (props, ctx) => {
  const { emit } = ctx
  const vm = getCurrentInstance()!

  // Dialog 组件基于 Popup,这里需要排除,否则会重复注册
  if (vm.parent?.type !== Dialog) {
    const close = () => {
      return new Promise<void>((resolve, reject) => {
        if (!props.show) {
          return resolve()
        }

        callInterceptor(props.beforeClose, {
          done() {
            emit('close')
            emit('update:show', false)
            resolve()
          },
          canceled() {
            reject(new Error('canceled'))
          },
        })
      })
    }

    const { push, remove } = useBackHandler(
      () => props.show,
      // closeOnPopstate 用于控制是否响应返回键
      () => !!props.closeOnPopstate && close(),
    )

    watch(
      () => props.show,
      (value) => (value ? push() : remove()),
      { immediate: true, flush: 'sync' },
    )
  }

  return setup!(props, ctx)
}

4. 适配 Vant Dialog

Dialog 需要单独适配,因为它基于 Popup 但有独立的关闭逻辑:

复制代码
import { useBackHandler } from '@vue-spark/back-handler'
import { Dialog, showLoadingToast } from 'vant'
import { callInterceptor } from 'vant/es/utils'
import { watch } from 'vue'

// Dialog 的 closeOnPopstate 默认为 true,可以不修改默认值
const { setup } = Dialog

Dialog.setup = (props, ctx) => {
  const { emit } = ctx
  const updateShow = (value: boolean) => emit('update:show', value)

  const close = (action: 'cancel') => {
    updateShow(false)
    props.callback?.(action)
  }

  const getActionHandler = (action: 'cancel') => () => {
    return new Promise<void>((resolve, reject) => {
      if (!props.show) {
        return resolve()
      }

      emit(action)

      if (props.beforeClose) {
        const toast = showLoadingToast({})
        callInterceptor(props.beforeClose, {
          args: [action],
          done() {
            close(action)
            toast.close()
            resolve()
          },
          canceled() {
            toast.close()
            reject(new Error('canceled'))
          },
        })
      } else {
        close(action)
        resolve()
      }
    })
  }

  const { push, remove } = useBackHandler(
    () => props.show,
    // closeOnPopstate 用于控制是否响应返回键
    () => !!props.closeOnPopstate && getActionHandler('cancel')(),
  )

  watch(
    () => props.show,
    (value) => (value ? push() : remove()),
    { immediate: true, flush: 'sync' },
  )

  return setup!(props, ctx)
}

效果

完成上述配置后:

  • Popup、ActionSheet、ShareSheet、Picker、Dialog 等弹出层在打开时,按返回键会关闭弹出层而不是退出页面
  • 多层弹出层会按打开顺序的逆序依次关闭
  • 支持 beforeClose 拦截器进行异步确认
相关推荐
华玥作者5 小时前
[特殊字符] VitePress 对接 Algolia AI 问答(DocSearch + AI Search)完整实战(下)
前端·人工智能·ai
Mr Xu_5 小时前
告别冗长 switch-case:Vue 项目中基于映射表的优雅路由数据匹配方案
前端·javascript·vue.js
前端摸鱼匠5 小时前
Vue 3 的toRefs保持响应性:讲解toRefs在解构响应式对象时的作用
前端·javascript·vue.js·前端框架·ecmascript
sleeppingfrog6 小时前
zebra通过zpl语言实现中文打印(二)
javascript
lang201509286 小时前
JSR-340 :高性能Web开发新标准
java·前端·servlet
ASKED_20196 小时前
Langchain学习笔记一 -基础模块以及架构概览
笔记·学习·langchain
好家伙VCC6 小时前
### WebRTC技术:实时通信的革新与实现####webRTC(Web Real-TimeComm
java·前端·python·webrtc
(❁´◡`❁)Jimmy(❁´◡`❁)7 小时前
Exgcd 学习笔记
笔记·学习·算法
未来之窗软件服务7 小时前
未来之窗昭和仙君(六十五)Vue与跨地区多部门开发—东方仙盟练气
前端·javascript·vue.js·仙盟创梦ide·东方仙盟·昭和仙君
baidu_247438617 小时前
Android ViewModel定时任务
android·开发语言·javascript