Appearance
创建跨页面组件同步状态 - 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 接收的第一个参数值一致。因此,你可以传入 ref
、computed
,对于 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
,在「数据详情页」发生数据变更时,将变更内容同步到「数据列表页」
正常情况下,useInfiniteList
的 staleTime
和 gcTime
都会被设置成 0,不存在缓存。那么用户在上述三个页面之间任意切换、修改数据、数据自动同步,这几个过程中都不会出现问题
特殊情况下,可能需要对 useInfiniteList
获取到的数据进行缓存,那么需要思考以下问题:
createShareState
修改的数据只是源数据的拷贝版本,并不会真正修改到源数据。也就是说「数据详情页」里面发生的变更同步到「数据列表页」时,并不会修改useInfiniteList
的缓存数据- 在用户重新进入「数据列表页」时,使用
useInfiniteList
获取到的数据可能是旧的、未经过createShareState
同步过的版本。这里的 进入「数据列表页」 是指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 = 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 内的所有方法,包括 useInfiniteList
、useQuery
、useQueryAll
等