手写超轻量前端框架hyperframes,让服务端渲染页面获得类SPA局部刷新体验

发布时间:2026/10/29 4:33:58

手写超轻量前端框架hyperframes,让服务端渲染页面获得类SPA局部刷新体验 1. 项目概述与设计思路为什么需要 hyperframeshyperframes 是我花了几周业余时间折腾出来的一个前端微型框架核心就解决一件事传统服务端渲染的页面能不能在不引入 React/Vue、不拆前后端的情况下获得接近 SPA 的局部刷新体验。这个问题的答案当然是能htmx、Turbo 这些年都在做同样的事我只是按自己的习惯又造了一个更小、更专注帧级更新的轮子。如果你维护的是 Rails、Django、PHP、Express 这类直接输出 HTML 的老项目又不想为了一个丝滑导航把全部页面重写成客户端渲染这篇文章值得你读完。1.1 从全量刷新到局部刷新的中间态先聊聊我为什么会对这个方向产生兴趣。过去七八年里我参与过的前端项目基本逃不过两条路要么是传统的多页应用MPA点一个链接整页刷新白屏、滚动跳动、用户体验粗糙要么是彻底的客户端渲染 SPA把状态管理、路由、数据请求、鉴权全部搬到浏览器里。SPA 表面上解决了体验问题实际上把复杂度转移到了客户端。我见过太多团队为了一个后台管理系统引入 React Redux React Router Axios Webpack产物体积动辄 300KB还要处理接口协议、缓存策略、权限校验、错误边界以及最折磨人的前后端状态不一致问题。页面刚加载时数据是新的过了五分钟操作完界面显示的状态可能已经和服务器脱节了。而 hypermedia 的理念恰好提供了一个中间态。简单说服务器负责管理状态每次访问一个 URL服务器返回完整的 HTML 表示浏览器负责渲染和交互。这里的超媒体指的就是 HTML 里那些链接和表单——它们本身就是应用状态的入口。Roy Fielding 在 REST 论文里那句hypermedia as the engine of application state说的就是这件事。浏览器其实天生就是一个超媒体客户端只是默认行为是全页面刷新我们在它之上加一层帧级导航就能把体验升级到接近 SPA同时保留服务端渲染的所有好处。1.2 五个不能妥协的设计约束做这种框架最难的不是实现而是明确不要做什么。我在写 hyperframes 之前先给自己定死了五条约束约束为什么不能妥协禁用 JavaScript 时仍完整可用渐进增强是底线脚本加载失败、浏览器异常时也要有退路URL 是唯一状态来源刷新、分享、后退都必须对应正确的页面服务端不用重写不发明新的渲染协议只认 HTML任何后端都能接入运行时体积足够小目标 3KB 以内gzip不引入虚拟 DOM不限定框架任何标注了帧区域的页面都能接入与开发栈无关这五条约束里第一条可以说是这类方案与 SPA 最本质的分水岭。SPA 把没有 JavaScript当成不存在的情况hyperframes 则把它当成第一公民。我之前在做一些对可访问性要求极高的项目时吃过亏——页面在关闭脚本的环境下完全空白那种感觉很糟糕。所以 hyperframes 从一开始就要求标签页里不放 JavaScript链接点击依然能整页跳转功能完整只是少了局部刷新的流畅感。1.3 与 htmx、Turbo Frames 的取舍做之前我认真看过 htmx 和 Hotwire 的 Turbo Frames这两个都是这个领域的成熟方案功能比我做的全得多。htmx 几乎把交互的每个维度都做成了属性hx-get、hx-target、hx-swap粒度很细上手不算难但它其实更像一个服务端驱动 UI 的完整工具箱。Turbo Frames 用自定义元素turbo-frame圈定刷新区域概念上跟 hyperframes 很接近但它更依赖 Rails 生态非 Rails 项目接入要费些心思。我的目标更窄只做帧级局部刷新这一件事用最原始的数据属性完成标记和关联不引入自定义元素不搞事件流不碰 WebSocket。可以说 hyperframes 是这三个方案里胆子最小的那个。好处是代码量极低逻辑一眼能看透出问题也好查坏处是你要的功能它未必都有只能自己去扩展。如果你想要一个能直接上生产、功能完备的方案htmx 或 Turbo 都更合适。如果你只是想理解这套机制、或者想要一个可以被自己完全掌控的迷你内核那下面的内容应该对你有用。2. 核心机制拆解帧、协议、渐进增强2.1 帧模型两个属性定义一切hyperframes 的核心概念只有一个帧frame。帧就是页面上一个普通的 DOM 区域用>!-- 页面一index.html -- nav>!-- 页面二about.html -- main idmain>// 客户端判断响应是完整文档还是片段 const isFullDocument /!doctype html/i.test(html) || /html[\s]/i.test(html);两种模式可以共存服务端有能力的就做局部渲染没能力的就传完整文档客户端自适应。这才是渐进增强在协议层面的体现——响应格式并不强制只要能对应上怎么都行。2.3 popstate 与浏览器历史history.pushState只更新 URL不触发页面跳转所以后退按钮会回到之前的 URL但内容还是旧的。这时候需要监听popstate事件window.addEventListener(popstate, (event) { if (event.state event.state.hf) { // 重新按当前 URL 加载帧内容但不重复 pushState load(location.href, null, false); } });有一个小细节值得注意popstate事件只在同一个文档内前进后退时触发。如果用户是从外部站点点了后退进入你的页面浏览器会执行整页加载不会走到这个分支。所以这里只需要处理hyperframes 自己造的 URL判断event.state.hf就能区分。另外pushState之后如果用户在地址栏手动输入了一个新的 hyperframes URL 并回车那是整页刷新服务端会正常返回完整文档帧标记依然生效用户得到的依然是可用页面。这正是第 1.2 节那条URL 是唯一状态来源约束的体现。2.4 渐进增强的边界哪些点击不该拦截做拦截逻辑时最容易犯的错误是什么都想管。我踩过一次坑测试时发现用户在手机上长按链接想在新标签页打开结果怎么点都没反应最后定位到是拦截逻辑没有判断event.metaKey和event.button。从那以后我把放行条件写得非常明确// 不符合条件的点击一律不拦截交给浏览器 const isPlainClick event.button 0 !event.metaKey !event.ctrlKey !event.shiftKey !event.altKey; if (!isPlainClick) return;还有三类链接必须跳过外链域名不同、纯锚点链接#section、带download属性的下载链接。外链跨域了 fetch 拿不到响应锚点链接本来就不该触发页面跳转下载链接交给浏览器处理更稳妥。处理方式就是统一加 URL 检查const url new URL(link.href, location.href); if (url.origin ! location.origin) return; if (link.getAttribute(download) ! null) return; if (url.hash url.origin url.pathname location.origin location.pathname) return;渐进增强的核心原则是增强不改变语义。链接还是链接表单还是表单只是在 JavaScript 可用且有改善空间的情况下改变它的交互形态。任何增强让原本能用的东西不能用了那就不叫增强叫破坏。3. 实操过程从零实现 hyperframes 内核3.1 项目文件结构我建议在现有项目里单独建一个目录不要急着接入先跑通最小 demohyperframes/ ├── src/ │ └── hyperframes.js // 核心脚本约 200 行 ├── demo/ │ ├── public/ │ │ ├── index.html │ │ ├── about.html │ │ └── contact.html │ └── server.js // Express 演示服务端 └── test/ └── basic.test.html // 手动测试页demo 里的三个 HTML 页面用完整的服务端渲染方式编写页面间通过导航链接互通。最简单的接入方式就是脚本引入 属性标记不需要构建工具。3.2 核心代码一个独立思考的迷你内核下面是 hyperframes.js 的核心实现我拆成几段来讲解。整个脚本不依赖任何第三方库使用事件委托的方式绑定在document上动态插入的内容也能自动获得导航能力。// src/hyperframes.js (() { use strict; const DEFAULTS { frameAttr: data-hf-frame, targetAttr: data-hf-target, staticAttr: data-hf-static, requestHeader: HF-Request, }; class Hyperframes { constructor(options {}) { this.options Object.assign({}, DEFAULTS, options); this.token 0; // 竞态控制令牌 this.busy false; this.bindEvents(); } bindEvents() { document.addEventListener(click, (event) { if (this.shouldIgnoreClick(event)) return; if (!this.isSameOriginLink(event.target.closest(a[href]))) return; const link event.target.closest(a[href]); const frame this.frameOf(link); if (!frame) return; event.preventDefault(); this.load(link.href, frame.selector, link.dataset.hfPush ! false); }); document.addEventListener(submit, (event) { const form event.target; const selector form.getAttribute(this.options.targetAttr); if (!selector || !document.querySelector(selector)) return; event.preventDefault(); this.submitForm(form, selector); }); window.addEventListener(popstate, (event) { if (event.state event.state.hf) { this.load(location.href, null, false); } }); } // 只拦截普通点击其他交给浏览器 shouldIgnoreClick(event) { const isPlain event.button 0 !event.metaKey !event.ctrlKey !event.shiftKey !event.altKey; return !isPlain || event.defaultPrevented; } isSameOriginLink(link) { if (!link) return false; if (link.getAttribute(download) ! null) return false; const url new URL(link.href, location.href); if (url.origin ! location.origin) return false; // 纯锚点同页面滚动的链接不需要局部刷新 const path url.origin url.pathname url.search; const current location.origin location.pathname location.search; if (url.hash path current) return false; return true; } // 读取链接上的>// 核心导航方法 async load(url, selector, push true) { const token this.token; // 记录本次请求的令牌 const frameEl document.querySelector(selector); this.busy true; this.emit(begin, { url }); try { const html await this.fetchHtml(url); if (token ! this.token) return; // 已有更新的请求丢弃本次结果 const target frameEl || document.querySelector(this.options.frameAttr); const newFrame this.extract(html, selector); this.swap(target, newFrame); if (push) history.pushState({ hf: true }, , url); this.focusFrame(); this.emit(end, { url }); } catch (error) { // 局部加载失败回退到完整导航 console.warn([hyperframes] 局部加载失败回退到整页导航, error); if (push) location.href url; } finally { if (token this.token) this.busy false; } } async fetchHtml(url) { const response await fetch(url, { headers: { Accept: text/html, [this.options.requestHeader]: true, }, credentials: same-origin, }); if (!response.ok) throw new Error(HTTP ${response.status}); return await response.text(); } extract(html, selector) { const isFullDocument /!doctype html/i.test(html) || /html[\s]/i.test(html); if (!isFullDocument) { // 服务端返回的是帧片段直接包装成 DOM 元素 const wrapper document.createElement(div); wrapper.innerHTML html; return wrapper.firstElementChild || wrapper; } const doc new DOMParser().parseFromString(html, text/html); const found doc.querySelector(selector); if (!found) throw new Error(文档中找不到帧${selector}); return found; }fetchHtml发送带HF-Request头的请求服务端可以根据这个头决定返回完整文档还是片段。extract里那个正则判断是整个框架的关键决策点——它决定了服务端是否愿意配合做局部渲染。愿意配合的返回片段客户端直接包一层使用不配合的返回完整文档客户端自己提取。交换和脚本重放也不能漏新帧插入后里面的script标签默认是不执行的需要手动重新创建swap(oldEl, newEl) { oldEl.replaceWith(newEl); this.executeScripts(newEl); } executeScripts(container) { container.querySelectorAll(script).forEach((oldScript) { const script document.createElement(script); oldScript.getAttributeNames().forEach((name) { script.setAttribute(name, oldScript.getAttribute(name)); }); script.textContent oldScript.textContent; oldScript.replaceWith(script); }); } focusFrame() { const heading document.querySelector([data-hf-heading]); if (heading) { heading.setAttribute(tabindex, -1); heading.focus({ preventScroll: true }); } } emit(name, detail) { document.dispatchEvent(new CustomEvent(hf:${name}, { detail })); } submitForm(form, selector) { const method (form.method || GET).toUpperCase(); const action form.action || location.href; let body null; if (method GET) { const params new URLSearchParams(new FormData(form)); this.load(${action}?${params.toString()}, selector, true); return; } const enc form.getAttribute(enctype) || application/x-www-form-urlencoded; if (enc multipart/form-data) { body new FormData(form); } else { body new URLSearchParams(new FormData(form)); } // 表单 POST 也走同样的帧交换流程 this.loadWithBody(action, selector, body, method); } } window.Hyperframes Hyperframes; })();executeScripts这段代码有个细节直接设置script.textContent再插入 DOM会触发脚本执行。外部脚本则保留src属性由浏览器重新加载。要注意的是这段逻辑会把新帧里的脚本在新 DOM 节点上重新执行一遍如果页面里有>// demo/server.js const express require(express); const app express(); app.use(express.static(public)); function layout(content) { return !DOCTYPE html html langzh-CN head meta charsetutf-8 titlehyperframes demo/title script src/hyperframes.js/script /head body nav>document.addEventListener(mouseover, (event) { const link event.target.closest(a[data-hf-target]); if (!link || link.dataset.hfPrefetched) return; const url new URL(link.href, location.href); if (url.origin ! location.origin) return; link.dataset.hfPrefetched true; fetch(url.href, { headers: { [this.options.requestHeader]: true }, credentials: same-origin, }).catch(() {}); }, { passive: true });这里要注意两点一是只对带>document.addEventListener(hf:end, (event) { if (typeof window.initPage function) { window.initPage(document.querySelector(#main)); } });以前端框架的思路理解这其实就是挂载的生命周期函数。hyperframes 只做导航不做依赖注入也不做状态管理所以初始化逻辑的编排需要开发者自己负责。这也解释了为什么我建议项目里的复杂模块尽量不在帧内放脚本——老项目改造时把模块逻辑抽成一个可在任何时刻安全调用的函数比在帧里塞一堆初始化代码要靠谱得多。5.3 焦点管理与可访问性第一次在真实项目里接入时我完全没考虑读屏用户。测试者反馈说每次点完链接我听到的还是旧页面的标题读者离开了上下文。 解决起来其实不复杂帧交换成功后把焦点移到新帧的标题上让辅助技术重新播报新内容。focusFrame() { const heading this.frame.querySelector([data-hf-heading]); if (heading) { heading.setAttribute(tabindex, -1); heading.focus({ preventScroll: true }); } }tabindex-1让一个本来不可聚焦的h1可以接收编程焦点但不会出现在 Tab 键的焦点序列里。preventScroll: true防止焦点移动时页面跟着滚动到顶部——局部刷新后用户可能正在页面的中部阅读突然跳到顶部会很困扰。这里还有一个细节焦点移到标题后读屏软件会朗读整个标题文本所以标题要写清楚当前页面/模块的内容而不是模糊的页面已更新。我在实际内容页上会把标题写得比较完整比如关于我们而不是关于确保朗读出来是有意义的。5.4 竞态处理一次失败的快速点击实验hyperframes 最初的版本没有 token 机制测试的时候我快速点击了三个不同的导航链接结果出现了经典的竞态问题——第三个链接的响应先回来页面显示正确第二个链接的响应后回来反而把页面覆盖成了错误内容。解决方案就是我前面提到的令牌token机制。每次load调用时this.token拿到响应后检查 token 是否还是当前值如果不是就丢弃const token this.token; const html await this.fetchHtml(url); if (token ! this.token) return;这就保证了只有最后一次发起的请求结果会被接受。另一个方案是AbortController在发起新请求时取消旧请求既能避免乱序又能省下无用的网络流量。我在版本里用 token 主要是实现更简单也不用处理取消请求带来的AbortError噪音。如果后续需要更精细的流量控制再替换成 AbortController 也不迟。5.5 调试技巧用日志快速定位帧匹配失败hyperframes 最常见的运行时错误是文档中找不到帧。我在extract方法里加了一行显眼的警告日志把目标选择器打印出来if (!found) { console.warn([hyperframes] 目标帧 ${selector} 在当前页面中存在但在响应文档中找不到。); console.warn(请检查响应文档, html.slice(0, 300)); }实际排查时我用得最多的是在 devtools 的 Network 面板里看响应内容确认服务端确实返回了包含目标帧的 HTML。如果服务端误判了请求头、返回了 JSON 或错误页这里一眼就能看出来。另外一个常被忽视的坑是选择器里的特殊字符。如果帧的 id 含有冒号、括号这类字符直接拼进querySelector会报错。现在CSS.escape可以安全处理这些const selector #${CSS.escape(frame.id)};但这只在帧元素有 id 时才适用。如果你依赖 class 选择器定位帧建议不要给帧加多值 class>
延伸阅读

更多相关文章

2026/10/29 4:33:58

从YOLO入门目标检测:算法选型、Darknet原理与实战避坑指南

简介:这份文档面向深度学习与计算机视觉方向的初学者及算法入门者,系统梳理基于YOLO的目标检测知识体系,帮助读者建立从算法发展到工程落地的完整认知。内容涵盖深度学习发展脉络与目标检测关键时间节点,重点介绍Faster R-CNN这一…

2026/10/29 4:28:58

Hyperframes:HTML+CSS+MP4协同渲染范式

1. “hyperframes”不是新框架,而是对HTML/CSS/MP4协同渲染范式的重新命名最近在多个前端技术社区和CLI工具讨论区里,“hyperframes”这个词突然高频出现。它既不像React、Vue那样有明确的GitHub仓库和文档站,也不像Tailwind CSS那样自带配置…

2026/10/29 4:28:58

产品经理核心竞争力解析与能力提升路径

1. 产品经理的角色定位与能力图谱产品经理这个岗位最早起源于快消品行业,后来被科技公司引入并赋予了新的内涵。在互联网和数字化时代,产品经理已经成为一个集商业思维、用户洞察和技术理解于一体的复合型角色。不同于项目经理专注于执行交付&#xff0c…

2026/10/29 5:29:01

