单页面应用的核心特征是不触发整页刷新,所有视图切换、数据加载、路由跳转均由 JavaScript 在当前页面内完成,关键判断标准为 location.href 变化而 document.body 未被整体替换且 DOMContentLoaded 不重复触发。
单页面应用(SPA)不是指“只有一个 HTML 文件”,而是指用户在访问过程中,不触发整页刷新,所有视图切换、数据加载、路由跳转都由 JavaScript 在当前页面内完成。关键判断标准是:location.href 变了,但 document.body 没被整个替换,DOMContentLoaded 不会再次触发。
不需要框架也能做 SPA,核心是监听 URL 变化并动态更新内容。重点不在“怎么写得漂亮”,而在“怎么避免常见断裂点”:
popstate 事件,而不是只靠 hashchange —— 否则前进/后退按钮失效history.pushState() 第一个参数(state 对象)不能为 null,Chrome 120+ 会静默失败,建议传空对象 {}
location.pathname 或 location.hash 并渲染对应视图,否则刷新页面直接白屏示例片段:
function navigate(path) {
history.pushState({}, '', path);
renderView(path);
}
window.addEventListener('popstate', () => {
renderView(location.pathname);
});
// 首次加载
renderView(location.pathname);
这是手写 SPA 最容易被忽略的坑:组件卸载时没清理副作用,导致旧事件监听器、定时器、动画帧持续运行,新内容渲染后行为异常。
renderView() 前,先调用上一个视图的 unmount()(如果存在),清掉 addEventListener、clearTimeout、cancelAnimationFrame
innerHTML = htmlString 替换整个区域 —— 已挂载的 会丢失焦点、已绑定的 oninput 会断开;改用 replaceChildren() 或细粒度 diffe.preventDefault(),否
当开始反复处理以下问题时,说明手写成本已超过收益:
/user/123/profile)且带参数解析history.pushState 的 polyfill 维护成本陡增renderView() 分支,却忘了同步更新 unmount() 逻辑,导致内存泄漏这时候引入 react-router 或 page.js 不是“过度设计”,而是止损。手写 SPA 的价值在于理解机制,而非长期维护。