夜雨聆风学习资料网

ARTICLE · 1337745

除了 80+ 组件,这个 uni-app x 组件库还自带 8 个页面级 Hook

除了 80+ 组件,这个 uni-app x 组件库还自带 8 个页面级 Hook

提到 UI 组件库,可能最先想到的多是按钮、表单、弹窗这些看得见的东西。

但在用 uni-app x 写移动端页面时,真正费时间的是另一层:窗口多高、状态栏要让多少、底部手势条会不会挡住按钮、手指滑到一半算横滑还是竖滑、倒计时切后台回来少了多少秒。

这些每个项目可能都要写一遍,不仅如此,你更多的还要考虑多平台兼容性。

uView Pro X 除了有丰富的组件外,还内置了一组页面级组合式函数(Hook/Composable)。它们和组件共用同一份实现和入口,页面拿到的值与组件内部用的是同一套。

下面我挑了 8 个最常用的能力,以帮我们在写页面时快速复用。

一、8 个 Hook 能力一览

Hook
干什么
一行用法
useWindowInfo
窗口尺寸、状态栏高度、安全区原始数据
const { statusBarHeight, windowWidth } = useWindowInfo()
useSafeArea
顶部 / 底部安全区插入距离
const { top, bottom } = useSafeArea()
useRect
量元素尺寸与位置
const { getRect } = useRect()
useTouch
触摸 / 鼠标手势状态与方向判定
const touch = useTouch()
useCountDown
倒计时,含暂停与重置
const { current, start } = useCountDown({ time: 60000 })
useDebounce
防抖,延迟可临时指定
const { debounce } = useDebounce(500)
useThrottle
节流,首触即执行
const { throttle } = useThrottle(500)
useDeviceInfo
机型、平台、系统版本
const { platform, deviceModel } = useDeviceInfo()

全部从库入口导入,不需要额外装包:

import {   useWindowInfo,   useSafeArea,   useRect,   useTouch } from '@/uni_modules/uview-pro-x'

即使一个 ux-* 组件都没用,这几个 Hook 也能单独用在现有项目里。

二、useWindowInfo:窗口数据全页共享,首帧就准

const {   info,   windowWidth,   windowHeight,   statusBarHeight,   safeArea,   safeAreaInsets } = useWindowInfo()

返回字段直接对应 uni.getWindowInfo() 的常用项:pixelRatio、screenWidth/Height、windowWidth/Height、statusBarHeight、windowTop/windowBottom、screenTop、safeArea、safeAreaInsets。既可以拿 info 整个对象,也可以按字段解构,解构出来的都是 computed。

刷新时机与共享

一般写法是在 setup 里调 uni.getWindowInfo(),但 App 端此时布局还没稳定,拿到的是启动阶段的尺寸,页面很可能会"塌一下再归位"。

而这个 Hook 的刷新时机是两次:

// 模块级单例,所有调用方共享同一份数据源const windowInfo = ref<WindowInfo>(gatherWindowInfo())export function useWindowInfo(): WindowInfoReturn {    refreshWindowInfo()    onMounted(() => {        // 挂载后再刷一次,等 App 端布局稳定        setTimeout(() => {            refreshWindowInfo()        }, 0)    })    // ...}

第一次保证 setup 阶段就能渲染,第二次保证首屏尺寸准确。

数据源是模块级的。页面里 10 个地方调 useWindowInfo(),读的是同一个 ref,不会出现"同一个页面两个高度"。页面和组件拿到的是同一份值,口径自然一致。

statusBarHeight、screenTop 这类字段在部分平台可能缺失,Hook 里统一兜成 0,页面不用到处写 ??。

边界:null 与旋转

safeArea / safeAreaInsets 保留了 null 语义(有的平台就是没有),取值前判空:

const insets = info.value.safeAreaInsetsconst bottom = insets != null ? insets.bottom : 0

另外它不监听 resize 和旋转,横竖屏切换后重新进入页面才会更新。需要实时响应方向变化时,一般用宽高关系判断即可(windowWidth > windowHeight)。

三、useSafeArea:安全区单独取,不和状态栏混用

const { top, bottom } = useSafeArea()

只有两个值,都是 computed,无安全区时为 0。uView Pro X 内部组件自己也在用:ux-tabbar 和 ux-safe-bottom 取底部安全区、ux-popup 给弹层留底部空间,走的都是这一个调用:

// ux-tabbar 内部const { bottom: safeBottom } = useSafeArea()

页面里写吸底按钮、悬浮球、全屏遮罩,也是同一个调用。

为什么要单独拆出来

这个 Hook 存在的理由,是把两个容易混的概念拆开:

名称
是什么
该用在哪
statusBarHeight
系统状态栏高度(时间、信号那一条)
自定义导航栏的顶部避让
safeAreaInsets.top / bottom
安全区插入距离(挖孔、圆角、底部手势条让出的边距)
内容不被遮挡的额外内边距