go-echarts opts.XAxis 实战避坑指南:时间/分类/数值轴配置要点

1. 这不是一份“API文档搬运工”指南,而是一线开发者用血泪踩坑后整理的 opts.XAxis 实战手册 你是不是也经历过这样的时刻:在 Go 项目里接入 go-echarts,想把时间序列数据按天展示,结果 X 轴上一堆重复的“2024-01-01”&#xf…

2026/10/29 5:29:01

AI Agent社交平台失控案例分析及稳健设计指南

1. 项目背景与现象观察最近在AI Agent领域出现了一个值得关注的现象:名为Moltbook的AI Agent社交平台在短短三天内出现了明显的失控情况。作为一个长期关注AI交互系统的开发者,我第一时间对这个案例进行了技术分析。Moltbook最初定位为"AI Agent的社…

2026/10/29 5:29:01

Qt TCP聊天室:从协议帧到状态机的底层实现指南

简介:本资源是一套基于Qt框架实现的TCP多人在线聊天室完整工程,面向C与Qt初学者及网络编程进阶学习者,解决跨平台实时通信、多客户端协同交互与本地消息持久化等典型问题。压缩包共130个文件,含5个核心cpp/h源码文件、3个ui界面设…

2026/10/29 5:29:01

CLion 2026.1新特性解析:AI编程与远程调试实战

