2026年3月31日

React 地理定位与设备 API Hooks

现代 Web 应用越来越依赖设备的能力——需要知道用户在哪里、是否在线、用的什么网络、运行在什么平台上。浏览器通过一系列 API(Geolocation、Network Information、Permissions、Navigator)暴露了这些信息,但要在 React 组件中正确使用它们并不简单。你需要管理监听器、处理权限状态、清理订阅、兼容 SSR——同时还要保持代码的可读性。

本文介绍 ReactUse 中五个封装了设备 API 的 hooks:useGeolocationusePermissionuseNetworkuseOnlineusePlatform。对于每个 hook,我们先看看手动实现有多麻烦,再看 hook 如何简化代码。最后,我们会用这些 hook 搭建三个实战案例。

1. 地理定位:获取用户位置

手动实现

Geolocation API 是基于回调的,需要仔细处理清理逻辑:

import { useState, useEffect } from "react";

function useManualGeolocation() {
  const [position, setPosition] = useState<{
    latitude: number | null;
    longitude: number | null;
  }>({ latitude: null, longitude: null });
  const [error, setError] = useState<GeolocationPositionError | null>(null);

  useEffect(() => {
    if (!navigator.geolocation) {
      return;
    }

    const watchId = navigator.geolocation.watchPosition(
      (pos) => {
        setPosition({
          latitude: pos.coords.latitude,
          longitude: pos.coords.longitude,
        });
      },
      (err) => {
        setError(err);
      },
      { enableHighAccuracy: true }
    );

    return () => navigator.geolocation.clearWatch(watchId);
  }, []);

  return { position, error };
}

这段代码只覆盖了基础功能——没有暴露精度、海拔、航向和速度信息,也没有追踪加载状态。而且每个需要定位的组件都得重复写一遍。

Hook 方案:useGeolocation

useGeolocation 把整个 Geolocation API 封装成了一个响应式对象:

import { useGeolocation } from "@reactuses/core";

function LocationDisplay() {
  const { coordinates, error, loading } = useGeolocation();

  if (loading) return <p>正在获取位置...</p>;
  if (error) return <p>定位失败:{error.message}</p>;

  return (
    <div>
      <p>纬度:{coordinates?.latitude}</p>
      <p>经度:{coordinates?.longitude}</p>
      <p>精度:{coordinates?.accuracy}m</p>
      <p>海拔:{coordinates?.altitude ?? "不可用"}</p>
      <p>速度:{coordinates?.speed ?? "不可用"}</p>
    </div>
  );
}

这个 hook 在内部调用 watchPosition,在等待首次定位时提供 loading 标志,暴露完整的 GeolocationCoordinates 对象(纬度、经度、精度、海拔、航向、速度),并在组件卸载时自动清理监听器。

2. 权限检测:检查浏览器授权状态

手动实现

Permissions API 本身很简单,但它是异步的,而且权限状态可能随时变化:

import { useState, useEffect } from "react";

function useManualPermission(name: PermissionName) {
  const [state, setState] = useState<PermissionState>("prompt");

  useEffect(() => {
    let permissionStatus: PermissionStatus | null = null;

    navigator.permissions.query({ name }).then((status) => {
      permissionStatus = status;
      setState(status.state);

      status.addEventListener("change", () => {
        setState(status.state);
      });
    });

    return () => {
      if (permissionStatus) {
        permissionStatus.removeEventListener("change", () => {
          setState(permissionStatus!.state);
        });
      }
    };
  }, [name]);

  return state;
}

这里有一个不易察觉的 bug:清理函数中创建了新的匿名函数引用,所以事件监听器实际上不会被正确移除。这是一个很常见的错误。

Hook 方案:usePermission

usePermission 正确处理了所有这些细节:

import { usePermission } from "@reactuses/core";

function CameraAccess() {
  const cameraPermission = usePermission("camera");

  return (
    <div>
      <p>相机权限:{cameraPermission}</p>
      {cameraPermission === "denied" && (
        <p>相机访问已被拒绝,请在浏览器设置中开启。</p>
      )}
      {cameraPermission === "prompt" && (
        <p>点击下方按钮请求相机访问权限。</p>
      )}
      {cameraPermission === "granted" && (
        <p>相机已就绪,可以使用。</p>
      )}
    </div>
  );
}

