Skip to content

创建跨页面组件同步状态 - createShareState

用于实现跨页面组件之间的状态数据同步。

背景介绍

在项目开发中,经常会遇到这样的情况:用户在列表页中点击进入详情页,然后在详情页中执行一些操作(例如,点击关注或更新某个状态),然后返回到列表页,但在返回后,列表页显示的状态仍然是旧的。

通常我们会怎么样解决这个问题呢?最简单的肯定是在列表页的 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 中监听事件,并同步更新对应用户的状态,这是比较繁琐的。

而现在,我们利用 createShareState 能够很轻松达到相同的效果。

首先,统一在 share-state 目录下管理需要同步的状态,比如在这个例子中我们需要同步用户状态,则创建 user.js,然后在各组件通过它使用共享状态:

js
import {createShareState} from '@ifanrx/uni-mp'

export default createShareState({keyFn: user => user.id})
js
import useUserState from '@/share-state/user'

const localUser = useUserState(user)

// 修改用户状态
const onFollow = () => {
  // 1. 发起修改状态请求
  // 2. 更新本地状态
  localUser.value.follow_status = 'followed'
}

然后在 user-card.vue 中同样创建一个变量:

js
import useUserState from '@/share-state/user'

const localUser = useUserState(() => props.user)

watchEffect(() => {
  console.log(localUser.value.follow_status) // 其它地方更新,会触发这里
})

因为两个使用 useUserState 的地方传进去的 user.id 一致,因此实际上它们使用的是同一个变量。

同步选项

keyFn

大部分场景下只需要在 createShareState 时指定 keyFn 的返回值即可:

js
import {createShareState} from '@ifanrx/uni-mp'

export default createShareState({
  keyFn: user => user.id,
})

如果 keyFn 的返回值需要动态设置,也可以在使用同步状态的时候传入,但要保证一致性:

js
const localUser = useUserState(() => users.value[0], {
  keyFn: user => user.id,
})

transform

如需进行统一的格式化,可在 createShareState 时传入 transform 回调函数:

js
import {createShareState} from '@ifanrx/uni-mp'

export default createShareState({
  keyFn: user => user.id,
  transform: user => ({...user, count: user.count + 1}),
})

也可以在使用同步状态的时候传入 transform,仅影响当前变量:

js
const localUser = useUserState(() => users.value[0], {
  // 格式化当前变量
  transform: user => ({...user, count: user.count * 2}),
})

进阶介绍

初始化参数

useUserState 接收的初始化参数会被监视,换句话说,它与 watch 接收的第一个参数值一致。因此,你可以传入 refcomputed,对于 reactive 对象,则需要传入一个函数并返回某个值,示例如下:

js
const ref1 = ref({ a: 1 });
const computedRef1 = computed(() => ref1);

const bar = useUserState(ref1, {...});
const foo = useUserState(computedRef1, {...});
const baz = useUserState(() => props.xxx, {...});

正如前面所述,当订阅的状态发生变化时,它将会同步到当前变量中,而当初始化参数发生变化时,也会同步到当前变量。请看下面的示例:

js
const ref1 = ref({id: '1', a: 1})

const bar = useUserState(ref1)

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

常见问题

本地存储共享状态

在项目中,经常出现一种常见需求:某个弹窗已经显示过,不再需要弹出(存储到本地),而这个弹窗存在于多个页面中。对于这种情况,createShareState 并不适用。我们建议查看《本地存储共享状态》

与 vue-query 数据保持一致性

假设存在以下页面:

  • 首页
  • 数据列表页
  • 数据详情页

页面的层级关系由上到下,即「首页」->「数据列表页」->「数据详情页」

「数据列表页」使用 useInfiniteList 获取数据列表,「数据列表页」和「数据详情页」引入了 createShareState,在「数据详情页」发生数据变更时,将变更内容同步到「数据列表页」

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

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

  1. createShareState 修改的数据只是源数据的拷贝版本,并不会真正修改到源数据。也就是说「数据详情页」里面发生的变更同步到「数据列表页」时,并不会修改 useInfiniteList 的缓存数据
  2. 在用户重新进入「数据列表页」时,使用 useInfiniteList 获取到的数据可能是旧的、未经过 createShareState 同步过的版本。这里的 进入「数据列表页」 是指 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 = createShareState(() => 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

相关文档

createShareState - API