news 2026/10/29 4:33:49

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

作者头像

张小明

前端开发工程师

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

1. 项目概述与设计思路:为什么需要 hyperframes

hyperframes 是我花了几周业余时间折腾出来的一个前端微型框架,核心就解决一件事:传统服务端渲染的页面,能不能在不引入 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 id="main">// 客户端判断响应是完整文档还是片段 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 项目文件结构

我建议在现有项目里单独建一个目录,不要急着接入,先跑通最小 demo:

hyperframes/ ├── 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 lang="zh-CN"> <head> <meta charset="utf-8"> <title>hyperframes 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,>

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/29 4:33:46

基于YOLOv10的道路垃圾检测系统实战指南

1. 项目概述&#xff1a;当计算机视觉遇上城市环卫去年参与某城市智慧环卫项目时&#xff0c;我们团队需要解决一个看似简单却令人头疼的问题——如何实时发现散落在城市道路上的各类垃圾。传统的人工巡检方式不仅效率低下&#xff0c;还存在漏检风险。这个基于YOLOv10的道路垃…

作者头像 李华
网站建设 2026/10/29 4:33:30

束平差工程实践:从理论到精度优化的关键技巧

1. 束平差工程实践的核心价值在测绘工程领域干了十几年&#xff0c;我处理过的各种平差问题能装满几个硬盘。要说最让人又爱又恨的&#xff0c;非束平差莫属。这玩意儿就像个精密的瑞士手表——理论优美但实操处处是坑。今天咱们不聊那些教科书上的矩阵推导&#xff0c;重点说说…

作者头像 李华
网站建设 2026/10/29 4:33:30

Superpowers:自然语言驱动的代码全生命周期开发范式

1. 项目概述&#xff1a;Superpowers 不是超能力&#xff0c;而是开发者工具链的“认知增强层”你搜“superpowers”时&#xff0c;看到的不是漫威电影里的变种人&#xff0c;而是一群在深夜改 Bug 的工程师、刚转行的前端新人、还有正在用 Cursor 写 Python 脚本的数据分析师—…

作者头像 李华
网站建设 2026/10/29 4:33:30

Agent模式与安全智能体的核心技术解析

1. Agent模式&#xff1a;安全智能体的核心价值与行业背景在AI技术快速迭代的当下&#xff0c;Agent模式正成为解决复杂任务的关键范式。不同于传统单向响应的AI系统&#xff0c;安全智能体通过自主调用工具链实现闭环决策&#xff0c;在金融风控、工业运维、医疗诊断等领域展现…

作者头像 李华
网站建设 2026/10/29 4:33:26

Java乡村人口管理系统开发实战与优化策略

1. 项目背景与核心价值乡村振兴战略的深入推进对基层治理能力提出了更高要求&#xff0c;而人口数据作为乡村治理的基础要素&#xff0c;其管理方式仍普遍存在纸质档案易损毁、信息更新滞后、统计效率低下等问题。这个基于Java的乡村人口管理系统正是针对这些痛点设计的数字化解…

作者头像 李华
网站建设 2026/10/29 4:33:19

营销技能不是话术,而是用户认知、信息干预与数据反哺的闭环能力

1. 这不是“营销话术速成班”&#xff0c;而是一套能立刻上手、反复验证的市场能力操作系统“marketingskills”这个标题乍看像一个泛泛而谈的课程标签&#xff0c;但在我过去十年带过37个初创团队、操盘过212个真实获客项目的实操经验里&#xff0c;它根本不是抽象概念——而是…

作者头像 李华