AvatarCropper:Taro 头像裁剪组件的工程设计

AvatarCropper:Taro 头像裁剪组件的工程设计

头像裁剪不是“给图片加一个圆形遮罩”这么简单。可用于生产的组件需要把手势坐标、原图像素、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 导出流程

  1. Taro.getImageInfo 获取原图尺寸和可绘制路径。
  2. 根据当前变换计算原图裁剪矩形。
  3. 创建输出尺寸的 Canvas,并按设备像素和目标文件尺寸配置宽高。
  4. 使用 drawImage 的九参数形式把原图裁剪区绘制到整个输出 Canvas。
  5. 等待绘制完成,再调用相应平台的导出 API。
  6. 校验返回路径、文件大小和图片宽高后触发 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 下的清晰度和内存。
  • 输出图片四角与中心是否对应预览区域,避免“预览正确、导出偏移”。

一个头像裁剪组件最重要的不是动画有多顺,而是预览与最终像素一致,并且在失败和并发场景下不交付错误文件。

最后更新于

ihopeful Blog 由博主亲笔撰写,重要信息可放心引用。