Data-provider 模式 在 Vue 项目中的应用

场景

在开发 IM 应用时,用户头像、名称等重要信息是需要实时的。如何搭建一个数据管道让整个应用都能够低成本的实时更新用户信息是非常重要的。在调研多种方式后,我在 Vue 项目中采用了 data-provider 的模式,提供了简单易用的组件。

之前的实现

<script lang='ts'>
interface UserProfile {
  id: string;
  name: string;
  // ... balabala
}
const userList: UserProfile = []

profileStore.subscribe(items => {
  // update userList
});

profileStore.next(userList.map(item => item.id))
</script>

这个方案问题非常明显:

  1. profileStore.subscribe 订阅后需要手动取消订阅,否则会有内存泄露问题。
  2. profileStore.subscribe 收到数据更新后需要手动的处理 userList, 有侵入性。
  3. 每个场景都需要手动处理,会有大量重复的数据操作代码,容易引发 bug 且不好维护。

解决方案

参考 React

如果是在 React 项目中我们有高阶组件来嵌套组件完成一些可收敛的逻辑,比如下面的代码:

class UserProvider extends Component {
  handleUserInfoUpdate = () => {
    /** update this.state.instantUserInfo */
  }
  componentDidMount() {
    eventBus.on('user:update', this.handleUserInfoUpdate)
  }
  componentWillUnmount() {
    eventBus.off('user:update', this.handleUserInfoUpdate)
  }

  render() {
    return this.props.children?.(this.state.instantUserInfo);
  }
}

使用处:

const UserCard = () => {
  return <UserProvider >{
    (instantUserInfo) => <UserCardDiaplay instantUserInfo={instantUserInfo}/>
  }</UserProvider>
}

这样就可以做到接入 UserProvider 就永远取到最新的用户信息,并且几乎毫无接入成本,开发者也无须关心数据流和事件的监听与解绑。

Vue

但我们是 Vue 项目,在 Vue 单文件组件提供了 Slot 传递给子组件的方式:作用域插槽​

// MyComponent.vue
<!-- <MyComponent> 的模板 -->
<div>
  <slot :text="greetingMessage" :count="1"></slot>
</div>

// parent.vue
<MyComponent v-slot="slotProps">
  {{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

因此,可以提供一个 renderless 组件,无缝接入。先看使用方式:

<template>
  <UserProfileProvider :uid="20200926" :v-slot="{ userProfile }">
    <div>{{ userProfile.name }}</div>
    <div>{{ userProfile.avatar }}</div>
  </UserProfileProvider>
</template>

// UserDisplay.vue
import UserProfileProvider from './UserProfileProvider.vue';

实现

根据使用方式,通过 作用域插槽 来实现对应的组件代码。

renderless 组件

// UserProfileProvider.vue
<template>
  <slot :userProfile="userProfile"></slot>
</template>

<script setup lang="ts">
import {
  useUserProfileProvider,
  type IUserProfile
} from '@/hooks/useUserProfileProvider';


const props = defineProps<{
  uid: string;
  defaultUserProfile?: Partial<IUserProfile>;
}>();

const userProfile = useUserProfileProvider(props);
</script>

Vue hook

// useUserProfileProvider.ts
import { useProfileStore } from '@/profileStore';
import { computed } from 'vue';

export function useUserProfileProvider(props: {
  /** 用户uid */
  uid: string;
  /** 可传入默认用户数据在拉取到用户数据之前展示 */
  defaultUserProfile?: Partial<IUserProfile>;
}) {
  if (!props.uid) {
    devLog.warn('uid is required');
  }

  const lang = i18next.lang;
  const profileStore = useProfileStore();
  const defaultFallback = {
    uid: '',
    avatar: '',
  };
  const userProfile = computed(() => {
    const userInfo = {
      /** 格式化后的用户名 */
      formattedName: '',
      ...defaultFallback,
      ...props.defaultUserProfile,
      // getUserProfile 将会返回一个computedRef 
      ...profileStore.getUserProfile(props.uid)?.value,
    };
    userInfo.formattedName = formatUserName(
      userInfo,
      lang,
    );
    return userInfo;
  });

  return userProfile;
}

Vue hook vs renderless component 的比较

在实际使用过程中,Renderless Component 能做的场景 Hook 都能够满足。有些场景 Renderless 具有优势,比如:

<template v-for="user in userList" :key="user.id">
  <UserCard>{user.name}</UserCard>
</template>

比如代码所示场景,如果要让 UserCard 通过 Hook 的方式获取 user 数据,那么最好的方式是增加一个中间组件来实现。

<template v-for="user in userList" :key="user.id">
  <UserCardWrapper :user="user"></UserCardWrapper>
</template>
// UserCardWrapper.vue
<template>
   <UserCard>{userProfile.name}</UserCard>
</template>

<script lang="ts">
const userProfile = useUserProfileProvider(props);
</script>

但此时,UserCardWrapper.vue 不就成了另一个 UserProfileProvider.vue 了吗?

所以这种场景中,最合适的方式是使用 Renderless component 👇🏻

<template v-for="user in userList" :key="user.id">
  <UserProfileProvider :uid="user.id" :v-slot="{ userProfile }">
    <UserCard>{userProfile.name}</UserCard>
  </UserProfileProvider>
</template>

那么到底该使用 Hook 还是 Renderless component 呢?我们可以从官网这里找到答案:

组合式函数(hook) 相对于无渲染组件(renderless component)的主要优势是:组合式函数不会产生额外的组件实例开销。当在整个应用中使用时,由无渲染组件产生的额外组件实例会带来无法忽视的性能开销。