react实现带叶子的可搜索树形结构

话不多说,先上效果图

1.png

然后是代码部分:

// departmentTreeData为初始化树形数据
// searchVal为搜索框的数据
// onSelectDepartment为选中节点的func

const DepartmentTreeList = ({ departmentTreeData, searchVal, onSelectDepartment }) => {
  const [flattenData, setFlattenData] = useState([]);
  const [filterItems, setFilterItems] = useState([]);
  const [expandItems, setExpandItems] = useState([]);

  // 展平数据
  const flatten = (data) => {
    if (data.length) {
      return data.reduce(
        (arr, { id, name, parentId, children = [] }) =>
          arr.concat([{ id, name, parentId, children }], flatten(children)),
        []
      );
    }
    return data;
  };

  // 找到当前元素的index
  const indexInFlattenData = (item) => {
    return flattenData.findIndex((val) => val.id === item.id);
  };

  // 找到包含该expandKey的父节点
  const getParentTree = (item, temp = []) => {
    const parent = flattenData.find((d) => d.id === item.parentId);
    if (parent) {
      temp.push(parent);
      getParentTree(parent, temp);
    }
    return temp;
  };

  // 当前节点是否展开
  const isOpen = (item) => {
    return expandItems.find((option) => option.id === item.id);
  };

  // 点击展开节点
  const openChildren = (item) => {
    // 如果已经open,则从expandItems中移除当前id,反之添加
    if (isOpen(item)) {
      const filterKeys = expandItems.filter((option) => option.id !== item.id);
      setExpandItems([...filterKeys]);
    } else {
      setExpandItems([...expandItems, item]);
    }
  };

  // 该元素是否参与其父元素leafLine的构成
  const isBefore = (key, item) => {
    let flag = true;
    // 为了让key对应parent,此处做一下reverse
    const parent = getParentTree(item).reverse()[key];
    const [lastChild] = parent.children.slice(-1);
    // 找到最后一个child在展开数据中的index与其比较
    // 如果child.index > item.index, 说明该父节点的最后一个子元素在当前item下方,所以要加上leafLine
    if (indexInFlattenData(lastChild) > indexInFlattenData(item)) {
      flag = false;
    }
    return flag;
  };

  // 渲染leafLine
  const renderLeafLine = (index, item) => {
    // index表示要在此元素前方插入多少个占位span
    const data = [...new Array(index - 1).keys()];
    return data.map((key) => (
      <span
        key={key}
        className={classNames(styles.treeIndent, {
          [styles.displayNone]: isBefore(key, item),
        })}
        style={{
          left: `${(key + 1) * 30}px`,
        }}
      />
    ));
  };

  const renderList = (data, index = 0) => {
    // 通过index控制样式
    index += 1;
    return data.map((item) => {
      const hasChildren = item.children && item.children.length;
      const openChildFlag = isOpen(item);
      return (
        <React.Fragment key={item.id}>
          <li
            className={styles.listItem}
            style={{
              paddingLeft: `${(index - 1) * 30}px`,
            }}
            onClick={() => onSelectDepartment(item)}
          >
            {index > 1 && renderLeafLine(index, item)}
            <span className={styles.leafLine} />
            {hasChildren && (
              <span
                className={styles.childIcon}
                onClick={(e) => {
                  e.stopPropagation();
                  openChildren(item);
                }}
              >
                <Icon name={openChildFlag ? 'down' : 'right'} />
              </span>
            )}
            {searchVal && item.name.includes(searchVal) ? (
              <span
                dangerouslySetInnerHTML={{
                  __html: item.name.replace(
                    searchVal,
                    `<span class=${styles.labelKeyword}>${searchVal}</span>`
                  ),
                }}
              />
            ) : (
              <span>{item.name}</span>
            )}
          </li>
          {hasChildren && openChildFlag ? renderList(item.children, index) : null}
        </React.Fragment>
      );
    });
  };

  useEffect(() => {
    const data = flatten(departmentTreeData);
    setFlattenData([...data]);
    // 初始化全部展开
    // setExpandItems([...data]);
  }, [departmentTreeData]);

  useEffect(() => {
    // 找到包括该关键字的选项
    const filterLists = searchVal
      ? flattenData.filter((item) => item.name.includes(searchVal))
      : [];
    setFilterItems([...filterLists]);

    // 找到所有包括该expandKey的父节点
    let result = [];
    filterLists.forEach((items) => {
      const parent = getParentTree(items);
      result.push(...parent);
    });
    setExpandItems([...new Set(result)]);
  }, [searchVal]);

  return (
    <ul className={styles.listBody}>
      {searchVal ? (
        filterItems.length ? (
          <ul className={styles.listBody}>{renderList(departmentTreeData)}</ul>
        ) : (
          <div className={styles.noData}>{i18n.t`暂无数据`}</div>
        )
      ) : (
        <ul className={styles.listBody}>{renderList(departmentTreeData)}</ul>
      )}
    </ul>
  );
};

