如何开发一个 Antd 级联多选控件

FESKY

本文也同步发布在掘金:https://juejin.cn/post/691499...

Intro

这篇文章将从零开始介绍如何开发一个 Antd 的级联多选选择器。先看效果:

GithubSandbox

阅读完这篇文章,不仅可以学会如何实现级联多选的功能,还可以顺便学会:

  • 如何发布一个 Typescript 编写的 NPM Package
  • 编写基本的单元测试,使用 Github Action 执行单元测试,在 Readme 上展示优雅的 Badge

(如果以上内容你都很熟练掌握,继续阅读可能不会有太大帮助🧐 )

背景

Ant Design 是阿里开源的,“The world's second most popular React UI framework“,不用多介绍,任何使用 React 开发管理后台的前端同学肯定都非常熟悉。

Antd 提供了非常多优秀的组件,Button/DatePicker/Form/Cascader/Tree/Notifaction/Modal,太多太多,这些组件组成完整的组件生态,极大程度地提高了普通开发同学的日常开发效率。

然鹅~有些特殊的场景,特殊的需求,可能 Antd 的组件无法支持,也不计划支持。比如这篇文章提到的级联多选,从 github 上可以找到很多相关的 issues, Antd 开发者给出的答复是不支持,或者建议用 TreeSelect。

(贴图没有不敬的意思,非常感谢 Antd 的开发者的输出)

但是作为普通的开发者,一个复杂度如 Tree Select 的组件,可能大多数人一周时间都实现不了(这里指的是只是 TreeSelect 的所有特性)。更何况在追求敏捷交付,快速试错的公司根本不可能给你这么多时间去实现一个看不到即时收益的公共组件。

你肯定也遇到过类似场景,面对产品强硬不可辩驳的需求和社区没有合适的开源组件时,作为”优秀开源库使用者“的一丝丝卑微无助😿。

同时,组件实现相关的教程文章也很少,所以这篇文章将介绍如何从零开始动手实现一个级联多选选择器。

需求理解

为了让读者能深入地了解需求,简单描述一下(可以在 Sandbox 上动手点一点):

  • 点击输入框,在输入框的上面或者下面展示弹窗,再次点击弹窗以外的其他区域关闭弹窗
  • 点击文本展开下一级菜单,点击 Checkbox 切换选中状态
  • Checkbox 有三种状态,选中(checked)、部分选中(indeterminate)、非选中(unchecked)
  • 支持 Cancel、Confirm 操作,Cancel 关闭弹窗、Confirm 提交选择。
  • 提交选择之后,在输入框内展示父节点的值,也就是Antd 中的 TreeSelect.SHOW_PARENT 策略。
  • 点击选中项的 x 号可以删除选中项。

组件设计

在开始动手写组建的代码之前,可以先想一下那些不重要的功能逻辑可能会带来额外的工作量,组件有哪些自己的状态,可以支持哪些参数。有一份这样的设计文档在后续编码时思路会比较清晰。

前置约定

为了减低复杂度,根据具体需求场景可以做一些前置约定:

  • 只支持多选。(单选呢?当然是用 Antd 的 Cascader 组件)
  • 所有节点的 key 都是字符串,整颗树范围内唯一,方便做节点是否被选中的判断
  • 组件的 value 字符串数组类型,不支持数字或 Symbol 等类型,字符串就可以满足绝大多数场景
  • 一个节点需要提供的信息有 value(必须,用作唯一标记), title(必须,节点文本展示) 和 children(非必须,子节点数组)

State

级联多选组件大致需要以下几个状态:

  • 控制是否显示级联菜单(布尔值)
  • 控制当前选中哪些节点(字符串数组),按照设计稿,onChange 是在点了 Confirm 之后执行,当前选中 value ≠= 组件 value)
  • 控制当前展开的层级数据(数组的数组)
  • 控制当前活跃状态的路径,也就是图中浅蓝色高亮的状态,(一个数组)