这个 hook 返回一个响应式的 PermissionState 值("granted""denied""prompt"),当用户在浏览器设置中修改权限时会自动更新。

3. 网络信息:连接类型与质量

手动实现

Network Information API(navigator.connection)提供了有效连接类型和下行速度等详细信息,但并非所有浏览器都支持,而且需要监听事件:

import { useState, useEffect } from "react";

interface NetworkState {
  online: boolean;
  downlink?: number;
  effectiveType?: string;
  type?: string;
  saveData?: boolean;
}

function useManualNetwork(): NetworkState {
  const [state, setState] = useState<NetworkState>({
    online: typeof navigator !== "undefined" ? navigator.onLine : true,
  });

  useEffect(() => {
    const connection = (navigator as any).connection;

    const updateState = () => {
      setState({
        online: navigator.onLine,
        downlink: connection?.downlink,
        effectiveType: connection?.effectiveType,
        type: connection?.type,
        saveData: connection?.saveData,
      });
    };

    updateState();

    window.addEventListener("online", updateState);
    window.addEventListener("offline", updateState);
    connection?.addEventListener("change", updateState);

    return () => {
      window.removeEventListener("online", updateState);
      window.removeEventListener("offline", updateState);
      connection?.removeEventListener("change", updateState);
    };
  }, []);

  return state;
}

为了读取一些简单的网络信息,这也太多样板代码了。

Hook 方案:useNetwork

useNetwork 提供完整的网络状态视图:

import { useNetwork } from "@reactuses/core";

function NetworkInfo() {
  const network = useNetwork();

  return (
    <div>
      <p>在线状态:{network.online ? "在线" : "离线"}</p>
      <p>连接类型:{network.type ?? "未知"}</p>
      <p>等效类型:{network.effectiveType ?? "未知"}</p>
      <p>下行带宽:{network.downlink ? `${network.downlink} Mbps` : "未知"}</p>
      <p>省流模式:{network.saveData ? "已开启" : "已关闭"}</p>
    </div>
  );
}

这个 hook 同时订阅了 online/offline 事件和 Network Information API 的 change 事件,提供一个始终反映当前连接状态的响应式对象。

4. 在线状态:简单的联网检测

有时候你不需要完整的网络信息——只需要知道用户是否在线。

手动实现

import { useState, useEffect } from "react";

function useManualOnline() {
  const [online, setOnline] = useState(
    typeof navigator !== "undefined" ? navigator.onLine : true
  );

  useEffect(() => {
    const handleOnline = () => setOnline(true);
    const handleOffline = () => setOnline(false);

    window.addEventListener("online", handleOnline);
    window.addEventListener("offline", handleOffline);

    return () => {
      window.removeEventListener("online", handleOnline);
      window.removeEventListener("offline", handleOffline);
    };
  }, []);

  return online;
}

Hook 方案:useOnline

useOnline 把这一切简化为一个布尔值:

import { useOnline } from "@reactuses/core";

function ConnectionBadge() {
  const online = useOnline();

  return (
    <span style={{
      display: "inline-flex",
      alignItems: "center",
      gap: 6,
      padding: "4px 12px",
      borderRadius: 16,
      backgroundColor: online ? "#dcfce7" : "#fee2e2",
      color: online ? "#166534" : "#991b1b",
      fontSize: 14,
    }}>
      <span style={{
        width: 8,
        height: 8,
        borderRadius: "50%",
        backgroundColor: online ? "#22c55e" : "#ef4444",
      }} />
      {online ? "在线" : "离线"}
    </span>
  );
}

5. 平台检测:识别运行环境

手动实现

检测用户平台需要解析 User Agent 字符串,这件事出了名的不可靠,代码也很冗长:

import { useState, useEffect } from "react";

function useManualPlatform() {
  const [platform, setPlatform] = useState<string>("");

  useEffect(() => {
    const ua = navigator.userAgent;
    if (/Win/.test(ua)) setPlatform("Windows");
    else if (/Mac/.test(ua)) setPlatform("macOS");
    else if (/Linux/.test(ua)) setPlatform("Linux");
    else if (/Android/.test(ua)) setPlatform("Android");
    else if (/iPhone|iPad/.test(ua)) setPlatform("iOS");
    else setPlatform("Unknown");
  }, []);

  return platform;
}

