- 前端
【免费下载链接】hyperapp
1kB-ish JavaScript framework for building hypertext applications
导读:本文围绕 Hyperapp 官方架构文档 docs/architecture/effects.md 展开,系统讲解 Hyperapp 中effect(效果)的核心概念、类型签名、定义与使用方式。读完本文,你将掌握如何在 action 中把状态迁移与副作用(HTTP 请求、DOM 操作、localStorage、WebSocket、自定义事件等)解耦组合,理解 effecter 的通用化设计原则与浏览器重绘周期同步机制,并能够基于仓库源码(index.js)印证 effect 的底层执行流程,为构建纯净、可测试、可复用的 Hyperapp 应用打下基础。
一、什么是 Effect:副作用的"安全表示"
定义
一个effect是 action 用来与某个外部进程交互的表示(representation)。
与 subscriptions(订阅) 类似,effect 用于以安全、纯净、不可变的方式处理与外部世界的"不纯"异步交互。发起 HTTP 请求、给 DOM 元素设置焦点、把数据写入 localStorage、通过 WebSocket 发送数据……这些都是概念层面上的 effect 例子。
关键点在于:effect 本身不直接执行副作用代码,它只是对"要做什么事"的一种数据化描述。真正执行副作用的函数被称为effecter(见下文第四节)。这种"描述"与"执行"分离的设计,让 action 可以保持为无副作用的纯函数,而所有不纯逻辑被集中、可控地隔离在 effecter 中。
签名
Effect : EffecterFn | [EffecterFn, Payload]即一个 effect 要么直接是一个 effecter 函数,要么是一个"[EffecterFn, Payload]"形式的二元组(元组)。这种二元组结构被称为effect 描述符。
命名规范
官方推荐 effect 使用camelCase命名,并采用动词(如log)或动词-名词短语(如saveAsPDF)的命令式形式。这一约定让代码阅读者一眼就能看出"这个 effect 要去执行什么动作",与推荐用PascalCase命名的 action(参见 actions 文档)形成鲜明区分:action 是"消息",effect 是"动作"。
二、在 action 中使用 Effects
一个 action 可以将自己的状态迁移与一个或多个 effect关联起来,让这些 effect 与状态迁移同时运行。做法是:让 action 返回一个带有 effects 的状态数组(即 state with effects),其中第一项是下一个状态,其余各项是要运行的 effects。
import { log } from "./fx" // Action : (State) -> [NextState, ...Effects] const SayHi = (state) => [ { ...state, value: state.value + 1 }, log("hi"), log("there"), ] // ... h("button", { onclick: SayHi }, text("Say Hi"))在这个示例中,点击按钮触发SayHi,Hyperapp 会先应用新的状态{ ...state, value: state.value + 1 },然后按数组顺序依次运行log("hi")和log("there")两个 effect。
action 当然也可以同时接收 payload 并使用 effects:
// Action : (State, Payload) -> [NextState, ...Effects] const SayBye = (state, amount) => [ { ...state, value: state.value + amount }, log("bye"), ] // ... h("button", { onclick: [SayBye, 1] }, text("Bye"))这里通过action 描述符[SayBye, 1]把 payload1传给SayBye,action 内部同时完成了状态增量迁移和log("bye")副作用。
源码印证:effect 的执行顺序
从核心实现 index.js 的app()内部 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) )当 action 返回的是一个"首项不是函数"的数组时(即[NextState, ...Effects]形态):
- 先调用
update(action[0])应用第一个状态; - 再对
action.slice(1)中的每一项 effect 执行(fx[0] || fx)(dispatch, fx[1])。
从中可以读出三条关键实现事实:
- state 先于 effects 被应用,且 effects 严格按数组顺序运行(
map顺序); - 每个 effect 都会拿到
dispatch与 payload,因此 effecter 可以通过dispatch把执行结果回报给应用(对应 dispatch.md 中的 DispatchFn 用法); - 表达式
fx && fx !== true && ...表明任何为 falsy 的 effect 会被自动忽略(这一点在下一节的条件 effect 中会再次用到),同时显式排除了字面量true。
三、排除 Effects:单元素返回数组与条件 effect
如果你在返回数组里不包含任何 effect,那么就只有状态迁移发生。
下面的OnlyIncrement在行为和使用方式上都与 actions 文档中展示的Increment(actual-state-transition)相同:
// Action : (State) -> [NextState] const OnlyIncrement = (state) => [{ ...state, value: state.value + 1 }] // ... h("button", { onclick: OnlyIncrement }, text("+"))这种单元素数组乍看之下有些冗余,但当你有一个按条件决定是否运行 effect的 action 时,它就派上用场了。
例如,对比下面这种"笨拙"的写法:
const DoIt = (state) => { let transition = { ...state, value: "MacGuffin" } if (state.eating) { transition = [transition, log("eating")] } if (state.drinking) { transition = Array.isArray(transition) ? [...transition, log("drinking")] : [transition, log("drinking")] } return transition }("MacGuffin" 是电影术语,指推动剧情但对情节本身无关紧要的事物。)
与其反复判断transition当前是普通对象还是数组,不如始终以数组为起点,统一用展开运算符追加:
const DoItBetter = (state) => { let transition = [{ ...state, value: "MacGuffin" }] if (state.eating) { transition = [...transition, log("eating")] } if (state.drinking) { transition = [...transition, log("drinking")] } return transition }坦白说这些示例有些刻意,但后者确实复杂度更低。
不过,针对这类场景还能做得更好——利用"任何为 falsy 的 effect 都会被忽略"这一事实(正是上一节源码中fx && fx !== true &&所体现的行为):
const DoItBest = (state) => [ { ...state, value: "MacGuffin" }, state.eating && log("eating"), state.drinking && log("drinking"), ]当state.eating为false时,false && log("eating")求值为false(falsy),该 effect 条目被忽略;为true时则正常返回log("eating")effect 并运行。这是 Hyperapp 中最优雅、最常用的条件副作用写法。
四、定义 Effects:effect 工厂函数
从语法上讲,一个 effect 采用"包含 effecter 及其关联数据的元组"形式。
技术上说,你可以直接使用这个元组;但官方推荐用一个函数来创建 effect(即工厂函数),因为它在"如何构建元组"这件事上提供灵活性,同时整体代码看起来更整洁:
const massFx = (data) => [runNormandy, data](massFx是对游戏系列 "Mass Effect" 的致敬,runNormandy则取自玩家飞船 SSV Normandy。)
这种"(payload) => [effecter, payload]"的工厂模式是整个 Hyperapp 生态中自定义 effect 的标准范式:使用方只关心"请求做什么"(调用工厂函数),而"怎么做"完全由 effecter 负责。仓库内的官方包也遵循相同约定,例如 packages/events/index.js 中const fx = (subscriber) => (action) => [subscriber, action],即把"订阅器 + action"封装成标准元组。
五、Effecters:真正执行副作用的函数
定义
一个effecter是真正去执行某个 effect 的函数。
签名
EffecterFn : (DispatchFn, Payload?) -> void与 subscribers(订阅器) 一样,effecter允许使用副作用,也可以手动dispatch一些 action,以便把执行过程中的相关结果通知给应用。
需要特别强调的是:effecter 不只是"包装任意不纯代码"那么简单。它的目的是成为应用业务逻辑与必须存在的不纯代码之间的通用桥梁。通过尽可能让 effecter 保持通用,我们就能在"被请求做什么"与"该请求如何被执行"之间形成干净、可管理的分离。
5.1 反例:与业务耦合的 effecter
先看一个不符合规范的 effecter:
// This effecter is ill-formed. const runHarvest = (dispatch, _payload) => { const tiberium = document.getElementById("tiberium") dispatch((state) => ({ ...state, tiberium })) }("Tiberium" 出自游戏 "Command & Conquer" 系列,是一种可采集的有毒外星晶体。)
它确实能运行,但问题很明显:
- 它耦合到了应用的状态结构(直接以
(state) => ({ ...state, tiberium })修改特定字段); - 被引用的元素 ID
"tiberium"被硬编码在函数内部。
5.2 逐步重构:让 effecter 通用化
第一步,利用"可以给 effecter 传 payload"的能力,把回调 action从 effecter 中解耦出来:
const runHarvest = (dispatch, payload) => dispatch(payload.action, document.getElementById("tiberium"))第二步,进一步利用 payload 传入 effecter 工作所需的数据(元素 ID):
const runHarvest = (dispatch, payload) => dispatch(payload.action, document.getElementById(payload.id))第三步,把函数名也改成能体现其通用性质的名字:
const runGetElement = (dispatch, payload) => dispatch(payload.action, document.getElementById(payload.id))现在runGetElement完全不关心"这个元素 id 属于哪个业务"、也不关心"拿到的元素会被哪个 action 消费"——它成了一个可以复用于任何"按 id 取元素并交给某个 action"场景的通用 effecter。这正是文档反复强调的原则:一个合格的 effecter 应当尽可能通用。
5.3 同步:与 Hyperapp 重绘周期对齐
如果一个 effecter 运行了某个异步操作,并希望把操作结果回报给应用,那么它必须确保通信 dispatch 的时机与 Hyperapp 的重绘周期对齐。这一点很重要,因为只有对齐才能保证状态被正确设置。
Hyperapp 的重绘周期与浏览器自身的自然重绘周期保持同步,因此异步 effecter 也必须这样做。首选方式是用requestAnimationFrame();如果该方法不可用,则退而求其次使用setTimeout()。
先看一个不符合规范的异步 effecter:
// This effecter is ill-formed. const runBrotherhood = async (dispatch, payload) => { const response = await fetch(payload.lookForKaneHere) const kaneLives = response.json() requestAnimationFrame(() => { dispatch((state) => ({ ...state, message: kaneLives ? "One vision! One purpose!" : "", })) }) }("Kane" 是 "Command & Conquer" 系列中 Nod 兄弟会的领袖,其经典台词是 "One vision! One purpose!"。)
它的问题依旧在于:dispatch 的内容内联耦合了业务状态结构(message字段、硬编码文案),无法复用。
再看一个更规范的异步 effecter:
const runSimpleFetch = async (dispatch, payload) => { const response = await fetch(payload.url) requestAnimationFrame(() => dispatch(payload.action, response.json())) }这个版本把"请求哪个 URL""拿到数据后派发哪个 action"全部交给 payload 决定,effecter 本身只负责:发起fetch→ 等响应 → 在下一个requestAnimationFrame回调中把response.json()作为 payload 派发给payload.action。于是runSimpleFetch成了通用的"fetch 并回传数据" effecter,可在任何需要 HTTP 请求的业务场景中复用。
5.4 源码印证:重绘周期的实现
在核心实现 index.js 中,update()正是通过requestAnimationFrame调度渲染的:
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)) } }可以看到:状态更新后,渲染通过requestAnimationFrame(render, ...)排入浏览器的下一个绘制帧(并用busy标志防止重复排队)。因此,异步 effecter 若在requestAnimationFrame回调中 dispatch 结果,就能保证该 dispatch 的状态变更恰好落在 Hyperapp 的渲染周期内被正确处理,避免出现"状态已更新但视图/订阅未同步"的时序问题。
六、实战:用自定义 Effect 与旧应用通过 CustomEvent 通信
使用自定义 effect 的理想场景之一,是让 Hyperapp 应用与某个旧版(legacy)应用通过自定义事件(CustomEvent)通信。
我们可以让 Hyperapp 应用用一个自定义 effect 来触发自定义事件。先定义 effect 工厂与 effecter(例如放在./fx.js):
// ./fx.js const runEmit = (_dispatch, payload) => dispatchEvent(new CustomEvent(payload.type, { detail: payload.detail })) export const emit = (type, detail) => [runEmit, { type, detail }]注意这里runEmit甚至不需要dispatch(用_dispatch占位),因为"发出事件"本身就是目的,不需要向应用回报结果。emit(type, detail)工厂函数则把两个参数打包进{ type, detail }payload,交给runEmit。
然后在应用中使用:
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"), })点击 "Send greetings" 按钮时,内联 action 返回[state, emit("outgoing", { message: "hello" })]:状态保持不变,同时触发名为outgoing、detail为{ message: "hello" }的自定义事件。旧版应用只需监听该事件即可接收到数据,从而实现跨框架通信。
延伸阅读:如果你需要的是"监听"外部事件而不是"发出"事件,Hyperapp 对应的方案是自定义 subscription(订阅器)。两者方向互补:effect 用于"向外部发出/执行",subscription 用于"从外部接收"。完整对比可参考 subscriptions.md。此外,effecter 通过
dispatch回报结果的完整语义(递归 dispatch、自定义 dispatch 增强)见 dispatch.md。
七、在应用初始化时使用 Effects
effect 不仅可以在事件触发的 action 中使用,也可以配合app()的init:属性在应用启动时运行。init:支持四种形式之一:直接设置状态、[state, ...effects]、action、[Action, payload]。
其中"状态 + effects"形式尤其适合做启动阶段的加载逻辑:
app({ init: [ { loading: true }, log("Loading..."), load("myUrl?init", DoneAction), ], // ... })这印证了 state.md 的 "State With Effects" 一节 所述规则:只要用数组设置状态,Hyperapp 就会把它解释为"第一个条目是状态,其余条目是待运行的 effects",并保证"先应用状态,再按顺序运行 effects"。因此,如果你真的想用数组作为状态本身,就必须把它再包一层:
[["a", "b", "c"]]对应的 action 侧写法与注意事项见 actions.md 的 "Transitioning Array State"。
八、总结
围绕 docs/architecture/effects.md 的核心脉络,我们可以提炼出 Hyperapp effect 体系的几个要点:
| 关注点 | 结论 |
|---|---|
| 本质 | effect 是副作用的"表示",而非执行;执行者是 effecter |
| 签名 | Effect : EffecterFn \| [EffecterFn, Payload],effecter 签名为(DispatchFn, Payload?) -> void |
| 使用方式 | action 返回[NextState, ...Effects],状态先行、effects 按序执行 |
| 条件 effect | 依赖"falsy effect 被忽略"的机制,用state.cond && fx(...)即可优雅实现 |
| 设计原则 | effecter 应尽可能通用:用 payload 传入"动作/数据",与具体业务解耦 |
| 异步同步 | 异步 effecter 回报结果须在requestAnimationFrame(退化方案setTimeout)回调中 dispatch,与 Hyperapp 重绘周期对齐 |
| 典型场景 | HTTP 请求、DOM 焦点、localStorage、WebSocket,以及与 legacy 应用的 CustomEvent 双向通信 |
这套"纯 action + 数据化 effect + 通用 effecter"的架构,让业务逻辑与不纯代码的边界清晰可管理,正是 Hyperapp 在 1kB 量级下依然能保持可测试性与可维护性的关键设计之一。如需继续深入,可阅读与之配套的 actions、subscriptions、dispatch、state 与 views 文档,或在 tests/index.test.js 与 index.js 中查看实际行为验证。
- 前端
【免费下载链接】hyperapp
1kB-ish JavaScript framework for building hypertext applications
相关推荐
Flame Effects 特效系统完全指南:从声明式动画到自定义 Effect
Flame Effects 特效系统完全指南:从声明式动画到自定义 Effect Flame 作为一款基于 Flutter 的游戏引擎,其 Effects(特效
游戏开发图形学redux-saga 的 put Effect 完全指南:以声明式方式向 Store 分发 Action
redux saga 的 put Effect 完全指南:以声明式方式向 Store 分发 Action 导读 在 redux saga 中,Saga 需要把异
前端告别副作用地狱:Lustre框架中的声明式副作用管理全景指南
告别副作用地狱:Lustre框架中的声明式副作用管理全景指南 为什么你的前端应用需要"副作用防火墙"? 当你在传统前端框架中编写如下代码时,是否意识到自己正踏入
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考