Hyperapp 从零到实战:基于 State、Action、Effects 与 Subscriptions 的完整入门教程
Hyperapp 从零到实战基于 State、Action、Effects 与 Subscriptions 的完整入门教程【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp导读本文是 1kB 级超轻量前端框架 Hyperapp 的官方入门教程对应仓库 docs/tutorial.md。教程将从一个空的 HTML 文件出发逐步构建一个人物列表 高亮 单选 远程拉取简介 键盘导航的完整可交互应用在此过程中系统掌握 Hyperapp 的五大核心概念——State状态、View视图、Action动作、Effects效应与 Subscriptions订阅并深入源码理解其背后的虚拟 DOM、事件委托与递归 dispatch 机制。读完本文你将能够独立编写、拆解和调试一个纯函数式的 Hyperapp 应用。准备环境一个 HTML 文件 一个 CSS 文件教程不需要任何构建工具浏览器直接以 ES Module 方式加载 Hyperapp 即可。先新建一个hyperapp-tutorial.html内容如下!doctype html html head meta charsetutf-8 / link relstylesheet href./hyperapp-tutorial.css / script typemodule /* 你的代码写在这里 */ /script /head body main idapp/ /body /html注意script typemodule允许我们在脚本内直接使用import。main idapp/是挂载点Hyperapp 会用它替换这个节点并渲染出我们声明的内容。在同一个目录下再创建hyperapp-tutorial.css。教程为其准备了演示用的样式人物卡片、高亮态、选中态、简介气泡等可展开复制展开教程配套 CSSimport url(...); /* 原教程在此引入一个开源的极简样式重置库water.css 的 CDN 版本可自行替换为任意你喜欢的样式方案 */ :root { --box-width: 200px; } main { position: relative;} .person { box-sizing: border-box; width: var(--box-width); padding: 10px 10px 10px 40px; position: relative; border: 1px #ddd solid; border-radius: 5px; margin-bottom: 10px; cursor: pointer; } .person.highlight { background-color: #fd9; } .person.selected { border-width: 3px; border-color: #55c; padding-top: 8px; padding-bottom: 8px; } .person input[typecheckbox] { position: absolute; cursor: default; top: 10px; left: 7px; } .person.selected input[typecheckbox] { left: 5px; top: 8px; } .person p { margin: 0; margin-left: 2px; } .person.selected p { margin-left: 0; } .bio { position: absolute; left: calc(var(--box-width) 2rem); top: 60px; color: #55c; font-style: italic; font-size: 2rem; text-indent: -1rem; } .bio:before {content: ;} .bio:after {content: ;}后续每完成一步就在浏览器中刷新该 HTML 文件查看效果。原教程的每个步骤都配有可在线运行的沙盒演示完整链接见 docs/tutorial.md 文末的引用块当本地运行出现问题时可对照参考。Hello world认识h、text与app在script typemodule中写入第一个 Hyperapp 应用// 从任意支持 ES Module 的 CDN如 unpkg、skypack、jsdelivr 等导入 hyperapp // 教程原版使用 CDN 方式加载生产环境也可通过 npm install hyperapp 安装后本地打包 import { h, text, app } from hyperapp app({ view: () h(main, {}, [ h(div, { class: person }, [ h(p, {}, text(Hello world)), ]), ]), node: document.getElementById(app), })保存并刷新页面你会看到一个带边框的Hello world卡片。这里只用了三个函数h(tag, props, children)创建表示 HTML 标签的虚拟节点VNode。参数依次是标签名如main、div、p、属性对象、子节点单个 VNode 或 VNode 数组。完整的签名与参数说明见 docs/api/h.md。text(value)创建表示文本节点的 VNode。它专门存在是为了让 Hyperapp 的实现保持简单——文本节点与元素节点在虚拟 DOM 中分属不同类型见 docs/api/text.md。app({...})初始化并挂载整个应用其参数对象构成了应用的定义见 docs/api/app.md。这段代码描述的视图等价于下面的纯 HTMLmain div classperson pHello world/p /div /main其中view是一个纯函数返回虚拟 DOM——一份我们希望真实 DOM 长什么样的蓝图。node声明了 Hyperapp 渲染到哪里Hyperapp 会用view生成的 DOM 节点替换#app这个挂载节点。从源码看这三个核心导出都定义在仓库入口 index.js 中h内部会把 props 中的class交给createClass处理并把非数组的 children 统一包装成数组index.js#L356-L361text创建的是类型为TEXT_NODE的虚拟节点index.js#L353-L354app则负责状态管理、视图渲染与订阅调度index.js#L363-L413。State、View 与 Action数据如何驱动界面用init初始化 State每个 Hyperapp 应用内部都有一个统一的值叫state状态。通过给app添加init属性来设置状态的初始值app({ init: { name: Leanne Graham, highlight: true }, // ... })view总是以当前 state 作为参数被调用于是我们可以在视图中展示 state 里的数据。把view改为state h(main, {}, [ h(div, { class: person }, [ h(p, {}, text(state.name)), h(input, { type: checkbox, checked: state.highlight }), ]), ])刷新后Hello world变成了 state 中的nameLeanne Graham复选框的选中状态则由highlight控制。init其实是初始被派发的那次动作的返回值因此它支持多种形态直接给 state、给[state, ...effects]、给一个 action、或给[action, payload]详见 docs/api/app.md 与 docs/architecture/state.md。state是整个应用唯一的数据源视图、动作、订阅都能访问它Hyperapp 不规定 state 的结构把组织权交给开发者。class属性字符串、对象与数组三种写法把 div 的定义改成h(div, { class: { person: true, highlight: state.highlight } }, [ ... ])class属性可以像普通 HTML 一样传以空格分隔的类名字符串也可以传对象对象的键是类名当对应的值为真值truthy时该类就会被赋给元素。由此highlight一个值同时控制了两处 UIdiv 是否带highlight类、复选框是否勾选。从源码看createClass同时支持字符串、数组与对象三种输入并做递归拼接index.js#L11-L29docs/api/h.md 中给出了数组形式的递归示例例如h(div, { class: [{ active: state.active }, card, state.dark dark] })此时你点击复选框会发现高亮并不生效——因为点击事件还没有连接到状态变换上。接下来引入Action。Actions描述状态变换的纯函数定义如下函数const ToggleHighlight state ({ ...state, highlight: !state.highlight })它接收一个形如应用 state 的值作为参数返回同构的新 state——除了highlight被翻转到相反值外其余保持不变。这类函数就是action动作一个无副作用、确定性地描述当前状态 → 下一状态过渡的函数。把它挂到复选框的onclick上h(input, { type: checkbox, checked: state.highlight, onclick: ToggleHighlight, })刷新后再点击复选框选中态与卡片高亮会同步切换。Action 是 Hyperapp 状态变更的唯一合法途径官方文档给出了推荐命名规范使用PascalCase如ToggleHighlight、GotData并且建议用动词或动词名词详见 docs/architecture/actions.md。写 action 时务必遵守不可变性返回全新的 state 快照而不是原地修改——如果直接修改并返回原对象引用Hyperapp 无法察觉变化action 将等同于无效docs/architecture/state.md。DispatchingHyperapp 的响应式闭环把ToggleHighlight赋给复选框的onclick实际上是在告诉 Hyperapp当复选框上发生 click 事件时dispatch派发ToggleHighlight。派发一个 action 意味着 Hyperapp 用该 action 变换 state再用新 state 重新计算 view最后更新真实 DOM。这套流程在 index.js 中清晰可见。事件到达时统一由listener处理器从节点的events表里取出对应动作交给dispatchindex.js#L393-L395而dispatch是递归实现的(action, props) typeof action function ? dispatch(action(state, props)) // 是 action执行后递归 : isArray(action) ? typeof action[0] function ? dispatch(action[0], action[1]) // 是 [action, payload] : action.slice(1).map(...) // 是 [nextState, ...effects] : update(action) // 是普通值直接作为新状态index.js#L397-L411。而update在新旧状态不同时会同步更新订阅并安排requestAnimationFrame驱动的重渲染index.js#L375-L381渲染阶段用 diff 算法把虚拟 DOM 的变化以最小代价打到真实 DOM 上index.js#L120-L313。这就是状态变化 → 视图自动更新的完整闭环。View components用普通函数组合视图view就是嵌套的函数调用因此很容易把其中一部分拆成独立函数以便复用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, }), ])view 随即简化为state h(main, {}, [ person({ name: state.name, highlight: state.highlight, ontoggle: ToggleHighlight, }), ])person就是一个view component视图组件。拆分组件的核心目的是管理大型视图但它并不依赖任何 Hyperapp 的特殊能力——就是普通的函数组合。组件可以直接消费全局 state子视图也可以只接收 props返回 VNode 的函数还可以返回 VNode 数组在兄弟节点中使用时用展开运算符...摊平详见 docs/architecture/views.md。Action payloads让一个动作服务多个数据拆出组件后很自然想渲染多张卡片。先把init扩展为名字数组与高亮数组{ names: [ Leanne Graham, Ervin Howell, Clementine Bauch, Patricia Lebsack, Chelsey Dietrich, ], highlight: [ false, true, false, false, false, ], }然后把 view 改成对names做映射state h(main, {}, [ ...state.names.map((name, index) person({ name, highlight: state.highlight[index], ontoggle: [ToggleHighlight, index], })), ])注意这里没有直接把ToggleHighlight赋给ontoggle而是赋了[ToggleHighlight, index]。这个二元组叫action descriptor它让 Hyperapp 以index作为payload载荷派发ToggleHighlightpayload 会成为 action 的第二个参数。相应地更新ToggleHighlight处理新的 state 形状并消费 index 载荷const ToggleHighlight (state, index) { // 浅拷贝原始 highlight 数组 let highlight [...state.highlight] // 翻转拷贝中 index 位置的值 highlight[index] !highlight[index] // 返回 state 的浅拷贝用新数组替换 highlight return { ...state, highlight } }刷新后五个卡片各自独立切换高亮。接下来实现点击卡片单选一人。先在init里加一个selected字段用索引记录被选中的人初始为null{ // ... selected: null, }再定义选中动作与组件参数const Select (state, selected) ({ ...state, selected }) // view 内 person({ name, highlight: state.highlight[index], ontoggle: [ToggleHighlight, index], selected: state.selected index, // --- onselect: [Select, index], // --- })最后让组件消费这两个新 prop给被选中的卡片加selected类并把onselect接到 div 的onclick上const person props h(div, { class: { person: true, highlight: props.highlight, selected: props.selected, // --- }, onclick: props.onselect, // --- }, [ h(p, {}, text(props.name)), h(input, { type: checkbox, checked: props.highlight, onclick: props.ontoggle, }), ])现在点击不同卡片可以切换选中。关于 payload 的更完整讨论——包括如何用 wrapped action 预处理事件载荷、如何链式包装 action——可参考 docs/architecture/actions.md。DOM 事件对象中间 action 与stopPropagation此时会发现一个交互问题勾选复选框也会触发选中。原因在于 DOM 本身的机制——checkbox 上的 click 事件会冒泡到外层 div从而同时触发onselect。如果我们能拿到事件对象调用stopPropagation()就能阻止冒泡。好消息是裸 action没有以[action, payload]形式给出的默认 payload 就是事件对象。所以可以把复选框的onclick写成onclick: (state, event) { event.stopPropagation() // ... }但原来的props.ontoggle怎么办——把它返回出去h(input, { type: checkbox, checked: props.highlight, onclick: (_, event) { event.stopPropagation() return props.ontoggle }, })当一个 action 返回的是另一个 action 或[action, payload]元组而不是新 state时Hyperapp 会转而派发那个 action。这里相当于定义了一个中间 action先拦截并停止事件冒泡再继续派发原本要执行的ontoggle即[ToggleHighlight, index]。这与 docs/architecture/actions.md 中的 wrapped action 思想一脉相承。刷新后高亮与选中就可以独立操作了。条件渲染用与三元表达式开关视图稍后我们会从服务器拉取被选中人物的简介bio现在先把展示层准备好。在init中加入空的bio{ // ..., selected: null, bio: , // --- }定义保存简介的 action假定服务器数据对象上有company.bs字段const GotBio (state, data) ({ ...state, bio: data.company.bs })在 view 末尾追加 bio 展示区域state h(main, {}, [ ...state.names.map((name, index) person({ name, highlight: state.highlight[index], ontoggle: [ToggleHighlight, index], selected: state.selected index, onselect: [Select, index], })), state.bio // --- h(div, { class: bio }, text(state.bio)), // --- ])state.bio为真值时 bio-div 才会渲染为空字符串时表达式直接得到false该位置渲染为空白。这种用开关视图某部分、或用三元表达式A ? B : C在多个部分间切换的技术就是条件渲染docs/architecture/views.md。可以手动把init里的bio设成一个非空字符串验证效果。Effects让动作与外部世界安全交互为什么不能在 action 里直接 fetch要拉取简介需要每个人的 id。先把 id 加进初始状态{ // ... selected: null, bio: , ids: [1, 2, 3, 4, 5], // --- }在选中一个人时发起请求这个需求下直觉写法是在Select里直接 fetchconst Select (state, selected) { fetch(https://jsonplaceholder.typicode.com/users/ state.ids[selected]) // 教程原版使用免费的 JSONPlaceholder 演示服务 .then(response response.json()) .then(data { console.log(Got data: , data) /* 接下来怎么办 */ }) return { ...state, selected } }数据确实能取到并打印出来——但它进不了 state这是因为Hyperapp 的 action 不是通用的事件处理器它被设计成只负责计算并返回一个值。想在不纯的动作旁执行任意代码正确做法是把代码包进一个函数与新 state 一起返回const Select (state, selected) [ { ...state, selected }, () fetch(https://jsonplaceholder.typicode.com/users/ state.ids[selected]) // 同上演示服务地址请替换为可达的 API .then(response response.json()) .then(data { console.log(Got data: , data) /* 接下来怎么办 */ }) ]Effecters派发中的副作用执行者当 action 返回形如[newState, fn]的结果时fn就是effecter又称effect runner效应执行器。作为 dispatch 流程的一部分Hyperapp 会替你调用这个函数。更关键的是Hyperapp 会把dispatch函数作为 effecter 的第一个参数传入让 effecter 能在数据就绪时回调你的应用const Select (state, selected) [ { ...state, selected }, dispatch { // --- fetch(https://jsonplaceholder.typicode.com/users/ state.ids[selected]) // 替换为你的 API 地址 .then(response response.json()) .then(data dispatch(GotBio, data)) // --- } ]现在点击一个人除了标记选中还会发出数据请求响应返回后GotBio以响应数据为 payload 被派发bio 写入 state视图随之更新。这正是 effecter 的标准签名(DispatchFn, Payload?) - voiddocs/architecture/effects.md。从源码看dispatch 处理[state, ...effects]数组时会把每个 effect 统一规范成函数后以(dispatch, payload)调用index.js#L401-L409。Effects[effecter, options]二元组接下来还要用同样方式拉取其他数据唯一区别只是 URL 和回调 action。把 effecter 泛化const fetchJson (dispatch, options) { fetch(options.url) .then(response response.json()) .then(data dispatch(options.action, data)) }再次改写Selectconst Select (state, selected) [ { ...state, selected }, [ fetchJson, { url: https://jsonplaceholder.typicode.com/posts/ state.ids[selected], // 替换为你的 API 地址 action: GotBio, } ] ]像[effecter, options]这样的二元组就叫effect效应。options 会作为第二个参数传给 effecter。行为与之前完全一致但fetchJson现在可复用了。官方文档给出的 effect 签名为Effect : EffecterFn | [EffecterFn, Payload]并建议用camelCase动词命名 effect如log、saveAsPDF——因为 effect 表达的是要做的事docs/architecture/effects.md。Effect creators更顺手的封装再定义一个函数const jsonFetcher (url, action) [fetchJson, { url, action }]Select进一步简化const Select (state, selected) [ { ...state, selected }, jsonFetcher(https://jsonplaceholder.typicode.com/users/ state.ids[selected], GotBio), // 替换为你的 API 地址 ]jsonFetcher就是effect creator效应创建器。它不依赖任何 Hyperapp 特性只是让 effect 的使用更便捷、可读的常规封装。在init阶段跑 effect启动即拉取之前提到init相当于初始派发的那个 action 的返回值所以把它设为[initialState, someEffect]应用启动时 effect 就会立刻执行。把init改为[ { names: [], highlight: [], selected: null, bio: , ids: [] }, jsonFetcher(https://jsonplaceholder.typicode.com/users, GotNames) // 替换为你的 API 地址 ]这样应用启动时没有任何名字和 id而是从服务器拉取。GotNames会以响应为 payload 被派发实现它const GotNames (state, data) ({ ...state, names: data.slice(0, 5).map(x x.name), ids: data.slice(0, 5).map(x x.id), highlight: [false, false, false, false, false], })现在名字与 id 都来自 API 而非硬编码。docs/api/app.md 展示了init的全部四种合法形态另外请留意一个隐含约定action 返回的数组会被特殊解释为[state, ...effects]因此若你的 state 本身就是数组需要再包一层如[[...state, one]]docs/architecture/state.md。关于异步 effecter 还有一条重要工程实践如果想在异步操作完成后把结果派发回应用最好用requestAnimationFrame回退方案setTimeout与浏览器的重绘周期保持同步避免状态写入的时序问题docs/architecture/effects.md。Subscriptions让应用对外部世界作出反应最后一个功能用方向键上下移动选中项。先定义移动选中的动作const SelectUp state { if (state.selected null) return state return [Select, state.selected - 1] } const SelectDown state { if (state.selected null) return state return [Select, state.selected 1] }没有选中项时移动没有意义两个动作直接返回state相当于 no-op。你可能还记得action 返回[otherAction, somePayload]时那个 action 会以给定 payload 被派发——这里正是借此搭便车复用Select里已经定义好的 fetch effect让方向键移动同样触发数据拉取。Subscribers启动监听并返回清理函数动作有了怎么让它在 keydown 事件发生时被派发如果 effect 是应用影响外部世界的方式那么subscription订阅就是应用对外部世界作出反应的方式。订阅需要一个subscriber订阅器。subscriber 与 effecter 很像但区别在于effecter 描述要做什么而 subscriber 描述如何开始监听并且 subscriber必须返回一个函数告诉 Hyperapp 如何停止监听const mySubscriber (dispatch, options) { /* 如何开始监听某事件 */ return () { /* 如何停止监听同一事件 */ } }定义监听 keydown 的 subscriber——当事件键匹配options.key时派发options.actionconst keydownSubscriber (dispatch, options) { const handler ev { if (ev.key ! options.key) return dispatch(options.action) } addEventListener(keydown, handler) return () removeEventListener(keydown, handler) }与 effect 类似再定义一个subscription creatorconst onKeyDown (key, action) [keydownSubscriber, { key, action }][subscriber, options]二元组就是一个subscription。通过app定义里的subscriptions属性告诉 Hyperapp 当前需要哪些订阅app({ // ..., subscriptions: state [ onKeyDown(ArrowUp, SelectUp), onKeyDown(ArrowDown, SelectDown), ] })这些订阅会随应用启动并持续存活。注意命名惯例官方建议订阅器/订阅创建者用on前缀的 camelCase如onEvery、onKeyDown以体现其事件处理特性docs/architecture/subscriptions.md。条件订阅用逻辑运算符开关订阅但这里并不希望订阅一直开着选中底部人物时ArrowDown 订阅没有意义选中顶部人物时ArrowUp 订阅没有意义没有选中时两者都不应开启。和条件渲染一样用逻辑运算符表达app({ // ..., subscriptions: state [ state.selected ! null state.selected 0 onKeyDown(ArrowUp, SelectUp), state.selected ! null state.selected (state.ids.length - 1) onKeyDown(ArrowDown, SelectDown), ], })每次 state 变化Hyperapp 都会用subscriptions函数计算应当激活的订阅并相应地启动/停止它们。这背后的生命周期管理在 index.js 的patchSubs中实现index.js#L39-L63逐位置比较新旧订阅newSub为假值时卸载旧订阅并执行其清理函数订阅器变化或shouldRestart判定 options 变更时先清理旧订阅再启动新订阅。两点工程细节值得记住subscriptions数组长度必须固定每个位置要么是布尔值、要么是某个固定订阅用动态数组或不内联订阅函数否则每次 state 变化都会重置。订阅器的 options 更新默认不会重启订阅除非选项内容实质变化源码中的shouldRestart会对新旧 options 逐键比较见 index.js#L31-L37。完整的订阅生命周期对照表未激活→激活、激活→未激活等四种情形及自定义订阅的完整示例见 docs/architecture/subscriptions.md。总结与下一步到这里教程的全部内容就完成了。我们一路构建了一个完整的应用并在此过程中掌握了 Hyperapp 的全部核心概念概念一句话理解仓库文档State应用唯一的统一数据源通过 action 以不可变快照方式更新docs/architecture/state.mdView接收 state 返回 VNode 的纯函数由h/text构建docs/architecture/views.mdAction描述状态变换的纯函数可携带 payload可返回下一个 actiondocs/architecture/actions.mdEffectsaction 与外界的桥梁[effecter, options]二元组effecter 可回调 dispatchdocs/architecture/effects.mdSubscriptions应用对外界的反应[subscriber, options]二元组subscriber 返回清理函数docs/architecture/subscriptions.md除了这五者Hyperapp 真的没有更多概念了。app()的完整 APIinit、view、node、subscriptions、dispatch见 docs/api/app.mddispatch的定制与中间件模式见 docs/architecture/dispatch.md框架入口与虚拟 DOM diff 的完整实现见 index.js相关行为验证可参考 tests/index.test.js。Hyperapp 还提供了一批官方扩展包DOM 检查、SVG、HTML 辅助、时间订阅、事件订阅等可在 packages 目录下查阅。至此你可以用自己的想法去构建应用了——状态、视图、动作、效应、订阅这五块拼图足以支撑绝大多数前端场景。【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考