头像裁剪不是“给图片加一个圆形遮罩”这么简单。可用于生产的组件需要把手势坐标、原图像素、Canvas 导出、文件体积和多端差异放在同一个模型里。本文不再把早期的样式原型描述成完整组件,而是整理一套可实现、可测试的设计。
组件的职责
一个边界清楚的 AvatarCropper 应负责:
- 展示本地临时图片或允许访问的网络图片。
- 支持拖动、双指缩放,以及可选的 90° 旋转。
- 保证图片始终覆盖裁剪框,不暴露空白区域。
- 将当前视口映射回原图像素,并导出指定尺寸。
- 返回临时文件路径和必要的元信息。
- 在取消、读取失败、图片过大和导出失败时给出明确结果。
它不应该直接上传头像、修改用户资料或决定压缩服务。裁剪与业务提交分开,才能重试、替换上传实现,也便于单独测试。
建议的 Props 与事件
interface CropperProps {
src: string
size?: number // 视口中的裁剪框尺寸
outputSize?: number // 输出图片像素,例如 512
minScale?: number
maxScale?: number
shape?: 'circle' | 'rect'
quality?: number
disabled?: boolean
}
interface CropResult {
tempFilePath: string
width: number
height: number
sourceWidth: number
sourceHeight: number
}
interface CropperEvents {
onConfirm?: (result: CropResult) => void
onCancel?: () => void
onError?: (error: Error) => void
}
圆形通常只是预览遮罩,导出文件仍是正方形。这样 JPEG 不需要透明通道,服务端也更容易统一处理;如果必须输出真正透明的圆形图片,应使用 PNG 并明确文件体积变化。
坐标模型
设原图尺寸为 sourceWidth × sourceHeight,在视口中的缩放比例为 scale,图片左上角位于 imageX, imageY,裁剪框左上角位于 cropX, cropY,边长为 cropSize。
裁剪框映射回原图:
interface Rect {
x: number
y: number
width: number
height: number
}
function viewportCropToSource(input: {
cropX: number
cropY: number
cropSize: number
imageX: number
imageY: number
scale: number
sourceWidth: number
sourceHeight: number
}): Rect {
const {
cropX,
cropY,
cropSize,
imageX,
imageY,
scale,
sourceWidth,
sourceHeight
} = input
const x = (cropX - imageX) / scale
const y = (cropY - imageY) / scale
const size = cropSize / scale
return {
x: Math.max(0, Math.min(x, sourceWidth - size)),
y: Math.max(0, Math.min(y, sourceHeight - size)),
width: Math.min(size, sourceWidth),
height: Math.min(size, sourceHeight)
}
}
这段换算成立的前提是图片没有任意角度旋转。加入旋转后,应使用统一的变换矩阵计算,不要继续堆叠针对四个方向的坐标补丁。
初始缩放与边界约束
图片必须完整覆盖裁剪框,所以最小缩放比例是:
function getCoverScale(
sourceWidth: number,
sourceHeight: number,
cropSize: number
) {
return Math.max(
cropSize / sourceWidth,
cropSize / sourceHeight
)
}
拖动或缩放后,将图片位置限制在:
function clampOffset(
offset: number,
renderedSize: number,
cropStart: number,
cropSize: number
) {
const min = cropStart + cropSize - renderedSize
const max = cropStart
return Math.min(max, Math.max(min, offset))
}
横纵轴分别约束。双指缩放应以手势中心为锚点,先记录开始时的距离、缩放和图片位置,再从初始状态计算;如果每个 touchmove 都在上一次结果上累加,误差会越来越大。
Canvas 导出流程
- 用
Taro.getImageInfo获取原图尺寸和可绘制路径。 - 根据当前变换计算原图裁剪矩形。
- 创建输出尺寸的 Canvas,并按设备像素和目标文件尺寸配置宽高。
- 使用
drawImage的九参数形式把原图裁剪区绘制到整个输出 Canvas。 - 等待绘制完成,再调用相应平台的导出 API。
- 校验返回路径、文件大小和图片宽高后触发
onConfirm。
示意代码:
context.drawImage(
image,
sourceRect.x,
sourceRect.y,
sourceRect.width,
sourceRect.height,
0,
0,
outputSize,
outputSize
)
Taro 同时存在旧版 createCanvasContext 和 2D/离屏 Canvas 能力,平台支持度并不完全一致。实现前应按项目所用 Taro 版本查看官方 Canvas 文档和离屏 Canvas 文档,不能只在微信开发者工具中验证一次就声明全端支持。
图片来源与安全
- 网络图片需要正确配置下载域名与 CORS;无法直接绘制时,先下载为本地临时文件。
- 不把用户选取的本地路径写入日志、埋点或错误上报。
- 限制原图尺寸和输出尺寸,避免超大图片导致内存峰值或 Canvas 失败。
- 上传前再次校验 MIME、后缀和服务端允许大小;客户端裁剪不能替代服务端校验。
- 页面退出或重新选图时,让上一次读取和导出结果失效,防止旧结果覆盖新图。
无障碍与交互
只有手势不够。至少提供:
- “放大、缩小、向左/右/上/下移动、旋转、重置”的可点击控制。
- 当前处理状态和导出失败提示。
- 明确的取消与确认按钮,确认中禁用重复提交。
- 足够大的触控区域,以及适配安全区的底部操作栏。
- 减少动态效果偏好下关闭非必要动画。
测试清单
- 横图、竖图、正方形、超长图和很小的图片。
- 图片恰好覆盖边界、最大/最小缩放、快速连续手势。
- 选图后立即取消、导出中重新选图、页面卸载。
- 中文和特殊字符文件名、网络图失败、Canvas 导出失败。
- 真机与开发者工具对比,尤其检查高 DPR 下的清晰度和内存。
- 输出图片四角与中心是否对应预览区域,避免“预览正确、导出偏移”。
一个头像裁剪组件最重要的不是动画有多顺,而是预览与最终像素一致,并且在失败和并发场景下不交付错误文件。