Tất cả bài viết

Nhúng bản đồ MapLibre + ô tìm địa chỉ Việt Nam bằng JavaScript thuần

Hướng dẫn nhúng bản đồ MapLibre và ô tìm kiếm địa chỉ Việt Nam bằng HTML/JavaScript thuần — không cần framework, dùng /v1/suggest của GoGoDuk, đặt ghim từ toạ độ.

Không phải dự án nào cũng cần React. Một landing page, một trang liên hệ, hay một form đặt lịch nhiều khi chỉ cần một bản đồ và một ô tìm địa chỉ — dựng bằng HTML và JavaScript thuần là xong, không phải kéo theo cả một framework.

Bài này làm đúng việc đó: nhúng bản đồ MapLibre GL JS và một ô autocomplete địa chỉ Việt Nam, dùng hai endpoint đã public của GoGoDuk — /v1/suggest để gợi ý và /v1/place/resolve để lấy toạ độ — rồi đặt ghim lên bản đồ. Toàn bộ vừa đủ trong một file HTML cộng một proxy backend nhỏ.

Vì sao chọn JavaScript thuần

Nếu bạn đã có sẵn ứng dụng React/Next.js, hãy dùng component autocomplete trong React — nó gọn gàng hơn cho form phức tạp. Nhưng với một trang tĩnh, thêm React chỉ để có một ô input là quá nặng: bundle lớn, build phức tạp, và một người mới khó sửa.

JavaScript thuần phù hợp khi:

  • Trang nhỏ, ít tương tác — landing page, trang "Liên hệ", "Cửa hàng".
  • Bạn nhúng bản đồ vào một site sẵn có (WordPress, HTML tĩnh) mà không muốn đổi toàn bộ stack.
  • Bạn muốn hiểu rõ từng mắt xích trước khi gói nó vào một component.

Nhúng bản đồ MapLibre cơ bản

