组件仍在补充, 欢迎加群交流哦,微信: a2298613245
headless ui
✕
组件还在补充中,希望能够帮助你有一个亮点项目放入简历!
全局方案
按钮 Button
图标 Icon
布局 Grid
间距 Space
输入框 Input
弹窗 Modal
弹出框 Popover
消息 Toast
警告 Alert
单选框 Radio
复选框 Checkbox
标签 Tag
其它组件
必读指南
自定义 Message
Message 完整案例
传统案例
创意案例

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

button

前言

注:以下 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 组件,所以弹出的内容完全是自定义的,后面,我们会再次封装,使用便捷性上会更上一层楼。

前言
分层设计
实现框架无关的数据层
函数调用的方式使用
其它技术细节
附录
安装 toast
引入和使用 toast