3

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

 11 months ago
source link: https://www.xiabingbao.com/post/react/react-antd-cropper-s2cbvh.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
我们有很多上传图片的场景,都要用到裁剪和压缩的功能!

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

  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 地址,方便我们后续的裁剪和压缩处理。

裁剪过程,我们是使用到了 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()在每次裁剪结束时都会触发,得到一个新的裁剪后的图片地址,但只有点击弹窗中的确定按钮后,才会指定压缩的操作。

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

  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 发生了变动。

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

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

裁剪压缩后的图片-蚊子的前端博客

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK