Svelte派生store使用$与subscribe行为差异原因及解决方案
问题背景
当前项目使用无法修改的固定数据模型,核心场景如下:
- 项目存在两个独立store:分别存储
containers(容器)、items(条目)数据,二者在各类业务场景中被独立调用 Container结构默认仅保存关联Item的ID列表,为满足部分页面使用需求,通过derivedstore做反规范化处理,将每个container对象转换为携带完整item数据的DenormalizedContainer结构- 在「创建新Item并将其添加到指定Container」场景下,derived store会触发两次更新(一次源于items变更,一次源于containers变更),不同调用方式会产生不同的更新表现
- 核心疑问:为什么用
$语法访问store和手动调用subscribeAPI行为存在差异?是否有Svelte原生方案,在不修改现有数据模型、保留subscribeAPI使用方式的前提下解决重复更新问题
复现代码
<script lang="ts"> import { derived, writable } from "svelte/store"; type Container = { id: string; itemIds: string[]; }; type Item = { id: string; name: string; }; type DenormalizedContainer = { id: string; items: Item[]; }; const containers = writable<Container[]>([]); const items = writable<Item[]>([]); const denormalizedContainers = derived( [containers, items], ([$containers, $items]): DenormalizedContainer[] => { return $containers.map(({ id, itemIds }) => { const denormalizedContainer = { id, items: itemIds.map((id) => $items.find((item) => { return item.id === id; }) ), }; return denormalizedContainer; }); } ); function handleAddContainer() { const value = Math.random() * 1000; const newContainer: Container = { id: `new-container-${value}`, itemIds: [] }; containers.set([...$containers, newContainer]); } function handleAddItem() { const value = Math.random() * 1000; const newItem: Item = { id: `new-id-${value}`, name: `new-name-${value}` }; items.set([...$items, newItem]); } function handleAddBoth() { const value = Math.random() * 1000; const newItem: Item = { id: `new-id-${value}`, name: `new-name-${value}` }; const newContainer: Container = { id: `new-container-${value}`, itemIds: [newItem.id] }; items.set([...$items, newItem]); containers.set([...$containers, newContainer]); } $: console.log(`$: There are ${$containers.length} containers`); $: console.log(`$: There are ${$items.length} items`); $: console.log(`$: There are ${$denormalizedContainers.length} denormalized containers`); denormalizedContainers.subscribe((newValue) => console.log( `Subscribe: There are ${$denormalizedContainers.length} denormalized containers` ) ); </script> <button class="block" on:click={() => handleAddContainer()}>Add container</button> <button class="block" on:click={() => handleAddItem()}>Add item</button> <button class="block" on:click={() => handleAddBoth()}>Add container and item</button>
注:原复现代码中$items.find回调缺少return语句,会导致查找逻辑永远返回undefined,上述代码已修正该问题
测试结果
依次点击每个按钮一次,控制台输出日志如下:
- 页面加载时:
Subscribe: There are 0 denormalized containers $: There are 0 containers $: There are 0 items $: There are 0 denormalized containers
- 点击Add container(新增容器):
Subscribe: There are 1 denormalized containers $: There are 1 containers $: There are 1 denormalized containers
- 点击Add item(新增条目):
Subscribe: There are 1 denormalized containers $: There are 1 items $: There are 1 denormalized containers
- 点击Add container and item(同时新增容器和条目):
Subscribe: There are 1 denormalized containers Subscribe: There are 2 denormalized containers $: There are 2 containers $: There are 2 items $: There are 2 denormalized containers
$语法的更新表现符合预期,但手动调用subscribe API会触发两次更新,部分业务逻辑位于.svelte文件外,必须使用subscribe API实现监听。
差异原因
两种调用方式的行为差异核心来自Svelte的更新调度机制:
$语法绑定的响应式语句,会被Svelte编译器统一收集,在当前同步执行栈清空后通过微任务批量执行。同一轮事件循环中触发的多次store变更,只会触发一次响应式语句重跑,因此不会出现重复执行。- 原生
subscribeAPI是同步触发的:只要依赖的store发生值变更,derived store会立即重新计算并通知所有订阅者。在handleAddBoth中先后调用items.set()和containers.set(),会让derived store连续触发两次重算:第一次是items更新后、containers还未追加新值的中间状态,第二次是两个store都更新完成的最终状态,因此会收到两次回调。
直接把subscribe内逻辑包裹在tick中无法解决问题,是因为两次subscribe回调本身在两个独立的同步执行点触发,每次回调内等待tick只会拿到当前触发点的最新值,两次回调依然会完整执行,无法实现合并。
原生解决方案
不需要修改现有数据模型,也不需要放弃subscribe API,基于Svelte store的标准契约即可实现批量更新:自定义一个带微任务调度的包装store,对derived store的输出做一层缓冲,同一轮事件循环中的多次更新只会推送最后一次最终值给订阅者,实现代码如下:
import type { Readable, Subscriber, Unsubscriber } from 'svelte/store'; function batched<T>(store: Readable<T>): Readable<T> { return { subscribe(run: Subscriber<T>): Unsubscriber { let latestValue: T; let scheduled = false; const unsub = store.subscribe(value => { latestValue = value; if (!scheduled) { scheduled = true; queueMicrotask(() => { scheduled = false; run(latestValue); }); } }); return unsub; } } }
使用时直接包装原derived store即可:
const batchedDenormalizedContainers = batched(denormalizedContainers); // 外部逻辑订阅这个包装后的store,只会收到同一轮更新的最终值,不会触发重复回调 batchedDenormalizedContainers.subscribe(value => { // 业务逻辑 })
该实现完全基于Web标准API和Svelte store的原生约定,无额外依赖,也不会破坏原有store逻辑,在.svelte文件外也可正常使用。
内容的提问来源于stack exchange,提问作者SJoshi
相关产品推荐
相关产品推荐

