封装一个用户体验非常棒的下拉选择框

前言

我们公司系统用的ui框架是antd,antd默认的下拉框只能显示名称,但是有些数据名称可能重复,比如说人名,这时候用户在选择的时候,希望看到更多的信息来区分,比如说工号或角色。

基于上面情况我们系统封装了两套组件,下面和大家分享一下。

表单实现

背景

因为我们系统使用的是antd,下拉框大部分场景都是配合Form组件使用。前段时间解决bug,看了一下antd form的源码,这里我自己简单实现一下Form组件,先让大家了解一下Form原理。

看一下上面的代码,很多新手可能会感到好奇,Input组件加了一个FormItem后,怎么就把值设置到了Form表单里,还能通过form实例获取到Input的值,下面我们来实现一下。

实现思路

Form组件核心是在FormItem组件使用了React.cloneElement对子组件进行克隆,并且可以对组件实例注入属性。

FormItem组件就是把onChange方法注入到子组件的属性里,然后拦截Input组件onChange事件,最后在方法里把值收集保存到form组件中。

FormItem实现

tsx 复制代码
import React from 'react';
import { FormContext } from './context';
import { useMemo } from 'react';

function FormItem({ label, name, children }) {

  // 从全局上下文中获取form已经收集的值和设置值的方法
  const { values, setValues } = React.useContext(FormContext);

  const childNodes = useMemo(() => React.Children.map(children, (child) => {
    return React.cloneElement(child, {
      ...child.props,
      onChange: (value) => {
        setValues({
          ...values,
          [name]:  value.target.value,
        })
      },
      value: values[name]
    })
  }), [children, values])


  return (
    <>
      <div>{label}:</div>
      <div>{childNodes}</div>
    </>
  )
}

export default FormItem;

Form组件实现

tsx 复制代码
import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';

import { FormContext } from './context';


