如何在代码质量上超过大多数 React UI 组件库拿 Message 组件举例

前言
注:以下 toast 这个名称在国外很常见,对应国内的组件是 message 和 notification 组件。
我的组件大多数是参考了很多国内外知名组件库,所以质量上一般都不会有问题(有些同学会问,为什么不自己从 0 到 1 自己实现组件库,其实很简单,我就是敢写,你也不敢用呀,哈哈)。
所以知道这些成熟的,经历过大项目考验的组件库的实现思路,才能有更强的实用性, 让组件库切切实实的可以用在实际项目中,或者在王炸的项目经历,让自己在面试中脱颖而出。
但在这个过程中,我发现大厂,写 UI 组件的时候基本上是不分层的? 最基础的业务组件分层,分为:
- 视图层:
react或者vue作为纯视图渲染 - 数据层:聚合了业务数据和业务数据的处理,在做技术方案的时候,其实大多数情况下是对数据层的数据流向变化做梳理
- 异步管理层:主要请求后端数据和解决一些复杂的异步管理问题
而且如果你要做开源的项目的话,逻辑分的细一些,对于测试是非常友好的(做过单元测的朋友都懂,哈哈)。 具体案例后面会讲,我们先看看为什么分层之后你的代码质量会更高。
我们拿一家国内算是 Top3 的大厂的 Message 组件的代码来看看我们的 Toast(消息通知) 组件为什么分层之后比它的代码质量好很多。
1const MessageContainer: React.FC<MessageContainerProps> = (props) => {
2 // xxx代码省略
3 useEffect(() => {
4 // xxx
5 }, []);
6 return (
7 xxx
8 );
9};
10function createContainer({ attach, zIndex, placement = 'top' }: MessageOptions): Promise<Element> {
11 // xxx
12}
13async function renderElement(theme, config: MessageOptions): Promise<MessageInstance> {
14 // xxx
15}
16function isConfig(content: MessageOptions | React.ReactNode): content is MessageOptions {
17 // xxx
18}
19const messageMethod: MessageMethod = (theme: MessageThemeList, content, duration?: number) => {
20 // xxx
21};
22// 创建
23export const MessagePlugin: MessagePlugin = (theme, message, duration) => messageMethod(theme, message, duration);
24
25MessagePlugin.info = xx
26MessagePlugin.error = xx
27MessagePlugin.warning = xx
28MessagePlugin.success = xx
29MessagePlugin.question = xx
30MessagePlugin.loading = xx
31MessagePlugin.config = xx
32MessagePlugin.close = (messageInstance) => {
33 // xx
34};
35MessagePlugin.closeAll = (): MessageCloseAllMethod => {
36 // xx
37};
38
39export default MessageComponent;这里最大的疑惑,例如
- 上面的
function isConfig这个函数属于工具函数,是否应该单独提到一个文件中,然后引入呢? createContainer和MessageContainer都属于跟创建容器相关的,是否应该放到一个文件中呢?renderElement这个函数属于渲染元素的函数,是否应该放到一个文件中呢?- ...等等
这里并不是为了 踩一捧一,而是很多会迷信大厂的代码,觉得自己实力不够不敢去面试,我想告诉你,并不是大厂的程序员
都是你想象的那样优秀,只是想激励这些对于面试大厂有些胆怯的朋友。
分层设计
再来看看经过简单的分层设计后,我们的代码的基本框架是什么:
1-- /toast
2 -- /hooks 存放 hooks 相关的代码的文件夹
3 -- /utils 存放工具函数相关的代码的文件夹
4 -- index.ts 入口文件
5 -- interface.ts 存放接口相关的代码
6 -- store.tsx 数据层
7 -- toast-container.tsx 创建单个 message 的组件
8 -- toast-provider.tsx 连接数据层(store)和视图层(ToastContainer)我们拿其中的数据层 store.tsx 来看看我的数据层设计,我个人觉得是十分清晰的。
1function createToastStore() {
2 const [state, setState] = useState([]);
3
4 return {
5 state, // 当前的 toast/message 列表
6 add: (noticeProps: MessageProps) => {
7 // 增加 toast/message
8 },
9
10 update: (id: number, options: MessageProps) => {
11 // 更新 toast/message
12 },
13
14 clearAll: () => {
15 // 清除所有 toast/message
16 },
17
18 remove: (id: number) => {
19 // 清除某个 toast/message
20 },
21 };
22}
23
24export default useStore;是不是看到我们的核心数据都在state中,然后对于数据的操作包含:
-
add方法,增加Message -
update方法,更新Message -
clearAll方法,清除所有Message -
remove方法,清除某个Message
实现框架无关的数据层
我们知道 react 有一些独立的数据层设计库,例如 zustand,redux 等, vue 中有例如 pinia, Vuex 等等。
其实本质就是一个数据集合,可以增删改查,然后通知订阅了这个数据集合的组件,再试用 react 或者 vue 中更新 DOM 的 API 进行更新。
例如以下代码后面会结合 react 的 useSyncExternalStore 来实现数据的订阅和更新。
具体代码如下,有兴趣的可以看一下(看不懂没关系,如果想了解 headless 的 Toast 组件如何实现,欢迎加入到我们的技术社区):
1import { DIRECTION } from './constants';
2import { getId, findToast, getToastDirection } from './utils';
3// types
4import type { ToastStates, ToastProps } from './interface';
5
6// state
7export function createToastStore() {
8let state: ToastStates = [];
9const listeners = new Set<() => void>();
10
11const setState = (setStateFn: (values: ToastStates) => ToastStates) => {
12 state = setStateFn(state);
13 listeners.forEach((l) => l());
14};
15
16return {
17 getState: () => state,
18
19 subscribe: (listener) => {
20 listeners.add(listener);
21 return () => {
22 setState(() => []);
23 listeners.delete(listener);
24 };
25 },
26
27 add: (noticeProps: ToastProps) => {
28 const id: number = getId(noticeProps);
29 setState((preState: ToastStates) => {
30 if (noticeProps?.id) {
31 const isExist = getToastDirection(preState, noticeProps.id);
32 if (isExist) return preState;
33 }
34 const direction = noticeProps.direction || DIRECTION.TOP_TO_BOTTOM;
35 const isBottom = direction === DIRECTION.BOTTOM_TO_TOP;
36 const toasts = isBottom
37 ? [...(preState ?? []), { ...noticeProps, id, direction }]
38 : [{ ...noticeProps, id, direction }, ...(preState ?? [])];
39
40 return toasts;
41 });
42 return noticeProps?.id ? noticeProps?.id : id;
43 },
44
45 update: (id: ToastProps['id'], options: ToastProps) => {
46 if (!id) return;
47
48 setState((preState) => {
49 const nextState = { ...preState };
50 const { index } = findToast(nextState, id);
51
52 if (index !== -1) {
53 nextState[index] = {
54 ...nextState[index],
55 ...options,
56 };
57 }
58 return nextState;
59 });
60 },
61
62 clearAll: () => {
63 setState(() => []);
64 },
65
66 remove: (id: number) => {
67 setState((prevState) => {
68 const isExist = getToastDirection(prevState, id);
69
70 if (!isExist) return prevState;
71 return prevState.filter((notice) => notice.id !== id);
72 });
73 },
74};
75}其实上面代码,有仅仅使用了一个简单的 发布订阅模式, 实现了一个小的数据流管理器,可以看到是没有耦合任何框架的。
这样的好处很显而易见,如果我需要做单元测试,那么我仅仅测试 javascript 代码就可以直接对管理 toast 或者说 message 组件的代码来看看我们的
核心数据流管理器进行测试,甚至都可以在没有测试组件之前,就能发现问题。
当然,如果用 hooks 管理数据层也是完全没有问题的,也相当于把数据层单独管理了。
函数调用的方式使用
消息提示类组件,一些组件库会使用类似 useMessage 或者 useToast 的 hooks 的方式使用。
但是我是非常不推荐这样用的,一是麻烦,二是前端有非常多的这样的场景,例如使用 axios 或者一些更新的 fetch 为基础封装请求库。
然后我们会在请求失败的时候做一层统一拦截,当后端返回错误的时候,我们就用 message 或者 toast 组件显示到前端页面。
此时很可能调用 message 或者 toast 组件的位置,并不在 react 或者 vue 文件中,而是在 axios 或者 fetch 封装的 .js 或者 .ts 结尾的文件中。例如:
什么意思呢?
这些文件是纯 js 或者 ts 文件。所以 API 设计的时候,一定要支持类似用法:
1import { createToastStore } from '@t-headless-ui/react';
2const toastStore = createToastStore();
3
4const App = () => {
5return (
6 <Button
7 onClick={() => {
8 toastStore.add(xxx参数);
9 }}
10 type='primary'
11 >
12 Open Message
13 </Button>
14);
15};当然这里不建议大家直接这样使用,因为 @t-headless-ui/react 是一个 headless 组件库,所以我们的 toast 组件是没有样式的,需要单独封装。
我们会单独封装一份业务上可以用的 message 组件和 notification 组件。
其它技术细节
- 如何在此基础上增加
limit限制 toast 组件的数量 - 如何在
hover的时候,暂停toast组件的自动关闭功能 - 如何在
toast组件的close按钮被点击的时候,立即关闭toast - 如何设计更多有趣的
toast组件的展示动画 - ...等等
欢迎大家加入我的国内首个 组件库技术社区 一起讨论。同时也提供手把手帮你打造一个自己的组件库相关项目服务,并成为你面试 亮点项目,在面试中 脱颖而出。
附录
安装 toast
1npm i @t-headless-ui/react
2yarn i @t-headless-ui/react
3pnpm i @t-headless-ui/react引入和使用 toast
toast 组件跟别的组件不太一样,首先需要你调用创建一个 store 实例:
1import { createToastStore } from '@t-headless-ui/react';
2const toastStore = createToastStore()目的是用这个 store 来管理 toast 组件的状态,例如添加、删除、更新 toast 组件的状态。
其中支持传入两个参数
- maxCount: 默认是 6,也就是最多同时展示 6 个
toast/message组件 - direction: 默认是
top-to-bottom,表示卡片是从上到下堆叠,还是从下到上堆叠,主要是在stack模式有用(后续会讲)
然后引入 ToastProvider 组件,将 store 实例作为 prop 传递给 ToastProvider 组件。ToastProvider 组件一般放在你的项目的入口文件中,
例如 App.tsx 或者 App.jsx 文件。目的是初始化 Toast 组件(其背后默认会在 document.body 创建一个可以出现 toast 信息框),当然我们也支持自定义插入
到某个 dom 元素中,一般情况使用默认的即可。
1import { ToastProvider } from '@t-headless-ui/react';
2// 以下的 TOAST_Z_INDEX 代表是的 z-[2000],也就是 z-index 是 2000 (popover 组件设计的 z-index 是 1000)
3// 其实 z-index 管理是一个比较复杂的问题,可以在全局方案设计看 z-index 管理问题
4<ToastProvider
5 store={toastStore}
6 containerProps={{
7 className: cs('w-full fixed top-8 flex flex-col items-center pointer-events-none', `${TOAST_Z_INDEX}`)}}
8 // stack 是否开启堆叠模式,默认是 false
9/>注意,上面有个 containerProps 参数,其中我们传入了 className 属性,目的是用来定义这个 toast 组件在哪个位置弹出,这里我们使用的是 top-8(可以理解为 top: 30px),并且 fixed 定位。
所以,你可以定位到任何位置,甚至在某个容器中,这个容易的定位设为 relative, 我们 toast 的定位设置为 absolute,这样就相当于某个容器定位了。
上面注释谈到 z-index 管理问题,这个问题实际上在多个弹框出现时,是很容易产生问题的,问题本身和解决思路,可以查看在我们的全局方管理方案系列文章。
然后之前的 toastStore 你可以通过 react 的 Provider 共享给 react 组件树中的所有组件,或者直接 export 直接导出,这样等页面渲染完毕,
如此的话,无论是在 react 组件内部,还是直接在 js 或者 ts 文件中调用 toastStore.add 方法,都可以添加一个 toast 组件了。
以下是通过 toastStore.add 方法来添加一个 toast 组件的案例,我们直接使用了 Alert 组件充当 toast 的内容。
1<button
2onClick={() =>
3 toastStore.add({
4 component: <TAlert message="这是一条通知" title="Alter" containerClassName="pb-2" />,
5 })
6}
7>
8点击我显示通知
9</button>因为我们的 toast 组件是一个 headless 组件,所以弹出的内容完全是自定义的,后面,我们会再次封装,使用便捷性上会更上一层楼。