从 antd 源码学习 react 的实践

1,603 阅读5分钟

「这是我参与11月更文挑战的第14天,活动详情查看:2021最后一次更文挑战

当我写一些类 react 项目的时候,总感觉写得很 low,这是因为接触 react 太少,没有找到 react 的最佳实践,为了弥补这个短板,我决定看看大厂是怎么写 react 项目的,而今天的文章内容就是关于 antd 组件库的源码的理解。

ant-design 是一个基于 React 编写的组件库,可以运用到你的 react 项目中,源码见 github仓库

下面开始对 ant-design 项目的内容解读。

项目规范化相关

可以看到 ant-design 项目根目录有很多配置文件,归类如下:

  • husky配置:.huskyrc
  • lint 配置
    • .eslintrc.js
    • .eslintignore
    • .stylelintrc
  • 编辑器配置:.editconfig
  • prettier配置
    • .prettierrc
    • .prettierignore
  • remark 配置:.remarkrc.js
  • lighthouse 配置: lighthouserc.js
  • webpack 配置: webpack.config.js
  • jest 配置
    • .jest.js
    • .jest.node.js
    • .jest.site.js
    • .jest.image.js
  • npm 配置: .npmignore

以文件上传组件为例分析

文件上传组件相关代码在项目的components/upload目录下,目录下的文件组织如下:

index.tsx
interface.tsx
utils.tsx
Upload.tsx
Dragger.tsx
UploadList目录
demo目录
style目录
__tests__目录

该目录下的 interface.tsx,功能主要是导出各种 type、interface,以支持 typescript项目,更具体点说,该模块导出的主要是文件上传组件的 prop的类型,如UploadListPropsUploadProps

这里有一个范式就是 index.tsx 只用来做统一导出,不承载核心逻辑,真正核心的逻辑按模块划分放在其他文件下,如Upload.tsx 承载 Upload 组件的渲染逻辑。

style 目录理所当然放置 *.less 样式文件, 但意外的是 style 目录还有一个 index.tsx,这个文件的作用是集中导入一批样式文件,内容全是 import 语句。

这启示我们,如果在定义组件的时候导入的资源文件很多时,可以将同一类资源的 import 语句写到一个import文件(专门用来import资源的文件)中,在组件中引用这类资源只要导入一个import文件即可,这减少组件代码的非核心逻辑的篇幅。

Dragger 是一个拖拽上传组件,它的本质只是对 Upload 组件的一个封装

const InternalDragger: React.ForwardRefRenderFunction<unknown, DraggerProps> = (
  { style, height, ...restProps },
  ref,
) => <Upload ref={ref} {...restProps} type="drag" style={{ ...style, height }} />;

const Dragger = React.forwardRef(InternalDragger) as React.FC<DraggerProps>;

Dragger.displayName = 'Dragger';

export default Dragger;

这个封装,我们可以学到两点:一是使用 forwardRef 来确保在封装后的组件取 ref 能够拿到内部承载核心逻辑的组件实例;二是借助形如{...restProps}的 jsx的 spread 操作能够将封装后组件上的所有prop 逐一透传到内部组件中。

util.tsx用于存放一些通用的工具函数,其中有一个previewImage函数的功能是对一个将用于预览的原图片文件进行等比例缩放到一定尺寸,生成缩放后的 base64图片,用于预览,它的实现思路如下:

  1. 创建canvas
  2. 通过 window.URL.createObjectURL(file) 生成用于 Image 对象加载图片的 src
  3. 加载成功后获取原始宽高,计算缩放后的尺寸和偏移量,通过canvas 的 drawImage api绘制到canvas上
  4. 通过 canvas.toDataURL()方法获取缩放后图片的base64编码

关于什么时候提取工具函数,我理解是只要组件中有一部分代码的逻辑可以内聚在一起,就可以考虑抽象成工具函数,这个提取工具函数的目标有两个:一是提高代码复用率,二是实现“自注释”,用几个单词组成的函数名体现一段代码的逻辑,提高可读性。

最后看回关键的Upload.tsx文件,Upload 组件也用了 forwardRef。

const Upload = React.forwardRef<unknown, UploadProps>(InternalUpload) as CompoundedComponent;

而在InternalUpload组件中用了useImperativeHandle钩子,这个是搭配 forwardRef 使用的。

  // Test needs
  React.useImperativeHandle(ref, () => ({
    onBatchStart,
    onSuccess,
    onProgress,
    onError,
    fileList: mergedFileList,
    upload: upload.current,
  }));

第一个参数 ref 就是 forwardRef 提供的,第二个参数是一个函数,返回值会传给 ref.current。定义ref能够访问的内容也是组件设计的一个重要环节。

Upload 组件的另一个点就是在组件函数内定义的函数都没有使用useCallback。这跟我在组件内部编写的事件回调几乎每个都用了useCallback完全是对立的。

Upload 组件的渲染部分如下:

  return (
      <span>
        <div
          className={dragCls}
          onDrop={onFileDrop}
          onDragOver={onFileDrop}
          onDragLeave={onFileDrop}
          style={style}
        >
          <RcUpload {...rcUploadProps} ref={upload} className={`${prefixCls}-btn`}>
            <div className={`${prefixCls}-drag-container`}>{children}</div>
          </RcUpload>
        </div>
        {renderUploadList()}
      </span>
    );

其中的onFileDrop函数是在组件内临时定义的函数。

  const onFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
    setDragState(e.type);

    if (e.type === 'drop') {
      onDrop?.(e);
    }
  };

可以分析下为什么在这里不需要用 useCallback。首先使用 useCallback 的原因一般是通过缓存引用,通过prop引用相等的比较跳过不必要的渲染,以实现性能优化。而在这里应用了回调函数的只是一个 div标签,即使重新渲染,也不过是将div的事件回调设置成另一个新的事件回调罢了,不会引起 dom 树的变化,并不会导致太大的性能损失。而调用useCallback本身也是有开销的,这个开销可能跟 prop变化导致div重新渲染的开销差不多甚至更大(虽然还没有实际测试过),而且使用useCallback的依赖项没写好很容易导致 bug,从降低代码复杂性的维度考虑,如果事件回调仅用在原生dom标签,可以不使用 useCallback,至少不要过早使用 useCallback,除非你发现用了 useCallback 可以带来明显的性能提升。

需要使用useCallback的场景可能有以下几个:

  1. 函数作为某个 hook 的依赖项,为了保证依赖项的 diff 是符合预期的,useCallback是必不可少的
  2. 函数如果作为 prop 传给另一个自定义组件,不必要的渲染可能导致另一个组件的副作用不必要地执行,从这点看使用 useCallback 会合适一点。