KunYu
h3-jsngeohashjavascriptspatial-indexnpm

h3-js vs ngeohash:选对 JavaScript 空间索引库

h3-js 和 ngeohash 的包体积、编码性能基准测试、API 对比和生产踩坑记录,附实测数据。

KunYu TeamMarch 25, 202611 分钟阅读

如果你还没决定用 H3 六边形还是 Geohash 矩形,先看 H3 vs Geohash vs S2 对比。本文只聊下一步的问题:装哪个 npm 包

h3-jsngeohash 分别是 H3 和 Geohash 在 JavaScript 生态中下载量最大的库。我们在坤舆的 GeoHash & H3 转换工具 里两个都用过。包体积差了 22 倍,编码速度差 4–6 倍,踩的坑完全不同。下面是实测数据和上线之后才发现的问题。

包体积:86 KB vs 3.8 KB

直接从 node_modules 取 ES module 入口文件 gzip 压缩:

指标 h3-js v4.4.0 ngeohash v0.6.3
ES module(gzipped) 86 KB 3.8 KB
包总体积 6.5 MB 52 KB
运行时依赖 0 0
实现方式 C → Emscripten 纯 JavaScript

差距来自实现方式。h3-js 是 Uber 的 C 库通过 Emscripten 编译出来的,正二十面体投影、六边形网格遍历、多边形裁剪全部编进一个 JS 文件,没法 tree-shake——只 import { latLngToCell } 也会把 464 KB 全部打包进去。ngeohash 就 6 个手写 JS 文件,整个库的体积比 h3-js 的类型定义还小。

86 KB gzipped 大概等于一个 React DOM。服务端无所谓,但前端项目如果只需要一次 encode 调用,这个体积要认真考虑。

编码速度

Node.js v24 上,每个操作跑 100,000 次(预热 1,000 次):

操作 h3-js ngeohash 差距
编码(lat/lng → cell) 85 万次/秒 486 万次/秒 ngeohash 5.7x
解码(cell → lat/lng) 173 万次/秒 758 万次/秒 ngeohash 4.4x
邻居查找 61 万次/秒 47 万次/秒 h3-js 1.3x
边界 / 包围盒 55 万次/秒 929 万次/秒 ngeohash 17x

测试脚本(可以直接跑):

import { latLngToCell, cellToLatLng, gridDisk, cellToBoundary } from "h3-js";
import ngeohash from "ngeohash";

const ITERATIONS = 100_000;

function bench(name, fn) {
  for (let i = 0; i < 1000; i++) fn(); // warmup
  const start = performance.now();
  for (let i = 0; i < ITERATIONS; i++) fn();
  const ms = performance.now() - start;
  const opsPerSec = Math.round((ITERATIONS / ms) * 1000);
  console.log(`${name}: ${opsPerSec.toLocaleString()} ops/sec`);
}

const h3Cell = latLngToCell(37.7749, -122.4194, 9);
const hash = ngeohash.encode(37.7749, -122.4194, 6);

bench("h3-js  encode",    () => latLngToCell(37.7749, -122.4194, 9));
bench("ngeohash encode",  () => ngeohash.encode(37.7749, -122.4194, 6));
bench("h3-js  decode",    () => cellToLatLng(h3Cell));
bench("ngeohash decode",  () => ngeohash.decode(hash));
bench("h3-js  neighbors", () => gridDisk(h3Cell, 1));
bench("ngeohash neighbors", () => ngeohash.neighbors(hash));
bench("h3-js  boundary",  () => cellToBoundary(h3Cell));
bench("ngeohash bbox",    () => ngeohash.decode_bbox(hash));

简单编解码 ngeohash 碾压,因为纯 JS 跑位交错操作 V8 JIT 很擅长,没有 FFI 开销。h3-js 每次调用都要把参数写进 Emscripten 堆、执行 C 函数、再把结果拷回 JavaScript。

但邻居查找 h3-js 反超了。gridDisk 在 C 端的紧凑循环里遍历正二十面体网格,编译代码的算法效率盖过了序列化成本。

边界那行有点不公平:cellToBoundary 要算六边形 6 个顶点坐标,decode_bbox 只是返回 4 个数字。不过这确实是你在生产中分别会调用的操作。

结论:除非你每个请求要处理百万级点位,编码速度不是选库的决定因素。 h3-js 编码一个坐标 1.2 微秒,够用了。

API 对比

编码和解码

import { latLngToCell, cellToLatLng } from "h3-js";
import ngeohash from "ngeohash";

// H3:分辨率 9 ≈ 0.11 km²
const h3Cell = latLngToCell(37.7749, -122.4194, 9);
// → "8928308280fffff"

// Geohash:精度 6 ≈ 0.74 km²
const hash = ngeohash.encode(37.7749, -122.4194, 6);
// → "9q8yyk"

// 解码
const [lat, lng] = cellToLatLng(h3Cell);
const { latitude, longitude } = ngeohash.decode(hash);

邻居查找

import { gridDisk } from "h3-js";

// H3:中心 + 6 个六边形邻居
const h3Neighbors = gridDisk(h3Cell, 1);

// Geohash:8 个邻居(N, NE, E, SE, S, SW, W, NW)
const ghNeighbors = ngeohash.neighbors(hash);

Cell 边界

import { cellToBoundary } from "h3-js";

// H3:6 个 [lat, lng] 顶点
const h3Boundary = cellToBoundary(h3Cell);

// Geohash:包围盒
const bbox = ngeohash.decode_bbox(hash);
// → [minLat, minLon, maxLat, maxLon]

