Appearance
用户拒绝定位权限怎么办?用 JavaScript 按 IP 归属地做城市级兜底
做同城功能的开发八成遇到过这个场景:功能上线第二天,用户反馈「我人在上海,列表给我推的全是北京」。查了下埋点,三成用户压根没授权定位——有人嫌弹窗烦,有人担心隐私,iOS 上拒绝过一次之后,系统连再弹的机会都不给。GPS 在室内和地铁里还经常漂移,定位模块直接成了整个产品的短板。
定位拿不到,同城推荐、默认城市、本地化内容全跟着瘫。好在还有条兜底路:IP 归属地。浏览器和 App 拿不到精确坐标没关系,通过 IP 至少能确定用户所在的省和市——城市级精度,做同城排序、展示默认城市绰绰有余。这篇文章用 JavaScript 把「定位权限 → IP 兜底」的降级链写完整,前端一套代码 + 后端一个代理接口,半小时落地。
一、为什么要留 IP 这条后路
先分清两类数据的能力边界:
- GPS/基站定位:能到街道级,但要有权限;被拒、超时、室内漂移都是常态;
- IP 归属地:只能到城市级(精度是城市中心点,不是街道),但永远拿得到——只要用户能联网,就能查出他所在的城市。
同城匹配、首页默认城市、按地区给默认语言和币种,这些场景要的就是「城市」两个字,IP 归属地完全够用。真正需要街道级的导航、找附近门店,才必须依赖 GPS。所以正确的产品逻辑不是二选一,而是降级链:能拿精确定位就用精确定位,拿不到就降级到 IP,功能照常跑,只是精度降一档,同时 UI 上告诉用户「当前按 IP 显示城市级位置」。
另外 App Store 和各大安卓市场对权限审核越来越严,「不给定位也能用」本身就是加分项——这条兜底链直接帮你过了审核里「权限必要性」那一关。
二、方案设计
- 前端三步走:先试
navigator.geolocation.getCurrentPosition(3 秒超时)→ 失败或拒绝 → 请求自己的同源接口/api/geo→ 拿到城市后写 localStorage 缓存(24 小时); - 后端代理:IP9 的免费接口
https://ip9.com.cn/get不带 CORS 头,浏览器直连会跨域失败,所以由自己的 Node 服务转发一次,顺便做缓存和限速; - 返回结构统一:不管走 GPS 还是 IP,前端拿到的都是
{ prov, city, country }这样的结构,业务层无感切换。
注意一个容易踩的坑:IP 归属地接口返回的是城市中心点经纬度,别拿它去做导航级别的定位,文档里写清楚「城市级」,UI 上标注「按 IP 估算」,既诚实又合规。
三、JavaScript 实现
前端(浏览器):
javascript
// geo.js —— 城市定位降级链:GPS → IP 归属地
const CACHE_KEY = 'city_fallback';
const CACHE_TTL = 24 * 3600 * 1000; // 24 小时
async function getCity() {
// 1. 命中缓存直接返回
try {
const cached = JSON.parse(localStorage.getItem(CACHE_KEY));
if (cached && Date.now() - cached.at < CACHE_TTL) return cached.city;
} catch (e) { /* 缓存损坏忽略 */ }
// 2. 优先浏览器定位(3 秒超时,失败/拒绝都不阻塞)
try {
const pos = await new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 3000 });
});
// 拿到经纬度后,逆地理转城市走你自己的服务(高德/腾讯 WebService API)
// 这里省略具体实现,只返回标记
return { source: 'gps', lng: pos.coords.longitude, lat: pos.coords.latitude };
} catch (e) {
// 用户拒绝 / 超时 / 不支持 → 走 IP 兜底
}
// 3. IP 归属地兜底:请求自己的同源代理(避免跨域)
const r = await fetch('/api/geo');
if (!r.ok) throw new Error('geo fallback failed');
const d = (await r.json()).data; // ip9 返回结构:data.country / prov / city
const city = {
source: 'ip',
country: d.country,
prov: d.prov,
city: d.city,
};
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ at: Date.now(), city }));
} catch (e) { /* 隐私模式下 localStorage 可能不可用 */ }
return city;
}
// 使用:同城列表默认排序
async function initHome() {
const city = await getCity();
if (city.source === 'ip') {
showToast('已按 IP 显示城市级位置:' + city.city);
}
// 拿 city.city 去请求同城列表接口……
}后端代理(Node.js,同一种语言):
javascript
// server.js —— /api/geo 同源代理,转发 IP9 并做缓存
const express = require('express');
const app = express();
const cache = new Map(); // ip -> { data, at }
app.get('/api/geo', async (req, res) => {
// 取真实客户端 IP(前面挂了 Nginx/CDN 时用 x-forwarded-for)
const clientIp = (req.headers['x-forwarded-for'] || req.socket.remoteAddress || '')
.split(',')[0].trim();
// 每个 IP 缓存 10 分钟,代理层就把免费版 60 次/分钟的额度省下来了
const hit = cache.get(clientIp);
if (hit && Date.now() - hit.at < 10 * 60 * 1000) {
return res.json(hit.data);
}
const r = await fetch(`https://ip9.com.cn/get?ip=${encodeURIComponent(clientIp)}`);
const data = await r.json();
cache.set(clientIp, { data, at: Date.now() });
if (cache.size > 5000) cache.clear(); // 简单防内存膨胀
res.json(data);
});
app.listen(3000);关键环节拆解:
- 降级链的顺序不能反:GPS 永远优先,IP 永远兜底。反过来,「先查 IP 再试 GPS」会让同城用户多等一轮网络请求;
- 代理是必须的,不是可选项:IP9 接口没有 CORS 头,浏览器直连直接报跨域,走自己的 Node 服务转发一次,顺带把缓存做了,双赢;
- 缓存分层:浏览器 localStorage 管 24 小时,后端 Map 管 10 分钟,两层一起把接口调用压到极低——免费额度 60 次/分钟/IP,对绝大多数站点是富余的。
四、落地注意事项
- 精度文案要诚实:IP 归属地是「城市中心点」,文案写「按 IP 显示的城市级位置」,别写「精准定位」;涉及用户位置展示的合规要求,把「通过 IP 推断城市」写进隐私政策;
- iOS 特殊处理:用户曾在设置里关过定位,
getCurrentPosition会秒拒,不要反复弹窗求授权——直接走 IP 兜底,比三番五次打扰用户体验好得多; - 代理也要防滥用:
/api/geo加个简单的频率限制(单 IP 每分钟最多 60 次),防止被刷;生产环境缓存建议按 IP 用 Map 或 Redis,并定期清理; - 灰度验证:上线后对比「GPS 城市」与「IP 城市」的命中差异,同城推荐转化率不掉即可放心铺量。
总结
定位权限是用户隐私和产品功能之间的拉扯,硬要权限只会把人推走。降级链的思路简单且实用:navigator.geolocation 拿得到就用,拿不到就调自己的 /api/geo 代理,代理层转发 https://ip9.com.cn/get?ip=<客户端IP>(免费版无需注册,返回国家/省市/运营商,城市级精度),前端无感切换、缓存 24 小时。同城推荐、默认城市、语言币种兜底,这一套就够了。真需要街道级的场景,再上 GPS 和逆地理,不迟。