竖屏手机上两者经常接近,所以"用安全区 top 当状态栏高度"在大部分机型上看不出问题。但 iOS 横屏下 statusBarHeight 是 0,安全区插入仍在,这时就会错位。

所以这层语义在 API 上就分开了:useSafeArea 只表达"安全区插入距离",不掺状态栏;要状态栏就走 useWindowInfo。

写页面时不需要判断机型,也不需要记这两个数的关系。

边界:0 也是正常值

  • 0 是正常值:没有安全区的机型 top / bottom 都会返回 0,所以不要拿 0 当"没取到"的判断依据。
  • 它只暴露上下两个方向。横屏时要避让左侧刘海,直接读 useWindowInfo().safeAreaInsets 的 left / right。

四、useRect:一行量出元素尺寸与位置

const { getRect } = useRect()const box = ref<UniElement | null>(null)async function measure(): Promise<void> {    const rect = await getRect(box.value)    if (rect == null) return    console.log(rect.width, rect.height, rect.top, rect.left)}onMounted((): void => {    measure()})

相比 createSelectorQuery

旧写法是 uni.createSelectorQuery().select(...).boundingClientRect(),回调里拿到的是一组字段,还要处理"没查到元素"的情况。

这个 Hook 直接走元素的 getBoundingClientRectAsync(),返回标准 DOMRect,width / height / top / left / right / bottom 齐全,Web / App / HarmonyOS / 微信小程序同一套。

ref 还没绑定、元素被条件渲染掉,都返回 null,不用区分"查询失败"和"元素不存在"等。

边界:渲染后调用

  • 在渲染后调用,onMounted 是最早的安全时机;setup 阶段时元素还没上屏。
  • 返回的矩形相对视口,页面滚动后要重新测,别缓存一次用到底。

五、useTouch:手势状态与方向判定,三端同一套代码

const touch = useTouch()
<view    @touchstart="touch.start"    @touchmove="touch.move"    @touchend="touch.end"/>

拿到的是一组响应式状态,覆盖了绝大多数手势交互:

字段
含义
startX/startY
起点坐标
deltaX/deltaY
相对起点的位移(带符号)
offsetX/offsetY
位移绝对值
direction'' / 'vertical' / 'horizontal'
isTap
本次是否仍算点击
skipMove
是否应停止消费横移
dragging
是否处于拖拽中

三端事件怎么归一

H5 有鼠标事件(UniMouseEvent),App 端只有触摸事件(UniTouchEvent)。只按一种写,另一边就是"没反应"。

Hook 内部用条件编译分别声明签名,再统一归一成 clientX / clientY:

// #ifndef APPconst start = (e: UniTouchEvent | UniMouseEvent): void => {    const touches = getClientXY(e)// #endif// #ifdef APPconst start = (e: UniTouchEvent): void => {    const touches = e.touches[0]// #endif