Hook 方案:usePlatform

usePlatform 提供结构化的平台信息:

import { usePlatform } from "@reactuses/core";

function PlatformBanner() {
  const platform = usePlatform();

  return (
    <div>
      <p>当前平台:{platform}</p>
    </div>
  );
}

这在展示平台相关的操作指引(比如 Cmd 和 Ctrl 的区别)、键盘快捷键提示或下载链接时非常有用。


实战案例 1:附近门店查找器

我们来构建一个门店定位器,根据用户的 GPS 位置显示最近的门店。这里结合使用 useGeolocationusePermission,实现流畅的权限交互。

import { useGeolocation, usePermission } from "@reactuses/core";

interface Store {
  name: string;
  lat: number;
  lng: number;
  address: string;
}

const STORES: Store[] = [
  { name: "南京东路店", lat: 31.2362, lng: 121.4737, address: "南京东路 123 号" },
  { name: "徐家汇店", lat: 31.1955, lng: 121.4365, address: "肇嘉浜路 456 号" },
  { name: "浦东陆家嘴店", lat: 31.2363, lng: 121.5056, address: "世纪大道 789 号" },
];

function haversineDistance(
  lat1: number, lon1: number,
  lat2: number, lon2: number
): number {
  const R = 6371; // 地球半径,单位:公里
  const dLat = ((lat2 - lat1) * Math.PI) / 180;
  const dLon = ((lon2 - lon1) * Math.PI) / 180;
  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos((lat1 * Math.PI) / 180) *
      Math.cos((lat2 * Math.PI) / 180) *
      Math.sin(dLon / 2) *
      Math.sin(dLon / 2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  return R * c;
}

function StoreLocator() {
  const locationPermission = usePermission("geolocation");
  const { coordinates, error, loading } = useGeolocation();

  if (locationPermission === "denied") {
    return (
      <div style={{ padding: 24, backgroundColor: "#fef2f2", borderRadius: 8 }}>
        <h3>需要位置权限</h3>
        <p>
          要查找附近门店,请在浏览器设置中开启位置权限,然后刷新页面。
        </p>
      </div>
    );
  }

  if (loading) {
    return <p>正在定位中...</p>;
  }

  if (error) {
    return <p>无法获取位置信息:{error.message}</p>;
  }

  const userLat = coordinates?.latitude ?? 0;
  const userLng = coordinates?.longitude ?? 0;

  const sortedStores = [...STORES]
    .map((store) => ({
      ...store,
      distance: haversineDistance(userLat, userLng, store.lat, store.lng),
    }))
    .sort((a, b) => a.distance - b.distance);

  return (
    <div>
      <h2>附近门店</h2>
      <p style={{ color: "#6b7280", fontSize: 14 }}>
        你的位置:{userLat.toFixed(4)}, {userLng.toFixed(4)}
      </p>
      <ul style={{ listStyle: "none", padding: 0 }}>
        {sortedStores.map((store) => (
          <li
            key={store.name}
            style={{
              padding: 16,
              marginBottom: 8,
              borderRadius: 8,
              border: "1px solid #e5e7eb",
            }}
          >
            <strong>{store.name}</strong>
            <p style={{ margin: "4px 0", color: "#6b7280" }}>{store.address}</p>
            <p style={{ margin: 0, fontWeight: 600 }}>
              距你 {store.distance.toFixed(1)} 公里
            </p>
          </li>
        ))}
      </ul>
    </div>
  );
}

这里的关键在于权限检查。通过读取 usePermission("geolocation") 的返回值,我们可以在用户与定位弹窗交互之前就显示有用的提示信息。如果权限是 "denied",直接展示引导说明,而不是一个报错的 UI。如果权限是 "prompt",浏览器会在 useGeolocation 尝试获取位置时自动弹出授权请求。

实战案例 2:离线感知的数据同步

这个组件根据网络状况自适应调整行为。离线时,将修改缓存到本地;网络恢复后,自动同步。它使用 useOnline 做简单的联网检测,配合 useNetwork 根据连接质量决定同步策略。

import { useState, useEffect, useCallback } from "react";
import { useOnline, useNetwork, useLocalStorage } from "@reactuses/core";

interface PendingChange {
  id: string;
  data: string;
  timestamp: number;
}

function OfflineAwareEditor() {
  const online = useOnline();
  const network = useNetwork();
  const [pendingChanges, setPendingChanges] = useLocalStorage<PendingChange[]>(
    "pending-changes",
    []
  );
  const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "error">("idle");
  const [content, setContent] = useState("");

  const isSlowConnection =
    network.effectiveType === "slow-2g" || network.effectiveType === "2g";

  const saveChange = useCallback(() => {
    if (!content.trim()) return;

    const change: PendingChange = {
      id: crypto.randomUUID(),
      data: content,
      timestamp: Date.now(),
    };

    if (online && !isSlowConnection) {
      // 网速正常:立即同步
      setSyncStatus("syncing");
      fetch("/api/save", {
        method: "POST",
        body: JSON.stringify(change),
      })
        .then(() => setSyncStatus("idle"))
        .catch(() => {
          // 同步失败——加入队列
          setPendingChanges((prev) => [...(prev ?? []), change]);
          setSyncStatus("error");
        });
    } else {
      // 离线或慢速连接:先保存到本地
      setPendingChanges((prev) => [...(prev ?? []), change]);
    }
  }, [content, online, isSlowConnection, setPendingChanges]);

  // 恢复在线后自动同步
  useEffect(() => {
    if (!online || !pendingChanges?.length) return;
    if (isSlowConnection) return; // 等待网络好转再同步

    setSyncStatus("syncing");

    Promise.all(
      pendingChanges.map((change) =>
        fetch("/api/save", {
          method: "POST",
          body: JSON.stringify(change),
        })
      )
    )
      .then(() => {
        setPendingChanges([]);
        setSyncStatus("idle");
      })
      .catch(() => {
        setSyncStatus("error");
      });
  }, [online, isSlowConnection]);

  return (
    <div style={{ maxWidth: 600 }}>
      {/* 状态栏 */}
      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
          padding: "8px 16px",
          marginBottom: 16,
          borderRadius: 8,
          backgroundColor: online ? "#f0fdf4" : "#fef9c3",
          fontSize: 14,
        }}
      >
        <span>{online ? "在线" : "离线——修改将保存到本地"}</span>
        {isSlowConnection && online && (
          <span style={{ color: "#92400e" }}>检测到慢速网络</span>
        )}
        {(pendingChanges?.length ?? 0) > 0 && (
          <span>
            {pendingChanges!.length} 条待同步
          </span>
        )}
      </div>

      {/* 编辑区 */}
      <textarea
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="在这里输入内容..."
        style={{
          width: "100%",
          height: 200,
          padding: 12,
          borderRadius: 8,
          border: "1px solid #d1d5db",
          resize: "vertical",
          fontFamily: "inherit",
        }}
      />

      <button
        onClick={saveChange}
        disabled={syncStatus === "syncing" || !content.trim()}
        style={{
          marginTop: 12,
          padding: "10px 20px",
          borderRadius: 8,
          border: "none",
          backgroundColor: "#2563eb",
          color: "#fff",
          cursor: "pointer",
          opacity: syncStatus === "syncing" || !content.trim() ? 0.5 : 1,
        }}
      >
        {syncStatus === "syncing"
          ? "同步中..."
          : online
            ? "保存"
            : "保存到本地"}
      </button>
    </div>
  );
}

