背景

前段时间接了个需求,需要做个图片上传,预览及裁切服务,调研了下社区的开源方案,cropperjs 进入视野,简单看了下 README,完美符合我的需求,最终实现效果如下:

安装

没啥好说的,直接 yarn 走起:

1
yarn add cropperjs

引入 CSS 文件:

1
import "cropperjs/dist/cropper.css";

封装

简单的分析下需求,整个图片裁切包含以下几个部件:

  • 图片裁切框 - 包含「网页端」和「移动端」,需要分开裁切
  • 替换图片按钮 - 替换图片后,「网页端」和「移动端」都需要替换
  • 图片预览 - 同样「网页端」和「移动端」也需要分开预览

Cropper.js 是个原生的裁切库,在我们的 React 项目中按照上面的功能划分封装成以下几个组件:

  • Upload.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { useContext, memo } from "react";
import { Upload as PDUpload } from "@universe-design/react";
import { CropperContext } from "./Context";

type UploadProps = {
maxSize?: number;
children?: React.ReactNode;
accept?: string;
};

export const Upload = memo((props: UploadProps): JSX.Element => {
const { accept = "image/*", maxSize } = props;
const { setSrc } = useContext(CropperContext);

const beforeUpload = (file: File) => {
// 检查文件大小
if (file.size && maxSize && file.size > maxSize) {
return PDUpload.LIST_IGNORE;
}

const reader = new FileReader();
reader.onload = () => {
setSrc?.(reader.result as string);
};
reader.readAsDataURL(file);

return false;
};

return (
<PDUpload
maxCount={1}
beforeUpload={beforeUpload}
fileList={[]}
accept={accept}
>
{props.children}
</PDUpload>
);
});

这个组件的功能很简单,就是使用组件库的上传组件获取文件的 base64,然后保存到 context 里,方便裁切组件消费。

  • Context.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import React, { createContext, FC, useMemo, useState, useRef } from "react";
import { uniqueId } from "lodash";

export interface CropperState {
src?: string | undefined;
setSrc: (src: string) => void;
previewClass: string;
}

export interface CropperProviderProps {
src?: string | undefined;
}

export const CropperContext = createContext<CropperState>({} as CropperState);

export const CropperProvider: FC<CropperProviderProps> = (props) => {
const [src, setSrc] = useState(props.src);
const previewClass = useRef(uniqueId("image_preview_"));

const contextState = useMemo<CropperState>(
() => ({
src,
setSrc,
previewClass: previewClass.current,
}),
[src]
);

return (
<CropperContext.Provider value={contextState}>
{props.children}
</CropperContext.Provider>
);
};
  • Prview.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { useContext, memo } from "react";
import classnames from "classnames";
import { CropperContext } from "./Context";
import styles from "./index.less";

export interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
name?: string;
}

export const Preview: React.FC<PreviewProps> = memo((props) => {
const { name = "default", className, children, ...rest } = props;
const { previewClass } = useContext(CropperContext);

return (
<div
{...rest}
className={classnames(
`${previewClass}-${name}`,
styles["image-preview"],
className
)}
>
{children}
</div>
);
});

预览组件本身也不复杂,就是需要注意的是,Cropper.js 的 preview 参数可以是个元素,元素数组或者能够被 Document.querySelectorAll 选中的 class 选择器,因为封装成了通用组件,因此使用了 lodash 的 uniqueId 方法生成唯一的 class,防止页面出现多个 Prview 组件时,class 类名冲突。

  • Cropper.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
import React, { useContext, memo, useRef, useEffect } from "react";
import Cropper from "cropperjs";
import { useMemoizedFn, useInViewport, useUpdateLayoutEffect } from "ahooks";
import classnames from "classnames";
import "cropperjs/dist/cropper.css";
import { CropperContext } from "./Context";
import styles from "./index.less";