页面这边不需要写任何平台判断。H5 / 小程序如果要额外支持鼠标拖拽,补一份 mouse 绑定即可(用 #ifndef APP 包住,App 端不编译)。

斜着滑的时候横向和纵向位移同时在涨,方向会在两者之间反复跳,浮层跟着抖。这里的做法是"先到先锁":

const MIN_DISTANCE = 10           // 有效滑动的最小位移const LOCK_DIRECTION_DISTANCE = 10 // 两轴都小于它时允许改判const TAP_OFFSET = 5              // 超过它就不再算点击
// 锁定方向后,只有两轴位移都很小才允许改判if (direction.value == '' ||    (offsetX.value < LOCK_DIRECTION_DISTANCE && offsetY.value < LOCK_DIRECTION_DISTANCE)) {    direction.value = getSlideDirection(offsetX.value, offsetY.value)}// 手指抖一下不该被当成点击if (isTap.value && (offsetX.value > TAP_OFFSET || offsetY.value > TAP_OFFSET)) {    isTap.value = false}// 判定为竖向滑动后,横向位移不再驱动if (direction.value == 'vertical') {    skipMove.value = true}

skipMove 是给滚动和拖拽抢手势准备的:一旦判定竖向,就别再消费横移,避免"想滚页面结果把浮层拖动了一半"。

一个下拉关闭的完整判定,就是几行:

function onTouchEnd(): void {    touch.end()    if (touch.direction.value == 'vertical' && touch.deltaY.value > 80) {        closePopup()    }}

边界:事件绑定与重复触发

  • 三个事件都要绑。缺 touchend 会让 dragging 一直停在 true。
  • 点击判定用 touchend 时的 isTap,不要再叠加一次 @click,否则一次操作触发两遍。

六、useCountDown:倒计时不用自己维护定时器

const { current, start, pause, reset } = useCountDown({    time: 60 * 1000,    onChange: (t) => { console.log(t.seconds) },    onFinish: () => { console.log('finished') }})start()

current 是 computed,直接给 days / hours / minutes / seconds / milliseconds / remainTime。reset() 不带参数回到初始值,带上参数就按新时长重来;pause() 随时停。

为什么不用 remain--

自己写 remain-- 的问题是会漂:setInterval 不保证准时,App 切后台、H5 标签页不活跃还会被节流甚至暂停,回来时数值就少了。这里记录 endTime,每次 tick 重算:

endTime.value = Date.now() + remainTime.valueconst getRemainTime = (): number => Math.max(endTime.value - Date.now(), 0)

定时器晚触发只会影响刷新频率,不影响剩余时间。

秒级模式下还有一处小优化:秒数没变就不写响应式状态,省掉不必要的渲染:

if (!isSameSecond(remain, remainTime.value) || remain == 0) {    setRemainTime(remain)}

onUnmounted 里会自动清掉定时器,页面退了不会再有回调打到已销毁的组件。

边界:time 与 reset

  • time 只在初始化与 reset() 时生效,运行中改这个配置不会改变当前倒计时。
  • reset() 内部先暂停,重置后要再调 start() 才会继续跑。

七、useDebounce / useThrottle:输入防抖与点击节流

const { debounce } = useDebounce(500)function onInput(val: string): void {    keyword.value = val    debounce(() => { doSearch() })          // 用默认 500ms    debounce(() => { doSearch() }, 200)     // 临时改成 200ms}
const { throttle } = useThrottle(500)// 滚动、拖拽、按钮防连点function onScroll(): void {    throttle(() => { syncPosition() })}

延迟变了怎么办

如果每次调用都新建一个 debounce 实例,上一个的计时器没人清,会出现"偶尔触发、偶尔不触发",改延迟时更明显。

这里按延迟缓存工厂,延迟不变就复用(计时状态保留),变了才重建,并用代数标记让旧实例里未触发的回调失效:

let generation = 0const makeRun = (wait: number): (() => void) => {    const myGen = ++generation    return debounce(() => {        // 代数过期说明工厂已重建,旧回调不再执行        if (myGen === generation && currentCallback != null) {            currentCallback()        }    }, wait)}

useThrottle 是同一套结构,底层是 leading-edge(首触即执行),适合滚动、拖拽、按钮防连点。

边界:别写在模板里

调用要放在函数体里,别写成模板表达式:

<!-- 不推荐:每次渲染都会新建实例 --><ux-input @input="debounce(search, 300)" />

八、useDeviceInfo:机型、平台、系统版本一行取

const {   platform,   deviceBrand,   deviceModel,   osName,   osVersion } = useDeviceInfo()

字段直接对应 uni.getDeviceInfo():deviceBrand、deviceModel、deviceType、deviceOrientation、platform、osName、osVersion、system。

采集时机

设备信息运行期不变,模块加载时采一次就够;窗口信息跟布局有关,才需要在挂载后再刷。两者都是模块级共享,刷新策略不同:

采集时机
挂载后是否刷新
useDeviceInfo
模块加载时一次
不需要
useWindowInfo
模块加载 + 挂载后延迟一次
需要

uni.getSystemInfoSync 已被拆成 getDeviceInfo / getAppBaseInfo / getWindowInfo,设备维度统一以 getDeviceInfo 为准,页面侧不用关心拆到了哪个接口。ux-top-tips 判断顶部偏移时,用的就是 useWindowInfo + useDeviceInfo 这一对。

边界:快照字段

deviceOrientation 是采集时的快照。要响应旋转,判断依据用窗口宽高(windowWidth > windowHeight),不要看这个字段。

九、总结

单独看,每个 Hook 都不复杂,自己也写得出来。最大的问题在于每个页面都得重新写一遍:判空漏一次、定时器忘清一次、挂载后的刷新省一次,线上就多一个偶发问题。

放进库里以后,页面和组件用的是同一套值。ux-tabbar 和 ux-safe-bottom 取底部安全区用 useSafeArea(),ux-popup 同时用 useSafeArea() 和 useWindowInfo(),ux-top-tips 用 useWindowInfo() 加 useDeviceInfo(),ux-sticky、ux-waterfall、ux-button 量尺寸用 useRect()。自己算出来的安全区和组件内建的不一致,这种情况就不会有了。

平台差异也只维护一次:触摸与鼠标的事件差异、条件编译分支、空值兜底、时序问题都写在库里,升级版本跟着修,不用每个页面重踩。

没有第三方运行时库,Hook 和组件从同一个入口导出。装了组件库就都有,不用组件也可以只拿这几个 Hook。

十、如何用

在 DCloud 插件市场搜 uView Pro X,导入 uni-app x 项目,文件会落在 uni_modules/uview-pro-x/。

插件地址:ext.dcloud.net.cn/plugin?id=30343

官方文档:x.uviewpro.cn

统一从库入口命名导入即可使用:

import {     useWindowInfo,     useSafeArea,     useRect,     useTouch,     useCountDown } from '@/uni_modules/uview-pro-x'

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料