DepartmentTreeList.defaultProps = {
  departmentTreeData: [],
  searchVal: '',
  onSelectDepartment: () => {},
};

DepartmentTreeList.propTypes = {
  departmentTreeData: PropTypes.array,
  searchVal: PropTypes.string,
  onSelectDepartment: PropTypes.func,
};

export default DepartmentTreeList;

css部分:

@import '~@SDVariable'
.list-body
  padding 8px 0 0 8px

  .list-item
    position relative
    padding 12px 0

    .tree-indent
      position absolute
      display inline-block
      width 22px

      &::before
        position absolute
        top -33px
        height 45px
        border-left 1px solid #dddfe3
        content " "

    .display-none
      display none

    .leaf-line
      position relative
      display inline-block
      width 22px
      height 100%

      &::before
        position absolute
        top -49px
        height 44px
        border-left 1px solid n20
        content " "

      &::after
        position absolute
        top -5px
        width 21px
        border-bottom 1px solid n20
        content " "

    .child-icon
      position relative
      z-index 1
      width 16px
      height 16px
      margin-right 8px
      border-radius 50%
      border 1px solid n20
      background n0

    .label-keyword
      color b50

.no-data
  text-align center
  color #9a9fac

对应的数据格式为:

const optionsData = [
  { id: 1348, name: '司法临时工啊叫', parentId: null },
  {
    id: 10,
    name: '产研部',
    parentId: null,
    children: [
      {
        id: 7,
        name: '研发部',
        parentId: 10,
        children: [
          {
            id: 3,
            name: '自动化测试',
            parentId: 7,
            children: [
              {
                id: 1,
                name: '自动化测试下一级部门',
                parentId: 3,
                children: [
                  {
                    id: 70,
                    name: '部门1',
                    parentId: 1,
                    children: [
                      {
                        id: 82,
                        name: '运营部',
                        parentId: 70,
                        children: [{ id: 83, name: '1', parentId: 82 }],
                      },
                    ],
                  },
                  { id: 71, name: '部门2', parentId: 1 },
                ],
              },
            ],
          },
          {
            id: 31,
            name: '后端小组',
            parentId: 7,
            children: [{ id: 79, name: '仅校招使用', parentId: 31 }],
          },
          {
            id: 73,
            name: '赵正果测试',
            parentId: 7,
            children: [
              { id: 72, name: '部门3', parentId: 73 },
              {
                id: 74,
                name: '部门1-子部门-子部门',
                parentId: 73,
                children: [{ id: 12, name: '产品运营部', parentId: 74 }],
              },
              { id: 78, name: '子部门子部门子部门子部门子部门子部门子部门子部门', parentId: 73 },
            ],
          },
          { id: 75, name: '研发部-其他', parentId: 7 },
          {
            id: 154,
            name: '干活222',
            parentId: 7,
            children: [{ id: 155, name: '干活333', parentId: 154 }],
          },
        ],
      },
      { id: 30, name: '前端开发', parentId: 10 },
      { id: 47, name: '后端开发', parentId: 10 },
      {
        id: 133,
        name: '产品部',
        parentId: 10,
        children: [
          {
            id: 11,
            name: '支付宝产品部',
            parentId: 133,
            children: [{ id: 77, name: '123', parentId: 11 }],
          },
          { id: 134, name: '微信支付', parentId: 133 },
        ],
      },
    ],
  },
];
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342