antd 的展开收起计算太耗时导致渲染卡顿, 可能是兼容的东西更多
https://github.com/bricksjs/ui/commit/3bb3d2bffb7dc27bd17dbd7b84d55835ac90ecef

结合 useResizeRect

  1. import React, { Fragment, useCallback, useEffect, useMemo, useState, useRef } from 'react';
  2. import { isEqual, isObject, toNumber } from 'lodash';
  3. import { Tooltip } from 'antd';
  4. import type { TooltipProps } from 'antd/lib/tooltip';
  5. import CopyOutlined from '@ant-design/icons/CopyOutlined';
  6. import CheckOutlined from '@ant-design/icons/CheckOutlined';
  7. import copy from 'copy-to-clipboard';
  8. import useResizeRect from '../hooks/useResizeRect';
  9. import useSetState from '../hooks/useSetState';
  10. import usePrevious from '../hooks/usePrevious';
  11. import HighLightWord from '../HighLightWord';
  12. import type { HighlightProps } from '../HighLightWord';
  13. import { getPrefixCls } from '../utils';
  14. import './index.less';
  15. /** 展开配置 */
  16. export type ExpandableType = {
  17. /** 展开时节点 */
  18. openText?: React.ReactNode;
  19. /** 收起时节点 */
  20. closeText?: React.ReactNode;
  21. };
  22. /** 复制操作 */
  23. interface CopyConfig {
  24. text?: string;
  25. onCopy?: () => void;
  26. // icon?: React.ReactNode;
  27. // tooltips?: boolean | React.ReactNode;
  28. }
  29. /** 记录当前文本 & 操作: 文本 | 展开操作 | 后缀 | 复制 */
  30. type MapStateTypes = 'data' | 'action' | 'suffix' | 'copyable';
  31. export interface ExpandIProps {
  32. className?: string;
  33. style?: React.CSSProperties;
  34. /** 展示仅支持字符串模式 */
  35. data: string;
  36. /** 后缀 */
  37. suffix?: string | false;
  38. high?: Omit<HighlightProps, 'text'>;
  39. /** 复制操作 */
  40. copyable?: boolean | CopyConfig;
  41. /** Tooltip */
  42. tip?: boolean | TooltipProps;
  43. /** 是否可展开 */
  44. expandable?: boolean | ExpandableType | ((opened: boolean) => string); // React.ReactNode 暂仅支持字符串
  45. prefix?: string;
  46. /** 多行 */
  47. lines?: number;
  48. /** 超过长度隐藏 */
  49. count?: number;
  50. /** 默认设置的 React 节点 */
  51. renderContent?: (dom: string | JSX.Element, texts: string) => JSX.Element;
  52. }
  53. const DEFAULT_ACTION_TEXTS: ExpandableType = {
  54. openText: '展开',
  55. closeText: '收起',
  56. };
  57. /** 展开收起文案 */
  58. const getActionContent = (expandable: ExpandIProps['expandable'], open: boolean) => {
  59. if (typeof expandable === 'boolean' && expandable) {
  60. return open ? DEFAULT_ACTION_TEXTS.closeText : DEFAULT_ACTION_TEXTS.openText;
  61. }
  62. /** 自定义函数渲染 */
  63. if (typeof expandable === 'function') {
  64. return expandable(open);
  65. }
  66. if (isObject(expandable)) {
  67. const actionTexts = { ...DEFAULT_ACTION_TEXTS, ...expandable };
  68. return open ? actionTexts.closeText : actionTexts.openText;
  69. }
  70. return null;
  71. };
  72. const Expand = React.memo<ExpandIProps>(
  73. (props) => {
  74. const {
  75. tip,
  76. style,
  77. high,
  78. className,
  79. data = '',
  80. copyable,
  81. lines = 1,
  82. expandable = true,
  83. suffix = '...',
  84. renderContent,
  85. prefix: customizePrefixCls,
  86. } = props;
  87. const prefixCls = getPrefixCls('expand', customizePrefixCls);
  88. /** 默认为收起状态 */
  89. const [map, setMap] = useState(
  90. new Map<MapStateTypes, any>([
  91. ['data', data],
  92. ['action', null],
  93. ['suffix', '...'],
  94. ['copyable', null],
  95. ]),
  96. );
  97. /** 控制文本长度 */
  98. const [sizes, setSizes] = useSetState({
  99. /** 允许显示文本的长度 */
  100. allow: 0,
  101. /** 文本总长度 */
  102. total: 0,
  103. });
  104. const [texts, setTexts] = useState('');
  105. /** 复制操作 */
  106. const [copies, setCopies] = useSetState({
  107. copied: false,
  108. title: '复制',
  109. });
  110. /** 定时关闭复制操作 */
  111. const copyRef = useRef<number>();
  112. /** 是否有操作及后缀: 允许长度大于总长度时 不展示后缀 和操作 */
  113. const allowActionAndSuffix = useMemo(() => sizes.allow > sizes.total, [JSON.stringify(sizes)]);
  114. const [open, setOpen] = useState(false);
  115. /** 指定外层容器 */
  116. const [containerRef, rect] = useResizeRect<HTMLDivElement>();
  117. /** 计算内容文本 */
  118. useEffect(() => {
  119. if (containerRef.current) {
  120. const textLen = data.length;
  121. const fontSize = toNumber(
  122. window.getComputedStyle(containerRef.current).fontSize?.replace('px', ''),
  123. );
  124. const containerWidth = rect.width || containerRef.current.getBoundingClientRect().width;
  125. /** 计算可容纳的字符长度 */
  126. const allowSize = containerWidth / fontSize;
  127. const lineSize = allowSize * lines;
  128. setSizes({
  129. allow: lineSize,
  130. total: textLen,
  131. });
  132. /** 大于总长度时 直接显示文本 */
  133. if (allowSize > textLen) {
  134. setTexts(data);
  135. return;
  136. }
  137. if (allowSize <= textLen && !open) {
  138. const sliceText = data.slice(0, lineSize - 6);
  139. setTexts(sliceText);
  140. }
  141. }
  142. }, [containerRef, rect, data, lines]);
  143. /** 更新操作文案 */
  144. useEffect(() => {
  145. /** 如果 false */
  146. if (!expandable || allowActionAndSuffix) {
  147. map.set('action', null);
  148. } else {
  149. const actiondom = (
  150. <a
  151. className={`${prefixCls}-action`}
  152. onClick={() => {
  153. setOpen((prevOpen) => !prevOpen);
  154. }}
  155. >
  156. {getActionContent(expandable, open)}
  157. </a>
  158. );
  159. map.set('action', actiondom);
  160. }
  161. setMap(new Map(map));
  162. }, [open, setOpen, expandable, allowActionAndSuffix]);
  163. const onCopy = useCallback(
  164. (evt: any) => {
  165. evt.preventDefault();
  166. clearTimeout(copyRef.current);
  167. const copyConfig: CopyConfig = {
  168. ...(typeof copyable === 'object' ? copyable : null),
  169. };
  170. if (copyConfig.text === undefined) {
  171. copyConfig.text = data ?? '';
  172. }
  173. const copied = copy(copyConfig.text ?? '');
  174. setCopies({ copied });
  175. /** 复制返回值 */
  176. if (copied) {
  177. copyConfig?.onCopy?.();
  178. } else {
  179. console.error('复制错了');
  180. }
  181. copyRef.current = window.setTimeout(() => {
  182. setCopies({ copied: false });
  183. }, 2000);
  184. },
  185. [map, data, setCopies],
  186. );
  187. useEffect(() => {
  188. if (copyable) {
  189. const copyCls = copies.copied
  190. ? `${prefixCls}-copy-success ${prefixCls}-copy`
  191. : `${prefixCls}-copy`;
  192. map.set(
  193. 'copyable',
  194. <Tooltip title={copies.copied ? '复制成功' : '复制'}>
  195. <span className={copyCls} onClick={onCopy}>
  196. {!copies.copied ? <CopyOutlined /> : <CheckOutlined />}
  197. </span>
  198. </Tooltip>,
  199. );
  200. } else {
  201. map.set('copyable', null);
  202. }
  203. setMap(new Map(map));
  204. }, [copyable, copies, prefixCls]);
  205. /** 更新后缀 */
  206. useEffect(() => {
  207. map.set(
  208. 'suffix',
  209. open || allowActionAndSuffix ? null : (
  210. <span className={`${prefixCls}-suffix`}>{suffix}</span>
  211. ),
  212. );
  213. setMap(new Map(map));
  214. }, [allowActionAndSuffix, suffix, prefixCls, open]);
  215. const prevTexts = usePrevious(texts);
  216. useEffect(() => {
  217. const getDataDom = (str: string) => {
  218. const baseDom = isObject(high) ? <HighLightWord {...high} text={str} /> : str;
  219. if (tip && !open) {
  220. const tipConfig = isObject(tip) ? tip : {};
  221. return (
  222. <Tooltip
  223. getPopupContainer={() => containerRef.current}
  224. placement="topLeft"
  225. title={data}
  226. {...tipConfig}
  227. >
  228. <span>{baseDom}</span>
  229. </Tooltip>
  230. );
  231. }
  232. return baseDom;
  233. };
  234. const fillStr = open ? data : texts;
  235. let dom = getDataDom(fillStr);
  236. if (typeof renderContent === 'function') {
  237. /** @todo: 若返回操作后的字符比原来的长, 其他操作将失效 */
  238. dom = renderContent(dom, fillStr);
  239. }
  240. map.set('data', dom);
  241. setMap(new Map(map));
  242. }, [texts, prevTexts, open, data, tip, high, renderContent]);
  243. useEffect(() => {
  244. return () => {
  245. clearTimeout(copyRef.current);
  246. };
  247. }, []);
  248. const cls = useMemo(
  249. () => (prefixCls ? prefixCls : `${prefixCls} ${className}`),
  250. [className, prefixCls],
  251. );
  252. return (
  253. <div ref={containerRef} style={style} className={cls} title={tip ? null : data}>
  254. {Array.from(map).map(([key, value]) => {
  255. return <Fragment key={key}>{value}</Fragment>;
  256. })}
  257. </div>
  258. );
  259. },
  260. (prev, next) => isEqual(prev, next),
  261. );
  262. export default Expand;
@import '../style/themes/index.less';

@tsz-expand-prefix-cls: ~'@{tsz-prefix}-expand';
@tsz-expand-copy-prefix-cls: ~'@{tsz-expand-prefix-cls}-copy';

.@{tsz-expand-prefix-cls} {
  &-action {
    color: #1890ff;
    cursor: pointer;
  }

  .@{tsz-expand-copy-prefix-cls} {
    margin-left: 4px;
    color: #1890ff;
    cursor: pointer;
    &-success {
      color: #52c41a;
    }
  }
}

参考