ARTICLE · 1337745
除了 80+ 组件,这个 uni-app x 组件库还自带 8 个页面级 Hook
提到 UI 组件库,可能最先想到的多是按钮、表单、弹窗这些看得见的东西。
但在用 uni-app x 写移动端页面时,真正费时间的是另一层:窗口多高、状态栏要让多少、底部手势条会不会挡住按钮、手指滑到一半算横滑还是竖滑、倒计时切后台回来少了多少秒。
这些每个项目可能都要写一遍,不仅如此,你更多的还要考虑多平台兼容性。
uView Pro X 除了有丰富的组件外,还内置了一组页面级组合式函数(Hook/Composable)。它们和组件共用同一份实现和入口,页面拿到的值与组件内部用的是同一套。
下面我挑了 8 个最常用的能力,以帮我们在写页面时快速复用。
一、8 个 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/ | |
deltaX/ | |
offsetX/ | |
direction | '' / |
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'














































