忘了在哪里看过的”能通过计算得到的状态都应该通过计算得到“的状态设计原则。在级联多选组件中,由于最终是以 TreeSelect.SHOW_PARENT 的策略展示,所见即所得,可以让 value 值也保持一致,下图中 value 值为 ['深圳市', '荔湾区']。而广州市,广东省的部分选中“状态”是通过计算出来的。

Props

作为一个表单组件,必有的参数就那些,很容易就可以列出来。

PropsTypeDescription
valuestring[]数据绑定
dataTreeNode[]节点数据 { title: string, value: string, children?: TreeNode }
allowClearboolean是否允许清楚
placeholderstringPlaceholder
onChange(newVal) => voidvalue 变更回调函数
classNameboolean额外的 CSS 类名
styleReact.CSSProperties额外的样式
disabledboolean是否禁用
支持 value 和 onChange props 就可以在 Antd 的 Form 中使用了。

实现细节

Selector 样式

首先要做的是,表单输入框,选中节点的标签 🏷 样式,由于交付时是作为 Antd 的表单控件使用。所有要和其他 Select 组件样式/行为一致。

需要考虑外观盒模型,hover/ 高亮状态样式,支持 allowClear 时的样式,disabled 的样式等等。这部分样式可以自己实现也可以直接从 antd Select 组件上扒样式。

不过这种情况容易疏漏,担心有一些没考虑到的场景。最节省时间成本的方法是,使用 Antd Selector 的类名,直接复用其样式,伪装成一个 Selector 🤓 。

弹窗及展开动画

接下来处理 Selector 的事件,控制菜单的展开和收起。这一步大概要做以下这些事情

  • 给 Selector 绑定监听事件,当发生点击时展示菜单
  • 为了不受 Selector 所在的容器及样式影响,需要使用 Portal 的形式将菜单渲染到整个 React Root Dom 节点的外部
  • 监听菜单 click outside 事件,事件触发时关闭菜单
  • 展示菜单和收起菜单时需要有动画,保持和其他“弹出组件”的统一交互。

最小实现的工作量不大,但,在我们要做级联多选组件这件事情上不是很重要,可以交给公共的库去做。在 Antd 的使用的 rc-cascader 组件源码中可以看到,它的最外层包裹了一个 rc-trigger 的组件,点过去看,果然这个抽象组件容器组件就帮我们做了👆 提到的功能。

return (
  <Trigger
    ...
  >
    {React.cloneElement(children, {
      onKeyDown: this.handleKeyDown,
      tabIndex: disabled ? undefined : 0,
    })}
  </Trigger>
);
rc-xxx 是 Antd 开发团队提供的基础组件,我们平时用到的 Antd 组件时在 rc-xxx 组件上的包装。

使用 rc-trigger,可以非常快速地完成弹窗基本功能。

import { Button, Empty } from 'antd'
import Trigger from 'rc-trigger'

const [popupVisible, setPopupVisible] = useState(false)

return (
  <Trigger
    action={!disabled ? ['click'] : []}
    popup={
      <Popup />
    }
    popupVisible={popupVisible}
    onPopupVisibleChange={setPopupVisible}
    popupStyle={{
      position: 'absolute',
    }}
    // 对齐方式
    popupAlign={{
      points: ['tl', 'bl'],
      offset: [0, 3]
    }}
    // 内置动画
    popupTransitionName="slide-up"
  >
    <Selector
      {...props}
    />
  </Trigger>
)

rc-trigger 不仅提供了弹窗,对齐方式,点击触发方式,甚至连动画都内置了,只需要设置popupTransitionName="slide-up" 就可以得到和其他 Select 组件一样的展开动画。

对于 rc-trigger 组件内部具体是如何实现可以看这篇源码解读

Checkbox 🌲 状态联动

接下来进入这个组件的重头戏,Checkbox 🌲 状态的维护。

以下面的例子来说,深圳市为选中状态,那么深圳市下的所有子节点都展示为选中状态(但不体现在 value 中)。广州市为部分选中状态,因为 value 中包含部分广州市下的区,同理广东省的半选中状态也是如此。

结构

考虑到树结构需要频繁的向上向下遍历的操作,我们可能需要的是一个双向多叉树的结构🌲 。从父 → 子的联系在 children 字段中已经体现了,还需要给每一个子节点添加一个 parent 属性指向父节点。

在使用引用计数的垃圾回收机制的语言,循环引用容易出现内存泄漏的问题。而现代的 Javascript 垃圾回收机制是标记清除法。

为了方便判断通过 value 去获取对应的节点,在关联完父 → 子后将树结构打平,得到一维数组,代码如下:

export function flattenTree(root: TreeNode[]): TreeNode[] {
  const res: TreeNode[] = []

  function dfs(nodes: TreeNode[], parent: TreeNode | null = null) {
    // ...
    for (let i = 0; i < nodes.length; i++) {
      const node = nodes[i]
      const { children } = node

      const newNode = { ...node, parent }

      res.push(newNode)
      if (children) { dfs(children, newNode) }
    }
    // ...
  }
  dfs(root)

  return res
}

Check

当用户点击某一级的 Checkbox,需要向上递归遍历所有直接父节点,判断其子节点是否都为 checked 状态。如果是,切换 value 为父节点的 value,并删除其所有子节点 value。

当前 value
['深圳市', '天河区', '荔湾区']

点击勾选”萝岗区” 之后,需要加到 value 中
['深圳市', '天河区', '荔湾区', '萝岗区']

向上遍历,来到’广州市‘发现所有子节点都被选中了,删除所有子节点 value,加入自身 value。
['深圳市', '广州市']

继续往上判断,来到广东省也是所有子节点都被选中了,删除所有子节点 value,加入自身 value。
['广东省']

由于没有更上一层了,向上遍历中止

代码如下:

// 状态提升
export function liftTreeState(
  item: TreeNode,
  curVal: ValueType[]
): ValueType[] {
  const { value } = item

  // 加入当前节点 value
  const nextValue = curVal.concat(value)
  let last = item

  // eslint-disable-next-line no-constant-condition
  while (true) {
    // 如果父节点的所有子节点都已经 checked, 添加该节点 value,继续尝试提升
    if (
      last?.parent?.children!.every((child: TreeNode) => nextValue.includes(child.value))
    ) {
      nextValue.push(last.parent.value)
      last = last.parent
    } else {
      break
    }
  }
  // 移除最后一个满足 checked 的父节点的所有子孙节点 value
  return removeAllDescendanceValue(last, nextValue)
}

UnCheck

当用户点击 Uncheck 时,逻辑基本是 Check 操作的反向处理。

取消勾选”荔湾区” 之后,先从该节点一路往上遍历,一直到当前 value 中的父节点,并暂时保存这条路径 parentPath ['荔湾区', '广州市', '广东省']

再从"广东省"一路向下遍历,如果当前节点在 parentPath 上直接丢弃,如果不在则需要将其放到 nextValue 中。

当前 value
['广东省', '湖南省']

广东省在 parentPath,需要丢弃。
['湖南省']

继续向下递归,深圳市不在 parentPath 上,需要加到 value 中,不在 parentPath 上,子节点可以不再遍历。
['湖南省', '深圳市']

广州市在 parentPath 上,不在 value 中,继续递归。
['湖南省', '深圳市']

荔湾区在 parentPath 上,不在 value 中,直接忽略,天河区和萝岗区不在 parentPath 上,需要加入到 value。
['湖南省', '深圳市', '天河区', '萝岗区']

没有更深的节点了,遍历中止

代码如下:

// 状态下沉
export function sinkTreeState(root: TreeNode, value: ValueType[]): ValueType[] {
  const parentValues: ValueType[] = []
  const subTreeValues: ValueType[] = []

  // 获取 parentPath
  function getCheckedParent(
    node: TreeNode | null | undefined
  ): TreeNode | null {
    if (!node) {
      return null
    }
    parentValues.push(node.value)
    if (value.includes(node.value)) {
      return node
    }

    return getCheckedParent(node.parent)
  }

  const checkedParent = getCheckedParent(root)
  if (!checkedParent) {
    return value
  }
  
  // 递归遍历所有子节点
  function dfs(node: TreeNode) {
    if (!node.children || node.value === root.value) {
      return
    }
    node.children.forEach((item: TreeNode) => {
      if (item.value !== root.value) {
        if (parentValues.includes(item.value)) {
          dfs(item)
        } else {
          subTreeValues.push(item.value)
        }
      }
    })
  }
  dfs(checkedParent)

  // 替换 checkedParent 下子树的值
  const nextValue = removeAllDescendanceValue(checkedParent, value).filter(
    (item) => item !== checkedParent.value
  )
  return Array.from(new Set(nextValue.concat(subTreeValues)))
}

Connected Checkbox

前面提到了我们只保存 Show_Parent 的值,部分选中的状态和子节点选中的状态是在运行时计算出来的。计算规则如下:

  • 某个子节点是否为 checked 状态 ⇒ 自己或本身的被 checked
  • 某个父节点是否为 indeterminate 状态 ⇒ 非 checked 状态,并且有部分子节点被 checked
  • 某个子节点是否为 unchecked 状态 ⇒ 默认状态
export const ConnectedCheckbox = React.memo(
  (props: Pick<MenuItemProps, 'node'>) => {
    const { node } = props
    const { value: containerValue, handleSelectChange } = MultiCascader.useContainer()

    const handleChange = useCallback(
      (event: CheckboxChangeEvent) => {
        const { checked } = event.target
        handleSelectChange(node, checked)
      },
      [node]
    )
    // 自己或父节点为 checked
    const checked = useMemo(() => hasParentChecked(node, containerValue), [
      containerValue,
      node,
    ])
    // 自己没有 checked,但是有子节点状态 checked
    const indeterminate = useMemo(
      () => !checked && hasChildChecked(node, containerValue),
      [checked, containerValue, node]
    )

    return (
      <Checkbox
        onChange={handleChange}
        checked={checked}
        indeterminate={indeterminate}
      />
    )
  }
)

hasParentCheckedhasChildChecked 方法就是简单的向上或向下遍历,代码比较简单这里就省略了。

为了方便组件内状态的管理(代码中的 MultiCascader.useContainer),我引入了 unstated-next 这个库,背后还是使用 Hooks + React Context,不过代码更简洁,Typescript 支持友好。

全选功能

有了以上的级联 Checkbox 联动之后,在继续实现【全选】的逻辑也变得非常简单。只需在原来的 data 至上再增加一个 All 节点,将改节点和 Footer 上的 All Checkbox 绑定即可。当第一级的所有节点都被选中之后,会自动沿 parent 向上提升 value。

const flattenData = useMemo(() => {
  // 如果需要支持全选,在原来的 data 之上添加一个 TreeNode 节点
  if (selectAll) {
    return flattenTree([
      {
        title: 'All',
        value: All,
        parent: null,
        children: data,
      },
    ])
  }
  return flattenTree(data || [])
}, [data, selectAll])

// 如果需要支持全选,在 Footer 渲染 ConnectedCheckbox,赋予 All 节点
{selectAll ? (
  <div className={`${prefix}-popup-all`}>
    <ConnectedCheckbox node={flattenData[0]} />
    &nbsp;&nbsp;{selectAllText}
  </div>
) : null}

级联菜单

默认情况展开菜单,需要列出所有第一级的父节点。如果支持全选,直接取第一个 flattenData 的 children,否则遍历一遍 flattenData ,找到所有没有 parent 的 children。

const [menuData, setMenuData] = useState([
  selectAll
    ? flattenData[0].children!
    : flattenData.filter((item) => !item.parent),
])

由于每个非叶子节点都保存了 children 的数据,级联菜单其实就是在父节点被点击之后,将其 children 添加到维护级联状态的数组中。