function Form({ children, onValuesChange }, ref) {

  const [values, setValues] = useState({});

  useEffect(() => {
    onValuesChange && onValuesChange(values);
  }, [values]);


  // 对外暴露getValues和setValues方法
  useImperativeHandle(ref, () => {
    return {
      getValues: () => {
        return values;
      },
      setValues: (values) => {
        setValues(values)
      }
    };
  }, [values]);


  return (
    <FormContext.Provider value={{ values, setValues }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
        {children}
      </div>
    </FormContext.Provider>
  )
}


export default forwardRef(Form);

小结

从上面代码可以看出,如果我们自己想封装一个可以被form-it包裹的表单元素组件,只需要对外暴露onChange事件和value属性就行了。

基础版本

背景

为了解决上面问题,我们产品最开始设计了一套选择方案,用户点击下拉框,会弹出一个弹框,弹框里放一个表格,因为表格可以展示更多的信息,然后用户找到想要的数据后,点击一下行就行了。

封装通用表格组件

根据上面内容,首先封装一个表格组件,antd的表格组件功能很强大,但是用起来比较麻烦,接口请求、分页、搜索都要自己单独实现,为了方便使用,把这些东西封装成一个组件。

表格组件

jsx 复制代码
import React, { useState, useImperativeHandle, forwardRef, useEffect, useMemo, useRef } from "react";
import { Table, Alert, Space } from 'antd'
import queryString from 'query-string';

import TableEllipsisCell from './table-ellipsis-cell';

function BaseTable({
  fetchDataHandle,
  url,
  method = 'GET',
  dataSource: dataSourceProps,
  allowChecked,
  onCheckedChange,
  selectedRows,
  ...rest
}, ref) {

  const [dataSource, setDataSource] = useState(dataSourceProps);
  const [loading, setLoading] = useState(true);
  const [pagination, setPagination] = useState({});
  const [searchParams, setSearchParams] = useState({});

  // 存放id和数据之间的映射关系,可以通过id快速获取到当前行数据。 ex: {1: {id: 1, name: '2}}
  const dataMap = useRef({});

  // 当前选中的行改变
  const onSelectChange = (selectedRowKeys) => {
    if (onCheckedChange) {
      // 通过id把当前行信息暴露出去
      onCheckedChange(selectedRowKeys.map(key => dataMap.current[key]).filter(o => o));
    }
  }

  const [rowSelection, setRowSelection] = useState(() => ({
    selectedRowKeys: [],
    onChange: onSelectChange,
    columnWidth: 40,
    preserveSelectedRowKeys: true,
    fixed: true,
  }));

  // 对外暴露搜索方法
  useImperativeHandle(ref, () => {
    return {
      search: async (params) => {
        setPagination(prev => ({
          ...prev,
          current: 1,
        }));
        setSearchParams(params);
      },
    }
  }, []);

  // 获取数据,这里支持两种获取数据方式,一个是注入请求方法,还有一个是通过url和请求方式在组件内部去请求数据。
  const getData = async (pagination, searchParams) => {

    let requestMethod = fetchDataHandle;

    if (!requestMethod && url) {
      requestMethod = async (params) => {
        return await window.fetch([url, queryString.stringify(params)].join(url.includes('?') ? '&' : '?'), {
          method,
        })
          .then(res => res.json())
          .then(data => {
            return data;
          });
      }
    }

    if (requestMethod && dataSourceProps === undefined) {
      setLoading(true);
      const { list, total } = await requestMethod({
        pageSize: pagination.pageSize,
        page: pagination.current,
        ...searchParams,
      })
        .then(data => data)
        .catch(() => null);

      if (list) {
        setDataSource(list);
        setLoading(false);
        setPagination({
          ...pagination,
          total,
        });
      }
    } else if (dataSourceProps) {
      setLoading(false);
    }
  }

  // 当分页信息或搜索参数发生变化时,设置分页信息
  const tableChange = (pagination) => {
    setPagination(pagination);
  }

  // 当选中的行发生变化时,设置选中的行
  useEffect(() => {
    if (allowChecked) {
      setRowSelection(prev => ({
        ...prev,
        selectedRowKeys: (selectedRows || []).map(o => o[rest.rowKey || 'id']),
      }));
    }
  }, [selectedRows]);

  useEffect(() => {
    // 当分页信息或搜索参数发生变化时,重新获取数据
    getData(pagination, searchParams);
  }, [
    pagination.current,
    pagination.pageSize,
    searchParams,
  ]);

  // 当数据源发生变化时,更新数据映射
  useEffect(() => {
    dataMap.current = {
      ...dataMap.current,
      ...(dataSource || []).reduce((prev, cur) => {
        prev[cur[rest.rowKey]] = cur;
        return prev;
      }, {})
    }
  }, [dataSource]);


  // 自定义单元格,当文本超出宽度时,显示省略号,并且显示tooltip
  const columns = useMemo(() => {
    return (rest.columns || []).map(item => {

      if (!item.ellipsis) return item;

      return {
        ...item,
        render: (text, record, index) => (
          <TableEllipsisCell value={item.render ? item.render(text, record, index) : text} />
        )
      }
    })
  }, [rest.columns])

  return (
    <Space direction='vertical'>
      {allowChecked && (
        <Alert
          message={`已选择 ${rowSelection.selectedRowKeys.length} 项,共 ${pagination?.total || 0} 项`}
          action={!!rowSelection.selectedRowKeys.length && (
            <a
              onClick={() => {
                onSelectChange([]);
              }}
            >
              取消选择
            </a>
          )}
        />
      )}
      <Table
        {...rest}
        columns={columns}
        rowSelection={allowChecked && rowSelection}
        dataSource={dataSource}
        loading={loading}
        pagination={pagination}
        onChange={tableChange}
      />
    </Space>
  )
}

export default forwardRef(BaseTable);

组件很简单,可以看下代码里的注释。有个点需要注意一下,antd table支持文字超出显示省略号,但是没有显示Tooltip,我这里封装了单元格,支持文本超出宽度显示省略号并且鼠标移动到文字上会显示Tooltip。

省略号单元格组件

jsx 复制代码
import React from "react";
import { Tooltip } from 'antd'
import { useRef, useState } from 'react';

function TableEllipsisCell({ value }) {

  const boxRef = useRef();

  const [open, setOpen] = useState(false);

  // 当文本超出宽度时,显示tooltip
  function onOpenChange(flag) {
    if (flag) {
      // 判断文本是否超出
      if (boxRef.current.offsetWidth < boxRef.current.scrollWidth) {
        setOpen(true);
      }
    }
    else {
      setOpen(false);
    }
  }

  return (
    <Tooltip open={open} onOpenChange={onOpenChange} title={value}>
      <div
        ref={boxRef}
        style={{
          width: '100%',
          overflow: 'hidden',
          textOverflow: 'ellipsis',
          whiteSpace: 'nowrap',
        }}
      >
        {value}
      </div>
    </Tooltip>
  )
}

export default TableEllipsisCell;

原理就是在气泡显示的时候,判断一下文本是否超长,如果超长就显示。

封装搜索表单组件

jsx 复制代码
import React from "react";
import { Form, Input, Button, Row, Col, Space } from 'antd'

function SearchForm({ items, onSearch }) {

  const [form] = Form.useForm();

  function onFinish(values) {
    onSearch && onSearch(values)
  }

  function renderFormELement() {
    // 这里可以根据其他类型,动态显示不同表单元素。
    return (
      <Input />
    )
  }

  return (
    <Form form={form} onFinish={onFinish}>
      <Row gutter={20}>
        {(items || []).map(item => (
          <Col key={item.name} span={8}>
            <Form.Item label={item.label} name={item.name}>
              {renderFormELement()}
            </Form.Item>
          </Col>
        ))}
        <Col span={8}>
          <Space style={{ marginBottom: 24 }}>
            <Button
              htmlType='submit'
              type="primary"
            >
              搜索
            </Button>
            <Button
              onClick={() => {
                form.resetFields();
                form.submit();
              }}
            >
              重置
            </Button>
          </Space>
        </Col>
      </Row>
    </Form>
  )
}

export default SearchForm;

封装带搜索的表格组件

jsx 复制代码
import React, { useRef, useMemo } from "react";
import BaseTable from './table';
import SearchForm from './search-form';

function ProTable({
  columns,
  allowChecked,
  ...tableProps
}) {

  const tableRef = useRef();

  // 从表格里中获取搜索项
  const searchFormItems = useMemo(() => {
    return (columns || [])
      .filter(item => item.search)
      .map(item => ({
        name: item.dataIndex,
        label: item.title
      }));
  }, [columns]);

  return (
    <>
      <SearchForm
        onSearch={(values) => {
          tableRef.current.search(values);
        }}
        items={searchFormItems}
      />
      <BaseTable
        {...tableProps}
        ref={tableRef}
        allowChecked={allowChecked}
        columns={columns}
      />
    </>
  )
}

export default ProTable;

测试

故意给手机号列设置短一点,为了测试显示气泡功能。

对接下拉框

上面我们实现了表格,下面我们来实现我们想要的下拉框。

这个组件的需求是,点击下拉框组件,然后弹出一个可以选择的表格弹框,选中表格的值后,在下拉框中展示。

下拉框组件

直接上代码,具体可以看一下代码中的注释

jsx 复制代码
import React, { useState } from "react";
import { Select, Tooltip } from 'antd'
import { TableOutlined } from '@ant-design/icons'

import TableSelectModal from './table-select-modal';


function TableSelect({
  onChange,
  value,
  mode,
  tableColumns,
  labelKey = 'label',
  rowKey = 'id',
  modalTitle,
  url,
  tableFetchDataHandle,
  tableProps,
}) {

  const [open, setOpen] = useState(false);

  // 把当前选中的值转换为select格式的值
  function getValue() {
    if (mode === 'multiple') {
      return value?.map(item => ({ value: item?.[rowKey], label: item?.[labelKey] }));
    } else {
      return { value: value?.[rowKey], label: value?.[labelKey] };
    }
  }

  return (
    <>
      <Select
        // 永远不要显示下拉内容
        open={false}
        // 加一个图标,用来区分标准下拉框
        suffixIcon={
          <TableOutlined
            style={{ color: '#000' }}
            onClick={() => {
              setOpen(true);
            }}
          />
        }
        value={getValue()}
        onClick={() => {
          // 点击下拉框会弹出弹框 
          setOpen(true);
        }}
        // 模式,支持单选和多选
        mode={mode}
        onDeselect={(v) => {
          // 通过标签移除某项值
          if (mode === 'multiple') {
            onChange(value.filter(item => item[rowKey] !== v.value));
          }
        }}
        labelInValue
        maxTagCount="responsive"
        maxTagPlaceholder={(omittedValues) => {
          // 用Tooltip显示多余的值
          return (
            <Tooltip title={omittedValues.map(o => o.label).join(',')}>
              <div>+ {omittedValues.length}</div>
            </Tooltip>
          )
        }}
      />
      <TableSelectModal
        rowKey={rowKey}
        open={open}
        setOpen={setOpen}
        mode={mode}
        onChange={onChange}
        value={value}
        columns={tableColumns}
        modalTitle={modalTitle}
        fetchDataHandle={tableFetchDataHandle}
        url={url}
        tableProps={tableProps}
      />
    </>
  )
}


export default TableSelect;

弹框组件实现

jsx 复制代码
import React, { useState, useEffect } from "react";
import { Modal } from 'antd'

import ProTable from '../pro-table';

function TableSelectModal({
  open,
  setOpen,
  mode,
  onChange = () => {},
  value,
  columns,
  fetchDataHandle,
  modalTitle,
  url,
  tableProps,
  rowKey,
  modalWidth = 920,
}) {

  const [selectedRows, setSelectedRows] = useState(value || []);

  const [show, setShow] = useState(false);

  useEffect(() => {
    if (open) {
      setSelectedRows(value);
    }
  }, [open]);

  return (
    <Modal
      onCancel={() => {
        setOpen(false);
      }}
      title={modalTitle}
      open={open}
      width={modalWidth}
      styles={{
        body: {
          padding: '20px 0 0 0',
          minHeight: 300,
        },
        content: {
          paddingBottom: mode !== 'multiple' && 0,
        }
      }}
      footer={mode === 'multiple' ? undefined : false}
      onOk={() => {
        if (mode === 'multiple') {
          onChange(selectedRows);
        }
        setOpen(false);
      }}
      afterOpenChange={(flag) => {
        setShow(flag);
      }}
    >
      // 动画没结束前不渲染表格
      {show && (
        <ProTable
          {...tableProps}
          size="small"
          url={url}
          allowChecked={mode === 'multiple'}
          fetchDataHandle={fetchDataHandle}
          rowClassName={record => {
            return mode !== 'multiple' && record[rowKey] === value?.[rowKey] ? 'ant-table-row-selected' : '';
          }}
          onCheckedChange={(_selectedRows) => {
            setSelectedRows(_selectedRows)
          }}
          selectedRows={selectedRows}
          rowKey={rowKey}
          onRow={(record) => {
            return {
              onClick: () => {
                if (mode !== 'multiple') {
                  onChange(record);
                  setOpen(false);
                }
              }
            }
          }}
          columns={columns}
          scroll={{ y: mode === 'multiple' ? 240 : 300 }}
          virtual
        />
      )}
    </Modal>
  )
}

export default TableSelectModal;

这里我发现了antd的一个bug,如果弹框中嵌入表格,并且表格开启了虚拟滚动的情况下,滚动条会有bug。这里我没有去看源码具体怎么出现的,但是我猜测是因为弹框的动画导致表格在开启虚拟滚动的情况下计算宽度和高度出现了问题,弹框显示动画过程中,弹框的高度和宽度是一直变化的,我曾经在弹框中嵌入codemirror代码编辑器时,也遇到了宽度问题。

在新版本中Modal组件加了个afterOpenChange弹框动画结束事件,我们可以在动画结束后再渲染内容。

以前没有这个事件的时候,可以用定时器来模拟动画结束事件。

测试

动画里姓名对不上是因为表格里的数据是mock的假数据,每次请求都不一样,但是id是固定的,都是从1开始的,所以出现了设置的默认值的姓名和表格里的姓名不一样的情况。

进阶版本

前言

上面版本用户用了一段时间后,觉得选值有点麻烦,操作步骤不多,但是鼠标位移有点多,基于这个问题,我们开发一个升级版本,在下拉框里嵌入表格,因为下拉框的空间比较小,把分页组件和搜索组件去除了,改为滚动到底部自动加载下一页,搜索改为使用antd原生下拉框搜索框去搜索。

封装可滚动表格

实现思路是在表格数据最后,插入一个loading元素,使用IntersectionObserver判断loading是否出现在视口,如果出现在视口,说明已经滚动到底部了,然后去加载下一页数据就行了。

先封装一个判断某个元素是否出现在视口的hooks,代码比较简单,我就不解释了,主要使用了IntersectionObserver这个api。

jsx 复制代码
import { useState, useEffect, useMemo } from 'react';

const useIntersectionObserver = (domRef) => {
  const [intersecting, setIntersecting] = useState(false);

  const intersectionObserver = useMemo(() =>
    new IntersectionObserver((entries) => {
      setIntersecting(entries.some(item => item.isIntersecting));
    }),
    [],
  );

  useEffect(() => {
    return () => {
      intersectionObserver.disconnect();
    };
  }, []);

  useEffect(() => {
    if (domRef.current) {
      intersectionObserver.observe(domRef.current);
    }
  }, [domRef.current]);

  return {
    intersecting,
    disconnect: () => {
      intersectionObserver.disconnect();
    }
  };
};

export default useIntersectionObserver;

表格组件和前面表格组件代码差不多,就把分页改成了下拉加载数据。

jsx 复制代码
import React, { useState, useImperativeHandle, forwardRef, useEffect, useMemo, useRef } from "react";
import { Table, Spin } from 'antd'
import queryString from 'query-string';

import TableEllipsisCell from './table-ellipsis-cell';
import useIntersectionObserver from '../useIntersectionObserver';

function ScrollTable({
  fetchDataHandle,
  url,
  method = 'GET',
  dataSource: dataSourceProps,
  allowChecked,
  onCheckedChange,
  selectedRows,
  summaryWidth,
  hiddenLoading,
  ...rest
}, ref) {

  const [dataSource, setDataSource] = useState(dataSourceProps);
  const [loading, setLoading] = useState(true);
  const [pagination, setPagination] = useState({});
  const [searchParams, setSearchParams] = useState({});

  const [hasMore, setHasMore] = useState(true);

  const loadingRef = useRef();

  const { intersecting, disconnect } = useIntersectionObserver(loadingRef);

  // 存放id和数据之间的映射关系 ex: {1: {id: 1, name: '2}}
  const dataMap = useRef({});

  // 当前选中的行改变
  const onSelectChange = (selectedRowKeys) => {
    if (onCheckedChange) {
      // 通过id把当前行信息暴露出去
      onCheckedChange(selectedRowKeys.map(key => dataMap.current[key]).filter(o => o));
    }
  }

  const [rowSelection, setRowSelection] = useState(() => ({
    selectedRowKeys: [],
    onChange: onSelectChange,
    columnWidth: 40,
    preserveSelectedRowKeys: true,
    fixed: true,
  }));

  // 对外暴露搜索方法
  useImperativeHandle(ref, () => {
    return {
      search: async (params) => {
        setSearchParams(params);
        setDataSource([]);
        setPagination(prev => ({
          ...prev,
          total: 0,
          current: 1,
        }));
      },
    }
  }, []);

  // 获取数据,这里支持两种获取数据方式,一个是注入请求方法,还有一个是通过url和请求方式在组件内部去请求数据。
  const getData = async (pagination, searchParams) => {
    let requestMethod = fetchDataHandle;

    if (!requestMethod && url) {
      requestMethod = async (params) => {
        return await window.fetch([url, queryString.stringify(params)].join(url.includes('?') ? '&' : '?'), {
          method,
        })
          .then(res => res.json())
          .then(data => {
            return data;
          });
      }
    }

    if (requestMethod && dataSourceProps === undefined) {
      setLoading(true);
      const { list, total } = await requestMethod({
        pageSize: pagination.pageSize,
        page: pagination.current,
        ...searchParams,
      })
        .then(data => data)
        .catch(() => null);

      if (list) {
        const newDataSource = [...dataSource || [], ...list]

        setDataSource(newDataSource);
        setLoading(false);
        setPagination({
          ...pagination,
          total,
        });

        setHasMore(total > newDataSource.length);

        // 没有更多数据了
        if (total <= newDataSource.length) {
          disconnect();
        }
      }
    } else if (dataSourceProps) {
      setLoading(false);
    }
  }

  // 当分页信息或搜索参数发生变化时,设置分页信息
  const tableChange = (pagination) => {
    setPagination(pagination);
  }

  // 当选中的行发生变化时,设置选中的行
  useEffect(() => {
    if (allowChecked) {
      setRowSelection(prev => ({
        ...prev,
        selectedRowKeys: (selectedRows || []).map(o => o[rest.rowKey || 'id']),
      }));
    }
  }, [selectedRows]);

  useEffect(() => {
    // 当分页信息或搜索参数发生变化时,重新获取数据
    getData(pagination, searchParams);
  }, [
    pagination.current,
    pagination.pageSize,
    searchParams,
  ]);

  // 当数据源发生变化时,更新数据映射
  useEffect(() => {
    dataMap.current = {
      ...dataMap.current,
      ...(dataSource || []).reduce((prev, cur) => {
        prev[cur[rest.rowKey]] = cur;
        return prev;
      }, {})
    }
  }, [dataSource]);

  // 自定义单元格,当文本超出宽度时,显示省略号,并且显示tooltip
  const columns = useMemo(() => {
    return (rest.columns || []).map(item => {
      if (!item.ellipsis) return item;
      return {
        ...item,
        render: (text, record, index) => (
          <TableEllipsisCell value={item.render ? item.render(text, record, index) : text} />
        )
      }
    })
  }, [rest.columns]);

  useEffect(() => {
    if (intersecting) {
      setPagination(prev => ({
        ...prev,
        current: (prev.current || 1) + 1,
      }));
    }
  }, [intersecting]);

  return (
    <Table
      {...rest}
      columns={columns}
      rowSelection={allowChecked && rowSelection}
      dataSource={dataSource}
      loading={loading}
      pagination={false}
      onChange={tableChange}
      virtual={false}
      tableLayout='fixed'
      summary={() => (
        <Table.Summary.Row>
          {pagination?.total > 0 && !hiddenLoading && <div
            style={{ width: summaryWidth - 23, padding: '10px 0', textAlign: 'center' }}
            ref={loadingRef}
          >
            {hasMore ? <Spin /> : '暂无更多数据'}
          </div>}
        </Table.Summary.Row>
      )}
    />
  )
}

export default forwardRef(ScrollTable);

这里使用了antd table的summary属性,把一个元素添加到最后,当这个元素出现的时候,就去加载下一页的数据。

这里有个坑要注意一下,表格渲染的时候,summary总是先渲染,这时候因为数据行还没渲染,导致loading出现在视口中,第一次加载还好,可以用total去判断,数据没加载完之前,不渲染loading。但是我们要实现下拉框关闭再打开还能看到前面已经加载过的数据,这时候表格会重新渲染,和第一次加载一样,summary还是先渲染,这时候已经没办法用total去判断了,只能加一个控制loading显示和隐藏的属性,在表格组件第二次加载的时候,用定时器控制loading延迟渲染。

这里面还有一个坑,select组件dropdownRender返回的组件,在隐藏的情况下改变组件的属性,组件里使用useEffect监听不到变化,这个很神奇,有时间去看一下源码。

下拉框组件

jsx 复制代码
import React, { useState, useRef } from "react";
import { Select, Tooltip } from 'antd'

import TableSelectRenderer from './table-select-renderer';

function ScrollTableSelect({
  onChange,
  value,
  mode,
  tableColumns,
  labelKey = 'label',
  rowKey = 'id',
  modalTitle,
  url,
  tableFetchDataHandle,
  tableProps,
}) {

  const [open, setOpen] = useState(false);
  const [searchValue, setSearchValue] = useState('');

  const selectBoxRef = useRef();
  const tableRef = useRef();

  // 把当前选中的值转换为select格式的值
  function getValue() {
    if (mode === 'multiple') {
      return value?.map(item => ({ value: item?.[rowKey], label: item?.[labelKey] }));
    } else {
      return { value: value?.[rowKey], label: value?.[labelKey] };
    }
  }

  return (
    <div ref={selectBoxRef}>
      <Select
        onDropdownVisibleChange={flag => {
          setOpen(flag);
          tableRef.current?.setHidden(!flag);
        }}
        onSearch={setSearchValue}
        searchValue={searchValue}
        showSearch
        open={open}
        style={{ width: 300 }}
        dropdownRender={() => (
          <TableSelectRenderer
            rowKey={rowKey}
            setOpen={setOpen}
            mode={mode}
            onChange={onChange}
            value={value}
            columns={tableColumns}
            modalTitle={modalTitle}
            fetchDataHandle={tableFetchDataHandle}
            url={url}
            tableProps={tableProps}
            selectBoxRef={selectBoxRef}
            searchValue={searchValue}
            ref={tableRef}
          />
        )}
        value={getValue()}
        mode={mode}
        onDeselect={(v) => {
          // 通过标签移除某项值
          if (mode === 'multiple') {
            onChange(value.filter(item => item[rowKey] !== v.value));
          }
        }}
        labelInValue
        maxTagCount="responsive"
        maxTagPlaceholder={(omittedValues) => {
          // 用Tooltip显示多余的值
          return (
            <Tooltip title={omittedValues.map(o => o.label).join(',')}>
              <div>+ {omittedValues.length}</div>
            </Tooltip>
          )
        }}
      />
    </div>
  )
}


export default ScrollTableSelect;

上面说的就是这一块代码,在TableSelectRenderer组件里,竟然监听不到某个属性的变化,所以只能使用ref,调用组件内部方法,把当前下拉框显示或隐藏状态传给下拉框里的组件。

TableSelectRenderer

jsx 复制代码
import React, { useState, useEffect, useImperativeHandle, forwardRef, useRef, useMemo } from "react";
import { useUpdateEffect } from 'ahooks'

import ScrollTable from '../scroll-table';

function TableSelectRenderer({
  setOpen,
  mode,
  onChange = () => { },
  value,
  columns,
  fetchDataHandle,
  url,
  tableProps,
  rowKey,
  selectBoxRef,
  searchValue,
  searchKey = 'keywords'
}, ref) {

  const [hidden, setHidden] = useState(false);
  const [selectedRows, setSelectedRows] = useState(value || []);

  const tableRef = useRef();

  useEffect(() => {
    setSelectedRows(value);
  }, [value]);

  useUpdateEffect(() => {
    tableRef.current?.search({ [searchKey]: searchValue });
  }, [searchValue]);

  const width = useMemo(() => {
    if (selectBoxRef.current) {
      return selectBoxRef.current.querySelector('.ant-select')?.offsetWidth;
    }
    return 0;
  }, [selectBoxRef.current]);

  useImperativeHandle(ref, () => {
    return {
      // 延迟显示表格的loading
      setHidden: (flag) => {
        if (!flag) {
          setTimeout(() => {
            setHidden(false);
          }, 100);
        } else {
          setHidden(true);
        }
      },
    }
  }, []);

  return (
    <ScrollTable
      {...tableProps}
      ref={tableRef}
      size="small"
      url={url}
      allowChecked={mode === 'multiple'}
      fetchDataHandle={fetchDataHandle}
      rowClassName={record => {
        return mode !== 'multiple' && record[rowKey] === value?.[rowKey] ? 'ant-table-row-selected' : '';
      }}
      onCheckedChange={(_selectedRows) => {
        onChange(_selectedRows)
      }}
      selectedRows={selectedRows}
      rowKey={rowKey}
      onRow={(record) => {
        return {
          onClick: () => {
            if (mode !== 'multiple') {
              onChange(record);
              setOpen(false);
            }
          }
        }
      }}
      columns={columns}
      scroll={{ y: 300 }}
      virtual
      summaryWidth={width}
      hiddenLoading={hidden}
    />
  )
}

export default forwardRef(TableSelectRenderer);

效果展示

小结

上面为大家提供了一个封装组件的思路,和封装这个组件踩过的一些坑,因为是公司组件,不能拿来开源,大家如果有兴趣可以在上面代码基础上给完善一下。还没结束,下面给大家分享如何使用dumi写组件文档。

使用dumi写组件文档

按照官方教程初始化一个项目

sh 复制代码
# 先找个地方建个空目录。
$ mkdir myapp && cd myapp


# 通过官方工具创建项目,选择你需要的模板
$ npx create-dumi


# 选择一个模板
$ ? Pick template type › - Use arrow-keys. Return to submit.
$ ❯   Static Site # 用于构建网站
$     React Library # 用于构建组件库,有组件例子
$     Theme Package # 主题包开发脚手架,用于开发主题包


# 安装依赖后启动项目
$ npm start

上面选择模板选择组件库

安装完依赖,启动项目,可以看到下面页面

安装主题

我不喜欢这个主题,比较喜欢vite的主题,dumi支持安装其它主题。

执行下面命令安装主题

sh 复制代码
pnpm i dumi-theme-vite -D

安装后,重新启动一下项目,访问页面

这样就好看多了,dumi还支持其它主题,可以到这里查看,选择一个自己喜欢的主题。d.umijs.org/theme/marke...

写组件文档

把首页改造一下,改造一下导航栏

js 复制代码
// .dumirc.ts
import { defineConfig } from 'dumi';

export default defineConfig({
  outputPath: 'docs-dist',
  themeConfig: {
    name: 'fluxy-components',
    footer: false,
    nav: {
      'zh-CN': [{ title: '组件', link: '/components' }],
      'en-US': [{ title: 'Components', link: '/en-US/components' }],
    },
  },
});
md 复制代码
// docs/index.md
---
title: fluxy-component | 业务组件库

hero:
  title: fluxy-component
  text: 业务组件库 
  tagline: 让开发变得简单
  actions:
    - text: Get Started
      link: /components
  image:

features:
  - title: hello
    emoji: 🚀
    description: fluxy-component业务组件库让开发变得简单
---

组件和组件文档写在src目录下,已经有了个例子Foo,index.md表示组件文档,index.tsx表示组件。

可以在md里面写组件例子,这个非常好用。

把我们组件迁移过来,虽然dumi推荐组件说使用tsx去写,但是也支持jsx。

在index.ts文件里,把我们组件按照上面格式导出。

编写组件index.md

重新启动一下项目,可以看到组件例子和组件文档都展示出来了

告诉大家一个技巧,如果不知道怎么写文档,可以参考antd的组件文档。

最后

由于篇幅有限,组件发布成npm和部署组件文档网站,这个后面再和大家分享。

相关推荐
却尘14 分钟前
Next.js 请求最佳实践 - vercel 2026一月发布指南
前端·react.js·next.js
ccnocare15 分钟前
浅浅看一下设计模式
前端
Lee川19 分钟前
🎬 从标签到屏幕:揭秘现代网页构建与适配之道
前端·面试
Ticnix1 小时前
ECharts初始化、销毁、resize 适配组件封装(含完整封装代码)
前端·echarts
纯爱掌门人1 小时前
终焉轮回里,藏着 AI 与人类的答案
前端·人工智能·aigc
twl1 小时前
OpenClaw 深度技术解析
前端
崔庆才丨静觅1 小时前
比官方便宜一半以上!Grok API 申请及使用
前端
星光不问赶路人1 小时前
vue3使用jsx语法详解
前端·vue.js
天蓝色的鱼鱼1 小时前
shadcn/ui,给你一个真正可控的UI组件库
前端
布列瑟农的星空1 小时前
前端都能看懂的Rust入门教程(三)——控制流语句
前端·后端·rust