useOnlineuseNetwork 的组合让这个组件具备了两层智能判断:它知道能否连通服务器,也知道连接是否快到可以发起同步。在 2G 网络下,先缓存到本地、等网络好转后再同步,比发起一个可能超时的慢请求要明智得多。

实战案例 3:基于权限的功能门控

这个组件在显示通知设置面板之前先检查用户是否已授予通知权限。如果权限被拒绝,会给出修复方法的说明。这个模式适用于任何需要权限的功能——摄像头、麦克风、地理定位或通知。

import { usePermission, usePlatform } from "@reactuses/core";
import { useState } from "react";

function NotificationSettings() {
  const notifPermission = usePermission("notifications");
  const platform = usePlatform();
  const [frequency, setFrequency] = useState("daily");

  const requestPermission = async () => {
    if ("Notification" in window) {
      await Notification.requestPermission();
    }
  };

  // 根据平台展示不同的设置指引
  const getSettingsInstructions = () => {
    const p = platform?.toLowerCase() ?? "";
    if (p.includes("mac")) {
      return "前往系统设置 > 通知 > 你的浏览器,开启通知权限。";
    }
    if (p.includes("win")) {
      return "前往设置 > 系统 > 通知,为浏览器开启通知权限。";
    }
    return "请检查系统通知设置,为浏览器开启通知权限。";
  };

  if (notifPermission === "denied") {
    return (
      <div style={{ padding: 24, backgroundColor: "#fef2f2", borderRadius: 8 }}>
        <h3>通知已被拒绝</h3>
        <p>
          你已拒绝了本站的通知权限。要重新开启:
        </p>
        <ol>
          <li>点击浏览器地址栏的锁形图标</li>
          <li>找到"通知"选项,将其改为"允许"</li>
          <li>刷新页面</li>
        </ol>
        <p style={{ color: "#6b7280", fontSize: 14 }}>
          {getSettingsInstructions()}
        </p>
      </div>
    );
  }

  if (notifPermission === "prompt") {
    return (
      <div style={{ padding: 24, backgroundColor: "#eff6ff", borderRadius: 8 }}>
        <h3>开启通知</h3>
        <p>开启通知,及时接收重要更新和提醒。</p>
        <button
          onClick={requestPermission}
          style={{
            padding: "10px 20px",
            borderRadius: 8,
            border: "none",
            backgroundColor: "#2563eb",
            color: "#fff",
            cursor: "pointer",
          }}
        >
          开启通知
        </button>
      </div>
    );
  }

  // 已授权——展示完整的设置面板
  return (
    <div style={{ padding: 24, backgroundColor: "#f0fdf4", borderRadius: 8 }}>
      <h3>通知设置</h3>
      <p style={{ color: "#166534" }}>通知已开启。</p>

      <label style={{ display: "block", marginTop: 16 }}>
        <strong>推送频率:</strong>
        <select
          value={frequency}
          onChange={(e) => setFrequency(e.target.value)}
          style={{ marginLeft: 8, padding: 4 }}
        >
          <option value="realtime">实时推送</option>
          <option value="daily">每日汇总</option>
          <option value="weekly">每周摘要</option>
        </select>
      </label>
    </div>
  );
}

注意 usePlatform 在通知被拒绝时用来展示对应平台的操作指引。这种上下文相关的帮助信息能显著减少因为”权限被拒绝”而直接放弃的用户。

useOnline 和 useNetwork 的选择

两个 hook 都跟网络连接相关,但应用场景不同:

特性useOnlineuseNetwork
返回值boolean包含 onlinetypeeffectiveTypedownlinksaveData 的对象
适用场景简单的在线/离线开关根据连接质量做自适应处理
浏览器兼容性所有浏览器基于 Chromium 的浏览器(Network Information API)
性能开销极小极小

只需要知道用户是否联网时用 useOnline。需要根据网络质量做差异化处理时用 useNetwork——比如在弱网环境下加载低分辨率图片,或者推迟非关键的网络请求。

错误处理与 SSR 兼容

这五个 hook 都是 SSR 安全的。它们在服务端返回合理的默认值,只在客户端组件挂载后才启动浏览器 API 的订阅:

  • useGeolocation 在首次定位完成前返回 loading: true
  • usePermission 默认返回 "prompt"
  • useNetwork 在服务端返回 { online: true }
  • useOnline 在服务端返回 true
  • usePlatform 在服务端返回空字符串

这意味着你可以在 Next.js、Remix 或任何 SSR 框架中直接使用,无需条件导入或动态加载。

安装

npm install @reactuses/core

然后按需导入:

import {
  useGeolocation,
  usePermission,
  useNetwork,
  useOnline,
  usePlatform,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。查看全部 →