做了快十年的 Web 开发,我这两年大部分时间都在和一套很纠结的组合较劲:后端是积累了很多年的服务端渲染项目,前端却老被产品要求“过渡得像 App 一样顺”。全量刷新的 MPA 扛不住这个期待,可一旦真把整块页面改成 SPA,数据接口、权限校验、首屏加载这三座大山,就足够让一个二十人不到的团队掉一层皮。后来我在一个开源方案里看到 hyperframes(超帧导航)这个思路,越想越觉得对症。
它的做法其实很朴素:服务端照常输出完整的 HTML 页面,但浏览器端在点击链接时,只从目标页里抽出对应的区块,原地替换当前页面的某个区域,实现无刷新切换。页面还是那个页面,链接还是那些链接,URL 也一个不少,但用户的体感从“跳转”变成了“过渡”。这篇文章我会从原理、核心机制讲到手写一个小引擎,再把我踩过的坑全部摊开,适合正在为“老旧 MPA 想变丝滑”而头疼的团队,也适合想看透局部刷新类方案本质的朋友。
1. 超帧导航到底是什么,解决什么问题
1.1 MPA 与 SPA 之外的第三条路
很多项目一开始都是传统多页应用(MPA)。每个页面是一个完整 HTML,点击链接就整页刷新。这种方式好处明显:路由简单、SEO 天然友好、后端怎么写的就怎么跑。坏处也摆在明面上——每次跳转都要重新下载整个文档,白屏、闪烁、状态丢失,在弱网环境下尤其难受。
SPA 把体验做好了,代价是把“页面状态”这件事完全搬到了前端。路由、鉴权、数据缓存、组件生命周期全部右移,团队得维护一套复杂的前端基建。更要命的是,很多传统项目的数据渲染逻辑已经和后端模板深度绑定,硬拆成 API + 前端渲染,成本并不比重写系统低。
hyyperframes 给的启发是:不一定非要二选一,而是可以把“服务端渲染”和“局部更新”组合起来。服务端仍然负责生成完整的、带内容的 HTML,浏览器只做一件事——在导航时把当前文档里对应的“帧”替换掉。这个概念后来也催生了不少类似实现(包括 Turbo Frames、Hotwire 生态里的 Frame 方案),超帧本质上是一类更通用的模式,不绑定某个具体框架。我最早是在 HyperFrameset 这个名字下看到它的,后来在自己项目里复刻过一版,收益非常直接。
1.2 一句话理解 hyperframes 的核心
把当前页面想象成一面由若干“帧”拼成的墙,每个帧都有明确的边界和身份(比如一个 id,或者一条 data 属性标记)。点击链接时,浏览器偷偷请求目标页面,拿回完整 HTML 后,从中挑出对应的那一帧,替换墙上原本的那一帧。
这句话拆开看有三个关键点:
- 目标页面是完整的,不是接口返回的 JSON。这意味着 URL 单独打开时,内容仍然成立,SEO 不需要额外处理。
- 只替换一个局部区域,而不是整页。其余帧保持原样,不重新请求、不重新渲染。
- 路由仍然由服务端定义,URL 是唯一的事实来源。前端没有造出一套新的路由状态。
我用生活里的例子类比过:订了一整箱货,快递员送上门,但你不是把整箱都搬进屋,而是只拿出里面需要的那一格。打开箱子=下载完整页面,取一格=提取帧,其他格子原封不动=其余 DOM 不重建。
这个模式解决的痛点是“状态保持”:页面顶部搜索框里正输着关键词,点击结果列表里的一页,列表局部刷新,搜索词不会因为整页刷新而消失。这个体验看着不起眼,但没有它,用户每次翻页都要重新输入、重新调整筛选条件,相当崩溃。
1.3 它和 iframe、Turbo Frames 有什么不一样
先别把 iframe 拉出来比。iframe 的隔离更彻底:子页面有独立的 window、独立的滚动容器、独立的 CSS,带来的全是样式继承、焦点管理和通信成本。超帧不是 HTML 里的 iframe,它更像“用一段新 HTML 替换旧 DOM 的机制”,隔离边界是由开发者控制的,样式也沿用父页面的规则。
再说说 Turbo Frames。Turbo Frames 是 Hotwire 中实现此类思路的一种工具,特征是给<turbo-frame id="...">套一层浏览器不认识的自定义元素,让需要局部更新的区域被显式标记出来。超帧的思想和它高度相似,但更强调“页面本身是完整文档”这个前提,关注点更偏状态协议和导航响应,而不是某一个框架的语法糖。
我现在团队里的习惯是:把 hyperframes 当作模式,而不是依赖。只要页面结构清晰、帧边界明确,用任何工具都能落地。接下来我会直接拆一个最精简的实现,把原理彻底讲明白。
2. 核心机制拆解:一次点击背后发生了什么
2.1 手动声明帧的边界
要替换,浏览器得先知道“该替换哪个区域”。所以超帧方案的第一步,是让页面结构里出现明确的帧标记。
一种比较通用的写法是用 id + data 属性约定:
<main> <nav> <a>window.addEventListener("popstate", () => { navigate(location.href, { replace: true }); });用大脑里的“播放器”来理解:每次前进后退,播放器都在重新拉取当前进度对应的画面,而不是播放器自己保存了一份画面快照。牺牲一点点网络开销,换来的是状态永不失真。这个取舍很划算,尤其是页面里但凡多一点联动筛选条件,快照模式的同步成本就会成倍上升。
3. 从零实现一个迷你超帧引擎
光讲概念不过瘾,我直接把一个可运行的最小实现拆给你看。这个实现足够简单,但已经覆盖了“拦截导航、请求片段、替换 DOM、恢复历史”四个核心动作。
3.1 准备服务端渲染的页面
我只需要两个静态 HTML 文件就够演示。每个文件都是完整页面,单独访问时也能正常显示,这就是渐进增强的基础。
index.html 是框架页,负责承载导航和内容帧:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Mini Frames</title> <style> .layout { display: grid; grid-template-columns: 220px 1fr; } nav a { display: block; margin: 4px 0; padding: 8px; text-decoration: none; color: #333; } nav a.active { background: #eef; border-radius: 6px; } #content { padding: 16px; border-left: 4px solid #eee; min-height: 300px; } #content.loading { opacity: 0.5; } </style> </head> <body> <div class="layout"> <nav> <a><!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>页面 1</title> </head> <body> <div class="layout"> <nav> <a>(function () { // 当前由哪个 id 充当“内容帧” const FRAME_ID = "content"; async function navigate(url, { replace = false } = {}) { const frame = document.getElementById(FRAME_ID); frame.classList.add("loading"); try { const res = await fetch(url, { headers: { "Accept": "text/html" }, }); if (!res.ok) { throw new Error("HTTP " + res.status); } const html = await res.text(); // 解析成完整文档,而不是字符串拼接 const doc = new DOMParser().parseFromString(html, "text/html"); const next = doc.getElementById(FRAME_ID); if (!next) { throw new Error("目标页面找不到指定帧"); } frame.innerHTML = next.innerHTML; // 更新 URL,同时保留历史栈行为 if (replace) { history.replaceState(null, "", url); } else { history.pushState(null, "", url); } document.title = doc.title || document.title; // 顺手把导航菜单的高亮状态修正一下 document.querySelectorAll("nav a[data-frameset]").forEach((link) => { link.classList.toggle("active", link.href === location.href); }); } catch (err) { console.error("超帧导航失败:", err); } finally { frame.classList.remove("loading"); } } // 事件委托:确保新插入的帧里,未来动态添加的链接也能被拦截 document.addEventListener("click", (event) => { const link = event.target.closest("a[data-frameset]"); if (!link) return; if (link.target && link.target !== "_self") return; const samePage = new URL(link.href).origin === location.origin; if (!samePage) return; event.preventDefault(); navigate(link.href); }); // 前进后退时就近恢复 window.addEventListener("popstate", () => { navigate(location.href, { replace: true }); }); })();这里面有两个细节值得多说。
第一,用closest("a[data-frameset]")而不是在每个链接上绑addEventListener,好处是事件委托天然免疫动态内容:就算新帧里塞进来一堆新链接,因为监听挂在整个 document 上,根本不需要重新绑定。
第二,fetch同源请求默认会带上 Cookie,所以大多数服务端会话校验不需要额外处理。但如果你把站点拆到了不同子域,要记得调整 credentials 配置,否则每次导航都会被重定向到登录页。
3.3 用 DOMParser 而不是正则提取内容
提醒一句:一定要用DOMParser解析 HTML,别用正则去抠标签。HTML 不是结构规整的文本,随手写的正则很容易被注释、转义字符、嵌套结构绕晕;DOMParser 由浏览器标准化解析,结果可靠,而且顺手还能拿到document对象,直接用getElementById定位帧。
我在早期版本里犯过用正则匹配<section id="content">...</section>的错误,页面文案里只要出现类似标签的字符串就当场翻车。换成 DOMParser 后,这类问题再也没出现过。
3.4 并发与加载体验
如果用户快速连点两个链接,前一个 fetch 还没返回,后一个 fetch 又发出去,后发先至就会导致帧里显示的是旧页面。我以前觉得这种边角料问题无所谓,直到真实项目里被吐槽“内容闪来闪去”才意识到,竞态是局部刷新方案里必须处理的基础问题。
最简单可靠的方式是给每次导航分配一个唯一的票据,只有最新票据对应的请求才允许替换 DOM:
let navTicket = 0; async function navigate(url, { replace = false } = {}) { const ticket = ++navTicket; const frame = document.getElementById(FRAME_ID); frame.classList.add("loading"); try { const res = await fetch(url, { headers: { "Accept": "text/html" } }); const html = await res.text(); if (ticket !== navTicket) return; // 说明这次请求已经过期 // ... 继续完成替换 } finally { if (ticket === navTicket) { frame.classList.remove("loading"); } } }再进一步,可以用 AbortController 取消过期的请求。取消的好处不只是省流量,还能避免浏览器在底层缓存里留下没必要的请求痕迹。我在演示版里用票据法,逻辑更直观,也足够打动九成场景。
加载体验方面,我习惯在帧容器上挂一个 loading class,用 CSS 控制透明度,避免换上内容时出现闪跳。你要是有精力,还可以在帧里放一个真实的骨架屏占位,等新帧解析好再整套替换。
4. 进阶实操:让超帧模式真正可用
4.1 表单提交也要走超帧
链接导航只是第一步,真实表单系统里到处是 POST。处理方案和 GET 导航基本一致:拦截 submit 事件,把表单序列化成FormData,用 fetch 提交,再把返回的 HTML 里的帧提取出来替换。
我对最小引擎加了这一段之后,分页条件筛选、搜索、点赞切换全都能无刷新完成:
document.addEventListener("submit", async (event) => { const form = event.target; if (!form.dataset.frameset) return; event.preventDefault(); const method = (form.method || "GET").toUpperCase(); const url = new URL(form.action || location.href); const frame = document.getElementById(FRAME_ID); frame.classList.add("loading"); try { const res = await fetch(url, { method, headers: { "Accept": "text/html" }, body: method === "POST" ? new FormData(form) : undefined, }); const html = await res.text(); const doc = new DOMParser().parseFromString(html, "text/html"); const next = doc.getElementById(FRAME_ID); if (next) frame.innerHTML = next.innerHTML; if (res.redirected || url.pathname !== location.pathname) { history.pushState(null, "", res.redirected ? res.url : url.pathname + url.search); } } catch (err) { console.error("表单帧提交失败:", err); } finally { frame.classList.remove("loading"); } });有几个表单专用的坑,我在这儿一并说了:
- POST 成功后如果服务端按常规返回 302,fetch 会自动跟随重定向,最终拿到的响应是重定向目标的页面。
res.redirected为 true 时要用res.url更新地址栏,否则页面显示的是新内容,地址栏却停在旧地址,刷新会恢复奇怪的状态。 - 表单校验应该尽量在服务端做,并把错误信息渲染回帧里。这样前端不用维护一份错误码映射表,用户体验也不割裂。
- 如果页面上存在多个独立帧(比如右侧详情、左侧列表),可以把表单绑定到具体帧 id 上。我见过一些实现给表单加
>function executeScripts(container) { container.querySelectorAll("script").forEach((oldScript) => { const script = document.createElement("script"); script.text = oldScript.text; if (oldScript.src) script.src = oldScript.src; oldScript.parentNode.replaceChild(script, oldScript); }); }但我要劝你一句:能不往帧里塞 script 就别塞。现代前端工程讲究入口收敛,动态内容里的内联脚本很容易变成“薛定谔的代码”——有时执行有时不执行,调试时完全靠缘分。我在项目里的做法是:帧内容只承载 UI 结构,交互逻辑全部用事件委托挂在 document 上;真需要新初始化逻辑,就手动调用一个全局注册表里的初始化函数,或者干脆 dispatch 一个自定义事件,让模块自己监听。
事件委托策略尤其重要。新帧进入 DOM 后,里面可能出现按钮、下拉框、分页器。如果初始绑定是在页面 load 时做的,那这些新节点全都绑不上。统一用
document.addEventListener走委托,就能永远避开这个问题。4.3 渐进增强与 SEO 兜底
超帧方案最让我放心的是渐进增强写得极其自然。没有 JavaScript 时,
>const next = doc.getElementById(FRAME_ID); if (!next) { location.href = url; return; }5.3 样式在帧切换后“串味”
这个坑来自 CSS 的全局性。帧里新内容带的 class 名,如果在全局样式表里定义了别的含义,或者帧内元素继承了父容器的一个诡异样式,页面就会突然变丑。
我从第二次重构开始强制给帧内容加一层命名空间。最简单的方式是在
#content根节点上挂一个固定的父类名,比如.frame-content,所有帧内样式都以它做前缀作用域;复杂一点可以引入 CSS Modules 或样式前缀工具。帧和帧之间也要尽量避免共用 class 名,防止切换后“这个页面的按钮样式污染了那个页面的按钮”。5.4 后退时滚动位置和加载态不一致
浏览器对 popstate 事件没有内置的滚动恢复机制,至少不像普通跳转那样可靠。我在实践中处理两个点:一是帧切换完成后手动
window.scrollTo(0, 0),保证从列表页点进详情页时不会停在半截;二是监听 popstate 导航时,给帧加 loading 态,避免用户在那一瞬看到旧内容的残留。有时你想保留列表滚动位置,只替换详情帧。这种更精细的场景,我建议给“滚动容器”也做标记,前进时记住
scrollTop,后退时恢复。实现不复杂,但需要业务约定清晰,别所有帧都套同一套逻辑。5.5 缓存把旧 HTML 带给用户
浏览器对 fetch 请求一般有自己的启发式缓存策略。页面更新后,用户可能因为缓存还看到旧帧内容。这不是大问题,但需要团队形成规范:动态内容的响应头里带上
Cache-Control: no-store,静态资源正常走 ETag/Last-Modified。如果你在 fetch 里手动加了
cache: "no-store",就要权衡是否舍弃了缓存带来的性能收益。我更推荐让服务端显式控制响应头的缓存策略,而不是在前端一刀切。一个很好的折中是:帧导航请求带上Cache-Control: no-cache,让浏览器每次都必须向服务端问“有更新吗”,但没有更新时仍旧用缓存内容,兼顾时效性和性能。5.6 常见问题速查表
现象 优先排查点 推荐解法 帧内容不更新 fetch 请求是否被浏览器缓存拦截 响应头加 no-store,或请求加 no-cache 前进后退无效 popstate 监听是否存在 用 replace=true 重新 navigate 当前 URL 动态链接点不了 事件是否委托到 document 使用事件委托,避免逐节点绑定 脚本不执行 是否使用 innerHTML 插入 按需手动重建 script 节点,或改用委托逻辑 登录后反复弹回登录页 session 过期后帧提取失败 帧缺失时整页跳转兜底 表单提交后地址栏不变 忘记用 res.url 更新历史 302 重定向后用 redirected/res.url 同步 新帧样式错乱 class 名冲突 给帧内容加独立命名空间 写在最后的一点个人体会
超帧方案教会我最重要的一件事,是不要为了“现代感”而推翻一切。当项目里已经有一套成熟的服务端渲染体系,与其把它拆成前后端分离的“标准姿势”,不如先想想:缺的到底是技术架构,还是那一段“切换不刷新”的体验。hyperframes 的思路不是银弹——页面状态特别复杂、组件交互特别重的时候,它比不过一个设计良好的 SPA;但在内容站、后台管理、文档系统这类场景里,它用极小的成本换来了非常显著的体验提升。
我个人实际使用下来最推荐的切入点,是挑一个流量高、交互浅、又最被吐槽“卡顿”的列表页先改造。当你验证了帧切换不仅没让系统变慢,反而因为不用重载整个壳子让首屏更快之后,再逐步扩大范围也不迟。改框架最忌一步到位,超帧这种“顺着原有结构长出新能力”的模式,反而是最适合老项目循序渐进的演化路线。