type CropperOptions = Cropper.Options<HTMLImageElement>;
type ReactCropperProps = Pick<CropperOptions, "aspectRatio"> & {
name?: string;
style?: React.CSSProperties;
className?: string;
alt?: string;
src?: string;
data?: Cropper.Data;
onCrop?: (data: Cropper.Data) => void; // 监听裁切框的变动
};

interface ReactCropperElement extends HTMLImageElement {
cropper: Cropper;
}

export type ReactCropperRef = Cropper;

const REQUIRED_IMAGE_STYLES = { opacity: 0, maxWidth: "100%" };

const ReactCropper = React.forwardRef<ReactCropperRef, ReactCropperProps>(
(props, ref) => {
const {
name = "default",
style,
className,
alt = "picture",
aspectRatio,
data,
onCrop,
} = props;
const boxRef = useRef<HTMLDivElement>(null);
const combinedRef = useRef<ReactCropperElement>(null);
const cropperState = useContext(CropperContext);
// 是否在可是区域内
const [inViewport] = useInViewport(boxRef);
const src = cropperState?.src || props?.src;
const preSrc = useRef<string | undefined>(src);
const preview = `.${cropperState.previewClass}-${name}`;

React.useImperativeHandle(ref, () => combinedRef.current!.cropper);

/**
* 满足以下条件才能重新 render
* 1. src 地址变更
* 2. 在可是区域内(如果不在可视区域,render 时会出问题)
*/
useEffect(() => {
if (
combinedRef.current?.cropper &&
typeof src !== "undefined" &&
inViewport &&
src !== preSrc.current
) {
combinedRef.current.cropper.reset().clear().replace(src);
preSrc.current = src;
}
}, [inViewport, src]);

const handleCrop = useMemoizedFn(() => {
if (combinedRef.current?.cropper) {
onCrop?.(combinedRef.current?.cropper.getData());
}
});

useUpdateLayoutEffect(() => {
data && combinedRef.current?.cropper.setData(data);
}, [data]);

useUpdateLayoutEffect(() => {
aspectRatio && combinedRef.current?.cropper.setAspectRatio(aspectRatio);
}, [aspectRatio]);

useEffect(() => {
if (combinedRef.current !== null) {
// eslint-disable-next-line no-new
new Cropper(combinedRef.current, {
viewMode: 1, // 限制裁剪框不能超出图片的范围
dragMode: "crop", // 拖拽图片时形成新的裁剪框
guides: true, // 是否显示裁剪框的虚线
scalable: false, // 是否可以缩放图片(可以改变长宽)
zoomable: false, // 是否可以缩放图片(改变焦距)
zoomOnTouch: false, // 是否可以通过拖拽触摸缩放图片
zoomOnWheel: false, // 是否可以通过鼠标滚轮缩放图片
center: false, // 是否显示裁剪框中间的 ‘+’ 指示器
responsive: false, // 是否在窗口尺寸调整后 进行响应式的重渲染
movable: false, // 是否可以移动图片
preview,
aspectRatio, // 设置裁剪框为固定的宽高比
data, // 之前存储的裁剪后的数据 在初始化时会自动设置
crop: handleCrop,
});
}

return () => {
combinedRef.current?.cropper?.destroy();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [preview, handleCrop]);

return (
<div
style={style}
className={classnames(styles.cropper, className)}
ref={boxRef}
>
<img
src={src}
alt={alt}
style={REQUIRED_IMAGE_STYLES}
ref={combinedRef}
/>
</div>
);
}
);

export default memo(ReactCropper);

最后就是重头戏的 Cropper 了,组件内部封装了 Cropper.js 初始化逻辑,图片切换的逻辑。

  • index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import CropperInner from "./Cropper";
import { CropperProvider } from "./Context";
import { Upload } from "./Upload";
import { Preview } from "./Preview";

export * from "./Cropper";
export * from "./Context";

const Cropper = Object.assign(CropperInner, {
Upload,
Provider: CropperProvider,
Preview,
});

export default Cropper;

最后就是将这些组件全部挂载到 Cropper,对外只暴露 Cropper 这一个组件。

使用

使用封装后的 Cropper 组件大概如下,其中 Cropper 组件和 Cropper.Preview 组件可以有任意多个,但是需要注意,两个组件的 name 字段要一一对应,否则会出现无法实时预览的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Cropper from "./Cropper";

export default () => {
return (
<Cropper.Provider src="xxxxx">
// 裁切组件
<Cropper
aspectRatio={PC_CROP_RATIO}
name="PC"
ref={(ref) => setRef(ref!, TYPE.web)}
className={styles["container-cropper"]}
onCrop={(data) => handleCrop(data, TYPE.web)}
data={cacheValue.current?.tailored_info?.web}
/>
// 上传组件
<Cropper.Upload accept={UPLOAD_ACCEPT} maxSize={UPLOAD_MAX_SIZE}>
<Button type="link">{i18next.t("replace-image")}</Button>
</Cropper.Upload>
// 预览组件
<Cropper.Preview
style={bannerStyle}
className={styles["preview-mobile"]}
name="mobile"
/>
</Cropper.Provider>
);
};

样式覆盖

组件逻辑解决了,现在解决样式问题。我们之前引入了 cropperjs 的默认样式文件,现在只需要覆盖掉它的样式就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 复写 Cropper 的样式
.cropper {
:global {
.cropper-modal {
opacity: 0.4;
}

.cropper-line {
background-color: #fff;
}

.cropper-point {
&.point-e,
&.point-n,
&.point-w,
&.point-s {
display: none;
}

&.point-ne,
&.point-nw,
&.point-sw,
&.point-se {
width: 20px;
height: 20px;
background: transparent;
opacity: 1;
}

&.point-nw,
&.point-ne {
border-top: 3px solid #fff;
}

&.point-nw,
&.point-sw {
border-left: 3px solid #fff;
}

&.point-ne,
&.point-se {
border-right: 3px solid #fff;
}

&.point-sw,
&.point-se {
border-bottom: 3px solid #fff;
}
}

.cropper-view-box {
outline-color: #fff;
}

.cropper-dashed {
border-style: solid;
opacity: 1;
}
}
}

问题

  1. 获取裁切后图片 blob

图片裁切,需要获取裁切图片的 blob 上传到 CDN,Cropperjs 提供了获取 blob 的方法:

1
cropper.getCroppedCanvas().toBlob((blob) => {})

但是在我们业务的场景下,同时会存在两个裁切框分别用来裁切「网页端」和「移动端」,用的都是同一个图片,也就是说替换图片会同时替换「网页端」和「移动端」的图片。这就导致了替换图片后,只有当前激活的 tab 下的 cropper 调用 getCroppedCanvas 才能获取到替换图片后的数据,没有激活的 tab 下还是获取旧图片的数据。

复现路径:

除非在提交前,手动切换到移动端的 tab,不然该问题会一直存在,这是 cropperjs 内部实现的问题,我们不能要求用户多这么一个无意义的步骤,因此我们自己实现 「getCroppedCanvas」方法,不用 cropperjs 暴露的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 使用 canvas 裁切图片
export const cropImage = (source: string, options: Options) => {
const { width, height, x, y } = options;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d")!;

const image = new Image();
image.src = source;
image.crossOrigin = "anonymous";

return new Promise<HTMLCanvasElement>((resolve) => {
image.onload = () => {
const [px, py, pw, ph] = [
inRange(x, 0, image.width) ? x : 0,
inRange(y, 0, image.height) ? y : 0,
inRange(width, 0, image.width) ? width : image.width,
inRange(height, 0, image.height) ? height : image.height,
].map((param) => Math.floor(normalizeDecimalNumber(param)));

canvas.width = pw;
canvas.height = ph;
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "low";
context.drawImage(image, px, py, pw, ph, 0, 0, pw, ph);
context.restore();
resolve(canvas);
};
});
};