Hyperapp Actions 深入解析:状态转换、Payload 与分发机制
【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp
导读
本文是 Hyperapp 架构系列中关于Actions的完整技术指南。Action 是 Hyperapp 中唯一合法地改变 state 的途径,理解它等于掌握了这个 1kB 级框架数据流的核心。你将学会 Action 的定义与签名、如何利用 Payload 与 Action 描述符传递数据、如何通过包装 Action 与 Transform 复用逻辑、如何优雅地停止应用,并借助 index.js 源码看到 dispatch 是如何一步步把这些"消息"变成真实状态更新的。
什么是 Action
Action(动作)是应用中用于表达"以何种合法方式改变 state"的消息。
Hyperapp 把状态更新建模为"消息传递"而非"直接赋值":视图里发生的事件不会直接改数据,而是产生一条 Action 消息,由框架统一执行。Action 由一个确定性的、不产生副作用的函数实现,它描述的是从当前 state 到下一个 state 的转换;在这个过程中,它还可以附带列出需要运行的 effects。
Action 的触发来源有三个:
- 应用内的 DOM 事件(如
onclick、oninput); - effecters(副作用执行器);
- subscribers(订阅器)。
无论从哪条路径被派发(dispatch),Action 的第一个参数总是当前的 state。
签名
Action : (State, Payload?) -> NextState | [NextState, ...Effects] | OtherAction | [OtherAction, Payload?]这个签名揭示了 Action 的四种合法返回值,后面的章节将逐一展开:
| 返回值形式 | 含义 |
|---|---|
NextState | 直接返回下一个 state,完成一次普通的状态转换 |
[NextState, ...Effects] | 返回"新 state + 若干 effects",state 先应用、effects 随后执行 |
OtherAction | 返回另一个 Action,由框架递归地继续派发 |
[OtherAction, Payload?] | 返回"另一个 Action + 可选 payload",相当于给后续 Action 预传数据 |
命名建议
官方强烈建议 Action 使用PascalCase命名,目的是向开发者传达"这些函数是被当作消息使用的、专门交给 Hyperapp 处理"的信号。同时建议名称包含动词或"动词 + 名词"短语:
- 祈使语气:
IncrementBy、ToggleVisibility、GetPizzas、SaveAddress; - 过去时态:
GotData、StoppedCounting——尤其适合放在 action-effect 链的终点,表示"最终状态转换已经完成"。
命名规范的深层原因与调试有关:文档在"非标准用法"一节明确指出,匿名函数没有名字可供调试工具使用,而"Action 应当有名字"是被推荐的实践。
最简单的状态转换
原样返回:Identity
最朴素的 Action 只是把当前 state 原样返回:
// Action : (State) -> SameState const Identity = (state) => state它看起来毫无用处,但在原型开发或组件占位时非常方便——你可以在 components 尚未实现时先用它占住事件处理器的位置:
h("button", { onclick: Identity }, text("Do Nothing"))直接设值:FeedFace
下一个层级是"不关心当前 state、直接返回一个固定值":
// Action : () -> ForcedState const FeedFace = () => 0xfeedface(0xfeedface是十六进制拼写 "hexspeak" 的一个经典例子,这里只是展示 Action 可以返回任意合法 state。)
真实的状态转换
你绝大多数情况下要做的是真正的状态转换——基于当前 state 派生出新 state:
// Action : (State) -> NewState const Increment = (state) => ({ ...state, value: state.value + 1 }) // ... h("button", { onclick: Increment }, text("+"))注意这里使用的是展开运算符创建新对象而非原地修改。这与 state 文档 中"状态变化应被视为快照"的理念一致:新版本的 state 被创建出来以反映某个时刻发生的变化。如果直接改动当前 state 并返回它,返回的仍然是同一个对象引用,Hyperapp 无法察觉到任何变化,该 Action 就等于什么都没做。
Payload:给 Action 传数据
Action 可以接收一个可选的payload(第二参数),与当前 state 一起传入:
// Action : (State, Payload?) -> NewState const AddBy = (state, amount) => ({ ...state, value: state.value + amount })要主动给 Action 提供 payload,需要用到Action 描述符(action descriptor)——一个[Action, payload]形式的元组,直接放进事件处理器的位置:
h("button", { onclick: [AddBy, 5] }, text("+5"))当用户点击时,Hyperapp 会以5作为 payload 派发AddBy,state 的value随之增加 5。
事件 Payload
当 Action 被用作事件处理器时,它会默认收到事件对象作为 payload。这一点在源码中有最直接的体现——index.js 中所有 DOM 事件共用的监听器:
var listener = function (event) { dispatch(this.events[event.type], event) }即:onclick: AddBy时,点击事件会把event对象直接当作 payload 传给AddBy:
h("button", { onclick: AddBy }, text("+5"))这显然是个 bug——AddBy会把事件对象"加"到 state 上。如果想正确利用事件对象,有两个选择:
- 改写
AddBy,让它兼容"可能收到事件对象"的情况; - 预处理事件对象,把它转换成
AddBy能接受的数据。
官方推荐后者,因为这样能让 Action 完全不关心 payload 从哪来,从而保持可复用性。这正是下一节"包装 Action"的用武之地。
包装 Action(Wrapped Actions)
Action 可以返回另一个 Action。最简单的形式相当于一个别名:
// Action : () -> OtherAction const PlusOne = () => Increment更有用的形式是在返回其他 Action 之前预处理 payload。例如做一个事件适配器,让主 Action 既能使用事件数据、又不与事件来源耦合:
// Action : (State, EventPayload) -> [OtherAction, Payload] const AddByValue = (state, event) => [AddBy, +event.target.value]这里我们用input节点而不是button,因为我们要从被预处理的事件中提取value属性:
h("input", { value: state, oninput: AddByValue })每次输入时,AddByValue收到input事件,把event.target.value转成数字后作为 payload 派发AddBy。
包装可以无限层嵌套,好处是能链式地调整 payload:
const 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") )点击后:AddByEvenMore收到 payload1→ 转成AddByMore及 payload11→ 再转成AddBy及 payload16→ 最终 state 的value增加 16。
源码视角:数组返回值如何被解析
包装 Action 之所以能工作,根源在 index.js 的 dispatch 实现:
(dispatch = dispatch((action, props) => typeof action === "function" ? dispatch(action(state, props)) : isArray(action) ? typeof action[0] === "function" ? dispatch(action[0], action[1]) : action .slice(1) .map( (fx) => fx && fx !== true && (fx[0] || fx)(dispatch, fx[1]), update(action[0]) ) : update(action) ))(init)这里的解析逻辑可以概括为:
typeof action === "function":直接调用action(state, props),并把返回值递归地再次派发——这就是"Action 返回另一个 Action"得以持续解析的根本机制;- 数组且首元素是函数:视为
[OtherAction, payload]描述符,递归派发dispatch(action[0], action[1]); - 数组且首元素不是函数:视为
[NextState, ...effects],先update(action[0])应用新 state,再用(fx[0] || fx)(dispatch, fx[1])依次执行每个 effect; - 其他值(字面量):直接
update(action)把字面量设为 state。
这也印证了文档中"dispatch 以递归方式实现"的描述——相关讨论详见 dispatch。
Transforms:把大 Action 拆成可复用的小函数
当一个 Action 变得非常大、非常复杂时,可以考虑把它重构为更小、更易管理的函数。但请记住:Action 是消息,概念上并不像实现它们的函数那样可以组合。不过,把一部分状态处理委托给其他函数常常是有利的,这些构成性函数就叫做transform(转换函数),专供 Action 或其他 transform 使用。
const Liokaiser = (state) => ({ ...state, combined: true, leftArm: hellbat(state), rightArm: guyhawk(state), upperTorso: leozack(state), lowerTorso: jallguar(state), leftLeg: drillhorn(state), rightLeg: killbison(state), })每个hellbat、guyhawk之类的函数都是一个纯函数 transform:它接收 state(或其片段),返回处理后的结果。这样,负责"组合"的 Action 保持短小清晰,各部分逻辑也能独立测试与复用。
停止应用(Stopping Your App)
可以通过把 state 转换为undefined来终止 Hyperapp 的一切进程:
// Action : () -> undefined const Stop = () => undefined应用停止后会发生以下几件事:
- 应用的所有订阅(subscriptions)全部停止;
- 不再触碰 DOM;
- 事件处理器不再工作。
已停止的应用无法重启。
源码层面,这个行为在 index.js 的update函数中有清晰体现:
var update = (newState) => { if (state !== newState) { if ((state = newState) == null) dispatch = subscriptions = render = id if (subscriptions) subs = patchSubs(subs, subscriptions(state), dispatch) if (view && !busy) requestAnimationFrame(render, (busy = true)) } }当新 state 为null/undefined时,dispatch、subscriptions、render全部被替换为恒等函数id(var id = (a) => a),于是后续的派发、订阅更新、渲染都变成空操作——应用"冻结"了。这也从侧面解释了:如果遇到"点了没反应"的情况,很可能是某个 Action 意外返回了undefined导致应用被误停。
同理,state 文档 也特别提醒:如果 Action 什么都不返回,应用就会停止。
其他注意事项
数组状态的转换(Transitioning Array State)
从 Action 返回的数组带有特殊含义(即[NextState, ...Effects]的语义,详见 effects 文档),因此如果你真的想用数组作为 state,需要特殊处理。文档给出了两个选项:
选项一:把返回的数组状态包进"带 effects 的状态数组"里。注意 app() 的init:选项如果也使用数组 state,同样需要这样包裹:
const ArrayAction = (state) => [[...state, "one"]]选项二:换一种 state 格式,把数组放进对象里,让 Action 像操作普通对象 state 一样操作它:
const ObjectAction = (state) => ({ ...state, list: [...state.list, "one"] })这与 state 文档 中的说明完全对应:真正的数组状态必须包在 effectful state array 里才能正常工作。
非标准用法
匿名函数作为 Action:缺点是没有名字可供调试工具使用。鉴于"Action 应该有名字"的建议,应尽量避免。
柯里化函数实现 Action:如果你确实想用柯里化,可以用具名函数表达式:
const Meet = (name) => function AndGreet(state) { return `${state.salutation}, my name is ${name}.` }自定义派发:如果有特殊需求,可以通过 dispatch 定制 Action 的派发方式。
字面量直接当处理器:由于 Hyperapp 的内部工作方式,凡是能放 Action 的地方,也可以放字面量来直接设置 state(甚至附带 effects):
h("button", { onclick: 55 }, text("55")) h("button", { onclick: [55, log] }, text("55 and log"))但这与"状态转换应通过 Action 发生"的理念相冲突。实现同样效果的合法写法是:
const FiftyFive = () => 55 const FiftyFiveAndLog = () => [55, log]h("button", { onclick: FiftyFive }, text("55")) h("button", { onclick: FiftyFiveAndLog }, text("55 and log"))唯一的例外是 app() 的
init属性——它是唯一允许"直接设置 state 或用 Action 设置 state"的地方。而init的四种形式(init: state、init: [state, ...effects]、init: Action、init: [Action, payload])恰好都能被上面 dispatch 的分支逻辑覆盖,这再次印证了源码与文档的一致性。不过,这种字面量用法也有妙用——利用三元表达式在字面量与 Action 之间动态切换:
h("button", { onclick: state.startingOver ? "Begin" : MyCoolAction }, text("cool"))这在"重置应用"类场景中尤其有用:当
startingOver为真时,直接以字面量"Begin"作为新 state,相当于一键重置。
串联全景:一条消息的完整旅程
把上述机制串起来,一次典型的"用户点击 → 状态更新"的旅程是这样的:
- 浏览器触发 DOM 事件,index.js 的
listener把事件对象作为 payload 派发对应的 Action(或 Action 描述符); - dispatch 递归解析:函数 Action 先执行、拿到返回值继续派发;描述符
[Action, payload]拆分后递归;[NextState, ...effects]则先更新 state 再依次运行 effects; - update 应用新 state 并触发订阅对比与下一帧渲染;
- 如果某个环节返回了
undefined,应用随之停止,一切归于平静。
Action 也因此成为连接 views、state、effects、subscriptions 与 dispatch 五者的中枢:视图声明"发生了什么",Action 决定"怎么改数据",effect 负责"对外部世界做什么"。掌握本文的 Action 语义,你就掌握了 Hyperapp 数据流的钥匙。
延伸阅读
- State 架构文档——state 的统一性、快照式更新与数组状态约定
- Effects 架构文档——
[NextState, ...Effects]的完整语义与 effecter 规范 - Subscriptions 架构文档——订阅器如何向应用派发 Action
- Dispatch 架构文档——dispatch 的递归实现与中间件式增强
- app() API 文档——
init:、dispatch:等选项的完整用法 - 核心实现——dispatch 与 update 的完整源码
- Hyperapp 快速上手教程 与 参考索引
【免费下载链接】hyperapp1kB-ish JavaScript framework for building hypertext applications项目地址: https://gitcode.com/gh_mirrors/hy/hyperapp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考