MapLibre GL JS là thư viện render bản đồ vector mã nguồn mở (nhánh tách từ Mapbox GL JS, miễn phí). Nạp nó qua CDN và tạo một bản đồ trỏ vào style của bạn:

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <link href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css" rel="stylesheet" />
  <style>
    body { margin: 0; font-family: system-ui, sans-serif; }
    #map { position: absolute; inset: 0; }
    #search { position: absolute; top: 12px; left: 12px; z-index: 1; width: 320px; }
    #search input { width: 100%; padding: 10px 12px; border: 1px solid #d6dce2; border-radius: 8px; }
    #results { background: #fff; border-radius: 8px; margin-top: 4px; box-shadow: 0 6px 18px rgba(0,0,0,.12); }
    .item { padding: 8px 12px; cursor: pointer; }
    .item:hover { background: #eef4fa; }
    .item small { color: #7d8c9a; display: block; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="search">
    <input id="q" type="text" placeholder="Tìm địa chỉ ở Việt Nam..." autocomplete="off" />
    <div id="results"></div>
  </div>
 
  <script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
  <script>
    const map = new maplibregl.Map({
      container: "map",
      style: "https://demotiles.maplibre.org/style.json", // thay bằng style của bạn
      center: [106.7009, 10.7769], // Hồ Chí Minh: [lon, lat]
      zoom: 12,
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");
  </script>
</body>
</html>

Lưu ý MapLibre nhận toạ độ theo thứ tự [lon, lat] (kinh độ trước), ngược với thói quen "lat, lon" — đây là lỗi sai phổ biến nhất khi mới dùng.

Tại sao phải có proxy backend

Cám dỗ lớn nhất là gọi thẳng api.gogoduk.com từ JavaScript trình duyệt. Đừng. Làm vậy thì X-API-Key của bạn nằm lộ thiên trong tab Network và trong source — ai cũng copy được và xài hết quota của bạn.

Cách đúng: trình duyệt gọi một route backend của chính bạn, route đó mới gắn X-API-Key rồi chuyển tiếp sang GoGoDuk. Key không bao giờ rời khỏi server. Ví dụ bằng Node thuần (không framework), nhưng nguyên tắc giống hệt với PHP, Python hay bất kỳ backend nào:

// server.js — proxy nhỏ giấu API key (Node, không framework)
const http = require("http");
 
const API = "https://api.gogoduk.com";
const KEY = process.env.GOGODUK_API_KEY; // đặt trong biến môi trường, KHÔNG hardcode
 
async function forward(path, res) {
  const r = await fetch(`${API}${path}`, { headers: { "X-API-Key": KEY } });
  const body = await r.text();
  res.writeHead(r.status, { "Content-Type": "application/json" });
  res.end(body);
}
 
http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname === "/api/suggest") {
    const input = url.searchParams.get("input") || "";
    return forward(`/v1/suggest?input=${encodeURIComponent(input)}&lang=vi&country=VN`, res);
  }
  if (url.pathname === "/api/resolve") {
    const id = url.searchParams.get("id") || "";
    return forward(`/v1/place/resolve?id=${encodeURIComponent(id)}`, res);
  }
  res.writeHead(404); res.end();
}).listen(3000);

Trình duyệt chỉ thấy /api/suggest/api/resolve — không thấy key.

Thêm ô tìm kiếm + gọi /v1/suggest (có debounce)

/v1/suggest nhận input (tối thiểu 2 ký tự) và trả về danh sách predictions, mỗi item có placeId, mainText (dòng chính) và secondaryText (phần hành chính). Đừng bắn request mỗi ký tự — debounce lại để gộp các lần gõ nhanh thành một request, vừa nhẹ vừa tiết kiệm quota:

const q = document.getElementById("q");
const results = document.getElementById("results");
let timer = null;
 
q.addEventListener("input", () => {
  clearTimeout(timer);
  const text = q.value.trim();
  if (text.length < 2) { results.innerHTML = ""; return; }
  // Gộp các lần gõ trong 300ms thành một request duy nhất.
  timer = setTimeout(() => fetchSuggestions(text), 300);
});
 
async function fetchSuggestions(text) {
  const res = await fetch(`/api/suggest?input=${encodeURIComponent(text)}`);
  const data = await res.json();
  renderResults(data.predictions || []);
}
 
function renderResults(predictions) {
  results.innerHTML = predictions.map((p) => `
    <div class="item" data-id="${p.placeId}">
      ${p.mainText}
      <small>${p.secondaryText || ""}</small>
    </div>
  `).join("");
}

Chọn kết quả → resolve toạ độ → đặt ghim

Autocomplete chỉ trả về placeId (định danh ổn định), chưa có toạ độ. Khi người dùng bấm một gợi ý, gọi /v1/place/resolve để lấy result.latresult.lon, rồi đặt marker và flyTo tới đó:

let marker = null;
 
results.addEventListener("click", async (e) => {
  const item = e.target.closest(".item");
  if (!item) return;
 
  q.value = item.textContent.trim();
  results.innerHTML = "";
 
  const res = await fetch(`/api/resolve?id=${encodeURIComponent(item.dataset.id)}`);
  const { result } = await res.json();
  if (!result) return;
 
  const lngLat = [result.lon, result.lat]; // chú ý: lon trước, và field tên là `lon` (không phải `lng`)
 
  if (marker) marker.remove();
  marker = new maplibregl.Marker()
    .setLngLat(lngLat)
    .setPopup(new maplibregl.Popup().setText(result.address || result.name))
    .addTo(map);
 
  map.flyTo({ center: lngLat, zoom: 16 });
});

Thử gõ "201 Trần Não" rồi chọn gợi ý đầu tiên — bản đồ sẽ bay tới 10.7866, 106.7298 và cắm ghim ngay tại địa chỉ đó. Toàn bộ luồng: gõ → /v1/suggest → chọn → /v1/place/resolve → marker — gói gọn trong một file HTML và một proxy chục dòng.

Vài lưu ý khi đưa lên production

  • Tuyệt đối không để X-API-Key ở client. Luôn đi qua proxy backend như trên.
  • Debounce ô input (200–400ms) để không spam request và không nhảy kết quả loạn.
  • Thứ tự toạ độ: MapLibre và GoGoDuk đều dùng [lon, lat]; field resolve tên là lon (không phải lng). Sai thứ tự là ghim rơi ra giữa biển.
  • Style bản đồ: demotiles.maplibre.org chỉ để thử. Production nên dùng một style/tile nguồn ổn định (tự host hoặc nhà cung cấp tile) để bản đồ Việt Nam nét và đủ chi tiết.

Khi nào nên lên framework

JavaScript thuần đủ tốt cho một ô tìm kiếm và một bản đồ. Nhưng khi form bắt đầu phức tạp — nhiều trường, validate, điều hướng bàn phím trong dropdown, gắn vào state của trang thanh toán — thì một component có cấu trúc sẽ dễ bảo trì hơn. Lúc đó chuyển sang component autocomplete trong React/Next.js, hoặc xem tổng quan về Vietnam Address Autocomplete API để hiểu thêm về thiết kế trải nghiệm. Muốn dựng cả tính năng tìm chi nhánh gần nhất thì có sẵn bài Store Locator bằng MapLibre.

/v1/suggest/v1/place/resolve nằm trong gói free của GoGoDuk: 100 request/ngày mỗi tài khoản, không cần thẻ tín dụng — đủ cho prototype và nhiều trang nhỏ chạy thật. Tạo tài khoản, lấy API key, và nhúng bản đồ đầu tiên ngay chiều nay. Có thắc mắc hay muốn khoe sản phẩm? Tham gia nhóm hỗ trợ Telegram của chúng tôi.

Muốn dùng GoGoDuk?

Miễn phí trọn đời — 100 request/ngày mỗi tài khoản, không cần thẻ tín dụng. Giới hạn cao hơn theo yêu cầu.

Đăng ký →