Appearance
跨页面同步状态 - useShareState
WARNING
在大多数场景中,建议使用 createShareState 方法,若无法满足可考虑使用当前方法。
利用 Event Bus 实现跨页面组件之间的状态数据同步,可以自由选择同步哪些字段。
背景介绍
在项目开发中,经常会遇到这样的情况:用户在列表页中点击进入详情页,然后在详情页中执行一些操作(例如,点击关注或更新某个状态),然后返回到列表页,但在返回后,列表页显示的状态仍然是旧的。
通常我们会怎么样解决这个问题呢?最简单的肯定是在列表页的 onShow
生命周期中重新获取数据,但这种方法会引入不必要的网络请求,增加了服务器和客户端之间的 I/O 负担。
另外一种较优雅的解决方案是使用事件总线(Event Bus):在执行操作时,可以使用事件总线发布一个事件,然后在列表页订阅并监听该事件,以便在收到事件时更新列表中对应记录的状态。
然而,即使使用事件总线,仍然需要编写大量的事件处理代码,这可能显得有些繁琐。
现在,我们有了更加智能和高效的解决方案。
基本用法
假设有一个用户列表:
js
const users = [
{
id: '1001',
nickname: '张三',
follow_status: 'not_followed',
},
{
id: '1002',
nickname: '李四',
follow_status: 'both_followed',
},
{
id: '1003',
nickname: '王五',
follow_status: 'followed_me',
},
]
在列表页中,每个卡片展示了一名用户:
vue
<user-card v-for="user in users" :key="user.id" :user="user" />
在 user-card.vue
中渲染用户信息、关注状态:
vue
<script setup>
const props = defineProps({
user: {
type: Object,
required: true,
},
})
</script>
<template>
<div class="user-card">
<div class="nickname">{{ user.nickname }}</div>
<div class="follow-status">{{ user.follow_status }}</div>
</div>
</template>
在详情页中,根据页面参数 id
获取对应的用户信息,可对该用户进行关注、取消关注:
vue
<script setup>
const props = defineProps({
id: {
type: String,
required: true,
},
})
const {
data: {user},
isLoading,
} = useQuery(() => io.table.userprofile.first({id: props.id}))
// 修改用户状态
const onFollow = () => {
// 1. 发起修改状态请求
// 2. 更新本地状态
}
</script>
<template>
<div v-if="!isLoading" class="user-detail">
<div class="nickname">{{ user.nickname }}</div>
<!-- 为了演示简化一下,真实场景下需要根据 follow_status 不同渲染对应的按钮 -->
<button class="follow-status" @click="onFollow">关注</button>
</div>
</template>
正如前面所说,在这种情况下,我们通常需要在「更新本地状态」的时候利用 Event Bus 发起一个事件,然后在 list.vue
或者 user-card.vue
中监听事件,并同步更新对应用户的状态,这是比较繁琐的。
而现在,我们利用 useShareState
能够很轻松达到相同的效果。
首先,我们需要在 detail.vue
中使用 useShareState
创建一个新的变量 localUser
:
js
const localUser = useShareState(() => user.value, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: (val) => val.id,
publishKeys: ['follow_status']
})
// 修改用户状态
const onFollow = () => {
// 1. 发起修改状态请求
// 2. 更新本地状态
localUser.value.follow_status = 'followed'
}
js
export SHARE_STATE_NAMESPACE = {
USER: 'user'
}
此时修改 localUser.follow_status
会自动发出一个更新事件。
因此,我们只需要在 user-card.vue
中同样创建一个变量,订阅 follow_status
字段,它将会自动监听并同步到当前变量:
js
const localUser = useShareState(() => props.user, {
// 注意要和前面一致
namespace: SHARE_STATE_NAMESPACE.USER,
// 注意要和前面一致
keyFn: val => val.id,
subscribeKeys: ['follow_status'],
})
watchEffect(() => {
console.log(localUser.value.follow_status) // 其它地方更新,会触发这里
})
同步选项
useShareState
支持自定义状态同步的颗粒度,也就是说我们可以只关心某一个属性。
还是前面的例子,假设 user
字段很多,我们只需要关心 follow_status
属性被修改时需要进行同步。
只需要使用 publishKeys
指定需要发布的字段,使用 subscribeKeys
指定需要同步的字段。
publishKeys
js
const localUser = useShareState(() => user.value, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
// 仅当 localUser.value.follow_status 修改时才会同步到其他组件
publishKeys: ['follow_status'],
})
subscribeKeys
js
const localUser = useShareState(() => props.user, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
// 仅当其它组件修改 follow_status 时才会同步到这里
subscribeKeys: ['follow_status'],
})
TIP
如果未设置 publishKeys
则默认会发布所有字段,未设置 subscribeKeys
则默认订阅所有字段。
然而,我们强烈建议你始终设置这些选项,避免不必要的数据更新,从而减少可能出现难以排查的问题的风险。
此外,这两个选项都支持深层对象的键,e.g. created_by.nickname
,这使得可以更精确地定义要同步的数据字段。
进阶介绍
初始化参数
useShareState
接收的初始化参数会被监视,换句话说,它与 watch 接收的第一个参数值一致。因此,你可以传入 ref
、computed
,对于 reactive
对象,则需要传入一个函数并返回某个值,示例如下:
js
const ref1 = ref({ a: 1 });
const computedRef1 = computed(() => ref1);
const bar = useShareState(ref1, {...});
const foo = useShareState(computedRef1, {...});
const baz = useShareState(() => props.xxx, {...});
正如前面所述,当订阅的状态发生变化时,它将会同步到当前变量中,而当初始化参数发生变化时,也会同步到当前变量。请看下面的示例:
js
const ref1 = ref({id: '1', a: 1})
const bar = useShareState(ref1, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
subscribeKeys: ['a'],
})
console.log(bar.value.a) // 1
// 假设在其他地方修改了 .a,此时 bar 也会同步
console.log(bar.value.a) // 2
// 这时候 ref1.value.a 发生变化,bar.value.a 会被重新初始化
ref1.value.a = 100
console.log(bar.value.a) // 100
更多使用场景
理解 useShareState
的使用场景可以帮助你在编写业务代码和封装自定义 hooks 时减少一些繁重的工作。让我们以 useLockPageScroll
为例说明这一点。在这个情景中,我们需要实现一个页面级的滚动锁定状态,核心问题是如何在页面的任意组件中控制当前页面的滚动是否可用,并且在任何地方监听这个状态的变化。这涉及到跨组件之间的通信。虽然这种需求可以通过 pinia
实现,但由于我们需要区分不同的页面,因此将这个状态存储在页面组件实例中更为合适。因此,最终决定使用 useShareState
来实现状态同步:
js
const sharedPageScrollLocked = useShareState(
() => ({
locked: pageInstance.__page_scroll_context.locked ?? false,
}),
{
namespace: SHARE_STATE_NAMESPACE,
keyFn: () => pageInstance.__route__,
publishKeys: ['locked'],
subscribeKeys: ['locked'],
}
)
如你所见,我们使用页面路由作为 keyFn
的返回值,使得在状态管理时能够轻松地自动区分不同页面。
常见问题
namespace 和 keyFn 的区别
首先,这些都是作为事件总线(Event Bus)中事件名称的一部分。仅当 namespace
和 keyFn
完全相同时,这两者才会进行同步。
为什么要引入 namespace
的概念呢?因为 keyFn
的值通常是动态的,比如 user.id
这样的结构。这会导致使用者在查找同一类别的共享状态时变得困难,并且难以有效地进行管理。因此,我们增加了 namespace
的概念。 namespace
应该放置在 constants.js
文件中,以便于在项目中管理共享状态。
这样做有助于更清晰地管理事件名称,并允许在事件总线中按照不同的 namespace
对事件进行分类和组织,从而提高了可维护性和代码的可读性。
useSyncRef 和 useShareState 的区别
也许你觉得 useSyncRef
和 useShareState
非常相似,的确它们都用于同步两个变量,但它们在使用场景方面有很大的不同。
const target = useSyncRef(source)
用于在组件内部, source
和 target
之间的同步,其核心是 Vueuse 的 syncRef。
const target = useShareState(source, {namespace, ...})
则用于跨组件时,两个不同 target
之间的同步(定义了相同的 namespace
),其核心是 Event Bus。
请确保理解并区分它们的概念和用途。
触发更新的时机和顺序
正如之前提到的,useShareState
内部利用 Event Bus 来实现数据同步更新,而 Event Bus 是在 onMounted
生命周期初始化的。因此,请务必确保更新操作发生在 onMounted
之后,如下所示:
vue
<script setup>
const source = ref({id: '1', foo: 1})
const a = useShareState(source, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
publishKeys: ['foo'],
})
const b = useShareState(source, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
subscribedKeys: ['foo'],
})
a.value.foo = 3 // 值不会同步更新,因此此时 eventBus 仍未初始化
</script>
正确的更新时机应该放在 Event Bus 初始化以后:
js
b.value.foo = 3// 值不会同步更新,因此此时 eventBus 仍未初始化
onMounted(() => {
a.value.foo = 3// eventBus 已初始化 会同步更新
})
另外,当数据发生变更时,并不会立即进行同步。因此,在获取数据时,请注意:
diff
a.value.foo = 3
console.log(b.value.foo)// 1,值还未被同步
nextTick(()=>{
console.log(b.value.foo)// 3,值已同步
})
这是因为 watch
默认在下一次触发 DOM 更新之前触发,并且多次更新会一并放入同一个队列中执行。
并且这引发了一个更需要注意的问题,考虑以下场景:
js
a.value.foo = 2
b.value.foo = 3
await nextTick()
console.log(a.value.foo) // 2
console.log(b.value.foo) // 2,是否觉得很奇怪?
这看起来非常不合理,为什么 b.value.foo
不是 3 呢?
但这是符合预期的。回想一下,watch
会将多次更新一并放入同一个队列中执行。
下面是这段代码背后的大致流程:
点我查看完整流程
const a = ...,监听 a 更新、创建 watcher、收集副作用函数、初始化 Event Bus
const b = ...,监听 b 更新、创建 watcher、收集副作用函数、初始化 Event Bus
a.value.foo = 2,更新派发,将副作用函数保存起来,留待 nextTick 之前执行(flush=pre)
b.value.foo = 3,更新派发,将副作用函数保存起来,留待 nextTick 之前执行(flush=pre)
执行 a 的副作用函数,利用 Event Bus 去发布一个更新事件
5.1. b 那边监听到更新事件,执行同步,执行
b.value.foo = 2
,重复 4 的流程执行 b 的副作用函数,此时
b.value.foo
有两次更新操作,第一次是更新为 3,第二次是更新为 2nextTick
console.log(a.value.foo),输出 2,合理
console.log(b.value.foo),输出 2,这样看是不是也合理...
虽然我们可以通过将 flush
选项设置为 sync
使其立即触发更新来避免上述问题,但这可能引发更多性能方面的考虑。鉴于 useShareState
通常用于跨组件情况,因此这个问题可以忽略不计。
理解了背后的流程后,要解决这个问题,只需要把更新操作放到 nextTick 之后即可:
js
a.value.foo = 2
await nextTick()
b.value.foo = 3
本节提到的问题总结如下:
- 避免在
onMounted
生命周期之前进行状态更新,以确保正常同步。 - 在更新状态后,被同步的变量需要在
nextTick
之后才能获取到最新的值。 - 多次更新会在
nextTick
之前合并为一次更新事件,可能会覆盖其它状态在同一宏任务中的更新操作。若需要确保当前更新操作不被其它状态的同步更新覆盖,应将本次更新操作延迟到nextTick
之后。
其中对于后面两点,在跨组件的情况下并不需要考虑,因为你总是需要通过 watch
才能得知更新情况。
本地存储共享状态
在项目中,经常出现一种常见需求:某个弹窗已经显示过,不再需要弹出(存储到本地),而这个弹窗存在于多个页面中。对于这种情况,useShareState
并不适用。我们建议查看《本地存储共享状态》。
与 vue-query 数据保持一致性
假设存在以下页面:
- 首页
- 数据列表页
- 数据详情页
页面的层级关系由上到下,即「首页」->「数据列表页」->「数据详情页」
「数据列表页」使用 useInfiniteList
获取数据列表,「数据列表页」和「数据详情页」引入了 useShareState
,在「数据详情页」发生数据变更时,将变更内容同步到「数据列表页」
正常情况下,useInfiniteList
的 staleTime
和 gcTime
都会被设置成 0,不存在缓存。那么用户在上述三个页面之间任意切换、修改数据、数据自动同步,这几个过程中都不会出现问题
特殊情况下,可能需要对 useInfiniteList
获取到的数据进行缓存,那么需要思考以下问题:
useShareState
修改的数据只是源数据的拷贝版本,并不会真正修改到源数据。也就是说「数据详情页」里面发生的变更同步到「数据列表页」时,并不会修改useInfiniteList
的缓存数据- 在用户重新进入「数据列表页」时,使用
useInfiniteList
获取到的数据可能是旧的、未经过useShareState
同步过的版本。这里的 进入「数据列表页」 是指reLaunch
或者在「首页」重新使用navigateTo
进入「数据列表页」之类的动作,而非navigateBack
,该场景下useInfiniteList
的queryFn
会重新执行
上述问题的解决核心就是 在数据发生变更时,如何将变更的内容同步给缓存。需要在「数据列表页」内对数据变更进行监听,发生数据变更时同步修改缓存数据
vue
<script setup>
const infiniteList = useInfiniteList({
queryFn: xxx,
staleTime: 1 * 60 * 60,
gcTime: 1 * 60 * 60,
// 若设置了 staleTime/gcTime,需要配置 queryKey,否则缓存不会生效
queryKey: ['userList'],
})
const {setQueryData} = infiniteList
// UserCard 发生数据变更时,将变更内容同步到缓存内
function onChange(user) {
// 在 updater 内完成缓存更新
setQueryData(updater)
}
</script>
<template>
<mp-query-list :queryResult="infiniteList" v-slot="{item: user}">
<UserCard :user="user" @change="onChange" />
</mp-query-list>
</template>
vue
<script setup>
const localUser = useShareState(() => props.user, {
namespace: SHARE_STATE_NAMESPACE.USER,
keyFn: val => val.id,
// 仅当其它组件修改 follow_status 时才会同步到这里
subscribeKeys: ['follow_status'],
})
const emit = defineEmits(['onChange'])
// 当拷贝数据发生变更时,将变更内容同步到父组件
watch(localUser, val => {
emit('onChange', val)
}, {deep: true})
</script>
上述方案适用于 vue-query 内的所有方法,包括 useInfiniteList
、useQuery
、useQueryAll
等