AJAX 实时搜索
Search as you type, with debouncing, request races and empty-state handling.
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
<mark><b></mark>x</b>Escape then replace. The reverse order escapes your own
<mark>; skipping escaping injects user <script>.搜索接口
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);请求 ?kw=o 返回:
Output
{"ok":true,"items":[{"id":2,"name":"Bob"},{"id":1,"name":"Tom"}]}最短长度限制很重要:输入 1 个字符就查库,等于把全表扫描的开销乘上按键次数。2~3 个字符起步是通行做法。
防抖
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));防抖的意思是:停止输入 300ms 后才发请求。连续敲 5 个字母只发 1 次,而不是 5 次。
请求竞态
输入 to 发请求 A,接着输入 tom 发请求 B。如果 A 比 B 后返回,界面会显示 to 的结果——这就是竞态。
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;
}
}序列号法简单可靠;
AbortController 更彻底(省带宽)。两种都要做,或者至少做一种——实时搜索不加竞态处理,用户会看到随机闪烁的结果。体验细节
- 加载中提示 —— 300ms 以上的等待要给反馈
- 空结果文案 —— 别留一片空白
- 高亮命中词 —— 结果里标出匹配部分
- 键盘操作 —— 上下键选择,回车跳转
- 点击外部关闭 —— 下拉列表的常规行为
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
<mark><b></mark>x</b>高亮必须先转义再替换。反过来(先替换再转义)会把
<mark> 也转义掉,高亮失效;而如果只替换不转义,用户输入 <script> 就直接注入了。