"点个赞数字要变""提交评论后列表追加一条""仪表盘数据要自动刷新"——这些都是局部更新。整页刷新的问题是:滚动位置丢失、闪白屏、无谓地重新加载全部资源。
下面按场景从简到繁给几种做法,全部原生 JS,不引框架。
场景一:点按钮拉数据,替换某块区域
最基础的形态。
<div id="status">
<span id="cpu">—</span>
</div>
<button id="refresh">刷新</button>
<script>
async function loadCpu() {
const el = document.getElementById('cpu');
el.textContent = '加载中...';
try {
const res = await fetch('/api/cpu');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
el.textContent = data.value + '%';
} catch (e) {
el.textContent = '加载失败';
console.error(e);
}
}
document.getElementById('refresh').addEventListener('click', loadCpu);
loadCpu(); // 首次自动加载
</script>
配套的后端(Express):
app.get('/api/cpu', (req, res) => {
const load = require('os').loadavg()[0];
res.json({ value: (load * 10).toFixed(1) });
});
要点:fetch 默认不带 cookie,跨域时要 credentials: 'include';res.ok 要判断,404/500 不会自动抛异常。
场景二:表单提交不跳转,成功后局部追加
比如评论框。关键是 e.preventDefault() 阻止默认提交。
<form id="comment-form">
<input name="content" placeholder="说点什么" required />
<button type="submit">发布</button>
</form>
<ul id="comment-list"></ul>
<script>
const form = document.getElementById('comment-form');
const list = document.getElementById('comment-list');
form.addEventListener('submit', async (e) => {
e.preventDefault(); // 阻止整页跳转
const btn = form.querySelector('button');
btn.disabled = true; // 防重复提交
btn.textContent = '发布中...';
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: form.content.value }),
});
if (!res.ok) throw new Error('提交失败');
const saved = await res.json();
// 局部追加,不动其他 DOM
const li = document.createElement('li');
li.textContent = saved.content; // 用 textContent 而不是 innerHTML
list.prepend(li);
form.reset();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = '发布';
}
});
</script>
两个容易踩的点:
list.innerHTML += ...这种写法会把整块重新解析,已绑定的事件监听全丢,还有 XSS 风险。用createElement+textContent更稳。- 提交期间要把按钮
disabled,网速慢的时候用户会连点好几下。
场景三:定时轮询
简单,但要知道代价。
let timer = null;
function startPolling(intervalMs = 5000) {
stopPolling();
timer = setInterval(loadCpu, intervalMs);
}
function stopPolling() {
if (timer) clearInterval(timer);
}
startPolling(5000);
// 页面切到后台就停,回来再启动——省流量也省服务端资源
document.addEventListener('visibilitychange', () => {
document.hidden ? stopPolling() : startPolling(5000);
});
轮询的取舍:实现成本最低,N 个用户就是 N 倍无效请求,数据没变化也在问。间隔设多少很纠结——1 秒太浪费,30 秒又不实时。数据变化不频繁、实时性要求不高的场景(比如每 5 分钟更新一次的报表)用它可以,别用在聊天或实时监控上。
场景四:服务端推送 SSE
服务端有变化才推,比轮询干净得多。适合监控面板、日志尾随、通知。
后端:
app.get('/api/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 先把响应头发过去
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 2000);
// 客户端断开时清理,否则定时器泄漏
req.on('close', () => {
clearInterval(timer);
res.end();
});
});
前端:
const es = new EventSource('/api/stream');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
document.getElementById('cpu').textContent = new Date(data.time).toLocaleTimeString();
};
es.onerror = () => {
// EventSource 会自动重连,这里只做提示
console.warn('连接断开,等待自动重连');
};
// 不用了记得关
// es.close();
SSE 的几个特点:单向(服务端→客户端)、基于 HTTP、自动重连、只能传文本(二进制要 base64)。如果只需要服务端推,SSE 比 WebSocket 简单太多,还天然兼容现有 HTTP 基础设施(nginx 记得关掉对该路径的缓冲)。
nginx 反代 SSE 时要加:
location /api/stream {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off; # 关键,否则数据被缓冲住不往下发
proxy_cache off;
proxy_read_timeout 3600s;
}
什么时候才需要 WebSocket
需要双向实时通信才上它——聊天室、协同编辑、多人游戏、需要客户端频繁主动发消息的场景。代价是要自己处理心跳、重连、消息顺序、水平扩展时的会话共享。能用 SSE 解决的别上 WebSocket。
调试小贴士
- Chrome DevTools → Network → 勾
Preserve log,看每次异步请求的完整记录 - 筛选
Fetch/XHR只看异步请求 - 请求慢时看
Timing标签,区分是排队、DNS、还是服务端处理慢 - 用
console.log打点不如直接在 Network 面板看,后者不会漏
选哪个
| 场景 | 方案 |
|---|---|
| 用户点一下才更新 | fetch + DOM 替换 |
| 表单提交 | preventDefault + fetch |
| 数据几分钟变一次 | 轮询(配合 visibilitychange) |
| 服务端主动推(监控/通知/日志) | SSE |
| 双向实时(聊天/协同) | WebSocket |