Vue3 + TypeScript + uniApp 跨端定位 Hook 封装实践

发布时间:2026/10/13 23:01:51
Vue3 + TypeScript + uniApp 跨端定位 Hook 封装实践
如果你跟我一样最近正好在做一个 Vue3 TypeScript uniApp 的跨端项目大概率会在某个需求里被“获取当前位置”这件事卡住。H5 要用浏览器定位App 要调系统原生定位小程序要走授权、还要看平台给的返回码——表面上看都是拿经纬度实际写起来是三种完全不同的接口和心智模型。这篇文章就是把我在项目里封装通用位置获取 Hook 的过程完整记录下来怎么设计类型和状态、怎么把三端差异关进适配器、怎么处理授权与错误、最后怎么接到业务页面里。适合正在做跨端项目、想让定位能力一套代码多端复用的同学参考。1. 定位需求为什么值得单独抽成一个 Hook1.1 同一个业务场景三套实现先说最直观的现象。同一个“获取用户当前位置”的需求我最早在三端分别写了三套代码// H5 端 navigator.geolocation.getCurrentPosition( (pos) { console.log(pos.coords.longitude, pos.coords.latitude) }, (err) { console.error(err.message) } )// App 端 plus.geolocation.getCurrentPosition( (p) { console.log(p.coords.longitude, p.coords.latitude) }, (e) { console.error(e.message) } )// 小程序端 uni.getLocation({ type: gcj02, success: (res) { console.log(res.longitude, res.latitude) }, fail: (err) { console.error(err.errMsg) } })注意这还只是“获取一次坐标”的最小示例真正的业务里还要考虑权限弹窗、定位失败提示、重复点击、坐标送给地图组件时的坐标系匹配。如果每个页面都把这套东西复制一遍很快就会出现大量条件编译片段和 errMsg 字符串解析改一处需求要同步改三端代码非常痛苦。所以我决定把它收口成一个 Hook让页面只面对一个useLocation。1.2 三端行为差异对照在设计之前先得把三端差异摊开看。我把自己实测到的行为整理成了下面这张表这也是整个封装方案的依据维度H5App小程序底层能力浏览器 geolocation原生定位 / 三方定位 SDK平台提供的 uni.getLocation返回坐标系固定 wgs84可配置常见有 wgs84 / gcj02默认 gcj02可指定授权交互浏览器地址栏弹窗每次调用都可能触发系统级权限询问与设备设置联动应用内授权弹窗拒绝后不再主动弹出失败信息数字错误码 1 / 2 / 3 英文 message中文提示 各平台错误码一段 errMsg内容混杂HTTPS 要求必须非 localhost 走 HTTP 会被限制无无超时控制支持支持但不同设备表现差异大无直接参数底层行为黑盒这张表告诉我们两件事第一H5 的核心难点是坐标系和 HTTPSApp 的核心难点是配置和不同系统版本差异小程序的核心难点是授权和隐私协议。第二既然差异这么大那 Hook 的设计就不能做薄的“统一调用”必须把各端行为都处理干净再对外暴露。1.3 业务侧会跟着踩的连锁问题如果不在 Hook 层面解决这些差异会直接漏到业务代码里最常见的连锁问题有三个。第一个是授权状态无法统一判断。小程序里用户拒绝授权后再次调用 getLocation 会直接失败页面很难用统一逻辑处理H5 则每次调用都可能重新弹窗用户体验完全不可控。第二个是错误信息解析一把梭。不同端返回的错误格式完全不同页面要同时处理“code1”“errMsg.indexOf(NOPERMISSION)”这样的判断写出来的代码基本没法维护。第三个是坐标系混用。H5 默认返回 wgs84而国内地图组件、后端服务大量使用 gcj02直接在业务里混用会出现几百米的偏移查问题的时候很难定位是 API 问题还是转换问题。所以这三点就成了我封装的硬性约束状态一致、错误类型一致、坐标系可配置。2. 把需求翻译成类型先定契约再写逻辑2.1 Hook 对外暴露的状态形状很多封装失败是因为对外暴露的数据结构太“随性”。我先把 Hook 返回的状态定义成固定契约export interface LocationState { loading: boolean longitude: number | null latitude: number | null accuracy: number | null timestamp: number | null coordinateType: wgs84 | gcj02 error: LocationError | null }我特意把经纬度、精度、时间戳都拍平成顶层字段而不是用一个coordinate: { longitude, latitude }对象包起来。原因是业务里经常要单独watch(() state.longitude)拍平之后可以直接监听不需要层层取属性在小程序端和 Vue 的响应式结合也更顺手。error不是字符串而是一个结构化的LocationError对象下面会详细说。这样页面拿到数据之后可以在模板里直接判断state.error?.type而不是去解析字符串。2.2 参数层的取舍Hook 的参数我定义成LocationOptionsexport interface LocationOptions { type?: wgs84 | gcj02 timeout?: number highAccuracy?: boolean autoRequest?: boolean }关于默认值我建议这样设type默认gcj02。因为国内主流地图组件和后端服务大多基于 gcj02小程序默认也返回 gcj02H5 端我们可以在适配器里做一次坐标转换只有明确需要原始 GPS 坐标的业务才传wgs84。timeout默认 6000 毫秒只在 H5 和 App 端生效小程序端没有直接超时参数我会用 Promise 竞速兜底。highAccuracy默认false开启后 H5 会给浏览器传enableHighAccuracy: true小程序端会透传给平台的isHighAccuracy但要注意高精度会明显增加定位耗时和耗电。autoRequest默认false我强烈不建议在组件 setup 里自动触发定位并弹出授权框最好由用户点击触发或者在页面级判断后调用否则用户刚进入页面就被权限弹窗打断被拒概率非常高。2.3 错误类型的归一设计三端错误信息完全不一样所以我把错误统一成几个类型export type LocationErrorType | denied // 用户拒绝授权或权限被移除 | unavailable // 当前环境不支持定位如浏览器无 geolocation | location-disabled // 系统定位服务关闭或某些端配置缺失 | timeout | failed // 其他错误 export interface LocationError { type: LocationErrorType message: string raw?: unknown }这里最关键的原则是不要吞错误也不要盲目归一。一开始我也偷懒只要失败就统一塞进failed结果线上反馈“页面没反应”很难定位。后来我要求三端适配器必须返回type同时把原始错误raw保留下来这样页面走统一文案排查时还能看到底层信息。另一个容易踩的坑是“拒授权”和“系统定位关闭”经常被平台混在一起返回需要我们进一步区分。3. useLocation 核心实现适配器 权限引导3.1 把三端差异关进适配器主 Hook 不应该写任何条件编译逻辑正确做法是把条件编译全部收敛到一个独立的适配器选择模块里。我先定义适配器协议export interface LocationResult { longitude: number latitude: number accuracy: number | null altitude: number | null timestamp: number } export interface LocationAdapter { getLocation(options: NormalizedLocationOptions): PromiseLocationResult }然后在一个专门文件里用 uniApp 的条件编译选择具体实现// #ifdef H5 import { createH5Adapter } from ./adapters/h5 // #endif // #ifdef APP-PLUS import { createAppAdapter } from ./adapters/app // #endif // #ifdef MP import { createMpAdapter } from ./adapters/mp // #endif export function getAdapter(): LocationAdapter { // #ifdef H5 return createH5Adapter() // #endif // #ifdef APP-PLUS return createAppAdapter() // #endif // #ifdef MP return createMpAdapter() // #endif }这里必须说明一个选择为什么不用一个大的if/else在运行时判断平台因为小程序的打包产物对动态导入和运行时平台判断都不友好条件编译是 uniApp 编译期行为没被选中的平台代码根本不会进入产物。把所有差异关进 adapter 目录之后Hook 主体对平台一无所知将来新增平台时也只是再加一个适配器。3.2 各端适配器的实现要点H5 适配器是最直接的把浏览器 geolocation Promise 化export function createH5Adapter(): LocationAdapter { return { getLocation(options) { return new Promise((resolve, reject) { if (!navigator.geolocation) { reject(createError(unavailable, 当前浏览器不支持定位)) return } let settled false navigator.geolocation.getCurrentPosition( (pos) { if (settled) return settled true resolve({ longitude: pos.coords.longitude, latitude: pos.coords.latitude, accuracy: pos.coords.accuracy ?? null, altitude: pos.coords.altitude ?? null, timestamp: Date.now(), }) }, (err) { if (settled) return settled true reject(normalizeH5Error(err)) }, { enableHighAccuracy: options.highAccuracy, timeout: options.timeout, maximumAge: 0, } ) }) }, } }注意settled标志很重要浏览器回调只会触发一次但加上这个标志可以防止极端情况下重复 resolve/reject 导致 Hook 状态错乱。normalizeH5Error负责把浏览器的数字错误码转成我们的LocationErrorTypecode 1 是用户拒绝code 2 是位置不可用code 3 是超时。小程序适配器其实就是对uni.getLocation的 Promise 化包装但要注意透传type和isHighAccuracyexport function createMpAdapter(): LocationAdapter { return { getLocation(options) { return new Promise((resolve, reject) { uni.getLocation({ type: options.type, isHighAccuracy: options.highAccuracy, highAccuracyExpireTime: 5000, success: (res) { resolve({ longitude: res.longitude, latitude: res.latitude, accuracy: res.accuracy ?? null, altitude: res.altitude ?? null, timestamp: Date.now(), }) }, fail: (err) { reject(normalizeMpError(err)) }, }) }) }, } }小程序端没有 timeout 参数我会在 Hook 主流程里统一做Promise.race超时兜底。另外normalizeMpError需要根据errMsg内容区分授权被拒比如包含 auth deny 相关字段和其他系统错误这里不建议写死平台关键词用正则匹配常见提示即可。App 端适配器则使用plus.geolocation.getCurrentPosition我会把geolocation: system作为默认 provider同时用Promise.race自己实现超时。部分 Android 设备首次调用系统定位经常在 6 秒内返回不了结果统一超时后至少能保证页面不会一直转圈。3.3 授权拒绝后的重试链路这是 Hook 里最容易写出“真香”感的部分。我直接把授权处理也纳入统一流程当适配器抛出denied错误时Hook 会暴露openSetting方法让业务层决定下一步。小程序端uni.openSetting会打开授权设置页H5 和 App 端则没有对应概念做成空操作即可。export function openSetting(): Promiseboolean { // #ifdef MP return new Promise((resolve) { uni.openSetting({ success: (res) { // 检查位置权限是否恢复 resolve(!!res.authSetting[scope.userLocation]) }, fail: () resolve(false), }) }) // #endif // #ifndef MP return Promise.resolve(true) // #endif }这里面的真实业务经验是小程序端用户拒绝授权之后再次调用 uni.getLocation 不会再弹授权框只会直接失败。所以页面上除了“重新定位”按钮通常还要放一个“去设置开启权限”的入口点击后调openSetting用户授权成功后再触发getLocation。H5 端则不存在这个困扰浏览器每次调用都可能弹框用户重新允许后马上能拿到坐标。3.4 状态合并与对外导出Hook 主体用一个shallowReactive管理状态不直接导出原始 reactive 对象而是通过readonly冻结export function useLocation(rawOptions: LocationOptions {}) { const options normalizeOptions(rawOptions) const adapter getAdapter() const state shallowReactiveLocationState({ loading: false, longitude: null, latitude: null, accuracy: null, timestamp: null, coordinateType: options.type, error: null, }) let requestSeq 0 async function getLocation(): PromiseLocationResult | null { const seq requestSeq state.loading true state.error null try { const result await withTimeout(adapter.getLocation(options), options.timeout) if (seq ! requestSeq) return null updateState(state, result) return result } catch (err) { if (seq ! requestSeq) return null state.error toLocationError(err) return null } finally { if (seq requestSeq) { state.loading false } } } function reset() { requestSeq state.loading false state.longitude null state.latitude null state.accuracy null state.timestamp null state.error null } if (rawOptions.autoRequest) { getLocation() } return { state: readonly(state), getLocation, openSetting, reset, } }requestSeq这个细节很重要尤其在高频点击、页面回跳的场景下它保证“旧请求永远覆盖不了新请求”。这比单纯try/catch更稳后面接表单选点时也能直接用它做请求失效。4. 接入业务页面三种实际用法4.1 页面加载后自动定位如果产品逻辑要求进入页面就展示附近门店可以用autoRequest: true再通过watch响应定位结果script setup langts import { watch } from vue import { useLocation } from /hooks/useLocation const { state } useLocation({ type: gcj02, autoRequest: true }) watch( () [state.longitude, state.latitude], ([lng, lat]) { if (lng null || lat null) return fetchNearbyShops({ longitude: lng, latitude: lat }) } ) /script这里有一个习惯值得养成永远不要只watchstate.longitude而不判断null。定位还没返回前它本来就是null直接拿去做请求会出很多奇怪的问题。另外自动定位适合内容型页面不适合表单页因为表单页用户刚进来就被弹授权框的体验太差了。4.2 手动触发并传给地图组件地图选点、打卡、派单这类场景通常是一个按钮触发。按钮的disabled直接绑state.loading避免用户疯狂点击script setup langts const { state, getLocation } useLocation({ type: gcj02 }) const center ref({ longitude: 116.4, latitude: 39.9 }) async function locate() { if (state.loading) return const loc await getLocation() if (loc) { center.value { longitude: loc.longitude, latitude: loc.latitude } } } /script这里要特别留意getLocation()已经会在 Hook 内部把坐标写进state所以这里如果只是展示当前位置直接state.longitude也够了。需要返回LocationResult的场景一般是为了把坐标作为函数返回值继续传给非响应式的第三方 SDK 或后端方法。4.3 表单选点请求序号防止旧结果覆盖这是很多项目会漏掉的一个坑。进入表单页的时候自动定位等到结果回来前用户已经在底图上手动拖选择了另一个点这时自动定位的结果返回直接把用户的手动选点覆盖了。我用 requestSeq 解决script setup langts const { state, getLocation } useLocation({ type: gcj02 }) const pickedCoord ref{ longitude: number; latitude: number } | null(null) let seq 0 async function autoLocate() { const currentSeq seq const result await getLocation() if (result currentSeq seq) { pickedCoord.value result } } function onManualPick(coord: { longitude: number; latitude: number }) { seq pickedCoord.value coord } /script用户一旦手动选点seq会让还在路上的定位结果直接作废这个模式比用时间戳比较更可靠。如果再配合 Hook 内部的requestSeq相当于做了一层双保险不会出现页面显示 A 点、表单提交却是 B 点的尴尬。5. 三端实测与踩坑记录5.1 H5 端的 HTTPS 与坐标系H5 定位在本地调试通常没问题但上线之后如果站点不是 HTTPS浏览器会直接拦截定位请求错误表现五花八门。这类问题在开发阶段很难复现我建议上线前先用线上地址真机测一遍。另一点是坐标系。H5 拿到的是 wgs84 原始坐标如果业务地图组件用 gcj02必须做转换。我在工具函数里放了标准 GCJ-02 与 WGS-84 互转实现其中gcj02ToWgs84的核心逻辑如下function outOfChina(longitude: number, latitude: number) { return ( longitude 72.004 || longitude 137.8347 || latitude 0.8293 || latitude 55.8271 ) } function gcj02ToWgs84(longitude: number, latitude: number) { if (outOfChina(longitude, latitude)) { return { longitude, latitude } } // 完整公式这里略主要做偏移补偿 const dLat transformLat(longitude - 105.0, latitude - 35.0) const dLng transformLng(longitude - 105.0, latitude - 35.0) const radLat (latitude / 180.0) * Math.PI let magic Math.sin(radLat) magic 1 - 0.006693421622965943 * magic * magic const sqrtMagic Math.sqrt(magic) const dLatFinal (dLat * 180.0) / ((6378245.0 * (1 - 0.006693421622965943)) / (magic * sqrtMagic) * Math.PI) const dLngFinal (dLng * 180.0) / ((6378245.0 / sqrtMagic) * Math.cos(radLat) * Math.PI) return { longitude: longitude * 2 - (longitude dLngFinal), latitude: latitude * 2 - (latitude dLatFinal), } }转换函数本身不难难的是决定在哪个层做。我的做法是如果 Hook 的options.type是gcj02H5 适配器拿到浏览器返回的 wgs84 后直接转换再返回业务层永远感知不到底层坐标系差异。5.2 App 端的 manifest 配置与系统定位App 端最容易误诊的问题是代码明明没报错但定位就是失败。最常见原因是 manifest 里没有勾选定位模块。需要在 manifest 源码视图的 App 模块权限声明中打开 Geolocation并且 iOS 端配置位置用途描述否则在部分系统版本上授权弹窗文案会是英文甚至直接不弹。plus.geolocation.getCurrentPosition首次调用在部分 Android 设备上非常慢我试过一台中端机冷启动首次定位耗时超过 10 秒。所以我给 App 适配器单独做了两件事一是 Promise 竞速统一超时二是超时后主动重试一次。实操证明第二次定位成功率明显高于第一次可能是系统定位服务还没完全就绪。5.3 小程序端的授权拒绝与隐私协议小程序端最大的坑是“拒绝后不再弹窗”。很多用户第一次点击了拒绝第二次进来时页面怎么引导都没有弹窗出现因为他们没去设置页恢复权限。所以 Hook 里的openSetting在这里是刚需页面上至少要给一句“请在设置中开启定位权限”的提示入口。另一个近期必须注意的问题是平台侧的隐私协议说明。如果小程序没有在对应平台配置位置信息的使用说明调用uni.getLocation会直接报隐私协议相关的错误对用户表现为“定位失败”。这种错误代码层面无法绕过需要在平台管理后台把位置接口的用途文案补上。我在接入文档里专门加了一行检查清单防止打包时漏配。5.4 一次线上定位失败排查复盘最后分享一次真实的排查经历。某天线上反馈部分 Android 设备进入页面后一直提示定位失败但用户说系统设置里已经允许了定位权限。我拉出 Hook 里保留的raw错误字段发现底层返回的errMsg混合了“定位失败”和“无权限操作”两类信息而我的normalizeMpError只按关键词匹配了权限场景错误地把系统定位失败判断成了用户拒绝。修复方式是不要用一两个关键字做粗暴映射而是先匹配“权限拒绝”相关关键词再做剩余兜底归类同时把raw字段原样留在错误对象中。排查这类问题Hook 层保留原始信息起到了决定性作用否则我只能让用户反复清缓存、重进页面根本看不到底层真正在报什么。6. 封装之外的延伸思考6.1 缓存与节流定位能力有一个明显特点短时间内连续调用结果变化不大但授权弹窗体验和耗电却是实打实的。我建议给 Hook 增加一个 5 分钟内的结果缓存key 至少包含type和highAccuracy两个维度因为用不同坐标系或精度参数拿到的结果不能混用。某些业务比如“打卡签到”可能需要每次都实时定位这种情况可以在参数里显式关掉缓存。6.2 持续定位与前后台切换单体 Hook 目前只做“单次获取”但很多业务会扩展出“持续定位”需求。App 端和 H5 端可以通过watchPosition或原生能力持续回调小程序端持续定位的权限和后台限制更复杂建议先不要在最底层做统一抽象。如果确实需要可以在适配器接口里增加startWatch和stopWatch让支持端实现不支持端直接返回unavailable。这样业务层依然是统一 API只是底层能力有差异。6.3 错误文案与埋点错误类型归一之后顺带可以解决一个文案问题不同端的报错信息中英文混杂直接展示给用户非常不友好。我在 Hook 输出的LocationError上做了一层文案映射三端统一成“定位失败请检查权限设置”这类可控文案同时把error.type上报到埋点。这样线上出问题时可以通过错误类型的分布快速判断是权限问题、超时问题还是系统问题不用再捞一堆原始日志。写到这里第三个跨端项目教会我的事其实很简单这种“平台差异大、业务影响广、但又容易被低估”的能力越早收口越好。把条件编译藏在 hooks 目录里让业务页面只面对useLocation整个团队的心智负担会小非常多。如果你也在做类似的封装不妨从本文的适配器结构开始先接一个端跑通再补齐其他端。定位问题大概率不会只出现一次早收口早省心。