Appearance
前端能直接查访客 IP 归属地吗?浏览器直连接口的三个坑(JavaScript 实战)
先给结论:技术上能发请求,但只要站点稍微正经一点,都不该让前端直连。
前端圈里搜「获取用户 IP 归属地」的教程一大堆,点进去十篇有八篇在讲怎么拿内网 IP、怎么用 WebRTC 试探局域网,真正能落地的没几篇。问题不在代码写不出来,而在架构放错了一层。这篇把三个坑讲清楚,再给一套能直接抄的 JavaScript 实现(前端 + 一层同语言的 Node 中间层)。
一、坑一:跨域不是配一下就能解决的
IP 归属地这类接口是给服务端调的。浏览器里 fetch('https://ip9.com.cn/get?ip=x.x.x.x'),请求确实会发出去,但浏览器会用同源策略卡住响应:没有 Access-Control-Allow-Origin 头,控制台直接报跨域错误,你拿不到数据。
有人会说那就让接口开 CORS。假设真开了,你会遇到下一个问题:你自己的域名也被加进了别人可以随意调用的名单。接口变成公开资源,被爬、被刷、被塞进别人的页面里,都只能自己承受。
还有一层坑更隐蔽:很多站点前面挂着 CDN 或 WAF,浏览器发出的预检请求(OPTIONS)经常被拦截或改写,本地调试通、线上偶发失败,排查起来非常费时间。
二、坑二:额度到底算在谁头上
这是很多人没算明白的一笔账。IP9 免费版的限速是60 次/分钟/IP,注意是「调用方 IP」:
- 如果前端直连,每个访客用的都是自己的 IP,额度天然分散;
- 如果走你自己的服务器代理,所有访客共享你服务器那一个出口 IP 的 60 次/分钟。
所以「改成后端代理」并不等于万事大吉——后端一定要配缓存,而且缓存粒度不能按 IP 一条条存,要按城市或者网段存。同城访客共用一条缓存,命中率能到 90% 以上,60 次/分钟的额度对日活几千的站完全够。反过来,如果不缓存,一个热门页面一秒钟就能把额度打满,后面全是 ret=429。
另外前端直连还有个现实问题:接口地址、参数格式、返回字段全部暴露在源码里。你要换服务商、加参数、限流,都得等用户浏览器缓存失效,改不动。
三、坑三:客户端拿到的归属地,只配用来展示
这一点是原则问题。浏览器里的 JavaScript 跑在用户设备上,用户想改就能改:改内存里的返回值、挂个代理、直接伪造 fetch 的结果,都是几分钟的事。
所以归属地数据在前端的定位只有一个——展示。凡是拿它做判断的地方,都必须放到服务端重新算:
- 按地区限制访问、按地区灰度 → 服务端判断,用请求头里的真实 IP;
- 风控、领券、投票、防刷 → 服务端判断;
- 只是个「您来自:广东深圳」的文案、或者给表单预填个城市 → 前端展示没问题。
四、JavaScript 正确实现
分层是这样:浏览器调自家接口(同源,无跨域问题),Node 中间层从请求头取真实 IP、调 IP9、缓存、只回必要字段。
前端部分(Vue / React / 原生都能用):
javascript
// geo.js —— 前端只管展示,失败就静默降级
const CACHE_KEY = 'geo_city';
const CACHE_TTL = 6 * 60 * 60 * 1000; // 一个浏览器 6 小时足够了
export async function getVisitorCity() {
// 1. 先看本地缓存,避免每次刷新都打自家接口
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (raw) {
const { city, ts } = JSON.parse(raw);
if (Date.now() - ts < CACHE_TTL) return city;
}
} catch (e) { /* 隐私模式下 sessionStorage 可能不可用,忽略 */ }
// 2. 调自家接口,同源、不带任何密钥
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 1500); // 展示用数据不值得等
const resp = await fetch('/api/visitor-geo', { signal: ctrl.signal });
clearTimeout(timer);
if (!resp.ok) return null;
const { city } = await resp.json();
if (!city) return null;
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ city, ts: Date.now() }));
return city;
} catch (e) {
return null; // 超时、接口挂了都不影响页面其他功能
}
}
// 用法:拿到就渲染,拿不到就整块不显示
getVisitorCity().then(city => {
const el = document.querySelector('#visitor-city');
if (el && city) el.textContent = `您来自:${city}`;
});Node 中间层(Express 写法):
javascript
// server.js —— 取真实 IP、调接口、缓存,判断逻辑都放这一层
import express from 'express';
const app = express();
const API = 'https://ip9.com.cn/get';
const cache = new Map(); // 演示用内存缓存,量大换 Redis
const CITY_TTL = 30 * 60 * 1000; // 城市级缓存 30 分钟
const IP_TTL = 24 * 60 * 60 * 1000; // 单个 IP 缓存 1 天,归属地变化很慢
const inflight = new Map(); // 并发去重:同城市同时只查一次
// 真实 IP:只信自己 CDN/网关写入的头,X-Forwarded-For 取第一段做兜底
function clientIp(req) {
const candidates = [
req.headers['cf-connecting-ip'],
req.headers['x-real-ip'],
(req.headers['x-forwarded-for'] || '').split(',')[0],
req.socket.remoteAddress,
];
for (const c of candidates) {
if (!c) continue;
const ip = c.trim().replace(/^::ffff:/, '');
if (ip && ip !== '::1') return ip;
}
return '';
}
async function queryIp9(ip) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 1200);
try {
const url = ip ? `${API}?ip=${encodeURIComponent(ip)}` : API;
const resp = await fetch(url, { signal: ctrl.signal });
const body = await resp.json();
if (body.ret !== 200) return null; // 400 非法 IP、429 限速
return body.data || null;
} catch (e) {
return null; // 超时/网络错误一律走兜底
} finally {
clearTimeout(timer);
}
}
app.get('/api/visitor-geo', async (req, res) => {
const ip = clientIp(req);
if (!ip) return res.json({ city: '' });
const hit = cache.get(ip);
if (hit && Date.now() - hit.ts < IP_TTL) return res.json({ city: hit.city });
// 同一城市并发去重:缓存里没有就先占位,别让 10 个请求打 10 次接口
const key = ip.split('.').slice(0, 3).join('.'); // IPv6 场景可换成 /64 前缀
if (!inflight.has(key)) {
inflight.set(key, queryIp9(ip).finally(() => inflight.delete(key)));
}
const data = await inflight.get(key);
const city = data?.city || data?.prov || '';
if (city) cache.set(ip, { city, ts: Date.now() });
// 只回展示需要的字段,isp、经纬度这些不必给前端
res.json({ city });
});
app.listen(3000);这里有个关键点:/api/visitor-geo 返回的只是展示数据,以后要做地区限制、领券判断,得在这个路由里另写一套服务端判定的逻辑,不能在前端拿 city 去 if-else。
五、上线前再确认几件事
缓存 TTL 别一刀切。 同一个 IP 的归属地几天内基本不变,缓存 1 天没问题;但别把城市映射也缓存 1 天去给别人用,城市级缓存 30 分钟就够,毕竟省市数据本来就不怎么动。
CDN 后面取头要对。 前面挂 Cloudflare 就取 CF-Connecting-IP,挂 Nginx 就让它写入 X-Real-IP。取错了会拿到 CDN 节点的 IP,全站访客显示成同一个城市,这种 bug 很常见。站内 Node 那篇专门讲过取错头的几种情况。
IPv6 别忘了。 现在手机流量很可能是 IPv6,切网段前缀时要按 /64 而不是 IPv4 的 /24,否则缓存命中率会掉一大截。
失败就降级,别开天窗。 展示位置拿不到城市就整块不渲染,比显示「未知」「--」好看得多。
真要用 CORS,只白名单自己的域名。 而不是 Access-Control-Allow-Origin: *。
总结
前端直连归属地接口,省下的那点转发成本,换来的是跨域不稳定、配额暴露、判定不可信三个问题。正确的分层是:浏览器调自家同源接口,Node 中间层负责取真实 IP、缓存、限速和服务端判定,前端只做展示和本地缓存。
接口本身用 IP9 的免费版就行:https://ip9.com.cn/get?ip=<IP>(不传参返回请求方自己的归属地,IPv4/IPv6 都支持,返回国家、省市、区号、运营商、大区、城市中心经纬度等字段),免注册、无需鉴权,免费版 60 次/分钟。官网:https://www.ip9.com.cn