Wenzi

基于 React 和 antd 实现的图片裁剪压缩功能

蚊子前端博客
发布于 2023/10/11 09:23
我们有很多上传图片的场景,都要用到裁剪和压缩的功能!

我们在日常开发过程中,有很多需要上传图片的场景。那用户在上传时,通常会遇到两个问题:

  1. 不知道前端显示的尺寸比例是多少,导致最终上传图片会变形;
  2. 图片质量或尺寸过大,比如可能就是个小 icon 图或者小封面图之类的,用户上传了一个好几兆的图片,尺寸可能对,但质量太大了,着实没有必要;

针对这种图片资源上传的类型,我们有必要在前端控制他的上传尺寸和上传质量。我这里讲下我们在业务中使用到的图片裁剪和压缩功能。

我们主要实现的功能:

  1. 读取 form 表单的内容,能回显图片;
  2. 能够按照裁剪比例进行裁剪;
  3. 可以指定最终生成图片的尺寸;

下面开始一一讲解下整个过程。

1. 上传图片 #

这里我们封装一个名叫ReactImageCropper的裁剪组件,同时接收从<Form.Item />传进来的参数:value, onChange 和 id(可选),主要是方便在 form 表单中使用。

选择上传图片的的功能,我直接使用了 antd 中的<Upload />组件:

import { Button, Upload } from "antd";

const ReactImageCropper = ({ value, onChange, id }: any) => {
  const [originalUrl, setOriginalUrl] = useState(""); // 刚上传得到的原始图片地址

  const handleUpload = (event: any) => {
    const { file } = event;

    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => {
      // 将读取的图片资源转为base64,接下来进行裁剪的过程
      setOriginalUrl(reader.result as string);
    };
    reader.onerror = () => {
      message.error('读取文件失败');
    };
  };

  return (
    <div id={id}>
      <Upload
        accept="image/*"
        listType="picture-card"
        showUploadList={false}
        customRequest={handleUpload}
      >
        {value ? (
          <img src={value} alt={id} style={{ maxWidth: "140px" }} />
        ) : (
          <Button>上传</Button>
        )}
      </Upload>
    </div>
  );
};

得到上传文件后,通过 FileReader 得到该文件的 base64 地址,方便我们后续的裁剪和压缩处理。

2. 裁剪 #

裁剪过程,我们是使用到了 react-cropper 的组件,将其放到 <Modal />组件中,裁剪完毕后,关闭弹窗。

这里我们主要是关注 2 个问题:

  1. 裁剪的比例是多少?
  2. 最终生成图片的尺寸是多少?
  3. 对质量大小有没有要求?

关于裁剪比例(即宽高比),一种是直接通过 aspectRatio 属性来设置比例。再有一种是通过设置的最终压缩尺寸,算出设置比例;比如我们最终需要的图片尺寸是 750*440,那比例就是 1.7 左右(750/440)。

若两者都存在,我们以最终输出的图片的尺寸算出来的比例优先,毕竟若两者不一致时,裁剪出来的图片和最终生成的图片,可能不一致。

我们上第 1 节获取到了上传图片的本地链接originalUrl,这里将其传入到 <Cropper /> 中。

import Cropper from "react-cropper";

const ReactImageCropper = ({ width, height, aspectRatio }) => {
  const tempUrlRef = useRef("");

  /**
   * 获取裁剪图片的宽高比
   */
  const aspect = useMemo(() => {
    if (width && height) {
      return width / height;
    }
    if (aspectRatio) {
      return aspectRatio;
    }
    return 1;
  }, [aspectRatio, width, height]);

  // 拖动结束时,获取裁剪后的图片,将其存储到临时变量等待进一步压缩处理
  const onCrop = () => {
    const cropper: any = cropperRef?.current?.cropper;
    if (!cropper) {
      return;
    }
    const src = cropper.getCroppedCanvas().toDataURL();

    tempUrlRef.current = src;
  };

  // 点击ok的时候,表示已裁剪好,开始按照约定的尺寸压缩图片
  // getImageFinalSize 和 compressImage ,在第3节会讲到
  const handleComporess = async () => {
    const { width, height } = await getImageFinalSize(); // 获取最终图片的尺寸
    console.log(width, height);

    // 对base64的图片进行压缩,然后得到压缩后的图片
    const file = await compressImage({
      info: { base64: tempUrlRef.current },
      width,
      height,
    });

    // 该上传该文件了
    console.log("file", file);
  };

  return (
    <Modal
      open={Boolean(originalUrl)}
      onCancel={() => setOriginalUrl("")}
      destroyOnClose
      maskClosable={false}
      width={600}
      onOk={handleComporess}
    >
      <Cropper
        cropend={onCrop}
        ref={cropperRef}
        src={originalUrl}
        viewMode={1}
        aspectRatio={aspect}
        style={{ height: 400, width: "100%" }}
        guides={false}
      />
    </Modal>
  );
};

