Hyperapp 使用指南:1kB 级的虚拟 DOM 与状态管理框架实战

发布时间:2026/9/20 13:17:14
Hyperapp 使用指南:1kB 级的虚拟 DOM 与状态管理框架实战 Hyperapp 使用指南1kB 级的虚拟 DOM 与状态管理框架实战【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp导读Hyperapp 是一个约 1 kBgive or take的 JavaScript 前端框架将虚拟 DOMVDOM、diff 算法与状态管理融为一体以 View、Action、Effect、Subscription 四种概念覆盖全部开发需求。本文以仓库 README.md 为主线结合 docs/tutorial.md、docs/reference.md 与 index.js 源码系统讲解安装、数据流模型、核心 API、官方扩展包并给出可复制的完整实战示例。读完本文你将掌握用 Hyperapp 从零构建声明式、纯函数式浏览器应用的全部能力。Hyperapp 是什么Hyperapp 的核心定位在 README.md 开篇被概括为三点Do more with less将需要学习的概念压缩到极致——Views、actions、effects 和 subscriptions 四个概念即可覆盖绝大多数开发场景且彼此无缝协作。Write what, not how声明式 API 易于阅读、写起来有趣是用符合惯例的 JavaScript 构建纯函数式、功能丰富浏览器应用的方式。Smaller than a favicon体积约 1 kB是一个极致追求极简主义的超轻量虚拟 DOM、高度优化的 diff 算法与状态管理库。在 package.json 中项目版本为2.0.22main指向 index.js采用 ES Moduletype: module并附带 index.d.ts 类型声明可直接在浏览器中以script typemodule方式使用无需构建步骤。数据流模型README 用一句话概括了 Hyperapp 的数据流核心The app starts by setting the initial state and rendering the view on the page. User input flows into actions, whose function is to update the state, causing Hyperapp to re-render the view.即初始化状态 → 渲染视图 → 用户输入进入 Action → Action 更新状态 → Hyperapp 自动重新渲染视图形成单向数据流闭环。Hyperapp 中的视图描述不使用模板标记语言而是通过h()与text()构建 DOM 的轻量内存表示即虚拟 DOM由 Hyperapp 负责高效地更新真实 DOM。这一点在 index.js 的源码中得到印证text(value)创建type 3TEXT_NODE的虚拟节点h(tag, props, children)创建通用虚拟节点tests/index.test.js 的测试用例直接验证了二者的虚拟节点结构tag、props、children、key、node、type六个字段。安装与第一个应用安装方式Hyperapp 通过 npm 安装npm install hyperapp由于它是纯 ES Module 包也可以直接在 HTML 中通过 CDN 引入无需任何构建步骤script typemodule import { h, text, app } from https://unpkg.com/hyperapp // ... /script第一个应用Todo ListREADME 给出的第一个完整示例是一个最简单的 Todo Listscript typemodule import { h, text, app } from https://unpkg.com/hyperapp const AddTodo (state) ({ ...state, value: , todos: state.todos.concat(state.value), }) const NewValue (state, event) ({ ...state, value: event.target.value, }) app({ init: { todos: [], value: }, view: ({ todos, value }) h(main, {}, [ h(h1, {}, text(To do list)), h(input, { type: text, oninput: NewValue, value }), h(ul, {}, todos.map((todo) h(li, {}, text(todo))) ), h(button, { onclick: AddTodo }, text(New!)), ]), node: document.getElementById(app), }) /script main idapp/main逐行拆解这段代码init: { todos: [], value: }设置应用初始状态。状态是一个统一的数据对象后续所有视图、Action、订阅都基于它工作详见 docs/architecture/state.md。view顶层视图函数接收当前状态并返回虚拟 DOM。它用h()描述main/h1/input/ul/li/button等元素用text()描述文本节点。事件绑定oninput: NewValue、onclick: AddTodo把 DOM 事件直接映射为 Action。用户在输入框敲字时NewValue收到(state, event)将event.target.value写入状态点击按钮时AddTodo把当前输入追加到todos数组并清空输入框。node: document.getElementById(app)指定挂载节点mount node。Hyperapp 会用其生成的 DOM 替换该元素详见下文app()一节。自动重渲染状态一旦变化Hyperapp 自动重新计算视图并 diff 更新真实 DOM无需手动操作。视图不是模板而是函数README 特别强调Hyperapp 中描述页面不使用标记语言而是用h()和text()创建虚拟 DOM。这种视图即纯函数的设计带来两个直接好处可组合性视图由嵌套的函数调用构成天然可以拆分为可复用的组件官方术语为 component/subview。表达力因为就是 JavaScript可以随意使用条件、循环、映射等语言特性动态决定渲染什么。核心 API 一览完整的 API 文档见 docs/reference.md四个核心导出函数分别是函数作用文档h()创建将被渲染的虚拟 DOM 节点VNodedocs/api/h.mdtext()把字符串转为 VNodedocs/api/text.mdapp()初始化并挂载 Hyperapp 应用docs/api/app.mdmemo()创建惰性渲染的特殊 VNodedocs/api/memo.mdh()描述元素的函数h()的签名为h(tag, props, children)三个参数中tag与props必填children可选单个 VNode 或 VNode 数组。h(input, { type: checkbox, id: picard, checked: state.engaging, })几个需要特别注意的 props 特性详见 docs/api/h.mdclass的三种写法字符串h(div, { class: muggle-studies })对象按布尔值开关类名h(div, { class: { arithmancy: true } })数组可递归组合上述格式h(div, { class: [magical theory, { xylomancy: true }] })从 index.js 的createClass实现可以看到Hyperapp 会递归地拼接这些格式为最终的 class 字符串。styleCSS 属性对象键名可写 camelCasebackgroundColor或带引号的连字符形式font-weight。key为数组渲染的 VNode 提供唯一标识如h(li, { key: p.id }, ...)帮助 diff 算法在元素增删、排序时精确定位。这一点与 index.js 中getKey以及 index.js 中基于 key 的 keyed-diff 分支直接对应。事件监听器以on开头的 propsonclick、oninput、onchange等用于绑定 Action。自定义合成事件同样适用只要事件名以on开头h(button, { onbuild: BuildAction }, ...)。其底层实现在 index.jsHyperapp 在node.events上登记处理器并用addEventListener/removeEventListener管理真实监听器。连字符属性需要加引号h(q, { data-zoq-fot-pik: Frungy })。text()文本节点text()把字符串变成文本 VNode通常作为h()的子节点使用h(p, {}, text(Hello world))从源码看text(value)创建的 VNodetype为3TEXT_NODE且带有一个可选的真实 DOM 节点引用参数用于服务端渲染回收场景index.js。app()应用初始化与挂载app()的签名Elm 风格描述app : ({ Init, View, Node, Subscriptions?, Dispatch? }) - DispatchFn属性类型必填init:State /[State, ...Effects]/ Action /[Action, any]否默认{}view:View 函数否node:DOM 元素当存在view:时必填subscriptions:函数否dispatch:Dispatch Initializer否init:的四种形态init:发生在首次视图渲染与订阅注册之前用于设置初始状态或执行初始 Action。它的四种形态详见 docs/api/app.md// 1. 直接设置初始状态 app({ init: { counter: 0 }, /* ... */ }) // 2. 设置状态并同时运行若干 Effect app({ init: [ { loading: true }, log(Loading...), load(myUrl?init, DoneAction), ], }) // 3. 运行一个 Action此时传入的状态为 undefined const Reset (_state) ({ counter: 0 }) app({ init: Reset }) // 4. 以 Action payload 方式初始化 const SetCounter (_state, n) ({ counter: n }) app({ init: [SetCounter, 10] })从 index.js 的 dispatch 实现看init会被当作一次初始 dispatch 处理普通对象直接update(action)Action 函数则执行action(state, props)数组则按[state, ...effects]语义依次update状态并逐个运行 effect。view:顶层视图每个应用只能有一个顶层视图Hyperapp 在状态初始设置及每次状态更新后都会调用它把状态映射为 UIapp({ // ... view: (state) h(main, {}, [ outworld(state), netherrealm(state), ]), })node:挂载节点node指定虚拟 DOM 要渲染到的 DOM 元素该元素会被 Hyperapp 应用整体替换这个过程称为挂载mounting。实践中通常预先在 HTML 中放置一个带 ID 的空元素main idapp/mainapp({ node: document.getElementById(app), })如果挂载节点内已有内容Hyperapp 会尝试**回收recycle**这些既有 DOM见 index.js 的recycleNode它将既有节点递归转换为SSR_NODE类型的 VNode。这为服务端渲染SSR或预渲染提供了开箱即用的水合hydration支持带来 SEO 与性能收益详见 docs/architecture/views.md。subscriptions:与dispatch:subscriptions接收当前状态返回订阅数组每次状态变化都会重新求值以决定订阅的启停。如果某一项为假值则该位置的订阅会被清理。省略时等价于subscriptions: (state) []。dispatch是一个 dispatch initializer可创建自定义 dispatch 函数用于调试、测试、遥测等场景。返回值与清理app()返回应用内部使用的 dispatch 函数可用于从外部控制应用例如在一个大应用中用 Hyperapp 实现局部模块。调用该 dispatch 且不传参数时会释放应用资源并运行所有活动订阅的清理函数。另外Hyperapp 应用可以互相嵌套或与其它框架共存页面上可同时运行多个彼此状态独立的 Hyperapp 实例详见 docs/api/app.md。memo()备忘惰性渲染memo()创建一种特殊 VNode仅在传入的 props 发生变化时才重新渲染其视图适用于不常更新或偶发更新的节点const view (state) memo(scenicView, state.vacationSpot)其原理依托不可变性若两次 props 引用相等则内容必然一致因此可以安全跳过重新计算详见 docs/architecture/views.md。memo的 VNode 结构在 index.js 中实现{ tag, memo }而maybeVNodeindex.js通过propsChanged比较新旧 memo props 决定是否重新执行tag。需要注意备忘并非万灵药如果对每次状态变化都要更新的节点使用检查 props 的开销反而可能得不偿失。状态State统一状态模型Hyperapp 的状态是整个应用的统一数据集合视图、Action、订阅都访问它。设计上 Hyperapp 对状态结构不做强约束只需统一组件不拥有本地状态因此可以直达整个状态的任意部分详见 docs/architecture/state.md。状态转变State Transition除init:外唯一改变状态的方式就是通过 Action。状态转变应被理解为快照式演进Action 返回全新的状态对象而不是原地修改。带 Effect 的状态数组如果使用数组设置状态Hyperapp 会把第一个元素解释为新状态、其余元素解释为需要运行的 Effects[state, log(state), log(MOAR)]重要陷阱若你的状态本身是数组必须将其包在带 Effect 的数组中才能生效[[a, b, c]]实践建议from docs可序列化避免在状态中存放符号、函数、递归引用以兼容本地存储持久化等场景。类型建议虽然状态可以是字符串、数字等基础类型但推荐使用对象以利用其表达能力。禁止直接修改若直接修改当前状态并返回同一个引用Hyperapp 无法察觉变化Action 将不产生任何效果。Action状态的唯一转变途径定义与签名Anactionis a message used within your app that signals the valid way to change state.Action 是实现状态转变的确定性纯函数无副作用被 dispatch 时总是隐式接收当前状态作为第一个参数Action : (State, Payload?) - NextState | [NextState, ...Effects] | OtherAction | [OtherAction, Payload?]四种返回形态分别对应新状态、新状态加 Effects、另一个 Action、另一个 Action 加 payload。Action 推荐用PascalCase命名且用动词或动词-名词短语如IncrementBy、GotData。最简单的转变// 原样返回当前状态可作占位符 const Identity (state) state // 直接设置状态 const FeedFace () 0xfeedface // 典型的状态转变 const Increment (state) ({ ...state, value: state.value 1 }) h(button, { onclick: Increment }, text())Payload 与 Action DescriptorAction 可接收可选的 payload用action descriptor二元组传参const AddBy (state, amount) ({ ...state, value: state.value amount }) h(button, { onclick: [AddBy, 5] }, text(5))事件 payload作为事件处理器的 Action其默认 payload 是事件对象。若AddBy被直接绑定到onclick点击时它会收到事件对象并试图加进状态从而产生 bug。正确的做法是用包装 Action 预处理事件const AddByValue (state, event) [AddBy, event.target.value] h(input, { value: state, oninput: AddByValue })包装 ActionWrapped ActionsAction 可以返回另一个 Action从而链式调整 payloadconst AddBy (state, amount) ({ ...state, value: state.value amount }) const AddByMore (_, amount) [AddBy, amount 5] const AddByEvenMore (_, amount) [AddByMore, amount 10] h(button, { onclick: [AddByEvenMore, 1] }, text(16))停止应用让状态转变为undefined即可停止整个应用const Stop () undefined停止后所有订阅停止、DOM 不再被触碰、事件处理器失效且已停止的应用无法重启。若你的应用点击无响应可能是被误停。关于数组状态Action 返回数组携带特殊含义因此操作数组状态需要二选一将其包在 effectful 数组中(state) [[...state, one]]或改用对象包含数组的结构(state) ({ ...state, list: [...state.list, one] })。EffectAction 与外部世界的桥梁定义与签名Aneffectis a representation used by actions to interact with some external process.HTTP 请求、DOM 焦点、本地存储、WebSocket 等与外部世界打交道的操作都以 effect 形式表达从而保持应用逻辑的纯、安全、不可变Effect : EffecterFn | [EffecterFn, Payload]Effect 推荐用camelCase动词命名如log、saveAsPDF。在 Action 中运行 EffectAction 返回[NextState, ...Effects]数组即可让状态转变与 Effect 同时进行import { log } from ./fx const SayHi (state) [ { ...state, value: state.value 1 }, log(hi), log(there), ] h(button, { onclick: SayHi }, text(Say Hi))Action 也可以同时接收 payload 并运行 Effectconst SayBye (state, amount) [ { ...state, value: state.value amount }, log(bye), ]排除 Effect 与条件 Effect返回[NextState]单元素数组表示只做状态转变。这特别适合按条件运行 Effect的场景——并且假值 Effect 会被自动忽略const DoItBest (state) [ { ...state, value: MacGuffin }, state.eating log(eating), state.drinking log(drinking), ]Effecter真正干活的函数Aneffecteris the function that actually carries out an effect.EffecterFn : (DispatchFn, Payload?) - voidEffecter 允许使用副作用并可手动dispatchAction 把结果回报给应用。它的定位是业务逻辑与不纯代码之间通用的桥梁因此越通用越好——不要在 Effecter 里硬编码状态结构或元素 ID而应通过 payload 传入// 良构的通用 effecter const runGetElement (dispatch, payload) dispatch(payload.action, document.getElementById(payload.id))异步 Effecter 的同步要求Hyperapp 的重绘周期与浏览器自然重绘周期同步因此异步 Effecter 回报结果时必须与之对齐优先使用requestAnimationFrame()不可用时才回退到setTimeout()。const runSimpleFetch async (dispatch, payload) { const response await fetch(payload.url) requestAnimationFrame(() dispatch(payload.action, response.json())) }从 index.js 可以看到Hyperapp 的update正是通过requestAnimationFrame(render, ...)把渲染调度到下一个浏览器重绘帧busy标志保证同一帧只渲染一次。自定义事件 Effect与遗留应用通信的典型场景是自定义事件。先定义 effecter 与 effect creator// ./fx.js const runEmit (_dispatch, payload) dispatchEvent(new CustomEvent(payload.type, { detail: payload.detail })) export const emit (type, detail) [runEmit, { type, detail }]再在应用中使用import { h, text, app } from hyperapp import { emit } from ./fx app({ view: () h(main, {}, [ h( button, { onclick: (state) [ state, emit(outgoing, { message: hello }) ], }, text(Send greetings) ), ]), node: document.querySelector(main), })Subscription响应外部世界定义与签名Asubscriptionfunction represents a dependency your app has on some external process.如果说 Effect 是应用影响外部世界的方式那么 Subscription 就是应用响应外部世界的方式时间变化、位置变化、键盘事件等。Subscription 代管资源管理添加/移除监听器、关闭连接等Subscription : [SubscriberFn, Payload?]推荐命名为on前缀的 camelCase如onEvery、onMouseEnter。注册订阅通过app()的subscriptions:属性注册。它是一个接收状态的函数返回订阅数组import { onEvery } from ./time app({ init: { delayInMilliseconds: 1000 }, subscriptions: (state) [ onEvery(state.delayInMilliseconds, RequestResource), ], })可以用布尔值控制订阅是否激活app({ subscriptions: (state) [ state.toBe onEvery(state.delay, ThatIsTheQuestion), state.notToBe || onEvery(state.delay, ThatIsTheQuestion), ], })数组格式约束与生命周期重要约束订阅数组必须是固定长度的每一项要么是布尔值要么是始终保持在同一数组位置的特定订阅函数。动态长度数组与内联订阅函数都无法正常工作内联会在每次状态变化时重置。订阅的生命周期由 Hyperapp 在每次状态变化时对比前后激活状态决定之前激活当前激活行为否否无操作否是订阅启动是否订阅关闭并清理是是订阅保持要重启订阅必须先停用它然后在下次状态变化时重新激活。自定义订阅与 Subscriber当官方包无法满足需求时可以自行编写订阅。Subscriber是实现活动订阅的函数它描述如何开始监听且必须返回一个如何停止监听的函数const keydownSubscriber (dispatch, options) { const handler ev { if (ev.key ! options.key) return dispatch(options.action) } addEventListener(keydown, handler) return () removeEventListener(keydown, handler) }其启停机制在 index.js 的patchSubs中实现Hyperapp 会比较新旧订阅的 effecter 与 options决定复用、重建或清理订阅newSub[0] ! oldSub[0]或shouldRestart判定需要重启时会先调用旧订阅的清理函数再启动新订阅。官方扩展包README 中列出官方包它们以符合 Hyperapp 的方式封装 Web 平台 API包状态用途位置hyperapp/dom已发布检查 DOM、focus 与 blurpackages/domhyperapp/svg已发布用纯函数绘制 SVGpackages/svghyperapp/html已发布用纯函数书写 HTMLpackages/htmlhyperapp/time已发布订阅定时器、获取当前时间packages/timehyperapp/events已发布订阅鼠标、键盘、窗口与帧事件packages/eventshyperapp/http规划中与服务器通信、发 HTTP 请求packages/httphyperapp/random规划中声明式随机数与随机值packages/randomhyperapp/navigation规划中订阅并管理浏览器 URL 历史packages/navigation若需要自定义 Effect 或 Subscription可以参考 docs/reference.md 与上述架构文档自行实现。实战演练从 Todo 到人物列表视图组件View Components由于视图只是嵌套的函数调用很容易拆分出可复用组件const person props h(div, { class: { person: true, highlight: props.highlight } }, [ h(p, {}, text(props.name)), h(input, { type: checkbox, checked: props.highlight, onclick: props.ontoggle, }), ]) // 视图简化为组件组合 state h(main, {}, [ person({ name: state.name, highlight: state.highlight, ontoggle: ToggleHighlight, }), ])注意这不需要任何 Hyperapp 特殊机制纯粹是普通函数组合。带 payload 的批量渲染把多个人物渲染出来并用[Action, index]传入索引 payloadconst ToggleHighlight (state, index) { let highlight [...state.highlight] highlight[index] !highlight[index] return { ...state, highlight } } state h(main, {}, [ ...state.names.map((name, index) person({ name, highlight: state.highlight[index], ontoggle: [ToggleHighlight, index], })), ])中间 Action 处理事件冒泡复选框的onclick会冒泡到外层 div。利用Action 的默认 payload 是事件对象以及Action 可以返回另一个 Action这两个特性可以定义中间 Action 先stopPropagation()再继续分发原 Actionh(input, { type: checkbox, checked: props.highlight, onclick: (_, event) { event.stopPropagation() return props.ontoggle }, })条件渲染用或三元表达式A ? B : C控制视图片段显隐state h(main, {}, [ ...state.names.map((name, index) person({ /* ... */ })), state.bio h(div, { class: bio }, text(state.bio)), ])用 Effect 获取远端数据Action 中不适合直接发起 fetchAction 应只负责计算并返回值应把副作用包进 Effecterconst fetchJson (dispatch, options) { fetch(options.url) .then(response response.json()) .then(data dispatch(options.action, data)) } // Effect creator更便利的封装 const jsonFetcher (url, action) [fetchJson, { url, action }] const Select (state, selected) [ {...state, selected}, jsonFetcher( https://jsonplaceholder.typicode.com/users/ state.ids[selected], GotBio ), ]在 init 时运行 Effectinit表现得像一次初始 dispatch 的返回值因此可以写成[initialState, someEffect]让 Effect 在启动时立即执行app({ init: [ { names: [], highlight: [], selected: null, bio: , ids: [] }, jsonFetcher(https://jsonplaceholder.typicode.com/users, GotNames) ], })用订阅响应键盘事件订阅是应用响应外部世界的方式。定义订阅 creator 并把 Action 派发绑定到按键上const onKeyDown (key, action) [keydownSubscriber, { key, action }] app({ // ... subscriptions: state [ state.selected ! null state.selected 0 onKeyDown(ArrowUp, SelectUp), state.selected ! null state.selected (state.ids.length - 1) onKeyDown(ArrowDown, SelectDown), ], })每次状态变化时 Hyperapp 都会重新评估订阅函数按需启动/停止对应订阅——用让无选中时两个方向订阅都停用、选中项到边界时停用越界方向的订阅。Action 之间还可以接力SelectUp返回[Select, state.selected - 1]即把Select连同新索引重新派发从而复用Select中携带的 fetch Effect。完整的 14 步增量式教程含每个步骤的沙盒链接见 docs/tutorial.md是学习上述所有概念的最佳顺序化材料。底层原理速览从源码看 VDOM 与 dispatchHyperapp 全部核心实现都在单文件 index.js 中几个值得留意的实现细节虚拟节点结构createVNode(tag, { key, ...props }, children, type, node)生成含tag/props/key/children/type/node六字段的 VNodeindex.js与 tests/index.test.js 的断言一一对应。diff 与 patchpatch采用头尾双指针 keyed 字典的 diff 策略index.js对文本节点做值比较、对元素节点做 props 差异补丁含style、事件、属性三种分支见 index.js并复用 key 快速匹配移动的列表项。事件委托所有事件 handler 都统一经由listener函数调用dispatch(this.events[event.type], event)index.js事件对象自然成为 Action 的默认 payload。dispatch 主循环dispatch统一处理函数 Action /[Action, payload]/[state, ...effects]/ 字面量状态四种输入index.js这正是上文 Action 四种返回形态的底层来源。SVG 与回收createNode通过SVG_NS创建 SVG 元素recycleNode把既有 DOM 转为 SSR VNode 以实现水合index.js、index.js。继续深入按顺序完成 教程状态、Action、Effect、Subscription 全覆盖的增量实战。查阅 API 参考h、text、app、memo与 架构文档、docs/architecture/actions.md、docs/architecture/effects.md、docs/architecture/subscriptions.md、docs/architecture/views.md。阅读核心实现 index.js 与类型声明 index.d.ts通过npm test见 package.json 的 test 脚本运行 tests/index.test.js 验证 VNode 构造行为。项目采用 MIT 许可证。Hyperapp 的全部概念止步于 state、actions、views、effects 和 subscriptions——学习成本极低却足以构建功能丰富的纯函数式浏览器应用。以 README.md 为起点配合教程与参考文档即可快速上手。【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询