1

前言

最近在业务中遇到了一个关于 多级下拉 需求,需要将后端树状数据显示在 textarea 上,同时 textArea 中也能对数据进行处理,转化为能进行多级选择树状数据。

拿问卷星的多级下拉举个例子,如下图所示,用户可以在 textArea 框进行多级下拉的数据的编写,第一行代表标题,余下的每一行代表一个多级下拉框中各级的数据,各级数据之间使用 / 来进行分隔。

数据编辑完成保存之后,我们将树状数据用在移动端或者小程序端,这样就完成了一个多级下拉的组件。

今天这篇文章就简单介绍一下这个工作流程,主要包括:

  • 怎样将 树状数据 转化为 textarea 上展示的 value 值 ?
  • 怎样将 textarea 中的数据 转化为 树状数据
  • 怎样判断 哪些数据已存在的,哪些数据是新增的,哪些数据是删除的 ?
  • 怎么测试一个将要发布到 npm 的组件?

关于多级下拉的 数据展示 在这篇文章中不会做介绍,那么接下来我们就开始发车。

 

项目说明

项目预览图

 

技术栈

这个组件是使用 React Hooks + TypeScript 来实现,众所周知,HooksReact 未来的趋势,同时 TypeScript 也是 JavaScript 未来的趋势,小弟刚好拿这个组件练练手。

打包工具用的是 Rollup,因为打包组件库的 RollupWebpack 更受欢迎,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='请输入标题,例:省份/城市/区县/学校&#10;浙江省/宁波市/江北区/学校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;
};

其中我们过滤了 hasChildrentrue,同时这里以 status = 2 表示删除的数据,也进行过滤, 这样我们便可以得到如下图所示的一个 textarea 数组:

接着我们将这些数组通过 \n 换行符 join 起来就是我们所需的 textareavalue 值了。

 

树状数据处理

这里是整个多级下拉逻辑中最核心的部分,我们需要将用户修改过的数据,与原来的数据进行关联,生成新的树状数据。我们分为四个步骤来讲解这一个步骤。

我们会创建一个 数据处理类 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,最后塞到对应的对象对应级数组中去 existNamesArrObjaddNamesArrObj

举个?

我们新增了 浙江省/宁波市/高新区,我们可以在新增数据中的第三级中找到 高新区,因为 浙江省宁波市 已经存在,他们不会被添加到新增的数组中去,只会在已存在的对象中被找到,并且会用后端给的 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,在这里我们也可以使用lodashdifference 方法来得到两个数组不同的值。

处理完上面三步之后,基本上就大公告成了,接下来就生成最终树状数据。

 

生成树状数据

最后我们就需要将 存在数据新增数据删除数据 生成一个新的扁平化数组,由这个新扁平化数据生成我们想要的树状数据。

比如我们新增 浙江省/宁波市/高新区,删除 江苏省/无锡市/惠山区,最终会得到新的扁平数据如下,我们可以看到 高新区 是新增的,惠山区 也加上了相应的 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_id0 开始进行 递归遍历,直到遍历完所有节点为止,与此同时我们需要在生成树之前,删除一些原本不需要的属性,比如新增属性 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-appreact-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"
  ]
}

这里面需要注意的一个问题就是,这里面的 reactreact-dom 两个依赖需要使用上一级根目录 node_modules 下的依赖。

因为使用 Hooks 写的插件会因为有多个 React 应用而报错,如下图:

导致这个问题的原因主要是第一个 React 版本没到 16.8,或者第三个,在项目中有多个 React 引用。

至于第二个问题,Hooks 不符合规范基本上在我们安装了 eslint-plugin-react-hooks 插件之后就基本上可以规避掉了。关于这个问题的更多信息大家可以参考 这条 issure

然后我们进入 exmaple 安装相应的依赖,直接运行 yarn start 就可以将我们的项目跑起来了。

 

主要代码

我们在 examplepublic 目录下新建

  • 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='请输入标题,例:省份/城市/区县/学校&#10;浙江省/宁波市/江北区/学校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 的错误提醒其实做的很清楚,自己只要跟着这个错误提示一步一步就能把问题解决掉。

实不相瞒,想要个赞!

 

参考内容


Darrell
38 声望5 粉丝