1. CLion 2026.1版本核心升级解析JetBrains推出的CLion 2026.1版本带来了三项重大革新,这些改进直接针对现代C/C开发者的实际痛点。作为长期使用CLion进行嵌入式开发的工程师,我认为这次更新在开发效率提升方面实现了质的飞跃。首先是AI编程助手的深度集…

2026/10/29 5:29:01

动态波动率智能算法在量化交易中的应用与优化

1. 项目概述:当波动率遇上智能算法金融市场就像一片波涛汹涌的海洋,而波动率就是测量海浪高度的标尺。传统量化交易策略往往使用固定参数计算波动率,就像用一把不变的尺子去测量不同季节的海浪——这显然不够精准。我们设计的这套动态波动率智…

2026/10/29 5:24:00

以道驭术:创业从约束条件到技术选型的底层逻辑

做创业这件事,越往后走越会发现一个扎心的现象:同一套方法论、同一个增长模型、同一批工具,有人用出来是加速器,有人用出来只是安慰剂。差别往往不在“术”的熟练度,而在“道”的清晰度。所谓以道驭术,说到…

2026/10/28 14:42:12

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/28 15:28:56

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/28 3:32:29

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/29 0:03:42

学生选课系统DFD图绘制指南:边界划分与分层校验实战

简介:文档以顶层与第一层DFD图为核心,完整刻画学生选课系统的逻辑模型,面向软件工程课程设计、系统分析学习及毕设前期建模人群。顶层图界定系统边界,外部实体包括管理员、教师和学生三类角色;第一层图进一步细化用户登…

2026/10/29 0:03:42

JIRA敏捷落地:Scrum角色权限、Sprint与燃尽图避坑指南

简介:围绕“基于JIRA的敏捷开发项目管理”这一主题,面向产品负责人、Scrum Master及研发测试成员,梳理Scrum角色分工、迭代流程与JIRA工具配置的对应关系,适合初、中级团队导入敏捷管理时参考。压缩包共1个docx文件,大…

2026/10/29 0:03:42

SEMI S8人机工程验收实战:从条款拆解到现场实测的完整指南

简介:面向半导体设备设计、制造与运维工程师及安全合规人员,这份资料聚焦SEMI标准体系下的设备安全规范,重点收录SEMI S8-0308《半导体制造设备人体工程学工程安全指南》。内容围绕设备设计原则、错误与事故预防、人体工程学适配三大主线展开…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