☰
@zag-js/svelte 适配层全解析:从 0.34 到 1.45 的 Svelte 状态机集成演进
2026/10/13 8:29:38 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】zag

Build your design system in React, Solid, Vue, Svelte or Vanilla. Powered by finite state machines

项目地址:https://gitcode.com/gh_mirrors/za/zag
点击查看免费下载

@zag-js/svelte是 zag 项目为 Svelte 框架提供的官方适配包,它以 Svelte 5 的 runes($state、$derived、$effect)为基础,把@zag-js/core的有限状态机接入 Svelte 组件生命周期。本文以该包的 CHANGELOG(0.34.0 → 1.45.0)为脉络,结合源码与测试,系统讲解useMachine、bindable、mergeProps、normalizeProps等核心 API 的设计动机、实现原理与常见修复点,帮助你理解"状态机 + Svelte"这套架构如何在多个版本迭代中逐步走向稳定。

一、包定位:Svelte 与状态机之间的桥梁

在 zag 的整体架构中,所有组件的交互逻辑都沉淀为框架无关的有限状态机(@zag-js/core),而@zag-js/svelte负责把机器"翻译"成 Svelte 开发者熟悉的响应式 API。从 package.json 可以看到,它对外只依赖三个核心包:

  • @zag-js/core:状态机运行时(createMachine、状态转换、actions/guards/effects);
  • @zag-js/types:提供createNormalizer等类型与工具;
  • @zag-js/utils:提供callAll、compact、isEqual等基础函数。

该包要求svelte >= 5(peerDependencies),内部以.svelte.ts后缀的模块组织 runes 代码。src/index.ts 公开的 API 全集为:

  • useMachine(machine.svelte.ts):核心 Hook;
  • mergeProps、normalizeProps与PropTypes类型;
  • portal(portal.ts):Svelte action 形式的传送门;
  • reflect(reflect.ts):响应式代理工具;
  • useSyncExternalStore(use-sync-external-store.svelte.ts):外部 store 桥接。

从版本演进看,早期(0.34–0.40)是 API 定型期:0.40.0 将useService与useMachine的 API 与其他框架统一;0.46.0 补充了useActorHook 以对齐其他框架;0.53.0 修复 Svelte runes 警告、新增reflect工具并公开PropTypes类型;0.71.0 起不再随包发布src文件。1.0.0 之后进入稳定的补丁期,修复重心转向属性合并、响应式与生命周期细节。

二、useMachine:runes 驱动的状态机接入

useMachine是整个适配层的核心,实现在 machine.svelte.ts。它的输入是机器定义与用户 props,输出是一个Service<T>对象,包含state、send、context、prop、scope、refs、computed、event、getStatus等成员。

2.1 响应式基础:$derived 与 $state

源码中三处关键的 runes 用法:

const scope = $derived.by(() => { const { id, ids, getRootNode } = access(userProps) as any return createScope({ id, ids, getRootNode }) }) const props: any = $derived(machine.props?.({ props: compact(access(userProps)), scope }) ?? access(userProps)) let propsAtStop: any const prop = useProp(() => (status === MachineStatus.Stopped ? propsAtStop : props))

scope与props都是$derived,当用户传入的 props(支持函数形式)变化时,机器会自动重新解析作用域与属性。这里有一个刻意设计:prop读取器在机器已停止时返回propsAtStop快照,而不是当前 props。对应 CHANGELOG 1.34.0 修复的state_unsafe_mutation警告——组件拆卸期间发生状态转换会导致状态不安全变更,因此拆卸时先用propsAtStop = { ...props }冻结现场,再执行onDestroy清理,保证退出动作读取到的 props/context 仍是停止时的值。测试 machine.test.ts(prop and context read after stop return the values at stop)专门验证了这一点。

2.2 生命周期:onMount 启动、onDestroy 收尾

onMount(() => { const started = status === MachineStatus.Started status = MachineStatus.Started state.invoke(state.initial!, INIT_STATE) }) onDestroy(() => { if (status !== MachineStatus.Started) return propsAtStop = { ...props } status = MachineStatus.Stopped effects.forEach((fn) => fn?.()) action(machine.exit) })

机器在onMount时启动并进入初始状态;onDestroy时按顺序清理全部 state effects 并执行机器级exit动作。0.78.1 版本明确修复了"机器在$effect时机而非onMount时机启动/停止"的问题,并提升了useService在context变化时的响应性——这说明适配层刻意把启动时机锚定在挂载阶段,避免与 Svelte 的 effect 调度冲突。1.43.0 又进一步:"组件在机器启动前被销毁时,跳过机器 exit actions"(Skip machine exit actions when a component is disposed before the machine starts),防止对从未启动的机器执行清理动作。

