作者:离尘不理人转发链接:https://segmentfault.com/a/1190000022909876最近在项目开发过程中,有个一个多级多选的公共组件开发需求,特在这里记录下开发过程中所做的一些优化以及分享一下我是如何从零开发并设计一个组件的思路,希望给阅读这篇文章的读者带来一点收获。效果预览单个项选中多个部分项选中需求分析在拿到需求之后,我们首先要做的是需求分析;通过上面的效果预览我们可以初步知道我们所需要处理的核心逻辑:默认加载第一层级数据鼠标 hover异步获取数据切换下级渲染数据鼠标点击点击当前项状态改变:选中 or 未选中当前项的父级状态改变:选中、半选、不选中,并且需要递归处理当前项的子级状态改变:全选、全不选组件设计在设计组件之前,我们需要考虑组件的性能、通用性等问题;如何设计一个与业务解耦的组件,是我们需要首先考虑的问题;那么,如何将组件数据请求与业务解耦呢:组件提供一个 service 入参,service 是一个返回 Promise 的异步请求方法组件提供一个 dataMapper,用来做数据转换,将 service 请求返回的值转化为符合我们组件数据结构的数据组件内部通过调用外部传入的 service 来获取数据入参设计如下:interface Props {
…
// 外部传入服务
service: (args: { parentId: string }) => Promise<{ list: SelectorItemType[] }>;
dataMapper?: (args: any) => { list: SelectorItemType[] };
/**
* 回显数据
* @default []
*/
data?: SelectorItemType[];
onSubmit?: SubmitCallback;
onCancel?: () => void;
}
try {
const data = await service({ parentId: itemId });
nextColumnList = dataMapper ? dataMapper(data).list : data.list;
} catch (error) {
Notification.error(error);
nextColumnList = [];
}整体思路设计通过上面的 UI 呈现,现在大家应该有个基础的认识,我们需要做什么样的需求了。我们在接到一个需求的时候,先不要着急着码代码,更好的方式是先规划我们的组件方案设计,并且提前思考好各种逻辑分支;这里给大家看下我的设计初稿,我习惯性的选择脑图来发散自己的思维:通过上图,我们能够在大脑中有个大概的清晰认识到我们需要做哪些核心模块的设计与开发,接下来就是规划我们的核心模块划分:数据缓存异步数据获取选中数据缓存渲染数据源设计数据缓存设计要设计一个高性能多级多选组件,肯定离不开我们的数据优化部分:数据缓存那么如果如何设计才能做到性能最优呢?通过上面的脑图,我们初步是通过一个 dataCaheMap 来缓存异步拉取回来的数据,这样子我们在取的时候,时间复杂度就是 O(1) ;既然是有 Map 来缓存数据,那么用什么作为 key 也是我们缓存的关键;在这个组件里面,最终我选择的是:列索引+行索引+id 作为缓存 key这样设计的目的是,防止后台出现同时操作增删改类目配置;通过这种方式,能避免因为后台在同步操作到新增加或者删除了某个类目之后,取的缓存数据还是旧数据,这点是很关键的!// 数据缓存映射 Map
const [dataCacheMap, setDataCacheMap] = useState<{ [x: string]: SelectorItemType[] }>({});
/**
* 获取缓存 key
* @param itemId selectedItem id
* @param itemIndex selectedItem 当前 item 索引
* @param columnIndex 当前 column 索引
*/
const getCacheKey = (itemId: string, itemIndex: number, columnIndex: number) =>
`${itemId}-${itemIndex}-${columnIndex}`;
// 取缓存值
async function getItemList() {
const cacheKey = getCacheKey(itemId, itemIndex, columnIndex);
let nextColumnList = dataCacheMap[cacheKey];
let _selectedValues = { …selectedValues };
if (!nextColumnList) {
setLoading(true);
const data = await service({ parentId: itemId });
// dataMapper 用来自定义数据转换
nextColumnList = dataMapper ? dataMapper(data.list) : data.list;
}
setDataCacheMap((prev) => ({
…prev,
[`${cacheKey}`]: nextColumnList,
}));
setLoading(false);
…
}数据请求设计如果我们组件要与业务解耦,那么必须要将数据请求与组件解耦;所以我们设计组件的是,提供了一个 service 属性作为异步数据请求服务传入;并且通过 TS 来约束 参数与响应体结构,让接口服务返回的数据符合我们的组件所需的数据结构:单个数据项必须含有 id, parentId, label 三个必须属性,其中 parentId 是我们处理级联依赖的关键;针对不同的业务,可能第一级的 parentId 不一样,所以我们也提供了一个 defaultParentId 作为属性供外部传入如果服务层的数据无法改变,我们还提供了 dataMapper 回调函数来帮助我们格式化返回的数据/**
* 单个类目项
*/
export interface SelectorItemType {
id: string;
/**
* @default '0'
*/
parentId: string;
/**
* 是否可选
* @default true
*/
disabled?: boolean;
/**
* 选项文案
* @default '-'
*/
label: string;
/**
* 是否半选状态
* @default false
*/
indeterminate?: boolean;
[x: string]: any;
}
interface Props {
…
// 外部传入请求数据服务
service: (args: { parentId: string }) => Promise<{ list: SelectorItemType[] }>;
defaultParentId: string;
dataMapper?: (args: any) => { list: SelectorItemType[] };
/**
* @default []
*/
data?: SelectorItemType[];
onSubmit?: SubmitCallback;
onCancel?: () => void;
}渲染数据源设计在有了前面的『数据缓存』、『数据请求』之后,我们接下来设计渲染所需的数据结构;从交互层面,我们最容易想到的是二维数组数据结构;通过二维数组的方式,能方便的帮助我们渲染所需的 UI;假设我们的数据是如下数据格式:// 组件内部数据源
const [source, setSource] = useState<SelectorItemType[][]>([]);但是因为我们的交互上面,是有个『部分选中』这个状态存在,但是这个状态与后台类目无关,只是前端展示需要用到的字段,所以我们需要对接口返回的数据做一个初始化的操作:将数据源项新增一个半选状态 indeterminate 标志位,后续我们在处理级联状态的时候,需要频繁的改动到这个状态值categoryList.forEach((item) => {
result.push({
…item,
id: item.categoryId,
label: item.title,
// 半选状态标志位
indeterminate: false,
});
});
<div className={styles.selectorItemContainer}>
{column.map((item, index) => {
return (
<div
key={`${item.id}-${columnIndex}`}
className={styles.selectorItem}
onMouseEnter={() => debouncedHoverCallback(item.id, index, columnIndex)}
>
<Checkbox
value={Boolean(selectedValues[item.id])}
disabled={item.disabled}
// 判断是否半选
indeterminate={item.indeterminate}
className={styles.checkbox}
onClick={() => handleItemClick(index, columnIndex)}
>
<div className={styles.labelText}>{item.label || '-'}</div>
</Checkbox>
<Icon className={styles.iconRight} type="arrowright" />
</div>
);
})}
</div>已选数据设计我们的组件是『多级多选』无限层级,在组件渲染的时候,如何判断当前 item 项是否选中,依靠的就是我们的已选数据 state:// 已选择类目,组件内部维护状态
const [selectedValues, setSelectedValues] = useState<SelectedMap>({});
<Checkbox
// 判断是否选中
value={Boolean(selectedValues[item.id])}
disabled={item.disabled}
indeterminate={item.indeterminate}
className={styles.checkbox}
onClick={() => handleItemClick(index, columnIndex)}
>
<div className={styles.labelText}>{item.label || '-'}</div>
</Checkbox>通过打平数据结构,我们无需关心渲染层级,时间复杂度层面也是保持 O(1);交互逻辑详解Hover 事件逻辑详情鼠标 hover 操作,我们主要是需要:处理异步数据的获取与缓存处理当前项的子级数据状态;通过在 Hover 的时候来控制子级的状态,可以让我省去递归子级的操作来提高我们的整体性能注意:在 Hover 事件过程中,我们需要对 debounce 操作import { useDebouncedCallback } from 'use-debounce';
const [debouncedHoverCallback] = useDebouncedCallback(
(itemId: string, itemIndex: number, columnIndex: number) => {
setQueryData({
itemId,
columnIndex,
itemIndex,
});
},
100,
);
<div
key={`${item.id}-${columnIndex}`}
className={styles.selectorItem}
onMouseEnter={() =>
debouncedHoverCallback(item.id, index, columnIndex)
}
>
….
</div>多选项 Click 逻辑详情鼠标 click 操作,核心逻辑:改变当前点击项状态改变子级状态改变父级状态数据回调在我们选中操作完成之后,我们需要将用户选择的数据提交给后台,通常多级多选的数据结构设计是平级设计,所以当我们父级如果是选中的数据,那么它的子级数据就没有必要提交给后台了;所以我们需要冲选中池中过滤出父级 parentId 不再选中池中的数据,这个就是我们最终需要返回给用户与后台的数据const handleSubmit = () => {
const result: SelectorItemType[] = Object.keys(selectedValues).map(
(key) => selectedValues[key],
);
// 核心逻辑:过滤出当前 parentId 不在选中池中数据,就表示它的父级没有选中
const filterData = result.filter((item) => !selectedValues[item.parentId] || !item.parentId);
onSubmit && onSubmit(filterData);
};Q&A到这里我们就基本介绍完了如何从 0 到 1完整的设计一个多级多选的组件;该组件支持任意层级的数据,只需要满足我们的层级依赖关系的数据结构,将能复用这个组件但是我们还有几个思考题:如果多选组件还需要能展示禁选项,逻辑如何调整?如何解耦 DOM 结构与 CSS 实现这两个问题欢迎各位在下面讨论推荐JavaScript经典实例学习资料文章《深入浅出讲解Node.js CLI 工具最佳实战》《延迟加载图像以提高Web网站性能的五种方法「实践」》《比较 JavaScript 对象的四种方式「实践」》《使用Service Worker让你的 Web 应用如虎添翼(上)「干货」》《使用Service Worker让你的 Web 应用如虎添翼(中)「干货」》《使用Service Worker让你的 Web 应用如虎添翼(下)「干货」》《前端如何一次性处理10万条数据「进阶篇」》《推荐三款正则可视化工具「JS篇」》《如何让用户选择是否离开当前页面?「JS篇」》《JavaScript开发人员更喜欢Deno的五大原因》《仅用18行JavaScript实现一个倒数计时器》《图文细说JavaScript 的运行机制》《一个轻量级 JavaScript 全文搜索库,轻松实现站内离线搜索》《推荐Web程序员常用的15个源代码编辑器》《10个实用的JS技巧「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(一)「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(二)「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(三)「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(四)「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(五)「值得收藏」》《细品269个JavaScript小函数,让你少加班熬夜(六)「值得收藏」》《深入JavaScript教你内存泄漏如何防范》《手把手教你7个有趣的JavaScript 项目-上「附源码」》《手把手教你7个有趣的JavaScript 项目-下「附源码」》《JavaScript 使用 mediaDevices API 访问摄像头自拍》《手把手教你前端代码如何做错误上报「JS篇」》《一文让你彻底搞懂移动前端和Web 前端区别在哪里》《63个JavaScript 正则大礼包「值得收藏」》《提高你的 JavaScript 技能10 个问答题》《JavaScript图表库的5个首选》《一文彻底搞懂JavaScript 中Object.freeze与Object.seal的用法》《可视化的 JS:动态图演示 – 事件循环 Event Loop的过程》《教你如何用动态规划和贪心算法实现前端瀑布流布局「实践」》《可视化的 js:动态图演示 Promises & Async/Await 的过程》《原生JS封装拖动验证滑块你会吗?「实践」》《如何实现高性能的在线 PDF 预览》《细说使用字体库加密数据-仿58同城》《Node.js要完了吗?》《Pug 3.0.0正式发布,不再支持 Node.js 6/8》《纯JS手写轮播图(代码逻辑清晰,通俗易懂)》《JavaScript 20 年 中文版之创立标准》《值得收藏的前端常用60余种工具方法「JS篇」》《箭头函数和常规函数之间的 5 个区别》《通过发布/订阅的设计模式搞懂 Node.js 核心模块 Events》《「前端篇」不再为正则烦恼》《「速围」Node.js V14.3.0 发布支持顶级 Await 和 REPL 增强功能》《深入细品浏览器原理「流程图」》《JavaScript 已进入第三个时代,未来将何去何从?》《前端上传前预览文件 image、text、json、video、audio「实践」》《深入细品 EventLoop 和浏览器渲染、帧动画、空闲回调的关系》《推荐13个有用的JavaScript数组技巧「值得收藏」》《前端必备基础知识:window.location 详解》《不要再依赖CommonJS了》《犀牛书作者:最该忘记的JavaScript特性》《36个工作中常用的JavaScript函数片段「值得收藏」》《Node + H5 实现大文件分片上传、断点续传》《一文了解文件上传全过程(1.8w字深度解析)「前端进阶必备」》《【实践总结】关于小程序挣脱枷锁实现批量上传》《手把手教你前端的各种文件上传攻略和大文件断点续传》《字节跳动面试官:请你实现一个大文件上传和断点续传》《谈谈前端关于文件上传下载那些事【实践】》《手把手教你如何编写一个前端图片压缩、方向纠正、预览、上传插件》《最全的 JavaScript 模块化方案和工具》《「前端进阶」JS中的内存管理》《JavaScript正则深入以及10个非常有意思的正则实战》《前端面试者经常忽视的一道JavaScript 面试题》《一行JS代码实现一个简单的模板字符串替换「实践」》《JS代码是如何被压缩的「前端高级进阶」》《前端开发规范:命名规范、html规范、css规范、js规范》《【规范篇】前端团队代码规范最佳实践》《100个原生JavaScript代码片段知识点详细汇总【实践】》《关于前端174道 JavaScript知识点汇总(一)》《关于前端174道 JavaScript知识点汇总(二)》《关于前端174道 JavaScript知识点汇总(三)》《几个非常有意思的javascript知识点总结【实践】》《都2020年了,你还不会JavaScript 装饰器?》《JavaScript实现图片合成下载》《70个JavaScript知识点详细总结(上)【实践】》《70个JavaScript知识点详细总结(下)【实践】》《开源了一个 JavaScript 版敏感词过滤库》《送你 43 道 JavaScript 面试题》《3个很棒的小众JavaScript库,你值得拥有》《手把手教你深入巩固JavaScript知识体系【思维导图】》《推荐7个很棒的JavaScript产品步骤引导库》《Echa哥教你彻底弄懂 JavaScript 执行机制》《一个合格的中级前端工程师需要掌握的 28 个 JavaScript 技巧》《深入解析高频项目中运用到的知识点汇总【JS篇】》《JavaScript 工具函数大全【新】》《从JavaScript中看设计模式(总结)》《身份证号码的正则表达式及验证详解(JavaScript,Regex)》《浏览器中实现JavaScript计时器的4种创新方式》《Three.js 动效方案》《手把手教你常用的59个JS类方法》《127个常用的JS代码片段,每段代码花30秒就能看懂-【上】》《深入浅出讲解 js 深拷贝 vs 浅拷贝》《手把手教你JS开发H5游戏【消灭星星】》《深入浅出讲解JS中this/apply/call/bind巧妙用法【实践】》《手把手教你全方位解读JS中this真正含义【实践】》《书到用时方恨少,一大波JS开发工具函数来了》《干货满满!如何优雅简洁地实现时钟翻牌器(支持JS/Vue/React)》《手把手教你JS 异步编程六种方案【实践】》《让你减少加班的15条高效JS技巧知识点汇总【实践】》《手把手教你JS开发H5游戏【黄金矿工】》《手把手教你JS实现监控浏览器上下左右滚动》《JS 经典实例知识点整理汇总【实践】》《2.6万字JS干货分享,带你领略前端魅力【基础篇】》《2.6万字JS干货分享,带你领略前端魅力【实践篇】》《简单几步让你的 JS 写得更漂亮》《恭喜你获得治疗JS this的详细药方》《谈谈前端关于文件上传下载那些事【实践】》《面试中教你绕过关于 JavaScript 作用域的 5 个坑》《Jquery插件(常用的插件库)》《【JS】如何防止重复发送ajax请求》《JavaScript+Canvas实现自定义画板》《Continuation 在 JS 中的应用「前端篇」》作者:离尘不理人转发链接:https://segmentfault.com/a/1190000022909876
本文出自快速备案,转载时请注明出处及相应链接。