const addMenu = useCallback((menu: TreeNode[], index: number) => {
  if (menu && menu.length) {
    setMenuData((prevMenuData) => [...prevMenuData.slice(0, index), menu])
  } else {
    // 如果 children 也要更新 menu
    // 比如当前展开了三级菜单,点击了另一个二级叶子节点
    setMenuData((prevMenuData) => [...prevMenuData.slice(0, index)])
  }
}, [])

到这里,整个级联多选的核心逻辑已经开发完毕,剩下的一些细碎的逻辑不在这里展开,感兴趣的同学可以 github 上看源码。

下一个部分介绍如何将用 Typescript 开发的 React 组件发布到 NPM。

Typescript NPM Package

我们的组件的代码是用 Typescript 编写的,然而用户不一定使用 Typescript,也不一定会编译 node_modules 下的文件,所以发布到 NPM 上的代码应该是 JS 的形式。

Typescript 项目,首先需要安装 typescript 和 tslib 两个包。

$ yarn add typescript tslib -D

修改 package.json,添加 tsc script 和 main 字段。

main 字段是指定当别人使用这个包时的入口文件。原来的 Typescript 代码入口是在 src/index.tsx 。
dev 里执行了 link 操作,这样可以在本地项目中先进行验证。tsc watch 可以再每次代码变更时立马重新编译。
prepublishOnly 的作用是每次准备发布前重新编译 Typescript。

"main": "dist/index.js",
"scripts": {
  "dev": "yarn link && yarn tsc --watch",
  "tsc": "tsc",
  "prepublishOnly": "rm -rf dist/ && npm run tsc"
},

在根目录下添加 tsconfig.json 文件,指定输出路径,是否生成声明文件,执行 jsx 等等。declaration 字段设为 true,在编译时,Typescript 会自动生成 d.ts 文件。提供这些声明文件,在 VSCode 等编辑器中就可以有代码联想提示的功能。

{
  "compilerOptions": {
        //...
    "outDir": "dist",
        // ...
    "declaration": true,
        // ...
    "jsx": "react"
  },
  "include": ["./src/**/*"]
}

Less 文件怎么办?

组件中的样式文件使用了 less 来编写,同样的不能要求使用这个包的用户也必须用 less,我们需要提供一份 css 文件(Typescript 是没办法处理 less 文件的)。

// 在 less 文件中引入 antd 自带的文件,以便使用其提供的变量
@import '../node_modules/antd/es/style/themes/default.less';

@prefix: ~'antd-multi-cascader';

.@{prefix} {
  text-align: left;

  &-hidden {
    display: none;
  }

  //...
}

安装 less

$ yarn add -D less

回到 package.json 文件,添加 lessc script,指定将 src/index.less 编译到 dist/index.less。 —js 参数是因为引用了 antd 的 dfault.less 文件,里面会用到了 inline javascript 的功能。

"scripts": {
    "compile": "yarn tsc && yarn lessc",
    "tsc": "tsc",
    "lessc": "lessc src/index.less dist/index.css --js",
    "prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
  },

发布到 npm 之后,就可以在项目中应用组件和样式文件了。✨

import MultiCascader from "antd-multi-cascader";
import "antd-multi-cascader/dist/index.css";

单元测试和 Github Actions

单元测试是成本较低却能有效保障代码质量的方法,除了能帮你找出代码实际运行和预期不符的问题,还是排查问题的好工具。一般而言,单元测试覆盖率越高,更容易获得其他开发者的认可。

在前端领域,我们可以为方法,模块,甚至一个组件编写单元测试。编写和运行单元测试首先需要安装单元测试框架,以 facebook 的 jest 为例。

yarn add -D jest @types/jest ts-jest

项目根目录下添加 jest.config.js 文件,让 jest 知道如何解析,匹配哪些单元测试文件。

module.exports = {
  preset: 'ts-jest',
  testMatch: ['<rootDir>/src/**/__tests__/*.tsx'],
  collectCoverageFrom: ['src/**/*.{ts,tsx}'],
}

然后就可以编写单元测试代码了。全局的 describe 方法声明一个单元测试分组,在这个组内可以定义单元测试生命周期的钩子,before, after, beforeEach, afterEach 等等,在这些钩子中可以为跑单元测试造数据。

