如果你還沒決定用 H3 六邊形還是 Geohash 矩形,先看 H3 vs Geohash vs S2 對比。本文只聊下一步的問題:裝哪個 npm 套件。
h3-js 和 ngeohash 分別是 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_int 和 decode_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 遷移指南。
怎麼選
| 場景 | 推薦 | 一句話原因 |
|---|---|---|
| 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 | polygonToCells、compactCells、父子層級 |
| 伺服器端批次處理 | 都行 | 按索引系統選,不用按函式庫選 |
JavaScript 生態裡 S2 的選擇比較尷尬:s2-geometry 8 年沒更新,nodes2ts 相對完整但社群很小。如果專案需要 S2,務實的做法是跑一個 Python/Go 微服務呼叫官方函式庫,Node.js 端去請求它。
想直接體驗 Geohash 和 H3 編碼的話,試試坤輿的 GeoHash & H3 轉換工具——貼上座標就能看到 cell ID 和邊界視覺化。
常見問題
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。兩套索引系統獨立運行,不需要互相轉換。