这个回调onCrop()在每次裁剪结束时都会触发,得到一个新的裁剪后的图片地址,但只有点击弹窗中的确定按钮后,才会指定压缩的操作。

3. 压缩 #

压缩的过程稍微长点,主要经过以下的几个步骤:

  1. 获取最终要生成的图片的尺寸,若没有指定,则使用裁剪时得到的图片尺寸;
  2. 使用 canvas,将图片压缩至指定的尺寸;
  3. 把 base64 图片转成 File 对象,等待接口的上传;

下面来一一讲解。

3.1 获取要生成的图片的尺寸 #

若开发者指定了宽度和高度,最终的图片就是这个尺寸,我们就使用已指定好的;若不在乎最终尺寸,则依照裁剪图片时得到的尺寸。

/**
 * 获取图片最终的宽高
 * 若传入了宽高的数值,则直接使用;否则就使用裁剪出来的尺寸
 */
const getImageFinalSize = () => {
  if (width && height) {
    return Promise.resolve({ width, height });
  }
  const img = new Image();
  return new Promise((resolve) => {
    img.onload = () => {
      resolve({ width: img.naturalWidth, height: img.naturalHeight });
    };
    // 读取刚才裁剪后的图片地址
    img.src = tempUrlRef.current;
  });
};

获取到尺寸后,就会进入到下一步。

3.2 将图片压缩至指定尺寸 #

裁剪后的图片一般地只是比例符合要求,但宽高尺寸实际上可能还是很大。这里我们使用 canvas 对其进行压缩。

/**
 * 将base64图片压缩到指定尺寸
 */
const compressCurSize = ({
  url,
  width,
  height,
}: {
  url: string, // base64图片的地址
  width: number,
  height: number,
}): Promise<{ url: string, ext: string }> => {
  const [match, imageType] = url.match(/data:image\/(.*?);/) ?? [];

  if (!match || !imageType) {
    return Promise.reject(
      new TypeError(`imgurl should be base64, your enter: ${url}`)
    );
  }

  const newImage = new Image();
  newImage.src = url;

  return new Promise((resolve, reject) => {
    newImage.onload = function () {
      const canvas = document.createElement("canvas");
      const ctx: any = canvas.getContext("2d");

      canvas.width = width;
      canvas.height = height;

      // 注意这里的 fillStyle
      ctx.fillStyle = "#fff";
      ctx.fillRect(0, 0, canvas.width, canvas.height);

      ctx.drawImage(this, 0, 0, canvas.width, canvas.height);
      canvas.toBlob(
        (blob) => {
          const file = new File(
            [blob as BlobPart],
            fileName || `${Date.now().toString(32)}.${imageType}`
          );

          resolve({ file, ext: imageType });
        },
        `image/${imageType}`,
        0.96
      );
    };
    newImage.onerror = reject;
  });
};

属性 fillStyle 是用来填充背景颜色,若允许上传 png 格式的图片,请一定要注意这里。如果可以的话,就将其设置为白底(#fff),若想要保留透明,需要将其设置成透明色(rgba(255, 255, 255, 0))。若不设置 fillStyle,图片的透明底会被转为黑底。

渲染完毕后,可以通过 cavans 的 toBlob()方法,直接将 blob 转为 File 对象。

到这里,裁剪压缩的过程基本是已经完成了。接下来就是通过接口上传的步骤了,各自按照接口的要求上传即可。

4. 我不想使用 Upload 组件 #

有的同学可能不想使用 antd 中的<Upload />组件,主要也是考虑到这个组件的很多功能都用不上。那可以用 <input type="file"> 标签来实现。

const ReactImageCropper = () => {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const [originalUrl, setOriginalUrl] = useState(''); // 刚上传得到的原始图片地址

  const handleChange = (event: any) => {
    const file = event.target.files[0];
    inputRef.current.value = '';

    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = () => {
      // 将读取的图片资源转为base64,接下来进行裁剪的过程
      setOriginalUrl(reader.result as string);
    };
    reader.onerror = () => {
      message.error('读取文件失败');
    };
  };

  return (
    <div>
      <input type="file" ref={inputRef} onChange={handleChange} />
    </div>
  );
};

回调函数 handleChange() 中,有一个将 value 清空的步骤。这主要是为了避免上传同一份图片时,该 change 方法不会触发。因为该回调函数触发的条件得是 value 发生了变动。

接下来的裁剪、压缩等步骤跟上面的一样。

5. 总结 #

到这里,图片整个的裁剪压缩过程已完成了。我们来模拟下业务里的场景,比如用户上传的头像图片,最终尺寸为 120*120 差不多就够用了。看下实现的效果:

裁剪压缩后的图片

有的同学可能也想把它封装成组件,然后把<Upload />或者其他组件以子组件的方式穿进去。后续我们会讲解如何封装该组件。

标签:reactantd
阅读(380)
Simple Empty
No data