前言
前一阵子通过bpmn.js
实现了流程设计器(先挖个坑,以后填😋),从而让咱公司的自研平台有了自己的审批流。正式上线后用了很久,老板和客户都很满意。
⚠️但是最近客户提了一个要求⚠️
有时候他在查看一些财务类型的审批时,期望能够一键复制审批详情的内容,留存下来,便于后续开内部会议时,作为文档内容的参考。
审批详情的文字内容很多,每次都要用鼠标手动一行行圈住再复制,并且还有可能复制漏了,很痛苦😫,因此他期望我们能够优化一下这一块。
那客户都亲自提要求了,而且还这么难得的晓之以理,动之以情,咱们研发还能拒绝吗?
于是我让产品匆忙跟了一个需求文档,和主管以及测试同学信息同步了一下,快速排期完毕后,便直接动手。
(再怎么样,也得按照研发流程来,这样才能保证稳定的项目周期,利好你我他❤️)
省流
使用Ant Design的
Typography.Paragraphy
组件,开启copyable
,秒了。
等会等会,这样玩这文章就没得写啦!!!
本文标题带着【源码阅读】,但是其实直接看源码,然后分析它为什么这么写,我个人感觉这样的方式给不了掘友一个比较舒服的阅读体验(比如本人的第一篇作品:【源码阅读】【万字长文预警】🔍水印保卫战 ),因为这样是“先射箭再画靶”。
(不过那篇文章里关于useClips
的分析还是挺精彩的,值得一看捏🥺)
举个例子,这就和以前读书的时候,遇到水平不高的代课老师来讲试卷,听着他对着参考答案反推题目的解题步骤一样,实在是太无聊了,大概率也听不进去。
不对啊,这个例子这么一举,我不就成了水平不高的代课老师了?(不是)
因此我就想,我写一篇【源码阅读】的文章,我先尝试一下自己能够想到的方案,然后再一步一步去优化方案,最后再和参考答案(源码)做个比较,这样是不是就更能体会到参考答案(源码)的巧妙性了?
如果你也想试着体验一下完全不同的【源码阅读】文章,烦请继续往下看~
造轮子,哐哧哐哧
不用键盘的Ctrl + C 和鼠标的右键复制,咋实现【复制】啊?
绝了,真是太绝了,平常总把自己是“CV工程师”挂在嘴边的人,这下连CV都不会了。
那么在开始实现文本复制组件之前,我们先来了解一下操作系统是怎么实现【复制】和【粘贴】的。
我们只需要点一下【复制】和【粘贴】的按钮就可以了,而操作系统要考虑的就多了
在Windows操作系统中,当我们使用鼠标右键点击【复制】来复制一段文本时,系统会通过以下几个步骤来处理这个操作:
-
【复制】操作:
- 当我们选中一段文本并点击右键选择【复制】时,操作系统会将选中的文本传递给剪贴板。
- 剪贴板是操作系统提供的一块缓冲区,用于临时存储复制或剪贴的数据。
- 传递到剪贴板的数据不仅仅是文本本身,还包括了文本的格式信息,如字体、颜色、样式等(如果是在支持富文本的应用程序中复制的话)。
- 不同于早期的剪贴板只能存储一定数量的字符,现代操作系统中的剪贴板可以存储大量的数据,包括图片、文件等。
-
保存到剪贴板:
- 选中内容被复制到剪贴板后,操作系统会将其保存在内存中。
- 这意味着剪贴板的内容在电源关闭或系统崩溃时会丢失,因为它并没有被永久地保存到硬盘上。
-
【粘贴】操作:
- 当我们点击鼠标右键选择【粘贴】或在编辑区域使用键盘快捷键(通常是Ctrl+V)时,操作系统会从剪贴板中读取数据。
- 读取的数据会被插入到当前光标所在的位置。
- 如果剪贴板中的数据包含格式信息,这些信息也会被应用到粘贴的文本上,以保持原有的样式。
OK,这下我们就可以确定大致的设计思路了:
- 我们要实现的这个组件,首先它要能够自动选中全部文本。
- 选中后,要触发一个类似【复制】的事件,提醒操作系统将选中的文本传递到剪切板上。
组件结构
先瞄一眼antd
的实现,分析一下结构,帮忙起个头。
-
复制前
-
复制后
可以看到,用于展示文本的组件在开启【复制】相关的API后,会在文本结尾处暴露一个可点击的Icon
,点击Icon
之后,组件提示我们“复制成功”,于是我们进行粘贴操作,会发现文本确实被成功复制了,可以正常粘贴。
This is a copyable text.
通过上面对该组件的实际使用,再结合我们在开头的设计思路,我们可以得出如下结论:
- 【复制】是通过点击事件触发的,此点击事件会通过某些手段捕获当前展示文本组件中的文本内容
- 捕获文本(即自动选中文本)后,再通过某个第三方库,提醒操作系统将此文本传递到剪切板上。
结构图
捕获当前组件内的内容
如何能够捕获到当前组件内的文本内容?
- 给此组件开一个
textContent
的API,用户将期望展示的文本内容通过此API传入此组件。而后,我们在组件内部,通过props
拿到textContent
即可,这样即可实现捕获当前组件内的文本内容。- 使用方式:
<CopiableParagraph textContent="一段展示用的文本内容"/>
- 内部获取:
export type CopiableParagraphProps = { textContent: string; }; export function CopiableParagraph({ textContent }: CopiableParagraphProps) { console.log('textContent', textContent); return <>{textContent}</>; }
- 使用方式:
- 给此组件开一个
children
的API,使用时将CopiableParagraph
包裹住需要展示的文本内容。同样,在我们的组件内部,通过props
拿到children
,这样也能够实现捕获当前组件内的文本内容。- 使用方式:
<CopiableParagraph>"一段展示用的文本内容"</CopiableParagraph>
- 内部获取:
export type CopiableParagraphProps = { children: React.ReactNode; }; export function CopiableParagraph({ children }: CopiableParagraphProps) { console.log('children', children); return <>{children}</>; }
- 使用方式:
那么新的问题又来了,选哪种方式实现呢?以及为什么这么选呢?
分析讨论
这篇文章的本质是【源码阅读】,因此,遇到这种选择的岔路口时,我们先看看antd的
Typography
组件 是如何选择的。
根据【组件结构】这一节的内容,我们期望实现的组件是类似Typography.Text
、Typography.Paragraph
的。因此我们可以先简单看下实现这类组件的源码。
使用
当我们访问antd的官网时,可以看到Typography.Paragraph
的使用方式这样的
<Typography.Paragraph>
In the process of internal desktop applications development, many different design specs and
implementations would be involved, which might cause designers and developers difficulties and
duplication and reduce the efficiency of development.
</Typography.Paragraph>
不难发现,它是采用了上面我们提到的第2种方案,即给此组件开一个children
的API,使用时将Typography.Paragraph
包裹住需要展示的文本内容。
接下来,咱们就围绕它为什么这么选这个问题,一起来看看它的源码。
组件导出
先看下此组件的导出方式,这里头的门道也不小哦。
import Link from './Link';
import Paragraph from './Paragraph';
import Text from './Text';
import Title from './Title';
import OriginTypography from './Typography';
export type TypographyProps = typeof OriginTypography & {
Text: typeof Text;
Link: typeof Link;
Title: typeof Title;
Paragraph: typeof Paragraph;
};
const Typography = OriginTypography as TypographyProps;
Typography.Text = Text;
Typography.Link = Link;
Typography.Title = Title;
Typography.Paragraph = Paragraph;
export default Typography;
为什么antd要这么设计
Typography
组件?
这样实现组件的好处主要体现在以下几个方面:
- 组件的统一性和一致性:通过将相关的组件组合在一起,并作为一个统一的
Typography
组件导出,可以确保在项目中使用这些组件时风格和接口的一致性。这有助于提高开发效率和减少错误。 - 易于导入和使用:开发者只需要从一个地方导入
Typography
,就可以访问到所有的子组件(如Text
、Link
、Title
、Paragraph
),而不需要单独导入每个组件。 - 清晰的组件结构:这种实现方式使得组件的结构非常清晰,开发者可以很容易地理解每个组件的作用和关系。
- 扩展性和维护性:如果以后需要添加新的组件或修改现有组件,这种结构使得修改和扩展变得更加容易,因为所有的组件都在一个地方统一管理。
- 类型安全:使用TypeScript的
typeof
和接口继承,可以确保Typography
组件的类型是正确的,这有助于在编译时捕获潜在的错误。
除此之外,我们也可以从设计模式的角度进行分析
-
这种实现方式采用了组合模式(Composite Pattern) 。组合模式是一种结构型设计模式,它允许客户端以统一的方式处理单个对象和对象的组合。在这个例子中,
Typography
组件是一个组合,它包含了多个子组件(Text
、Link
、Title
、Paragraph
),并且这些子组件可以被当作一个整体来使用。 -
这种实现方式体现了工厂模式(Factory Pattern) 的思想,因为
Typography
组件充当了一个工厂,用于创建和管理一组相关的组件。开发者可以通过Typography
组件来创建所需的子组件,而不需要直接与具体的子组件类交互。
层次图
Typography.Paragraph
接下来回到正题,我们来看下Typography.Paragraph
的代码。首先Paragraph.tsx
文件内部的实现是很简单的,
-
render:直接return一个
Base
组件export interface ParagraphProps extends BlockProps<'div'>, Omit<React.HTMLAttributes<HTMLDivElement>, 'type' | keyof BlockProps<'div'>> {} const Paragraph = React.forwardRef<HTMLElement, ParagraphProps>((props, ref) => ( <Base ref={ref} {...props} component="div" /> )); export default Paragraph;
-
props:可以看到
ParagraphProps
继承了BlockProps
,我们临时看下定义BlockProps
的代码:export interface BlockProps<C extends keyof JSX.IntrinsicElements = keyof JSX.IntrinsicElements> extends TypographyProps<C> { title?: string; editable?: boolean | EditConfig; copyable?: boolean | CopyConfig; type?: BaseType; disabled?: boolean; ellipsis?: boolean | EllipsisConfig; // decorations code?: boolean; mark?: boolean; underline?: boolean; delete?: boolean; strong?: boolean; keyboard?: boolean; italic?: boolean; }
我们发现,
BlockProps
实际上又继承了TypographyProps
,我们追根溯源,再来看看定义TypographyProps
的代码,export interface TypographyProps<C extends keyof JSX.IntrinsicElements> extends React.HTMLAttributes<HTMLElement> { id?: string; prefixCls?: string; className?: string; rootClassName?: string; style?: React.CSSProperties; children?: React.ReactNode; /** @internal */ component?: C; ['aria-label']?: string; direction?: DirectionType; }
我们看到了我们所期望的
children
字段
Base
-
render:可以看到
Base
组件实现的功能还是挺多的,比如:多行文本溢出省略、Tooltip展示文本完整内容,监听子组件大小变化(若发生变化,则调用onResize)等等,不展开详细的分析了(再聊就跑题了)。
Typography
通过上方的Base
组件render部分的代码,我们可以看到它内部包含了Typography
这个组件。
- render:通过这部分代码,我们可以知道
Typography
的主要功能是将多个样式对象合并,然后将合并后的样式赋给Component组件,最后把Component传给wrapCSSVar
函数(wrapCSSVar
主要用于注册CSS变量,而后给组件注入样式,这里出于篇幅考虑,简单带过)。const mergedStyle: React.CSSProperties = { ...typography?.style, ...style }; return wrapCSSVar( // @ts-expect-error: Expression produces a union type that is too complex to represent. <Component className={componentClassName} style={mergedStyle} ref={mergedRef} {...restProps}> {children} </Component>, );
分析
经过上面的源码阅读,我们大致了解了Typography
组件的设计以及具体的功能。那么接下来,针对它具备的功能,我们聊一聊以children
的形式实现的好处都有哪些。
- 灵活性:使用
children
可以包含任何React元素,不仅仅是文本。这意味着我们可以传递如<span>
,<strong>
,<em>
等丰富的内容,而不仅仅是字符串。这为内容展示提供了更高的灵活性(比如展示某段文本时,加粗显示一些内容)。 - CSS样式继承:使用
children
可以直接应用外部的CSS样式,而不需要额外的属性传递。这可以方便样式的管理和维护(这一点我们从Typography
的源码可见一斑)。 - 易于集成:通过包裹内容,组件可以更容易地与其他组件或功能集成。
- 内容保留:使用
children
可以保留内容的原始格式和结构,这对于保持HTML原有的样式和语义非常重要。 - 统一性:在React社区中,使用
children
来传递内容是一种常见的模式。遵循这种模式可以使组件更容易被其他开发者理解和采用。 - 嵌套组件:使用
children
可以允许嵌套其他React组件,使得组件更加通用和可复用,我们从上面的源码中可以发现Typography
组件存在非常多的嵌套场合。
结论
我们选择第2种实现方式,即给此组件开一个children
的API,使用时将CopiableParagraph
包裹住需要展示的文本内容。
技术选型
根据【组件结构】这一节的内容,我们不难发现,所谓的实现文本内容的【复制】,本质上是传递文本至操作系统剪切板。
我们要在网页上实现将文本传递至操作系统剪切板的操作,可以先了解下相关的Web API
。
window.clipboardData
:这是一个IE特有的API,专门用于在Internet Explorer中操作剪贴板。 它允许开发者直接设置剪贴板中的数据,而不需要先选中页面上的文本。这个方法可以设置不同格式的数据,例如text/plain、text/html等,这意味着它可以用来复制富文本。由于这个API是IE特有的,它不适用于其他非IE浏览器,包括Microsoft Edge(基于Chromium的新版本)。execCommand
:document
暴露execCommand
方法,该方法允许运行命令来操纵可编辑内容区域的元素。例如,document.execCommand('copy')
可以触发复制操作。-
已弃用: 不再推荐使用该特性。虽然一些浏览器仍然支持它,但也许已从相关的 web 标准中移除,也许正准备移除或出于兼容性而保留。请尽量不要使用该特性,并更新现有的代码。——MDN
copy
:拷贝当前选中内容到剪贴板。启用这个功能的条件因浏览器不同而不同,而且不同时期,其启用条件也不尽相同。使用之前请检查浏览器兼容表,以确定是否可用。- 一个使用
document.execCommand('copy')
的👉Demo👈,有兴趣的同学可以尝试一下
-
Clipboard API
:剪贴板 Clipboard API 提供了响应剪贴板命令(剪切、复制和粘贴)与异步读写系统剪贴板的能力。从权限 Permissions API 获取权限之后,才能访问剪贴板内容;如果用户没有授予权限,则不允许读取或更改剪贴板内容。- 举个例子,咱们在浏览器的控制台输入这段代码
- 此时,再【点击】一下页面,就会有一个授权弹窗弹出
document.execCommand()
的剪贴板访问方式。
至此,我们了解了一些能够将文本传递至操作系统剪切板的Web API
,如果是我们自己来写的话,需要考虑浏览器的兼容性,还得花时间去彻底掌握这些API的使用方式和注意事项。因此,考虑到本文的篇幅和文章的主旨,我们选择调研一些现有的第三方库,从中选取1个来帮我们解决【复制】的问题。
(这个轮子就不造了,不然造不完了😣)
那么,都有哪些库能够提供这个能力呢?
- copy-to-clipboard:一个简单的模块,抛出了一个
copy
方法,该方法将尝试使用execCommand
方法,如果不可行,则降级使用IE特定的clipboardData
接口,最后,如果前两者都不可用,则弹窗提示用户。 - clipboard.js:一个使用原生JavaScript实现剪贴板操作的现代库(不依赖于Flash或任何笨重的框架),它提供了简单的API来复制文本到剪贴板,并且兼容多种浏览器。
zeroClipboard:一个使用Flash的JavaScript库,用于实现跨浏览器的复制到剪贴板功能。由于Flash技术的淘汰,这个库的使用已经逐渐减少。【⚠️请勿使用⚠️】
clipboard.js
有30000多颗⭐,copy-to-clipboard
只有1300颗⭐,选什么无需多言了吧?
所以我们选择
copy-to-clipboard
,理由如下:
copy-to-clipboard
只有15kb,而clipboard.js
有95kb。库的大小是一个重要的考虑因素,因为它直接影响页面加载时间和性能。- 我们仅需要一个复制功能即可,
clipboard.js
的其他功能,我们完全不需要,在此前提下,使用较小的库是有意义的。 - 在兼容性上,我们可以看到
copy-to-clipboard
针对IE浏览器也做了兼容,足够全面。
才不是因为antd选的是copy-to-clipboard
第一版实现
"Talk is cheap, show me the code."
ok,讨论完基础的实现方式,我们直接开始动手设计这个组件。
(这里附上CodeSandbox的👉Demo👈,感兴趣的大家也可以自己体验一下~)
代码
import React from "react";
import { Button } from "antd";
import copy from "copy-to-clipboard";
export type CopiableParagraphProps = {
children: React.ReactNode;
};
export function CopiableParagraph({ children }: CopiableParagraphProps) {
return (
<>
{children}
<CopyButton textContent={children?.toString()} />
</>
);
}
type CopyButtonProps = {
textContent?: string;
};
function CopyButton({ textContent }: CopyButtonProps) {
return (
<Button
onClick={() => {
copy(textContent ?? "");
}}
>
复制
</Button>
);
}
效果
静态图
动图
对比
可以看到,第一版只是成功地把【复制】这个功能做上去了,存在以下几个问题:
- 没有通过API来控制开启或关闭【复制】功能。
- 【复制】是个default类型按钮,带着“复制”两个字,太突兀了。
- 【复制】交互体验差,用户点击完按钮后,并没有任何提示性反馈。
第二版实现
首先咱们要新增一个API,叫做copyable
。接着就是在CopiableParagraph
的render里做一个条件渲染,只有copyable
为true
的时候,才会展示复制的按钮。
于是我们的代码就变成这样
export type CopiableParagraphProps = {
children: React.ReactNode;
copyable?: boolean;
};
export function CopiableParagraph({
children,
copyable,
}: CopiableParagraphProps) {
const renderCopy = () => {
return copyable ? <CopyButton textContent={children?.toString()} /> : null;
};
return (
<>
{children}
{renderCopy()}
</>
);
}
antd的部分源码
// Copy
const renderCopy = () => {
if (!enableCopy) {
return null;
}
return (
<CopyBtn
key="copy"
{...copyConfig}
prefixCls={prefixCls}
copied={copied}
locale={textLocale}
onCopy={onCopyClick}
loading={copyLoading}
iconOnly={children === null || children === undefined}
/>
);
};
由于我们需要考虑的情况并没有它那么复杂,因此我们的代码用三元表达式的写法return出来更简洁直观一些。
紧接着,我们考虑将Button
组件更换成Icon
组件,即<CopyOutlined/>
需要注意的是,这里的改造可不仅仅是把Button
替换成<CopyOutlined/>
这样1个操作,我们还得考虑以下问题:
- 替换成图标后,用户是否会第一时间就明白这个图标的功能是【复制】?
- 因此我们需要给Icon加上一个Tooltip
- 替换图标之后,是否要给此图标颜色设置成主题亮色(因为Icon默认颜色较暗,不够明显)?
- 设置为亮色主题色,和黑色的文本内容区分,突出Icon,吸引用户的注意力。
- 当用户的鼠标悬浮在图标上,或者选中/点击图标时,图标是否也要做对应的颜色变化,从而给到用户一个交互行为上的反馈?
- 当Icon处于hover、focus状态时,Icon的颜色变浅,给用户提供视觉反馈。
- 当用户点击Icon完成【复制】操作后,Icon应该要发生变化,并且提示用户【复制】成功。
- Icon发生变化,变为
<CheckOutlined/>
,同时Tooltip的内容也发生变化,文案变为“复制成功”,这样可以增强用户体验并确认他们的操作已被系统识别。 - 还需要注意一点,就是Icon变换之后,过一段时间还得再变回去,这样用户才可以多次进行【复制】操作。
- Icon发生变化,变为
确定好这些需要注意的方面后,我们直接动手写代码。
由于这次对CopyButton
的改动较多,我们将代码抽离,作为一个独立的组件进行开发。
代码
CopyButton
import React, { useState } from "react";
import { Tooltip, message } from "antd";
import copy from "copy-to-clipboard";
import CopyOutlined from "@ant-design/icons/CopyOutlined";
import CheckOutlined from "@ant-design/icons/CheckOutlined";
import styled from "styled-components";
// 创建一个样式化的Icon组件
const IconProvider = styled.i<{
primaryColor?: string;
hoverColor?: string;
successColor?: string;
}>`
.copyIcon {
color: ${(props) => props.primaryColor || "darkblue"};
&:hover {
color: ${(props) => props.hoverColor || "blue"};
}
}
.copiedIcon {
color: ${(props) => props.successColor || "green"};
}
`;
type CopyButtonProps = {
textContent?: string;
};
export function CopyButton({ textContent }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const onCopyClick = () => {
copy(textContent ?? "");
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
};
return (
<Tooltip title={copied ? "复制成功" : "复制"}>
<IconProvider
primaryColor="#1677ff"
hoverColor="#6aa7fe"
successColor="#52c41a"
>
{!copied ? (
<CopyOutlined className="copyIcon" onClick={onCopyClick} />
) : (
<CheckOutlined className="copiedIcon" />
)}
</IconProvider>
</Tooltip>
);
}
CopiableParagraph
import React from "react";
import { CopyButton } from "./copy-button";
export type CopiableParagraphProps = {
children: React.ReactNode;
copyable?: boolean;
};
export function CopiableParagraph({
children,
copyable,
}: CopiableParagraphProps) {
const renderCopy = () => {
return copyable ? <CopyButton textContent={children?.toString()} /> : null;
};
return (
<>
{children}
{renderCopy()}
</>
);
}
效果
静态图
动图
对比
从【实现效果】来说,第二版的代码看起来已经和我们期望的最终组件能够实现的效果差不多了。
从【使用体感】来说,似乎也和开启copiable
后的Typography.Paragraph
差不多。
第一版完成了交互上的对比和改进,那么第二版咱们就完成代码上的对比和改进
改进
在开始改进咱们的代码前,需要铺垫一些前置信息。可以看到,在CopyButton的代码里,使用了这样的写法去写样式
import styled from "styled-components";
// 创建一个样式化的Icon组件
const IconProvider = styled.i<{
primaryColor?: string;
hoverColor?: string;
successColor?: string;
}>`
.copyIcon {
color: ${(props) => props.primaryColor || "darkblue"};
&:hover {
color: ${(props) => props.hoverColor || "blue"};
}
}
.copiedIcon {
color: ${(props) => props.successColor || "green"};
}
`;
这是采用了CSS-in-JS
的写法。
知识铺垫
⚠️出于篇幅考虑,本环节涉及到的相关知识点不会进行太深入的讲解,大家脑海里有个模糊的概念即可⚠️
如果大家之前也使用过antd,会发现4.x版本的antd是使用
Less
去管理样式的。但是在5.x版本的antd,又改成了CSS-in-JS
,这是为什么呢?
- 动态主题支持:在4.x版本中,使用
Less
管理样式时,动态更换主题和混合使用主题存在一定的困难。CSS-in-JS
通过运行时的样式计算能力,完美解决了这些问题。 - 更小的Bundle Size:
CSS-in-JS
可以按需引入样式,不依赖任何插件,从而减少了打包后的文件大小。 - 性能优化:
CSS-in-JS
支持样式的缓存,避免了重复生成样式进行对比的操作,从而提升了性能。 - 更灵活的定制主题方案:基于Design Token和
CSS-in-JS
,antd 5.x提供了更加灵活的定制主题方案,包括全局主题定制、局部主题(嵌套主题/多主题并行)和组件主题定制。
什么是动态主题支持,为什么用
Less
管理样式会存在困难,都有哪些困难?
动态主题支持是指在应用程序运行时,根据用户的需求或环境的变化,实时更改应用程序的主题(如颜色、字体、间距等)。这种能力使得应用程序可以提供更个性化和灵活的用户体验。
为什么用Less管理样式会存在困难?
-
编译时间长:
- 使用Less时,所有的样式都需要在构建时编译成CSS。这意味着每次更改主题时,都需要重新编译整个样式文件,耗时较长。——antd官方文档。
-
缺乏运行时支持:
- Less是预处理器,主要在构建阶段工作,而不是在运行时工作。这使得在应用程序运行时动态更改主题变得非常困难。
-
样式隔离性差:
- 在大型应用中,使用Less可能会导致样式冲突和覆盖问题,特别是在多个组件共享相同的样式变量时。
什么是CSS-in-JS
一言以蔽之,
CSS-in-JS
是一种将 CSS 样式直接写在 JavaScript 代码中的技术。
我们常常听到过这么一句话存在即合理。那么就顺着这个理,我们聊聊CSS-in-JS
设计理念
-
组件化思维:
-
封装性:每个组件的样式与其逻辑和结构紧密结合,形成一个独立的模块。这种封装性使得组件更易于维护和复用。(这一点在我们的第二版代码里可见一斑,我们将
CopyButton
作为独立组件抽出的时候,在组件内部编写相关的样式代码,使得样式和组件紧密结合)const IconProvider = styled.i<{ primaryColor?: string; hoverColor?: string; successColor?: string; }>`some css code`... export function CopyButton...
-
局部作用域:避免了全局 CSS 的命名冲突问题,因为每个组件的样式都是局部的。
-
-
动态样式:
-
基于状态的样式:可以根据组件的状态(如 props 或 state)动态地调整样式,而不需要手动管理多个类名。
const IconProvider =styled.i<{ primaryColor?: string; hoverColor?: string; successColor?: string; }> <IconProvider primaryColor="#1677ff" hoverColor="#6aa7fe" successColor="#52c41a" > {children} </IconProvider>
-
主题支持:更容易实现基于主题的样式切换,适应不同的设计需求。
-
言归正传,动手改进
在上一节的内容中,我们对一些技术和解决方案有了一个初步的了解,在脑海中也形成了一个大致的概念,接下来,我们就开始根据antd的源码,去改进CopiableParagraph
的代码。
CSS
第一个问题
当我们把点击【复制】的交互UI从Button
更换成Icon
之后,我们会发现,Icon
要比Button
小很多,也许用户第一下的点击行为可能点不到Icon
上,从而导致使用体验较差。
如何解决这个问题呢?
我们来看一下antd的样式代码:
${antCls}-typography-copy {
position: relative;
margin-inline-start: 0;
// expand clickable area
&::before {
content: '';
display: block;
position: absolute;
top: -5px;
left: -9px;
bottom: -5px;
right: -9px;
}
}
可以看到,它给Icon
加了一个伪元素,这个伪元素用来扩大点击的区域(focus和hover的判定区域也是同样会被扩大)。
将目标元素的position设置为relative之后,给伪元素的position设置为absolute。通过绝对定位的方式,利用top、bottom、left、right的负值设置,从而形成一个盖在目标元素上的伪元素。
这里提到了position
(定位),因此我们简单地回顾一下CSS样式,拿top举个例子:
top
的效果取决于元素的position
属性:
- 当
position
设置为absolute
或fixed
时,top
属性指定了定位元素上外边距边界与其包含块上边界之间的偏移。- 当
position
设置为relative
时,top
属性指定了元素的上边界离开其正常位置的偏移。- 当
position
设置为sticky
时,如果元素在 viewport 里面,top
属性的效果和 position 为relative
等同;如果元素在 viewport 外面,top
属性的效果和 position 为fixed
等同。- 当
position
设置为static
时,top
属性无效。- 当
top
和bottom
同时指定时,并且height
没有被指定或者指定为auto
的时候,top
和bottom
都会生效,在其他情况下,如果height
被限制,则top
属性会优先设置,bottom
属性则会被忽略。 ——MDN
我们从MDN了解了top的相关描述,不知道你是否和我在阅读时有同样的想法,我真的觉得下面这句话太拗口了😶。
当
position
设置为absolute
或fixed
时,top
属性指定了定位元素上外边距边界与其包含块上边界之间的偏移。
于是结合上面的这句描述,针对我们这里的使用,我写了1个👉Demo👈,方便大家理解。
简单介绍下这个
Demo
,这里设置了2个父级元素及其子元素,区别在于:
- 子元素1的top等样式值是正值。
- 当top是正值时,所谓的定位元素上外边距边界与其包含块上边界之间的偏移就可以理解成下图中的红色框选部分(水平方向同理)。
- 子元素2的top等样式值是负值。
- 当top是负值时,就相当于定位元素的偏移向上超出了,我们也可以成理解成下图中的红色框选部分。
至此,我们了解了使用相对定位结合绝对定位的样式设置如何实现一个比当前元素还要大一些的区域。紧接着,下一个问题就来了
为什么要使用伪元素
::before
去实现,而不是用一个块级元素去实现这个扩展的区域?
因为这就是一种比起用块级元素更加优雅的实践。原因如下:
- 减少DOM的节点数量:伪元素是通过样式来达到元素效果的,不会占用DOM节点,从而减少页面的节点数。那么减少节点数量有哪些好处?
- 性能优化:我们都清楚,每个DOM节点都需要浏览器处理和渲染,因此替浏览器减少工作量,就能加快页面的加载速度和渲染性能。
- 内存占用减少:每个DOM节点都会占用内存。减少节点数可以降低页面的内存占用,从而改善整体性能。
- 不影响语义: 伪元素不会影响页面的语义,不会添加无意义的元素。
终于,我们彻底理解了antd这么做的原因,从多个角度明白了为什么会这么设计(交互体验、设计理念、社区规范等等)。我们给自己的组件CopiableParagraph
也加上这段代码。
(上图为了更好地展示,我将值都扩大了10px,截图完毕后改回去了,如下)
第二个问题
在优化第一个问题的代码时,我们引入了伪元素的样式写法。但是这么写会存在一个很明显的问题。
当我们点击完Icon后,按照我们之前的逻辑,Icon会替换成新的图标,而新的图标采用的是另一个类名copiedIcon
,而我们的伪元素写法却只给类名copyIcon
加上了。
因此,如果要保持一致性的话,我们就得这么写了:
可以看到,相同的样式逻辑,我们却写了两遍,
显然这样的代码是不够优雅的,那么我们该如何进行优化呢?
copiedIcon
和copyIcon
在样式上的不同之处只有2处:
- color
- hover/focus时的color
而造成这种不同的根本原因,就是交互行为导致元素的状态改变,从【未复制】状态进入【已复制】状态时,样式也要产生变化。
聊到这里,不知你的脑海里是否会浮现出四个字?
动态类名
而能够帮我们去写动态类名的好帮手,自然便是classNames
这个第三方库。
我们先把render里的代码写了,理顺大致的逻辑,然后再去抠细节。
return (
<Tooltip title={copied ? "复制成功" : "复制"}>
<IconProvider
primaryColor="#1677ff"
hoverColor="#6aa7fe"
successColor="#52c41a"
>
<div
className={classNames("copyIcon", {
[`copyIcon-copied-success`]: copied,
})}
onClick={onCopyClick}
>
{!copied ? <CopyOutlined /> : <CheckOutlined />}
</div>
</IconProvider>
</Tooltip>
);
我们用一个块级元素包裹原来的条件渲染逻辑,这个块级元素的类名便是基于classNames
帮我们完成的动态类名。只有当元素被点击后,且copied
等于true
也就是【已复制】状态时,copyIcon-copied-success
才会生效,生效后,用对应的color设置,覆盖原设置即可。
接着我们再去写CSS的代码,如下:
.copyIcon {
position: relative;
display: inline-flex;
margin-inline-start: 0;
color: ${(props) => props.primaryColor || "darkblue"};
&:hover {
color: ${(props) => props.hoverColor || "blue"};
}
// expand clickable area
&::before {
content: "";
display: block;
position: absolute;
top: -5px;
left: -9px;
bottom: -5px;
right: -9px;
}
}
.copyIcon-copied-success {
color: ${(props) => props.successColor || "green"};
&:hover {
color: ${(props) => props.successColor || "green"};
}
}
这样我们的代码变得比之前更加的简洁,可读性也更强了。
第三个问题
不是,怎么还有问题?
这里我们可以从组件设计要考虑的几个方面出发,还记得我们在render里是使用
<div>
去包裹原来的条件渲染吗?如果仅仅只是放一个<div>
元素在这里的话,从设计理念出发,有两点我们没有做好:
- 语义化:元素的行为与元素的外观并不一致。
CopyButton
说到底是这一个可点击的按钮。 - 无障碍性:屏幕阅读器和其他辅助技术依赖于语义化的标记来正确解读页面。仅仅放一个
<div>
,那么视觉障碍用户不能够充分理解它是一个按钮。
因此我们需要改动一下代码,通过新增role="button"
,来将<div>
元素标记为一个按钮。
<div
className={classNames("copyIcon", {
[`copyIcon-copied-success`]: copied,
})}
role="button"
onClick={onCopyClick}
>
{!copied ? <CopyOutlined /> : <CheckOutlined />}
</div>
源码对比
在这一节的内容里,我们讨论了当前代码在CSS层面的缺陷(总计3个问题),以及解决这些缺陷的方案,并不断优化,力图做到最佳实践。
解决问题的思路当然不是凭空而来,而是源自对antd组件源码的学习。这里我贴上部分的源码内容,大家可以结合上面关于三个问题的讨论一起看。
动态类名
<Tooltip key="copy" title={copyTitle}>
<TransButton
className={classNames(`${prefixCls}-copy`, {
[`${prefixCls}-copy-success`]: copied,
[`${prefixCls}-copy-icon-only`]: iconOnly,
})}
onClick={onCopy}
aria-label={ariaLabel}
tabIndex={tabIndex}
>
{copied
? getNode(iconNodes[1], <CheckOutlined />, true)
: getNode(iconNodes[0], btnLoading ? <LoadingOutlined /> : <CopyOutlined />, true)}
</TransButton>
</Tooltip>
这里根据copied
来进行条件渲染的处理内容与我们的组件直接放2个Icon不同,而是依赖于getNode
函数的返回值。getNode
函数里面针对用户可能传入的自定义渲染内容做了区分,如果传入了自定义渲染内容,则展示,否则,默认展示Icon。
扩展点击区域
一部分是基类的样式
copyIcon: css`
${antCls}-typography-copy {
position: relative;
margin-inline-start: 0;
// expand clickable area
&::before {
content: '';
display: block;
position: absolute;
top: -15px;
left: -19px;
bottom: -15px;
right: -19px;
}
}
${antCls}-typography-copy:not(${antCls}-typography-copy-success) {
color: ${colorIcon};
&:hover {
color: ${colorIcon};
}
}
`,
一部分是mixin的内容
export const getCopyableStyles: GenerateStyle<TypographyToken, CSSObject> = (token) => ({
[`${token.componentCls}-copy-success`]: {
[`
&,
&:hover,
&:focus`]: {
color: token.colorSuccess,
},
},
[`${token.componentCls}-copy-icon-only`]: {
marginInlineStart: 0,
},
});
可以很明显地看到我们之前解决问题时考虑到的扩大点击区域和实现动态类名均源自于此。不过在动态类名上它们采取了:not
利用伪类的方式实现的样式区分。
与我们写的样式覆盖不同,它们是选择性展示对应的样式,这点比我们的代码还要更加规范和合理一些。
组件复用
我们看下antd的CopyBtn
的render部分代码
<TransButton
className={classNames(`${prefixCls}-copy`, {
[`${prefixCls}-copy-success`]: copied,
[`${prefixCls}-copy-icon-only`]: iconOnly,
})}
onClick={onCopy}
aria-label={ariaLabel}
tabIndex={tabIndex}
>
{copied
? getNode(iconNodes[1], <CheckOutlined />, true)
: getNode(iconNodes[0], btnLoading ? <LoadingOutlined /> : <CopyOutlined />, true)}
</TransButton>
可以看到它们使用了TransButton
组件去包裹了有关Icon的条件渲染的内容。当我们去查看TransButton
组件的源码时(出于篇幅考虑,仅展示部分)
/**
* Wrap of sub component which need use as Button capacity (like Icon component).
*
* This helps accessibility reader to tread as a interactive button to operation.
*/
return (
<div
role="button"
tabIndex={tabIndex}
ref={ref}
{...restProps}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
style={mergedStyle}
/>
);
可以看到,这个组件是需要用作按钮功能的子组件(例如 Icon 组件)的包装,这有助于屏幕阅读器将其视为一个交互式按钮,从而提高用户体验。这里考虑到业务的通用场景将其抽离成了一个公共组件TransButton
,而非像我们的代码一样,用一个div
简单包裹一下。
TSX
那么不仅仅是CSS上我们的代码写得有缺陷,我们写的TSX也一定存在一些可以提升的方面,就让我们一起来看看吧(坚持一下,本文的最后一部分内容了✊)。
第一个问题
内存泄漏!!!我们目前的代码存在内存泄漏的隐患。
可以看到,我们之前为了能够让用户在点击一次【复制】后,能够让按钮的状态在一段时间后从【已复制】状态恢复成【复制】状态,而使用了
setTimeout
。
const onCopyClick = () => {
if (!copied) {
copy(textContent ?? "");
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 3000);
}
};
(在第二版代码中贴的动图里也可以看到,点击后3秒,Icon会恢复成初始状态)
虽然setTimeout在执行完毕后,会自动被清除。但是为了避免一些预期之外可能会造成内存泄露的隐患。还是建议在每次重新调用时(即每次重新创建setTimeout定时器时),把可能存在的上一次没有被清除的定时器手动清除一下。
不仅如此,在组件卸载的时候,我们还要把当前点击事件触发后,最新生成的定时器也要清除。
所以我们要增加以下代码:
const copyIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cleanCopyId = () => {
if (copyIdRef.current) {
clearTimeout(copyIdRef.current);
}
};
useEffect(() => cleanCopyId, []);
const onCopyClick = () => {
if (!copied) {
copy(textContent ?? "");
setCopied(true);
cleanCopyId();
copyIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
}
};
第二个问题
当我们去写一个可能会被当作子组件嵌套在父组件的组件时,我们要考虑阻止事件冒泡。简单来说,就是我们不希望在点击当前组件的时候,同时触发上层元素的点击事件。(当然这里的事件不仅仅只是点击事件)。
举个例子,如果我们在一个按钮组件上放了一个Icon图标,我们希望点击Icon图标时可能是切换按钮的文案之类的效果,不希望点击Icon图标时触发按钮自己的点击事件,此时就要阻止事件冒泡。
因此我们要再修改一下onCopyClick
的函数代码,让他接收一个参数e,代表发生的事件。
const onCopyClick = (e?: React.MouseEvent<HTMLDivElement>) => {
e?.stopPropagation();
if (!copied) {
copy(textContent ?? "");
setCopied(true);
cleanCopyId();
copyIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
}
};
源码对比
在这一节内容中,我们讨论了当前代码在TSX上的缺陷(总计2个问题),以及解决这些缺陷的方案,并不断优化,力图做到最佳实践。
同样,我们也一起来看下antd组件里相关的代码。
和我们代码不同的是,antd将点击事件抽成了一个hook,useCopyClick
。
import * as React from 'react';
import copy from 'copy-to-clipboard';
import { useEvent } from 'rc-util';
import type { CopyConfig } from '../Base';
const useCopyClick = ({
copyConfig,
children,
}: {
copyConfig: CopyConfig;
children?: React.ReactNode;
}) => {
const [copied, setCopied] = React.useState(false);
const [copyLoading, setCopyLoading] = React.useState(false);
const copyIdRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const cleanCopyId = () => {
if (copyIdRef.current) {
clearTimeout(copyIdRef.current);
}
};
const copyOptions: Pick<CopyConfig, 'format'> = {};
if (copyConfig.format) {
copyOptions.format = copyConfig.format;
}
React.useEffect(() => cleanCopyId, []);
// Keep copy action up to date
const onClick = useEvent(async (e?: React.MouseEvent<HTMLDivElement>) => {
e?.preventDefault();
e?.stopPropagation();
setCopyLoading(true);
try {
const text =
typeof copyConfig.text === 'function' ? await copyConfig.text() : copyConfig.text;
copy(text || String(children) || '', copyOptions);
setCopyLoading(false);
setCopied(true);
// Trigger tips update
cleanCopyId();
copyIdRef.current = setTimeout(() => {
setCopied(false);
}, 3000);
copyConfig.onCopy?.(e);
} catch (error) {
setCopyLoading(false);
throw error;
}
});
return {
copied,
copyLoading,
onClick,
};
};
export default useCopyClick;
阅读源码,我们能够发现一些老朋友,比如手动清除定时器的cleanCopyId
,以及阻止事件冒泡的e?.stopPropagation()
。但是我们还会发现,在此源码中多了一个e?.preventDefault()
,这是为什么呢?
回想一下我们在CSS那一节提到的,antd的组件源码得到的条件渲染的内容是来自于getNode
,即这里用于点击的图标并不一定是Icon,也有可能是用户传入的自定义渲染的元素。而这些元素在被点击的时候就有可能会触发浏览器的默认行为。我们只希望点击图标后,触发的是【复制】操作,而不会触发浏览器的默认行为,因此需要再加上e?.preventDefault()
。
什么元素的点击事件会触发浏览器的默认行为?
举个例子,假如用户传入了一个<a>
标签作为自定义渲染的元素,那么此时点击,如果不设置e?.preventDefault()
的话,则会触发浏览器的默认行为——跳转至链接的目标页面。
除了上述提到的内容,我们还可以看到此hook返回的onClick
函数是被useEvent
包装后的,那么useEvent
具体做了什么呢?大家可以查看一下我这篇文章里对于它的源码分析:【源码阅读】【万字长文预警】🔍水印保卫战
简单来说,就和它上面的注释一样,它确保了每一次点击事件后,【复制】的内容一定是最新的,即保持【复制】操作最新。
最终版代码
经过了不断地优化以及和antd组件源码的比对,我们迭代出了组件的最终版本。链接如下:
结语
不知道这种形式的源码阅读文章是否会让你更有阅读兴趣并且拥有更好的阅读体验呢? 每一篇【源码分析】的文章,篇幅都特别长,我也写得特别慢。
- 一方面是因为这些开源项目的代码写得都很优雅,都是对最佳实践的尝试,在阅读上我会遇到很多困难。如果连我自己都读不懂的话,我又怎么能去向内消化,再去转换成自己的文字,最后向外输出呢。因此每次写的时候,都要一边读,一边查阅相关知识点的文档,逐行分析为什么代码这么写的原因。
- 另一方面,我读源码也好,还是做其他事情也好,有一种刨根问底的习惯。比如明明我的目标是放在【复制】的这个功能上,但是往往在阅读过程中,会接触到很多不同的知识点,而这些不同的知识点,又大概率是我不懂的知识点,于是我就会想着,啊,得先搞懂它们才能继续呀。所以文章的内容也就越写越多了。
很开心你能将这篇文章读到最后,希望你能从中有所收获。如果有任何的意见和建议,欢迎在评论区留言或者私信我~
我们下一篇文章再见~