AJAX 实时搜索

Search as you type, with debouncing, request races and empty-state handling.

Intermediate
Key PointsLive search must debounce, or every keystroke fires a request.

The search endpoint

PHP
<?php
// /api/search.php
header("Content-Type: application/json; charset=utf-8");

$kw = trim((string)($_GET["kw"] ?? ""));

if (mb_strlen($kw) < 2) {
    echo json_encode(["ok" => true, "items" => [], "msg" => "至少输入 2 个字"]);
    exit;
}

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare("SELECT id, name FROM users WHERE name LIKE ? ORDER BY name LIMIT 10");
$stmt->execute(["%" . $kw . "%"]);

echo json_encode(["ok" => true, "items" => $stmt->fetchAll()], JSON_UNESCAPED_UNICODE);

Requesting ?kw=o returns:

Output
{"ok":true,"items":[{"id":2,"name":"Bob"},{"id":1,"name":"Tom"}]}
A minimum length is essential: querying on one character multiplies a full scan by every keystroke. Two or three characters is standard.

Debouncing

JavaScript
function debounce(fn, wait = 300) {
  let timer = null;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), wait);
  };
}

const input = document.querySelector("#kw");
const list  = document.querySelector("#list");

const search = debounce(async (kw) => {
  if (kw.trim().length < 2) {
    list.innerHTML = "";
    return;
  }

  const res  = await fetch("/api/search.php?kw=" + encodeURIComponent(kw));
  const data = await res.json();

  list.innerHTML = data.items.length
    ? data.items.map(u => `<li>${escapeHtml(u.name)}</li>`).join("")
    : "<li class='empty'>没有匹配结果</li>";
}, 300);

input.addEventListener("input", e => search(e.target.value));

Debouncing means wait 300ms after typing stops. Five keystrokes produce one request, not five.

Request races

Typing to fires A, then tom fires B. If A returns after B, the UI shows results for to — a race.

JavaScript
let seq = 0;

async function search(kw) {
  const my = ++seq;

  const res  = await fetch("/api/search.php?kw=" + encodeURIComponent(kw));
  const data = await res.json();

  if (my !== seq) return;      // 有更新的请求了,丢弃本次结果
  render(data);
}

// 或者用 AbortController 直接取消旧请求
let controller = null;

async function search2(kw) {
  controller?.abort();
  controller = new AbortController();

  try {
    const res = await fetch("/api/search.php?kw=" + encodeURIComponent(kw), {
      signal: controller.signal,
    });
    render(await res.json());
  } catch (e) {
    if (e.name !== "AbortError") throw e;
  }
}
A sequence number is simple; AbortController also saves bandwidth. Do one or both — without it users see results flicker randomly.

UX details

  • Loading state —— give feedback beyond 300ms
  • Empty state copy —— never leave a blank area
  • Highlight matches —— mark the matched substring
  • Keyboard support —— arrow keys and Enter
  • Close on outside click —— expected dropdown behaviour
PHP
<?php
// 服务端做高亮,避免前端拼接 HTML 时的 XSS 风险
function highlight(string $text, string $kw): string {
    if ($kw === "") {
        return htmlspecialchars($text, ENT_QUOTES, "UTF-8");
    }
    $safe = htmlspecialchars($text, ENT_QUOTES, "UTF-8");
    $safeKw = htmlspecialchars($kw, ENT_QUOTES, "UTF-8");
    return str_ireplace($safeKw, "<mark>$safeKw</mark>", $safe);
}

echo highlight("Tom Hanks", "tom"), "\n";
echo highlight("<b>x</b>", "<b>"), "\n";
Output
<mark>Tom</mark> Hanks
&lt;mark&gt;&lt;b&gt;&lt;/mark&gt;x&lt;/b&gt;
Escape then replace. The reverse order escapes your own <mark>; skipping escaping injects user <script>.

Friends & Partners