Skip to content

跨页面同步状态 - 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 接收的第一个参数值一致。因此,你可以传入 refcomputed,对于 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)中事件名称的一部分。仅当 namespacekeyFn 完全相同时,这两者才会进行同步。

为什么要引入 namespace 的概念呢?因为 keyFn 的值通常是动态的,比如 user.id 这样的结构。这会导致使用者在查找同一类别的共享状态时变得困难,并且难以有效地进行管理。因此,我们增加了 namespace 的概念。 namespace 应该放置在 constants.js 文件中,以便于在项目中管理共享状态。

这样做有助于更清晰地管理事件名称,并允许在事件总线中按照不同的 namespace 对事件进行分类和组织,从而提高了可维护性和代码的可读性。

useSyncRef 和 useShareState 的区别

也许你觉得 useSyncRefuseShareState 非常相似,的确它们都用于同步两个变量,但它们在使用场景方面有很大的不同。

const target = useSyncRef(source) 用于在组件内部, sourcetarget 之间的同步,其核心是 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 会将多次更新一并放入同一个队列中执行。

下面是这段代码背后的大致流程:

点我查看完整流程
  1. const a = ...,监听 a 更新、创建 watcher、收集副作用函数、初始化 Event Bus

  2. const b = ...,监听 b 更新、创建 watcher、收集副作用函数、初始化 Event Bus

  3. a.value.foo = 2,更新派发,将副作用函数保存起来,留待 nextTick 之前执行(flush=pre)

  4. b.value.foo = 3,更新派发,将副作用函数保存起来,留待 nextTick 之前执行(flush=pre)

  5. 执行 a 的副作用函数,利用 Event Bus 去发布一个更新事件

    5.1. b 那边监听到更新事件,执行同步,执行 b.value.foo = 2,重复 4 的流程

  6. 执行 b 的副作用函数,此时 b.value.foo 有两次更新操作,第一次是更新为 3,第二次是更新为 2

  7. nextTick

  8. console.log(a.value.foo),输出 2,合理

  9. 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,在「数据详情页」发生数据变更时,将变更内容同步到「数据列表页」

正常情况下,useInfiniteListstaleTimegcTime 都会被设置成 0,不存在缓存。那么用户在上述三个页面之间任意切换、修改数据、数据自动同步,这几个过程中都不会出现问题

特殊情况下,可能需要对 useInfiniteList 获取到的数据进行缓存,那么需要思考以下问题:

  1. useShareState 修改的数据只是源数据的拷贝版本,并不会真正修改到源数据。也就是说「数据详情页」里面发生的变更同步到「数据列表页」时,并不会修改 useInfiniteList 的缓存数据
  2. 在用户重新进入「数据列表页」时,使用 useInfiniteList 获取到的数据可能是旧的、未经过 useShareState 同步过的版本。这里的 进入「数据列表页」 是指 reLaunch 或者在「首页」重新使用 navigateTo 进入「数据列表页」之类的动作,而非 navigateBack,该场景下 useInfiniteListqueryFn 会重新执行

上述问题的解决核心就是 在数据发生变更时,如何将变更的内容同步给缓存。需要在「数据列表页」内对数据变更进行监听,发生数据变更时同步修改缓存数据

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 内的所有方法,包括 useInfiniteListuseQueryuseQueryAll

相关文档

useShareState - API