send函数内部先检查status !== MachineStatus.Started直接返回(1.6.2 修复"确保机器启动后才处理事件"),随后执行完整的转换流程:findTransition查找当前状态对应事件 →choose按 guard 选择第一个通过的转换 →resolveStateValue解析目标 → 若目标变化则state.set(target),若目标相同但设置了reenter: true则重新调用当前状态(1.3.0 引入reenter:true支持),否则只执行转换 actions。测试中的reenter transition、same-state transition does not reenter by default、reenter transition action order(顺序为exit → transition → entry`)等用例完整覆盖了这些分支。

2.3 状态对象的配套能力

getState()返回的 state 除了值本身,还提供两个常用方法:

hasTag(tag) { ... } matches(...values) { return values.some((value) => matchesState(currentState, value)) }

matches支持多值匹配(state.matches("idle", "loading")),hasTag则检查机器标签。在 1.35.0 中,核心层全面支持嵌套状态,Svelte 适配层同步跟进——nested-states.test.ts 验证了深层嵌套转换(dialog.open.viewing)、相对目标(target: ".closed")、跨层#id目标、以及嵌套转换时 entry/exit/effect 的执行顺序(见 1.35.0 条目:"ensure effects/entry/exit ordering for nested transitions")。

三、bindable:受控与非受控的统一抽象

机器的 context 值通过bindable工厂创建,实现在 bindable.svelte.ts。它同时支持受控(外部传入value)与非受控(内部$state自持)两种模式:

export function bindable<T>(props: () => BindableParams<T>): Bindable<T> { const initial = props().value ?? props().defaultValue const eq = props().isEqual ?? Object.is let value = $state(initial) const controlled = () => props().value !== undefined ... }

3.1 取值优先级:value 先于 defaultValue

CHANGELOG 1.43.0 特别记录了一个行为对齐修复:bindable 解析值时value优先于defaultValue,与 React/Solid 适配层保持一致。观察initial = props().value ?? props().defaultValue与get()中controlled() ? props().value : value的实现可以确认:一旦外部传入value,读取一律走外部值;外部不传时才回落到内部$state。这种"受控优先"策略保证了跨框架行为一致。

3.2 同步与更新策略

  • set方法支持传入函数式更新((prev) => next),内部用valueRef追踪最新值;
  • 通过props().sync ? flushSync : identity决定是否同步冲刷(flushSync包裹queueMicrotask,见machine.svelte.ts的flush);
  • 变更时若isEqual判定值不同,才触发onChange;
  • 1.8.0 为 bindable 函数补充了ref与cleanup能力(bindable.ref提供轻量引用容器,bindable.cleanup注册销毁回调),便于组合"使用中状态"(create using state compositions)。

useMachine内部把 context 包装成BindableContext<T>(get/set/initial/hash),机器的 actions、guards、computed 均通过context.get(key)读取。受控场景的测试(context: controlled)验证了外部传value: "foo"时,即使内部执行VALUE.SET也不会覆盖外部值。

四、mergeProps:属性合并的持续打磨

mergeProps是设计系统维护者使用最频繁的工具,负责把机器 API 返回的 props 与用户 props 合并。Svelte 版在 merge-props.ts 中基于 @zag-js/core 的 mergeProps 之上做 Svelte 定制,其演进贯穿多个版本:

4.1 class:从字符串拼接到 ClassValue 数组

1.26.4 版本将mergeProps的 class 返回值重构为数组,把解析职责交给 Svelte 5.15+ 的原生 class 处理(内部使用clsx)。源码中:

const classNames: any[] = [] for (const props of args) { if ("class" in props && props.class != null) classNames.push(props.class) } const merged = zagMergeProps(...args) if (classNames.length > 0) { merged.class = classNames.length === 1 ? classNames[0] : classNames }

这意味着你可以传入字符串、数组、对象(条件类名)混用的 class 值而不必担心拼接错误。merge-props.test.ts 用clsx(props.class)模拟 Svelte 运行时解析,覆盖了数组、嵌套数组、对象、falsy 过滤等场景。

4.2 style:字符串解析与分号保留

style 合并经历了两次重要修复:

  • 0.78.0:修复 SveltemergeProps中 style 合并;
  • 1.45.0:修复mergeProps截断含分号内联 style 值的问题,包括带引号的 CSS 自定义属性(如--label:"a;b")和data URL(如url("data:image/svg+xml;base64,..."))。

实现上,mergeProps先用serialize()把 style 字符串解析为对象,再通过toStyleString重新序列化。serialize是一个带状态的小型解析器:它追踪引号("/')、转义符、括号深度((/))与注释(/* */),只在括号深度为 0 且不在引号内时才把;当作声明分隔符。这样url(...)内部的;和引号内的;都不会被误切。对应测试keeps semicolons inside quoted values and url() when combining styles精确验证了这一行为。

4.3 事件处理器与>export function reflect<T extends Record<string, any>>(obj: () => T): T { return new Proxy(obj() as T, { get(_, prop) { const target = obj() let value = Reflect.get(target, prop) return isFunction(value) ? value.bind(target) : value }, }) }

每次属性访问都重新求值obj()(可传入$derived表达式),从而拿到最新的 API 对象;函数属性自动bind(target)保证this正确。它解决的核心痛点是:把机器 connect 出的api传给组件时,如果只捕获了旧对象引用,响应式更新会丢失;通过reflect每次访问都取最新值,实现"懒响应"。

6.2 portal:Svelte action 形式的传送门

portal.ts 导出portal(node, props)action,支持disabled、container、getRootNode三个配置:默认挂载到document.body,可指定容器或自定义根节点(Shadow DOM 场景),disabled时保持原位。它返回标准的{ destroy, update },随 Svelte action 生命周期自动更新与销毁。

6.3 track 与 useSyncExternalStore

  • track(track.svelte.ts):用于machine.watch,通过$effect对比依赖数组前后值(isEqual深度比较),只在真正变化时执行回调——对应 1.12.3"提升事件未触发状态转换时的响应性"与 1.12.4"提升同步状态更新的响应性"两条修复;
  • useSyncExternalStore:以$state保存快照,$effect订阅外部 store,支持服务端快照参数(getServerSnapshot)。SSR 场景下(typeof window === "undefined")直接取服务端快照,见 ssr.test.ts(服务端渲染不执行根级 exit actions)。

七、响应性、时序与警告类修复的演进脉络

CHANGELOG 中相当一部分条目属于"Svelte 5 运行时磨合",理解它们有助于避开同样的坑:

版本修复内容说明
0.78.1机器启动/停止从$effect移到onMount时机锚定生命周期,避免 effect 调度竞态
1.13.0修复 Svelte5.28.3+的微任务时序问题适配新版 runes 调度
1.21.3移除 refs/events 中的状态性对象运行时性能优化
1.24.0修复状态引用捕获初始值而非当前响应式值的警告避免闭包捕获旧 state
1.34.0修复组件拆卸期间状态转换导致的state_unsafe_mutation警告拆卸时冻结 props 快照
1.41.0修复 React 19 Strict Mode 下 dialog/drawer/popover 关闭后<body>残留data-scroll-lock、data-inert、overflow:hidden状态机清理幂等性
1.43.0组件在机器启动前被销毁时跳过 exit actions防止清理未启动的机器
1.45.0修复延迟清理在组件卸载后产生的derived_inert警告bindable 避免组件拥有的 derived

这些条目共同指向一个设计原则:状态机的副作用必须与 Svelte 组件生命周期严格对齐——启动要在挂载后、清理要在卸载时、且清理过程本身不能触碰已失效的响应式状态。machine.svelte.ts中state的 onChange 回调在退出状态时同步执行 effects 清理、effects用 Map 按状态路径管理清理函数,正是这条原则的具体实现。

八、从源码到实践:一个完整的 Popover 示例

仓库的 examples/svelte-ts 提供了 128 个 Svelte 示例页面,全部基于@zag-js/svelte。以 popover.svelte 为例,可以看清整条使用链路:

<script lang="ts"> import * as popover from "@zag-js/popover" import { normalizeProps, useMachine } from "@zag-js/svelte" const id = $props.id() const service = useMachine(popover.machine, { id }) const api = $derived(popover.connect(service, normalizeProps)) </script> <button {...api.getTriggerProps()}> Click me </button> <div {...api.getPositionerProps()}> <div {...api.getContentProps()}> ... </div> </div>

关键点:useMachine创建 service;popover.connect(service, normalizeProps)生成组件 API;因为api由$derived驱动,机器状态变化会触发重渲染;getTriggerProps()等返回的 props 内部已经过mergeProps处理,展开后即可直接使用。配合 use-controls.svelte.ts 的$state控件上下文,可以构建交互式 playground。

九、如何阅读与验证这些行为

该包自带完整的 vitest 测试套件(pnpm test,见 package.json),建议按以下顺序阅读:

  1. machine.test.ts:状态机核心行为——初始状态、entry/exit、转换 actions、guard 回退、reenter、事件 current/previous 追踪、拆卸后忽略事件、同 tick 内连续 send 的确定性处理;
  2. nested-states.test.ts:嵌套状态与相对/#id目标解析;
  3. merge-props.test.ts:class/style/事件/data-ownedby/symbol 键的合并语义;
  4. ssr.test.ts:服务端渲染下的生命周期行为。

测试基础设施 render.ts 与 MachineHarness.svelte 展示了如何用svelte的mount/settled/unmount在测试中驱动机器,这套模式同样适用于你自己的组件测试。

结语

回看 0.34 → 1.45 的整个 CHANGELOG,@zag-js/svelte的演进主线十分清晰:早期定型 API 形态(useMachine/useService/useActor、reflect、PropTypes),中期适配 Svelte 5 runes 并修正生命周期与响应式细节,后期则集中打磨mergeProps的属性合并精度(class 数组化、style 分号保留、data-ownedby组合)。对于在 Svelte 中构建设计系统的团队,这套适配层提供了一个可复用的范式:逻辑收敛于框架无关的有限状态机,框架层只负责响应式桥接与属性归一化,从而让同一套机器代码在 React、Solid、Vue、Svelte 与 Vanilla 之间保持一致行为。

  • 前端
  • UI组件

【免费下载链接】zag

Build your design system in React, Solid, Vue, Svelte or Vanilla. Powered by finite state machines

项目地址:https://gitcode.com/gh_mirrors/za/zag
点击查看免费下载
上一篇:如何彻底清理显卡驱动:Display Driver Uninstaller终极指南
下一篇:永久保存QQ空间青春记忆:GetQzonehistory一键备份完整指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询