前言
最近在业务中遇到了一个关于 多级下拉 需求,需要将后端树状数据显示在 textarea
上,同时 textArea
中也能对数据进行处理,转化为能进行多级选择树状数据。
拿问卷星的多级下拉举个例子,如下图所示,用户可以在 textArea
框进行多级下拉的数据的编写,第一行代表标题,余下的每一行代表一个多级下拉框中各级的数据,各级数据之间使用 /
来进行分隔。
数据编辑完成保存之后,我们将树状数据用在移动端或者小程序端,这样就完成了一个多级下拉的组件。
今天这篇文章就简单介绍一下这个工作流程,主要包括:
- 怎样将 树状数据 转化为
textarea
上展示的value
值 ? - 怎样将
textarea
中的数据 转化为 树状数据 ? - 怎样判断 哪些数据已存在的,哪些数据是新增的,哪些数据是删除的 ?
- 怎么测试一个将要发布到
npm
的组件?
关于多级下拉的 数据展示 在这篇文章中不会做介绍,那么接下来我们就开始发车。
项目说明
项目预览图
技术栈
这个组件是使用 React Hooks + TypeScript
来实现,众所周知,Hooks
是 React
未来的趋势,同时 TypeScript
也是 JavaScript
未来的趋势,小弟刚好拿这个组件练练手。
打包工具用的是 Rollup
,因为打包组件库的 Rollup
比 Webpack
更受欢迎,Webpack
更适合打包复杂的大型项目。关于 Webpack
的学习,大家可以参考笔者整理的 Webpack 学习文档。
项目结构
项目结构如下所示:
.
├── node_modules // 第三方的依赖
├── example // 开发时预览代码
├── public // 放置静态资源文件夹
├── src // 示例代码目录
├── app.js // 测试项目 入口 js 文件
└── index.html // 测试项目 入口 html 文件
├── yarn.lock // 测试项目 yarn lock 文件
└── package.json // 测试项目 依赖
├── src // 组件源代码目录
├── components // 轮子的目录
├──textarea // 项目内部使用的一个 textarea 组件
├── index.less // 组件核心代码样式文件
└── textarea.tsx // 组件核心代码
├── types // typescripe 的接口定义
├── utils // 工具函数目录
├── assets // 静态资源目录
├── index.tsx // 项目入口文件
└── index.less // 项目入口样式文件
├── lib // 组件打包结果目录
├── test // 测试文件夹
├── typings // 放置项目全局 ts 申明文件目录
├── .babelrc // babel 配置文件
├── .eslintignore // eslintignore 配置文件
├── .eslintrc.js // eslint 配置文件
├── .gitignore // git上传时忽略的文件
├── api-extractor.json // 用于将多个 ts 声明文件合成一个
├── jest.config.js // 测试配置文件
├── .npmignore // npm 上传忽略文件
├── README.md
├── tsconfig.eslint.json // ts 的 eslint 文件
├── tsconfig.json // ts 的配置文件
├── rollup.config.js // rollup 打包配置文件
├── yarn.lock // yarn lock 文件
└── package.json // 当前整一个项目的依赖
对于项目中除了源码以外的一些知识点,笔者就不细说了,比如Rollup
如何配置;api-extractor
如何将多个声明文件生成一个等等,大家可以自行查阅一波。
仓库地址
仓库地址在此:多级下拉 textarea 组件
Hooks
骨架代码
我们使用的 React Hooks
来编写这个组件,一般编写组件之前我们需要明确这个组件该支持哪些功能,即支持哪些 props
,在这个组件中暂时支持下面这些参数:
<TreeTextArea
treeTitle={title} // 多级下拉 标题数据
treeData={tree_value} // 树状数据
row={21} // textarea 的行数
showNumber // 是否展示左侧 textarea 数字
shouleGetTreeData // 是否开启 处理树数据的功能
delimiter='/' // 以什么符号切割
maxLevel={4} // 支持的最大级数
onChangeTreeData={ // 与 shouleGetTreeData 进行搭配使用,返回处理后的标题和树状数据
(treeTitle, treeData) => {
console.log('---treeTitle---', treeTitle);
console.log('---treeData---', treeData);
}
}
defaultData={DEFAULT_TEXT} // 树状数据默认值
placeholder='请输入标题,例:省份/城市/区县/学校 浙江省/宁波市/江北区/学校1'
/>
我们在 src/components/textarea.tsx
中进行相应代码的编写,其中包括接收相应的传入相应的 props
值、刚进入页面的时候去 初始化数据、监听数据变化、获取树状值 等操作:
const TreeTextArea = (props: Props): JSX.Element => {
// 一系列 props 数据的接受
// ...
const [__textAreaData, setTextAreaData] = useState('');
const [__flattenData, setFlattenData] = useState([]);
// 数据初始化
useEffect(()=>{
if (isArray(__treeData) && isArray(__treeTitle)) {
// ...
const flattenData = flattenChainedData(__treeData);
const textAreaData = getTextAreaData(flattenData, titles);
setFlattenData(flattenData);
setTextAreaData(textAreaData.join('\n'));
}
return ()=>{
// willUnMount
}
}, [])
// 监听数据变化
const onChange = (data: any): void => {}
// 设置默认值
const getDefaultData = (): void => {}
// 获取树状值
const getTreeData = (e: any): void => {
const { onChangeTreeData } = props;
// ...
if (onChangeTreeData) {
onChangeTreeData(levelTitles, valueData);
}
}
return (
<div className={styles.wrapper}>
<NumberTextArea
row={__row}
value={__textAreaData}
onChange={onChange}
showNumber={__showNumber}
placeholder={__placeholder}
errCode={__errCode}
errText={__errText}
/>
{
// ...
// 填充默认值、获取树状值 代码
}
</div>
)
}
我们内部还封装了一个 NumberTextArea
,在这个组件中我增加了 左侧序号显示、错误显示 等逻辑,具体的代码就不贴上来了,大家有兴趣可以参考源码。
关于相关的 React Hooks
知识大家可以自行查阅相关资料学习,笔者在这里也不做介绍了。
接下来我们来看一下组件中最核心的 多级下拉逻辑处理。
多级下拉逻辑核心代码
在整体骨架代码搭建好之后,我们就只需关注 textarea
处理数据的逻辑就行了。
首先我们在 utils
目录下新建 testData.ts
,模拟后端的 json
数据,如下图所示:
数据渲染
我们先从编辑开始说起,假如后端给了我们要渲染的标题以及多级下拉的树状数据:
接着我们希望通过一些处理将后端给的数据修改成 textarea
中可以展示的 value
值,类似于下面的字符串作为 value
值:
这里我们需要做的是将 树状数据 进行 扁平化处理,给每一级的数据增加一个 title
属性,这便是我们需要在 textarea
每一行中所要展示的数据,类似如下的数据:
我们需要将每一级的数据的都扁平化出来。
但这里有一个问题比如 浙江省/宁波市 和 浙江省/宁波市/海曙区 这是两个不同的数据,但是在 textarea
中其实不需要展示 浙江省/宁波市 这一个数据的,所以我在这里做了一个判断,如果这一个数据有孩子的话,就给他增加一个属性 hasChildren
,让我们在获取 textarea
的数据的时候做一下过滤就行了,不展示有属性 hasChildren
的数据。
那么我们如何来扁平化数据呢?其实只要对树状数据做一下递归处理就行了。
/**
* 将后端的 树状结构 数据 扁平化
* @param {Array} data : 后端 tree_node 数据
*/
export const flattenChainedData = (data: any) => {
let arr = [];
forEach(data, (item) => {
const childrens = item.children;
const rootObj = createNewObj(item, item.value);
if (childrens) {
rootObj.hasChildren = true;
}
arr.push(rootObj);
if (childrens) {
// 递归获得所有扁平的数据
const dataNew = getChildFlattenData(childrens, item.value, 1);
arr = concat(arr, dataNew);
}
});
return arr;
};
/**
* 递归获得 扁平数组
* @param {*} data : 要处理的数组
* @param {*} title : 前几级 拼的 title
* @param {*} level : 当前级数
*/
const getChildFlattenData = (data, title, level) => {
// 超过最大级数
if (level > MAX_LEVEL) return false;
if (!data) return false;
let arr = [];
forEach(data, (item) => {
const { children } = item;
const rootObj = createNewObj(item, `${title}/${item.value}`);
if (children) {
rootObj.hasChildren = true;
const childrenData = getChildFlattenData(children, `${title}/${item.value}`, level + 1);
arr.push(rootObj);
arr = concat(arr, childrenData);
} else {
arr.push(rootObj);
}
});
return arr;
};
其中上面的 createNewObj
是为扁平数据新增 value/title 属性,返回新的对象,具体就不上代码了。
转化为扁平数据之后,我们就可以将数据中的 title 属性拿出来,组成 textarea 所需的数据即可:
/**
* 将 扁平数据 转化为 textarea 中 value
* @param {Array} flattenData : 扁平化数据
* @param {String} titles : textarea 第一行的 title
*/
export const getTextAreaData = (flattenData, titles) => {
const newData = filter(flattenData, (item) => {
return !item.hasChildren && item.status !== 2;
});
const arr = [];
arr.push(titles);
forEach(newData, (item) => {
arr.push(item.title);
});
return arr;
};
其中我们过滤了 hasChildren
为 true
,同时这里以 status = 2
表示删除的数据,也进行过滤, 这样我们便可以得到如下图所示的一个 textarea 数组:
接着我们将这些数组通过 \n
换行符 join
起来就是我们所需的 textarea
的 value
值了。
树状数据处理
这里是整个多级下拉逻辑中最核心的部分,我们需要将用户修改过的数据,与原来的数据进行关联,生成新的树状数据。我们分为四个步骤来讲解这一个步骤。
我们会创建一个 数据处理类 treeTextAreaDataHandle
,并在 constructor
构造函数中传入 扁平化数据、textarea
文本框的值 来初始化一个实例对象。之后我们会在此类中完善我们处理数据的一些属性和方法。
class treeTextAreaDataHandle {
// 扁平化数组
private flattenData: FlattenDataObj[];
// textarea 框 文本值
private textAreaTexts: string;
constructor(options: treeTextAreaData) {
const { flattenData, textAreaTexts } = options;
this.flattenData = flattenData;
this.textAreaTexts = textAreaTexts;
}
}
生成修改的初始映射
第一步我们会生成用户修改后的 textarea
初始映射数据,我们会根据级数分别放在不同的数组中,举个?:
我们会根据用户最后输入完成的 textarea
值,来生成一组根据级数排布的对象数据,如下图:
如上面这张图会转化为如下面图中的数据,这个数据会是我们进行接下去三步操作的关键:
这里需要注意的一个问题,有可能在某一级是有同名的值存在,这个时候我们不能单纯的就把这两个值认为是同一个值,而要去比较他们的爸爸是否是一样的,以此类推,递归比较直到第一级,如果都是一样的话,那么他们才是同一个值,否则就是不同的值。
举个简单例子,比如有两个数据: 浙江省/宁波市/海曙区 和 江苏省/无锡市/海曙区 这两个值,虽然第三级中的 海曙区 名字是相同的,但是他们是两个不同的值,他们应该被分配到两个不同的 id,并且各自的 parent_id 也不一样。
接下来上代码,我们新建一个实例方法 transDataFromText
,在这个方法中进行树状数据的转化,并得到最后的数据:
/**
* 将 textarea 数据 转化为 后端所需的树状结构数据
* @param {Array} flattenData : 扁平数据
* @param {String} texts : textarea 的文本
*/
public transDataFromText() {
const texts = this.textAreaTexts;
const arr = texts.split('\n');
// 去除标题
if (arr.length > 1) {
arr.shift();
}
// 赋值每一行文字为数组
this.textAreaArr = arr;
// 解析 TextArea 数据 为 指定 层级映射数据
this.parserRootData();
// ...
}
我们在 parserRootData
这个方法中去生成修改后的初始映射:
/**
* 将 textarea 数据 转化为 相应级数 的 数据
* @param {Array} textArr : textarea 的文本 转化的数组
* @param {Number} handleLevel : 要处理的级数
*/
private parserRootData() {
// 每一行的 textArea 值
const textArr = this.textAreaArr;
// 最大级数
const handleLevel = this.MAX_LEVEL;
// 以什么分隔符切割
const delimiter = this.delimiter;
// 去重 每一级 textArea 值
const uniqueTextArr = uniq(textArr);
// 映射数据存放对象
const namesArrObj: namesArrObj = {};
// 根据最大级数为每一级创建一个数组
for (let i = 1; i <= handleLevel; i++) {
namesArrObj[`${ROOT_ARR_PREFIX}_${i}`] = [];
}
// 遍历 每一行的 textArea 值
forEach(uniqueTextArr, (item: string) => {
// 切割 每一行 字符串,生成字符串
const itemArr = item.split(delimiter);
// 根据最大级数往 namesArrObj 塞数据
for (let i = 1; i <= handleLevel; i++) {
if (
!treeTextAreaDataHandle.sameParentNew(namesArrObj, itemArr, i)
&& itemArr[i - 1]
) {
// 创建一个对应级数的对象,塞入对应的数组
const obj: parserItemObj = {};
obj.id = _id();
obj.value = itemArr[i - 1];
obj.level = i;
// 获取当前的级数的值,爸爸的 id
const parentId = treeTextAreaDataHandle.getParentIdNew(
namesArrObj, itemArr, i
);
obj.parent_id = parentId;
namesArrObj[`${ROOT_ARR_PREFIX}_${i}`].push(obj);
}
}
});
// 保存到对象的 rootArrObj 属性值中
this.rootArrObj = namesArrObj;
}
上面最为关键的一个方法就是静态方法 sameParentNew
,作用是帮我们递归判断 两个相同名称的值是否真的相同。其实原理也很简单,也是 递归 判断他们各自的爸爸是否相同。具体代码大家可以参考源码。
其次这里还有用到类似:
- 创建
id
的方法:_id()
- 找
parent_id
的静态方法:getParentIdNew
到这里我们第一步生成 初始映射 数据就完成了,接下来我们就需要结合后端提供给我们的扁平数据 flattenData 来填充已存在的数据,同时筛选出新增的数据。
填充存在数据并筛选新增数据
这一步我们需要将后端给我们的数据 flattenData
与我们的初始映射数据进行比对。填充存在数据的属性同时筛选出新增的数据,并给新增数据加上属性 new = true
,最后塞到对应的对象对应级数组中去 existNamesArrObj
和 addNamesArrObj
。
举个?
我们新增了 浙江省/宁波市/高新区,我们可以在新增数据中的第三级中找到 高新区,因为 浙江省 和 宁波市 已经存在,他们不会被添加到新增的数组中去,只会在已存在的对象中被找到,并且会用后端给的 id
替换掉我们之前生成映射数据是生成的 id
,如下图:
存在数据:existNamesArrObj
新增数据:addNamesArrObj
这里我们还要注意的一个点是,我们在进行数据筛选之前,需要将后端给的数据flattenData
数据中加上一个属性root_id
,它的作用是帮我们将 修改后数据 和之前 后端给的数据 进行关联,比如上面我们新增 高新区 这个例子,他的爸爸是已经存在的,他的id
是已经存在的 36178,但是新增的高新区的parent_id
是我们在映射数据时生成的,这两个肯定不相等,我们需要借助root_id
来将这两个数据联系起来。
接下来上代码,我们将这一波处理放到 handleExistData
方法中,
/**
* 填充已有的数据,并筛选出新增的数据
* @param {*} TextAreaData : parserRootData() 处理的数据
* @param {*} newFlattenData : 扁平化数据
* @param {Number} handleLevel : 要处理的级数
*/
private handleExistData() {
const namesArrObj = this.rootArrObj;
const newFlattenData = this.flattenData;
const handleLevel = this.MAX_LEVEL;
// 存在的数据
const existNamesArrObj = {};
// 新增的数据
const addNamesArrObj = {};
for (let i = 1; i <= handleLevel; i++) {
addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`] = [];
existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`] = [];
}
// flatten 加上 parser 的 映射 id
this.setMapIdForFlattenDataToRootData();
for (let i = 1; i <= handleLevel; i++) {
// 获取出事映射相应级数的数据
const curNamesArr = namesArrObj[`${ROOT_ARR_PREFIX}_${i}`];
forEach(curNamesArr, (item) => {
// 设立一个标志位
// 标志这一级的数据是否存在
let flag = false;
// 映射数据的属性
const { value, parent_id, id } = item;
// 新增数据 obj
const addNewObj: addNewObj = {
level: i,
value,
id,
new: true,
root_id: id,
};
// 遍历比较后端数据 与 映射数据 的 `value` 和 `level`
// 来确定他们映射数据是否存在
// 存在就
forEach(newFlattenData, (val) => {
if (value === val.value) {
if (val.level === i) {
// level 等于 1
if (val.level === 1 && val.parent_id === 0) {
const obj = { ...val };
existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
flag = true;
}
// level 大于 1
if (val.level !== 1 || val.parent_id !== 0) {
if (this.isExistitem(val, parent_id, i)) {
const obj = { ...val };
existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`].push(obj);
flag = true;
}
}
}
}
});
// 如果是新增数据
if (!flag) {
// 塞入 addNamesArrObj
addNamesArrObj[`${ADD_ARR_PREFIX}_${i}`].push(addNewObj);
// 塞入 最新 扁平化数据
newFlattenData.push(addNewObj);
}
});
}
// 将 existNamesArrObj 挂到类属性 existNamesArrObj 上
this.existNamesArrObj = existNamesArrObj;
this.addNamesArrObj = addNamesArrObj;
}
上面的方法中还用到了一个比较重要的方法 isExistitem
方法,判断当前级数据是否存在,其原理跟parserRootData
中用到的 sameParentNew
类似,也是去递归比较在他们的爸爸是否是相同的,直到找到不同的爸爸或者到第一级为止,只不过这里面比较的是 初始映射数据 和 后端扁平数据。
还有一个方法就是我们上面讲到的给 后端扁平数据 添加 root_id
的方法 setMapIdForFlattenDataToRootData
,具体代码笔者不贴了,大家有兴趣可以自行查看。
处理完已存在数据,和新增数据,我们还需要处理删除的数据。
这里如果需求中要出对数据进行排序的话,其实可以吧 存在数据 和 新增数据 放在一个对象中,这样每次有新增或者存在数据的时候都会从上都下依次塞入,现在笔者是吧 存在数据 和 新增数据 分开来了,新增数据 默认都是在最后的。
处理删除数据
一般来说,如果数据删除了,前端还需要将数据传给后端,告诉后端这条数据删除了。所以我们需要给删除的数据中加上相应的 状态值,这里我们加了 status = 2
,代表此条数据在前端已经被删除了。
实现起来很简单,因为我们通过第二步已经得到了 已经存在的数据,只需要拿它与最初的 后端提供的扁平数据 进行比较一波就能得出哪些数据被删除了,筛选出来之后将他们将上相应的属性即可。
比如我们删除了 江苏省/无锡市/惠山区 这一行,其实是删除了 无锡市 和 惠山区 两个数据,我们可以得到如下结果:
接下来上代码,我们将筛选删除数据的方法写在 handleTagForDeleleByLevel
方法中,
/**
* 根据标题 几级 来获取删除的数据,并给删除数据打上标签,并返回删除数据
* @param {*} handleDataArr : fillExistData() 处理的数据
* @param {*} newFlattenData : 扁平化数据
* @param {Number} handleLevel : 要处理的级数
*/
private handleTagForDeleleByLevel = () => {
const existNamesArrObj = this.existNamesArrObj;
const handleLevel = this.MAX_LEVEL;
// 存放 存在扁平数据 的数组
let existData = [];
// 遍历 存在数据对象 扁平化存在数据
for (let i = 1; i <= handleLevel; i++) {
const curArray = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
existData = concat(existData, curArray);
}
// 给删除数据添加属性 status = 2
const deleteData = this.addTagForDeleleData(existData);
// 将 deleteData 挂到属性 deleteData 上
this.deleteData = deleteData;
};
我们通过 addTagForDeleleData
方法来比较不同的值,并加上属性 status=2
,在这里我们也可以使用lodash
的 difference
方法来得到两个数组不同的值。
处理完上面三步之后,基本上就大公告成了,接下来就生成最终树状数据。
生成树状数据
最后我们就需要将 存在数据、新增数据、删除数据 生成一个新的扁平化数组,由这个新扁平化数据生成我们想要的树状数据。
比如我们新增 浙江省/宁波市/高新区,删除 江苏省/无锡市/惠山区,最终会得到新的扁平数据如下,我们可以看到 高新区 是新增的,惠山区 也加上了相应的 status=2
的属性:
接着我们就可以根据这个扁平化数据,递归生成树状数据,如下图:
接下来上代码,首先 getLastFlattenData
方法,通过这个方法我们可以获取到最新的扁平化数据:
/**
* 生成最新的数据
* @param {*} existNamesArrObj : existNamesArrObj 已存在数据
* @param {*} addNamesArrObj : addNamesArrObj 新增数据
* @param {*} deleteData : addTagForDeleleByLevel() 得到的删除数据
* @param {Number} handleLevel : 要处理的级数
*/
private getLastFlattenData() {
const existNamesArrObj = this.existNamesArrObj;
const newAddNamesArrObj = this.newAddNamesArrObj;
const deleteData = this.deleteData;
const handleLevel = this.MAX_LEVEL;
let lastData = [];
let AddLast = [];
let ExistLast = [];
// 遍历 扁平化 存在和新增数据
for (let i = 1; i <= handleLevel; i++) {
const curArrayExist = existNamesArrObj[`${EXIST_ARR_PREFIX}_${i}`];
const curArrayAdd = newAddNamesArrObj[`${HANDLE_ADD_ARR_PREFIX}_${i}`];
ExistLast = concat(ExistLast, curArrayExist);
AddLast = concat(AddLast, curArrayAdd);
}
// 合并三种类型的数据
lastData = concat(lastData, ExistLast, AddLast, deleteData);
// 将 lastData 挂到 类属性 newDataLists 上
this.newDataLists = lastData;
};
最后就是生成最终树状数据,原理就是从 parent_id
为 0
开始进行 递归遍历,直到遍历完所有节点为止,与此同时我们需要在生成树之前,删除一些原本不需要的属性,比如新增属性 new
,映射关联的 root_id
等。
/**
* 删除 之前 组装 树状结构时 使用的 一些自定义属性
* 后端不需要
* @param {Object} item : 每一项的 item
*/
static clearParamsInTreeData = (item) => {
delete item.title;
delete item.hasChildren;
delete item.root_id;
if (item.new) {
delete item.new;
delete item.id;
delete item.parent_id;
}
};
/**
* 递归 将扁平数据转化为 树状 结构数据
* 用于 transDataFromText
* @param {Array} lists : 扁平数据
* @param {Number} parent_id : 爸爸的 id
*/
private getTreeDataBylists = (parent_id: number | string): any => {
const lists = this.newDataLists;
//递归,菜单
const tree = [];
forEach(lists, (item) => {
const newItemId = item.parent_id;
if (parent_id === newItemId) {
const childrenTree = this.getTreeDataBylists(item.id);
if (isArray(childrenTree) && childrenTree.length > 0) {
item.children = childrenTree;
} else {
item.children = null;
}
// 删除不必要属性
treeTextAreaDataHandle.clearParamsInTreeData(item);
tree.push(item);
}
});
return tree;
};
到这里我们变完成了对 textarea
的处理,最终的 transDataFromText
方法如下:
/**
* 将 textarea 数据 转化为 后端所需的树状结构数据
* @param {Array} flattenData : 扁平数据
* @param {String} texts : textarea 的文本
*/
public transDataFromText() {
const texts = this.textAreaTexts;
const arr = texts.split('\n');
if (arr.length > 1) {
arr.shift();
}
this.textAreaArr = arr;
// 解析 TextArea 数据 为 指定 层级映射数据
this.parserRootData();
// 填充已有数据 并 筛选新增数据
this.handleExistData();
// 处理新增数据
this.handleParamsInAddData();
// 获取删除数据
this.handleTagForDeleleByLevel();
// 获取最新扁平数据
this.getLastFlattenData();
// 获取最新树状数据
this.lastTreeData = this.getTreeDataBylists(0);
return this.lastTreeData;
}
错误处理
我们需要对一些错误进行处理,比如 用户可能不会输入标题、又或者 用户输入的标题大于了最大支持级数(当然在我们项目中,这个最大支持级数用户可以自己来控制)、又或者 标题的级数与下面内容的级数不对应,这些都应该被归为错误列表中。
举个例子,当用户没有输入标题的时候,我们应该提示其输入标题,如下图:
我们新建一个方法 isEquelLevel
方法,来检测用户输入的值是否符合规范,代码其实也很简单,我们可以取到最终的数据,遍历数据中是否存在错误,存在错误就抛出相应的 错误码 errorCode
和 错误信息 ERROR_INFO
,错误类型如下:
/**
* 校验信息
*/
export const ERROR_INFO = {
1: '第一行标题不可为空',
2: `第一行标题不可超过 ${MAX_LEVEL} 列`,
3: '标题和选择项的层级数请保持一致',
4: `选择项不可超过 ${MAX_LEVEL} 行`,
5: '请至少填写一行选择项',
};
测试
功能写完之后,我们需要测试一下组件的功能,可以借助使用 create-react-app
的 react-scripts
帮我们快速启动一个应用:
package.json
配置:
以下是测试项目 package.json
文件:
{
"name": "example",
"version": "0.0.0",
"description": "",
"license": "MIT",
"private": true,
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject",
},
"author": "Darrell",
"dependencies": {
"lodash": "^4.17.15",
"react": "link:../node_modules/react",
"react-dom": "link:../node_modules/react-dom",
"react-scripts": "^3.4.1"
},
"browserslist": [
">0.2%",
"not dead",
"not ie <= 11",
"not op_mini all"
]
}
这里面需要注意的一个问题就是,这里面的 react
和 react-dom
两个依赖需要使用上一级根目录 node_modules
下的依赖。
因为使用 Hooks
写的插件会因为有多个 React
应用而报错,如下图:
导致这个问题的原因主要是第一个 React
版本没到 16.8
,或者第三个,在项目中有多个 React
引用。
至于第二个问题,Hooks
不符合规范基本上在我们安装了 eslint-plugin-react-hooks
插件之后就基本上可以规避掉了。关于这个问题的更多信息大家可以参考 这条 issure。
然后我们进入 exmaple
安装相应的依赖,直接运行 yarn start
就可以将我们的项目跑起来了。
主要代码
我们在 example
的 public
目录下新建
-
index.html
:项目的模版文件,即负责项目显示的html
文件
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<title>测试页面</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>
-
manifest.json
:如果写手机端的h5app
,图标、主题颜色等是在这个文件里是设置的,在这里我们可以随意配置一波。
同时在 src
目录下新建:
-
index.js
:项目入口文件 -
index.css
:入口文件的样式
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css'
import App from './App'
ReactDOM.render(<App />, document.getElementById('root'))
-
App.js
:测试组件的文件
import React, { Component } from 'react'
// 测试数据
import { title, tree_value, DEFAULT_TEXT } from './testData'
import TreeTextArea from 'darrell-tree-textarea'
export default class App extends Component {
render () {
return (
<div className='App'>
<TreeTextArea
treeTitle={title}
treeData={tree_value}
row={21}
showNumber
shouleGetTreeData
delimiter='/'
maxLevel={4}
onChangeTreeData={
(treeTitle, treeData) => {
console.log('---treeTitle---', treeTitle);
console.log('---treeData---', treeData);
}
}
defaultData={DEFAULT_TEXT}
placeholder='请输入标题,例:省份/城市/区县/学校 浙江省/宁波市/江北区/学校1'
/>
</div>
)
}
};
发 npm
之前的测试
上面的测试文件是写在我们的组件项目中的。
但是一般在发包之前,我们需要在其他的项目里面测试使用一下,这个时候我们可以借助 npm link
。
- 首先在组件下根目录下 执行
npm link
,这句命令意思就是将组件引入到全局的node_modules
。
- 在你要使用组件的目录下,通过下面命令引用组件:
npm link <package 名>
看个?:假设我们使用 create-react-app
新建了一个项目 my-app
,我们就可以在此项目的根目录下面,运行:
npm link @darrell/darrell-tree-textarea
这个时候我们可以在项目中有了我们项目的依赖:
但是因为是项目的引用,所以这个依赖包含了我们插件项目中的所有内容,包括 node_modules
,这里会出现我们上面提到的 Hooks
开发组件 Invalid hook call
这个错误,因为在我们的依赖下有 @darrell/darrell-tree-textarea
下有 node_modules
文件下,在它下面有 React
依赖,同时在 my-app
下面的 node_modules
下也有 React
依赖,所以就会出现 多个 React
引用 这个问题。
这个问题在我们发到 npm
上之后不会出现,因为在上传到 npm
上的时候是不会把 node_modules
目录传上去的。
解决办法有两个:
- 删除
@darrell/darrell-tree-textarea
下的node_modules
,但是每次都需要重新安装 -
推荐使用,在
my-app
项目下,改一下配置文件,将所有的react
引用指向同一个引用
alias: {
// ...
'react': path.resolve(__dirname, '../node_modules/react'),
'react-dom': path.resolve(__dirname, '../node_modules/react-dom'),
},
关于如何发包大家可以参考这篇文章:从零开始实现类 antd 分页器(三):发布npm,这篇文章中有详细的介绍组件的测试和 npm
的发布,在本篇文章中就不涉及了。
小结
本文主要讲了如何制作一个能处理 多级下拉树状数据 的 textarea
组件的编写,整体来看还是比较简单,整个组件的难点应该是如何有效的 递归处理 数据:
- 比如如何扁平化树状数据:递归处理他们的
children
。 - 如何判断两个名字相同的数据是否相同:递归判断他们的爸爸是否相同 等等问题
还有在组件测试那里也折腾了蛮久的,因为碰到了 React Hooks
组件不能运行的问题,我曾一度以为是 Hooks
的写法有问题,后来没想到是多个 React
引用出现的错误。
不过现在回过头来思考这个问题,发现 React
的错误提醒其实做的很清楚,自己只要跟着这个错误提示一步一步就能把问题解决掉。
实不相瞒,想要个赞!
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。