decode_bbox 的返回顺序是 [minLat, minLon, maxLat, maxLon]——南、西、北、东。不是 GeoJSON 的 [west, south, east, north],也不是 Leaflet LatLngBounds 的顺序。搞反了你会拿到地球另一侧的包围盒,而且不会报错,只是结果完全不对。建议永远显式解构:

const [minLat, minLon, maxLat, maxLon] = ngeohash.decode_bbox(hash);

h3-js 自带 TypeScript 类型定义;ngeohash 需要额外装 @types/ngeohash

生产踩坑

集成看起来很顺,上线之后暴露的问题。

H3 Cell ID 被 JSON 序列化截断

这个坑我们踩得最深。H3 cell ID 是 64 位整数,超出了 JavaScript 的安全整数范围(2^53 − 1),所以 h3-js 把 cell ID 作为十六进制字符串返回:"8928308280fffff"

问题出在 API 层。如果后端某个环节把 H3 ID 当 number 写进 JSON,JSON.parse 会静默截断——你拿到的是一个合法但完全不同的数值,不会报错,查询直接返回空结果。我们当时在接口联调的时候数据死活对不上,查了一圈才定位到是序列化的问题。

修复很简单但要做彻底:数据库字段用 string、API schema 用 string、TypeScript 类型用 string。没有例外,不要心存侥幸想着"这个 ID 数值没那么大"。

ngeohash 整数编码的 52 位上限

encode_intdecode_int 用整数代替 Base32 字符串,但上限是 52 位(IEEE 754 float64 的安全整数极限)。bitDepth 超过 52 的话,精度静默丢失,不报错。

Redis 内部也用 52 位 Geohash 整数,所以跟 Redis 配合没问题。但如果你在移植 Python 或 Java 代码里 64 位 Geohash 整数的逻辑,数值对不上,而且很难 debug——因为结果"看起来差不多",只是最后几位不一致。

ngeohash 和其他 Geohash 实现的边界差异

不同语言的 Geohash 库对同一坐标可能给出不同结果,原因是浮点舍入:坐标恰好落在两个 cell 边界上时,不同实现选不同的邻居 cell。

我们在做坤舆 Geohash 工具的时候碰到过这个——前端 ngeohash 编码的结果和后端 Python geohash 库偶尔不一致,概率很低但确实存在。对于 GPS 坐标(精度 ~3 米)编进几百米宽的 cell,实际影响不大。但如果你需要 Node.js 和 Python/Redis 严格一致,拿几组边界坐标写集成测试验证。Redis 的 GEOHASH 命令 是最安全的参考实现。

h3-js v3 → v4 函数全面改名

如果你的项目还在用 v3,升级 v4 的时候几乎所有函数名都变了:

v3 v4
geoToH3 latLngToCell
h3ToGeo cellToLatLng
kRing gridDisk
hexRing gridRingUnsafe
compact compactCells

v4 还改了错误处理——非法输入抛错而不是返回 null。有个兼容层 h3-js/legacy,但错误行为跟 v4 一致,挂兼容层不如一次性全局替换干净。完整改名表见 v3→v4 迁移指南

GeoHash 转换

在 GeoHash、H3、Plus Code 之间转换。

Try it now

怎么选

场景 推荐 一句话原因
Redis GEO / Elasticsearch ngeohash Redis 内部用 Geohash,行为一致
前端近邻搜索 ngeohash 3.8 KB vs 86 KB
六边形热力图 / Deck.gl h3-js Deck.gl 原生支持 H3HexagonLayer
全球聚合(需要一致的 cell 面积) h3-js H3 cell 面积跨纬度差异 <3%
日志里需要人类可读的 ID ngeohash "9q8yyk""8928308280fffff" 好认
多边形覆盖 / 多分辨率 h3-js polygonToCellscompactCells、父子层级
服务端批量处理 都行 按索引系统选,不用按库选

JavaScript 生态里 S2 的选择比较尴尬:s2-geometry 8 年没更新,nodes2ts 相对完整但社区很小。如果项目需要 S2,务实的做法是跑一个 Python/Go 微服务调用官方库,Node.js 端去请求它。

想直接体验 Geohash 和 H3 编码的话,试试坤舆的 GeoHash & H3 转换工具——粘贴坐标就能看到 cell ID 和边界可视化。

GeoHash 转换

在 GeoHash、H3、Plus Code 之间转换。

Try it now

常见问题

h3-js 有 WebAssembly 版本吗?

没有。虽然底层是 C 代码,但 h3-js 通过 Emscripten 编译为纯 JavaScript 而不是 .wasm。好处是不需要单独加载 wasm 文件,所有现代浏览器直接可用;代价是包体积(86 KB gzipped)和调用开销都比原生 wasm 高。社区有过 wasm 版本的讨论但目前没有官方计划。

ngeohash 有 ESM 导出吗?

没有原生 ESM 导出,包只提供 CommonJS。在前端打包工具(Vite、webpack)里通常可以直接 import,打包工具会处理 CJS → ESM 转换。但如果你在 Node.js 里用 "type": "module" 的纯 ESM 项目,需要用 createRequire 或者等社区 fork。这个库 npm 周下载量 10 万+,但维护频率很低,最后一次发布是 2020 年。

两个库可以同时用吗?

可以,没有冲突。一个常见场景是:后端用 H3 做空间聚合分析,前端用 ngeohash 做轻量的地理围栏判断,中间通过 API 传递各自的 cell ID。两套索引系统独立运行,不需要互相转换。