it 方法则是具体某个用例执行的地方,会用到 jest 提供的 expect 方法,这个方法接受一个参数,然后返回一个具有很多断言方法的对象。调用这些断言方法即可起到验证预期执行的效果。比如下面的例子中断言 hasChildChecked(flattenValue[0], ['1']) 的返回值将为 true。给定输入,验证方法允许结果,如果下次不小心改坏了这个方法,单元测试就可以立即告诉你,及时修复避免雪球越滚越大。

import { hasChildChecked } from '..'

describe('src/components/MultiCascader/utils.tsx', () => {
  describe('hasChildChecked', () => {
    let flattenValue: TreeNode[]

    beforeEach(() => {
      flattenValue = createFlattenTree()
    })

    it('should tell has child checked or not', () => {
      expect(hasChildChecked(flattenValue[0], ['1'])).toEqual(true)
    })
  })
})
  • 在这个组件中,存在很多🌲 操作相关的纯方法,非常写单元测试。如果你想为 React 组件编写单元测试,推荐使用 https://testing-library.com/

编写完单元测试,在 package.json scripts 中添加 test 命令,同时在 prepublishOnly 前也加上 test 任务,这样每次打包前都能跑一遍单元测试。

"scripts": {
  "test": "jest",
  "prepublishOnly": "yarn test && rm -rf dist/ && npm run compile"
},

在持续集成的过程中,我们可以使用 Github Actions,Gitlab CI,Travis CI 等工具来跑单元测试、lint,sonarqube 等任务,保证代码提交质量。

使用 Github Actions 很简单,只需要在项目根目录创建 .github/workflows/test.yml 文件。复制一下代码,然后在每次提交代码和 pr 时都会启动一个 node 服务来运行单元测试了。

name: Test

on: [push, pull_request]

jobs:
  release:
    runs-on: ${{ matrix.os }}

    strategy:
      matrix:
        os: [macos-10.14]

    steps:
      - uses: actions/checkout@v2
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '12.x'
      - run: npm install
      - run: npm run test -- --coverage

想要获得展示 Coverage 的 Badge?,登录 https://codecov.io/gh,选择对应仓库,将页面上展示的 CODECOV_TOKEN 填到 Github 项目 Settings-Secrets 页面上,然后把以下的代码补充到 test.yml 文件中。

- name: Upload coverage to Codecov
        uses: codecov/codecov-action@v1
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          flags: unittests
          name: codecov-umbrella
          fail_ci_if_error: true

然后,就可以通过 https://img.shields.io/codecov/c/github/[user]/[project]/master.svg 链接拿到 badge 啦。🎉

结语

相信阅读完本文之后,你也可以动手开发一个级联多选选择器了。如果阅读过程中有疑问欢迎留言讨论。

作为满足产品需求的最小实现,本文内容仅供参考,在实际项目中使用还需慎重,因为至少还存在以下缺陷。

  • 数据量大时的性能要求,笔者在项目中的数据量非常小,编码的时候没有过多考虑性能问题。数据量很大的时候需要上 Virtual List
  • 不支持搜索,动态加载菜单内容,不支持自定义 render
  • 没有经过严格测试,使用过程中参数变化可能会带来预期之外的结果
  • 没有支持按键操作,Web 无障碍等
关于Checkbox 🌲 状态联动部分的实现,参考另一个非常棒的开源组件库 rsuitejs - multi-cascader 的源码。由于样式、生态以及为了灵活应对项目需求,最终没有选择它,而是自行实现。

最后,所有代码都在 github 上可以找到,如果你想了解更多细节可以点过去👀 欢迎 star

有同样需求的同学,也欢迎试用,使用过程中有问题也欢迎提 issue~

// npm
$ npm install antd-multi-cascader
// yarn
$ yarn add antd-multi-cascader

链接

阅读 1.8k
13 声望
1 粉丝
0 条评论
你知道吗?

13 声望
1 粉丝
宣传栏