antd 的展开收起计算太耗时导致渲染卡顿, 可能是兼容的东西更多
https://github.com/bricksjs/ui/commit/3bb3d2bffb7dc27bd17dbd7b84d55835ac90ecef
结合 useResizeRect
import React, { Fragment, useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { isEqual, isObject, toNumber } from 'lodash';
import { Tooltip } from 'antd';
import type { TooltipProps } from 'antd/lib/tooltip';
import CopyOutlined from '@ant-design/icons/CopyOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
import copy from 'copy-to-clipboard';
import useResizeRect from '../hooks/useResizeRect';
import useSetState from '../hooks/useSetState';
import usePrevious from '../hooks/usePrevious';
import HighLightWord from '../HighLightWord';
import type { HighlightProps } from '../HighLightWord';
import { getPrefixCls } from '../utils';
import './index.less';
/** 展开配置 */
export type ExpandableType = {
/** 展开时节点 */
openText?: React.ReactNode;
/** 收起时节点 */
closeText?: React.ReactNode;
};
/** 复制操作 */
interface CopyConfig {
text?: string;
onCopy?: () => void;
// icon?: React.ReactNode;
// tooltips?: boolean | React.ReactNode;
}
/** 记录当前文本 & 操作: 文本 | 展开操作 | 后缀 | 复制 */
type MapStateTypes = 'data' | 'action' | 'suffix' | 'copyable';
export interface ExpandIProps {
className?: string;
style?: React.CSSProperties;
/** 展示仅支持字符串模式 */
data: string;
/** 后缀 */
suffix?: string | false;
high?: Omit<HighlightProps, 'text'>;
/** 复制操作 */
copyable?: boolean | CopyConfig;
/** Tooltip */
tip?: boolean | TooltipProps;
/** 是否可展开 */
expandable?: boolean | ExpandableType | ((opened: boolean) => string); // React.ReactNode 暂仅支持字符串
prefix?: string;
/** 多行 */
lines?: number;
/** 超过长度隐藏 */
count?: number;
/** 默认设置的 React 节点 */
renderContent?: (dom: string | JSX.Element, texts: string) => JSX.Element;
}
const DEFAULT_ACTION_TEXTS: ExpandableType = {
openText: '展开',
closeText: '收起',
};
/** 展开收起文案 */
const getActionContent = (expandable: ExpandIProps['expandable'], open: boolean) => {
if (typeof expandable === 'boolean' && expandable) {
return open ? DEFAULT_ACTION_TEXTS.closeText : DEFAULT_ACTION_TEXTS.openText;
}
/** 自定义函数渲染 */
if (typeof expandable === 'function') {
return expandable(open);
}
if (isObject(expandable)) {
const actionTexts = { ...DEFAULT_ACTION_TEXTS, ...expandable };
return open ? actionTexts.closeText : actionTexts.openText;
}
return null;
};
const Expand = React.memo<ExpandIProps>(
(props) => {
const {
tip,
style,
high,
className,
data = '',
copyable,
lines = 1,
expandable = true,
suffix = '...',
renderContent,
prefix: customizePrefixCls,
} = props;
const prefixCls = getPrefixCls('expand', customizePrefixCls);
/** 默认为收起状态 */
const [map, setMap] = useState(
new Map<MapStateTypes, any>([
['data', data],
['action', null],
['suffix', '...'],
['copyable', null],
]),
);
/** 控制文本长度 */
const [sizes, setSizes] = useSetState({
/** 允许显示文本的长度 */
allow: 0,
/** 文本总长度 */
total: 0,
});
const [texts, setTexts] = useState('');
/** 复制操作 */
const [copies, setCopies] = useSetState({
copied: false,
title: '复制',
});
/** 定时关闭复制操作 */
const copyRef = useRef<number>();
/** 是否有操作及后缀: 允许长度大于总长度时 不展示后缀 和操作 */
const allowActionAndSuffix = useMemo(() => sizes.allow > sizes.total, [JSON.stringify(sizes)]);
const [open, setOpen] = useState(false);
/** 指定外层容器 */
const [containerRef, rect] = useResizeRect<HTMLDivElement>();
/** 计算内容文本 */
useEffect(() => {
if (containerRef.current) {
const textLen = data.length;
const fontSize = toNumber(
window.getComputedStyle(containerRef.current).fontSize?.replace('px', ''),
);
const containerWidth = rect.width || containerRef.current.getBoundingClientRect().width;
/** 计算可容纳的字符长度 */
const allowSize = containerWidth / fontSize;
const lineSize = allowSize * lines;
setSizes({
allow: lineSize,
total: textLen,
});
/** 大于总长度时 直接显示文本 */
if (allowSize > textLen) {
setTexts(data);
return;
}
if (allowSize <= textLen && !open) {
const sliceText = data.slice(0, lineSize - 6);
setTexts(sliceText);
}
}
}, [containerRef, rect, data, lines]);
/** 更新操作文案 */
useEffect(() => {
/** 如果 false */
if (!expandable || allowActionAndSuffix) {
map.set('action', null);
} else {
const actiondom = (
<a
className={`${prefixCls}-action`}
onClick={() => {
setOpen((prevOpen) => !prevOpen);
}}
>
{getActionContent(expandable, open)}
</a>
);
map.set('action', actiondom);
}
setMap(new Map(map));
}, [open, setOpen, expandable, allowActionAndSuffix]);
const onCopy = useCallback(
(evt: any) => {
evt.preventDefault();
clearTimeout(copyRef.current);
const copyConfig: CopyConfig = {
...(typeof copyable === 'object' ? copyable : null),
};
if (copyConfig.text === undefined) {
copyConfig.text = data ?? '';
}
const copied = copy(copyConfig.text ?? '');
setCopies({ copied });
/** 复制返回值 */
if (copied) {
copyConfig?.onCopy?.();
} else {
console.error('复制错了');
}
copyRef.current = window.setTimeout(() => {
setCopies({ copied: false });
}, 2000);
},
[map, data, setCopies],
);
useEffect(() => {
if (copyable) {
const copyCls = copies.copied
? `${prefixCls}-copy-success ${prefixCls}-copy`
: `${prefixCls}-copy`;
map.set(
'copyable',
<Tooltip title={copies.copied ? '复制成功' : '复制'}>
<span className={copyCls} onClick={onCopy}>
{!copies.copied ? <CopyOutlined /> : <CheckOutlined />}
</span>
</Tooltip>,
);
} else {
map.set('copyable', null);
}
setMap(new Map(map));
}, [copyable, copies, prefixCls]);
/** 更新后缀 */
useEffect(() => {
map.set(
'suffix',
open || allowActionAndSuffix ? null : (
<span className={`${prefixCls}-suffix`}>{suffix}</span>
),
);
setMap(new Map(map));
}, [allowActionAndSuffix, suffix, prefixCls, open]);
const prevTexts = usePrevious(texts);
useEffect(() => {
const getDataDom = (str: string) => {
const baseDom = isObject(high) ? <HighLightWord {...high} text={str} /> : str;
if (tip && !open) {
const tipConfig = isObject(tip) ? tip : {};
return (
<Tooltip
getPopupContainer={() => containerRef.current}
placement="topLeft"
title={data}
{...tipConfig}
>
<span>{baseDom}</span>
</Tooltip>
);
}
return baseDom;
};
const fillStr = open ? data : texts;
let dom = getDataDom(fillStr);
if (typeof renderContent === 'function') {
/** @todo: 若返回操作后的字符比原来的长, 其他操作将失效 */
dom = renderContent(dom, fillStr);
}
map.set('data', dom);
setMap(new Map(map));
}, [texts, prevTexts, open, data, tip, high, renderContent]);
useEffect(() => {
return () => {
clearTimeout(copyRef.current);
};
}, []);
const cls = useMemo(
() => (prefixCls ? prefixCls : `${prefixCls} ${className}`),
[className, prefixCls],
);
return (
<div ref={containerRef} style={style} className={cls} title={tip ? null : data}>
{Array.from(map).map(([key, value]) => {
return <Fragment key={key}>{value}</Fragment>;
})}
</div>
);
},
(prev, next) => isEqual(prev, next),
);
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;
}
}
}
参考
- webkit-line-clamp: css 实现多行省略
- 或者使用 before after 定位方案