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 呼叫,這個體積要認真考慮。

編碼速度:ngeohash 快 4–6 倍

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 檔案,所有現代瀏覽器直接能跑,但套件體積和呼叫開銷都比原生 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。兩套索引系統獨立運行,不需要互相轉換。