动手实现一个头像上传功能

开篇

在一些 toc 类面向个人用户的网站中,都会为用户提供自由选择上传头像功能。前端可以借助 canvasCanvas API 实现图像绘制,并在此基础上进行 缩放、移动 调整头像位置。

类似于微软 Microsoft 头像上传交互:

下面会先介绍一些与实现 头像上传 有关的基础知识,如果这些内容你都掌握,可跳过进入 「头像上传具体实现」 查看具体实现。

前置知识 - 如何让 canvas 绘制图像不模糊

在使用 canvas 绘制 img 图片时常会遇到一个问题:绘制出来的图片,比使用 <img /> 标签呈现的图片要模糊

要解答这个疑惑,需要先了解一个前置知识:window.devicePixelRatio 设备像素比。

devicePixelRatio 返回当前显示设备的物理像素分辨率与 CSS 像素分辨率之比。当 devicePixelRatio 不等于 1 时说明屏幕像素与 CSS 像素有差异,出现模糊现象。

为解决 canvas 绘制图片的模糊问题,通常是将 canvas 画布大小按照 devicePixelRatio 设备像素比 进行 等比例缩放 来解决,示例如下:

js 复制代码
<canvas id="canvas"></canvas>

var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');

// 1. canvas 的布局大小不变
var size = 200;
canvas.style.width = size + "px";
canvas.style.height = size + "px";

// 2. canvas 实际大小与 设备像素比 同步
var scale = window.devicePixelRatio;
canvas.width = Math.floor(size * scale);
canvas.height = Math.floor(size * scale);

// 3. canvas 绘制内容需要与 实际大小 同步
ctx.scale(scale, scale);

前置知识 - 让图片自适应并居中展示在容器中

通常头像上传区域是一个正方形容器,将用户选择的图片合理展示在容器内。

然而,图片的尺寸大小存在不同,如正方形尺寸、横向长方形尺寸、竖向长方形尺寸 等。

现在我们期望图片的核心区域(取中间内容)能够完全展示在裁剪区域中,类似于实现 img 标签 object-fit: cover; 这样的效果。

html 复制代码
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    .container{
      width: 200px;
      height: 200px;
      margin: 200px auto;
      border: 2px solid #000;
    }
    img{
      width: 100%;
      height: 100%;
      object-fit: cover;
    }
  </style>
</head>
<body>
  <div class="container">
    <img src="https://img1.baidu.com/it/u=1458656822,2078909008&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=750" alt="">
  </div>
</body>
</html>

那么使用 JS 如何实现呢?我们以正方形容器为例(正方形便于理解),要实现图片自适应居中展示分两个步骤:

  1. 将图片短的一边(宽/高 取一边)完全展示在容器内,让图片长的一边按照短边与容器的比例,以等比例的方式计算出新的尺寸;
  2. 长的一边经过计算后会有多出的部分,让长边居中展示在容器内,上/下(或者 左/右)平分多出的部分。

首先拿到图片的实际宽高:

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    .container{
      width: 200px;
      height: 200px;
      margin: 200px auto;
      position: relative;
    }
    .container-cover{
      position: absolute;
      inset: 0;
      z-index: 100;
      border: 2px solid aqua;
    }
  </style>
</head>
<body>
  <div class="container">
    <div class="container-cover"></div>
    <img class="img" src="https://img1.baidu.com/it/u=1458656822,2078909008&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=750" alt="">
  </div>

  <script>
    const container = document.querySelector('.container'),
      img = document.querySelector('.img');
    const { clientWidth: containerWidth, clientHeight: containerHeight } = container;

    img.addEventListener('load', () => {
      const { width: imgWidth, height: imgHeight } = img;
    });
  </script>
</body>
</html>

接着,让图片短边与容器对齐,等比例计算长边的尺寸,并设置为图片的新宽高,代码如下:

js 复制代码
img.addEventListener('load', () => {
  const { width: imgWidth, height: imgHeight } = img;

  // 1. 第一步:以图片的短边(宽/高 取一边)完全展示在容器内,另一个长边自适应展示,计算得到图片基于容器要展示的 宽/高(不伸缩,会有多出的部分)
  const getImageRenderSize = () => {
    let width = containerWidth, height = containerHeight;
    // 情况一:以容器正方形宽高比例等于 1 为例,图片的宽要大于高,属于横向长方形
    // 让图片「高度」完全展示在容器内,图片宽度会有多出部分,等比例计算图片新宽度
    if (imgWidth / imgHeight > containerWidth / containerHeight) {
      width = imgWidth * (height / imgHeight);
    } 
    // 情况二:以容器正方形宽高比例等于 1 为例,图片要么是正方形,要么高要大于宽,属于竖向长方形
    // 让图片「宽度」完全展示在容器内,图片高度会有多出部分,等比例计算图片新高度
    else {
      height = imgHeight * (width / imgWidth);
    }
    return { width, height }; // 基于容器计算得到了 图片 cover 渲染尺寸
  }

  const { width, height } = getImageRenderSize();
  img.style.width = width + 'px';
  img.style.height = height + 'px';
}

到这里,已经实现了图片最短一侧完全展示在了容器中,另一长边等比例进行自适应,但会有多出的部分。下面来让长边居中展示在容器内,以此来平分剩余的部分。

js 复制代码
img.addEventListener('load', () => {
  ...
  // 2. 第二步:将长边内容居中展示,取中间的部分展示在容器中
  img.style.marginLeft = - (width - containerWidth) / 2 + 'px'; // 计算偏移量
  img.style.marginTop = - (height - containerHeight) / 2 + 'px';
}

示例中使用的是一个竖向长方形图片,在容器中自适应居中效果如下:

前置知识 - PC Web 实现双指缩放

对于一个头像上传功能,头像预览区域需要支持对图片进行 放大。缩小。在电脑端比如 Mac,可以借助触摸板来实现双指缩放,这需要用到 onwheel 事件。

滚轮(wheel)事件会在滚动鼠标滚轮或者支持双指滑动(如 MAC 触摸板)设备上触发。wheel 事件代替了已被弃用的非标准 mousewheel 事件。

onwheel 事件类型为 WheelEvent,继承父接口:MouseEvent、UIEvent 和 Event 下的相关属性。主要的事件参数信息:

  1. WheelEvent.deltaX,返回一个浮点数(double),表示水平方向的滚动量。
  2. WheelEvent.deltaY,返回一个浮点数(double),表示垂直方向的滚动量。
  3. WheelEvent.deltaZ,返回一个浮点数(double)表示 z 轴方向的滚动量。

下面通过一个示例来理解 onwheel 如何实现双指缩放。

js 复制代码
<div>使用鼠标滚轮来进行缩放</div>

function handleWheel(event) {
  event.preventDefault(); // 阻止浏览器默认缩放行为
  
  // 在触摸板使用两指垂直方向向外滑动,视为放大操作,event.deltaY 为 - 负数;
  // 在触摸板使用两指垂直方向向内滑动,视为缩小操作,event.deltaY 为正数;
  // 一次事件触发,若操作两指滑动的距离越长,滚动量数值会越大;两指一点点滑动,得到的滚动量数值就越小;
  scale += event.deltaY * -0.01;

  // 限制 scale, .125 <= scale <= 4
  scale = Math.min(Math.max(.125, scale), 4);

  // Apply scale transform
  el.style.transform = `scale(${scale})`;
}

let scale = 1;
const el = document.querySelector('div');
el.onwheel = handleWheel;

如果你使用的是 React 框架,并且在 JSX 中为元素绑定 onWheel 事件,缩放时你会发现浏览器也跟着缩放,即 event.preventDefault() 未生效,并且在控制台会看到以下信息:Unable to preventDefault inside passive event listener invocation.。建议使用 el.addEventListener('wheel', handleWheel, { passive: false }); 来绑定事件允许执行 preventDefault。

前置知识 - 移动 H5 实现双指缩放

若想在 H5 下实现双指触摸屏幕进行拉伸缩放,需要借助 touch 相关事件来实现。

js 复制代码
let scale = 1; // 缩放大小
let startScale = scale; // 记录起始 scale
let startTouchs = []; // 记录起始 touchs

// 计算两指之间的距离(如:H5 两指 touch 后,两点之间的距离)。
function getDistance(p1, p2) {
  const x = p2.clientX - p1.clientX;
  const y = p2.clientY - p1.clientY;
  return Math.sqrt(x * x + y * y); // 求平方根,计算两指之间的直线距离(单位像素)。
}

const handleTouchStart = event => {
  // 两指 touch
  if (event.touches.length === 2) {
    event.preventDefault();
    startScale = scale;
    startTouchs = event.touches;
  }
}
const handleTouchMove = event => {
  if (event.touches.length === 2) {
    event.preventDefault();

    // 基于 touchStart 起始位置,计算与每次 move 的间距,并转换为 scale
    let moveDistance = getDistance(event.touches[0], event.touches[1]) / getDistance(startTouchs[0], startTouchs[1]);
    scale = startScale * moveDistance;
    // or scale = startScale + (moveDistance - 1);
    
    // 限制 scale, 1 <= scale <= 2.5
    scale = Math.min(Math.max(1, scale), 2.5);

    // Apply scale transform
    el.style.transform = `scale(${scale})`;
  }
}

const el = document.querySelector('div');
el.ontouchstart = handleTouchStart;
el.ontouchmove = handleTouchMove;

头像上传具体实现

下面我们将分为三步来实现 更换和裁切 用户头像功能,其中区域和图像均采用 canvas 来绘制。

第一步,canvas 绘制用户头像裁剪区域

头像裁剪区域由三部分组成:

  • 方形图像展示区域;
  • 圆形头像裁剪区域及圆形边框;
  • 在裁剪区域绘制头像图片。
  1. 绘制方形图像展示区域

首先定义一个 canvas 元素,尺寸为 200*200,并绘制一层半透明图像遮罩。

js 复制代码
<!-- 现在有一个 canvas 元素 -->
<canvas id="canvas"></canvas>

// 首先初始化画布绘制上下文,并根据设备像素处理画布模糊问题
const containerWidth = 200, containerHeight = 200, border = 4; // 尺寸
const pixelRatio = window.devicePixelRatio ? window.devicePixelRatio : 1; // 设备像素比

const canvas = document.querySelector('#canvas');
canvas.width = containerWidth * pixelRatio;
canvas.height = containerHeight * pixelRatio;
canvas.style.width = containerWidth + 'px';
canvas.style.height = containerHeight + 'px';
canvas.style.cursor = 'move';

const ctx = canvas.getContext('2d'); 
// 设备像素模糊处理
ctx.scale(pixelRatio, pixelRatio);

// 接着,绘制正方形图片背景区域,为半透明状态
ctx.fillStyle = 'rgba(0, 0, 0, .5)';
ctx.fillRect(0, 0, containerWidth, containerHeight);
  1. 绘制圆形头像裁剪区域及圆形边框

在这里会用到 canvas arc() 来绘制圆形,使用 clip() 和 clearRect() 裁切出圆形区域,用于后续预览用户期望上传的图像。

js 复制代码
// 绘制圆形头像裁剪区域
ctx.save();
ctx.beginPath();
ctx.arc(containerWidth / 2, containerHeight / 2, containerWidth / 2, 0, 2 * Math.PI); // 画圆
ctx.clip(); // 裁切
ctx.clearRect(0, 0, containerWidth, containerHeight); // 清除圆形中心区域
ctx.restore();

// 绘制圆形头像边框
ctx.strokeStyle = '#fff'; // 边框颜色
ctx.lineWidth = border; // 边框宽度
ctx.beginPath();
ctx.arc(containerWidth / 2, containerHeight / 2, containerWidth / 2 - border / 2, 0, 2 * Math.PI);
ctx.stroke(); 

效果图如下:

  1. 绘制头像

canvas api 提供了 drawImage() 方法实现图像绘制。它有三种使用形式,最基础的一种是 drawImage(image, x, y)

image 是 image 或者 canvas 对象,x 和 y 是其在目标 canvas 里的起始坐标。

这里我们先使用第一种方式将图像绘制出来:

js 复制代码
// 渲染头像图片
const image = new Image(); // 创建图像
image.onload = () => {
  drawImage();
}
image.src = "https://img1.baidu.com/it/u=1458656822,2078909008&fm=253&fmt=auto&app=120&f=JPEG?w=500&h=750";

const drawImage = () => {
  // 在现有的画布内容后面绘制新的图形。
  ctx.globalCompositeOperation = 'destination-over';
  // 1. 第一版:按照图片原样大小,渲染在容器内,不做任何适配
  ctx.drawImage(image, 0, 0);
}

在这里,我们给 image.src 使用了线上图片 url 地址,在真实业务场景通常是由用户选择本地文件,作为 image.src 图片展示,具体参考:

js 复制代码
<input type="file" id="file" onchange="changeFile(this)">

function changeFile(target) {
  const file = target.files[0];
  const previewUrl = URL.createObjectURL(file);

  image.src = URL.createObjectURL(file); // 生成 blob url
  URL.revokeObjectURL(previewUrl); // 释放内存
}

绘制出来图像后你会发现,图片是从画布容器左上角开始绘制,我们期望图像能够 自适应居中 放置在容器内,结合上文 [「前置知识 - 让图片自适应并居中展示在容器中」](#「前置知识 - 让图片自适应并居中展示在容器中」 "#img_center_show") 可以拿到图像要渲染的尺寸以及偏移量:

js 复制代码
const calculatePosition = () => {
  const getImageRenderSize = () => {
    let width = containerWidth, height = containerHeight;
    let imgWidth = image.width, imgHeight = image.height;
    // 让图片「高度」完全展示在容器内,等比例计算图片新宽度
    if (imgWidth / imgHeight > containerWidth / containerHeight) {
      width = imgWidth * (height / imgHeight);
    } 
    // 让图片「宽度」完全展示在容器内,图片高度会有多出部分,等比例计算图片新高度
    else {
      height = imgHeight * (width / imgWidth);
    }
    return { width, height }; // 基于容器计算得到了 图片 cover 渲染尺寸
  }
  let { width, height } = getImageRenderSize();

  return {
    width,
    height,
    x: - (width - containerWidth) / 2, // 计算缩放后的居中偏移量
    y: - (height - containerHeight) / 2,
  }
}

有了图片在画布容器内展示的实际宽高及居中偏移量,接下来会用到 canvas drawImage 的第二种形式 缩放 Scaling: drawImage(image, x, y, width, height)

对比第一种形式多了 2 个参数:width 和 height,这两个参数用来控制 当向 canvas 画入时应该缩放的大小(渲染图像的实际尺寸)。我们改造一下 drawImage 具体如下:

js 复制代码
const drawImage = () => {
  // 在现有的画布内容后面绘制新的图形。
  ctx.globalCompositeOperation = 'destination-over';
  // 2. 第二版:图片取中间部分做展示,类似于 css object-fit: cover;
  const { width, height, x, y } = calculatePosition();
  ctx.drawImage(image, x, y, width, height);
}

到这里,图像的展示完成实现。接下来是对图像进行操作:双指缩放鼠标移动 图像。

第二步,实现图像缩放

图像缩放能够让用户自由选择合适的图像区域进行上传。这里可以为裁切区域添加「双指缩放」事件来实现,下面以 PC Web 端 onwheel 交互为例。

首先是定义一个 scale 变量用于记录当前缩放值,接着为 canvas 绑定 onwheel 事件来调整 scale 的大小。

js 复制代码
// 实现双指缩放头像
let scale = 1;
canvas.addEventListener('wheel', event => {
  event.preventDefault(); // 禁用默认行为

  // 限定缩放区间
  scale += event.deltaY * -0.01;
  scale = Math.min(Math.max(1, scale), 4);
  
  // 清除画布再进行绘制
  ctx.clearRect(0, 0, containerWidth, containerHeight);
  paint();
  drawImage();
});

这里需要先清除画布内容,再基于 scale 进行图片缩放渲染。其中 paint() 封装了第一步提到的绘制图像区域相关代码,在每次执行清除后,所有内容都需要重新绘制。

js 复制代码
const paint = () => {
  // 绘制正方形图片背景区域,为半透明状态
  ctx.fillStyle = 'rgba(0, 0, 0, .5)';
  ctx.fillRect(0, 0, containerWidth, containerHeight);

  // 绘制圆形头像裁剪区域
  ctx.save();
  ctx.beginPath();
  ctx.arc(containerWidth / 2, containerHeight / 2, containerWidth / 2, 0, 2 * Math.PI); // 画圆
  ctx.clip(); // 裁切
  ctx.clearRect(0, 0, containerWidth, containerHeight); // 清除圆形中心区域
  ctx.restore();

  // 绘制圆形头像边框
  ctx.strokeStyle = '#fff'; // 边框颜色
  ctx.lineWidth = border; // 边框宽度
  ctx.beginPath();
  ctx.arc(containerWidth / 2, containerHeight / 2, containerWidth / 2 - border / 2, 0, 2 * Math.PI);
  ctx.stroke(); 
}

drawImage() 绘制时,图像的尺寸及偏移量需要基于 scale 重新计算,在 calculatePosition 中新增代码如下:

js 复制代码
const calculatePosition = () => {
  const getImageRenderSize = () => {
    ...
  }
  let { width, height } = getImageRenderSize();
+  width = width * scale;
+  height = height * scale;

  return {
    width,
    height,
    x: - (width - containerWidth) / 2, // 计算缩放后的居中偏移量
    y: - (height - containerHeight) / 2,
  }
}

现在双指在 canvas 区域进行滑动,图像会同步进行缩放。

第三步,实现图像移动(拖动)

头像裁切增加移动交互,能够在缩放的前提下,用户可以自由选择要上传的图像位置。

我们先来思考下 「移动图像」 的原理。

calculatePosition() 中返回了 x、y 标识图像在容器中展示的偏移量,如果我们通过控制 偏移量 是不是就可以实现图像移动。

这里我们需要定义 position 变量来记录移动的数值,数值范围为 0 - 1,默认数值我们给 0,5 表示将图像居中展示在画布容器内。

js 复制代码
let position = { x: 0.5, y: 0.5 };

接下来的移动操作会通过修改 position 来影响图片的展示位置,所以在 calculatePosition() 中偏移量的计算方式由 (width - containerWidth) / 2 调整为 (width - containerWidth) * position.x

js 复制代码
const calculatePosition = () => {
  ...
  return {
    width,
    height,
    x: - (width - containerWidth) * position.x, // 计算缩放后的居中偏移量
    y: - (height - containerHeight) * position.y,
  }
}

对于 PC Web 端实现移动,需要用到 onmousedown、mousemove、mouseup 鼠标移动事件。

根据 mousemove 移动事件中鼠标移动的位置 减去 鼠标按下开始时的位置,即可计算得到 position 的偏移量。完整实现如下:

js 复制代码
// 记录鼠标按下移动前的起始信息
let startPosition = { 
  // 当前偏移量
  x: 0,
  y: 0,
  // 当前鼠标位置
  clientX: 0,
  clientY: 0,
}

canvas.addEventListener('mousedown', event => {
  event.stopPropagation();
  const { clientX, clientY } = event;
  startPosition = { x: position.x, y: position.y, clientX, clientY }

  const onMouseMove = event => {
    event.preventDefault();
    const { clientX, clientY } = event;

    // 从鼠标按下起,当前移动的距离
    const diffX = clientX - startPosition.clientX, diffY = clientY - startPosition.clientY;
    const { width, height, x, y } = calculatePosition();
    const marginWidth = width - containerWidth, marginHeight = height - containerHeight; // 除画布容器外,边缘的宽高

    // (移动前的偏移量 - 本次移动的距离)/ 可移动的总距离,得到偏移量
    const newPositionX = marginWidth === 0 ? 0 : (marginWidth * startPosition.x - diffX) / marginWidth; // 兼容 0 不能做被除数
    const newPositionY = marginHeight === 0 ? 0 : (marginHeight * startPosition.y - diffY) / marginHeight;

    position.x = Math.min(Math.max(0, newPositionX), 1); // 限制区间范围 0 - 1(这里不用做小数点保留,会导致移动过程中卡顿)
    position.y = Math.min(Math.max(0, newPositionY), 1); // 限制区间范围 0 - 1

    // 重新绘制
    ctx.clearRect(0, 0, containerWidth, containerHeight);
    paint();
    drawImage();
  }

  const onMouseUp = event => {
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mouseup', onMouseUp);
  }

  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
});

到这里,图像移动我们已经实现。

如果你发现移动过程中并不丝滑,可能是对 position 数值进行了小数位保留(如:position.x.toFixed(2)),这里无需做这样的处理,否则会影响移动图像的流畅度。

最后,生成图片

在前端可以通过 canvas.toDataURL('image/png') 将 canvas 结构生成图片。

但对于头像裁剪上传,图像的生成通常由后台来完成,可以将 scale 缩放 和 position 偏移量 及 图片 信息,传递给到后台来做生成图片操作。

另外 canvas drawImage() 还存在第三种形式 drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

第一个参数代表 image 图像,其他 8 个参数:前 4 个是定义「图像」的切片位置和大小,后 4 个是定义图像在「画布」中的位置和大小。

在本文中并为使用到,感兴趣的同学可以进一步了解。

参考

mdn web docs canvas.

相关推荐
余生H3 分钟前
深入理解HTML页面加载解析和渲染过程(一)
前端·html·渲染
吴敬悦33 分钟前
领导:按规范提交代码conventionalcommit
前端·程序员·前端工程化
ganlanA34 分钟前
uniapp+vue 前端防多次点击表单,防误触多次请求方法。
前端·vue.js·uni-app
卓大胖_36 分钟前
Next.js 新手容易犯的错误 _ 性能优化与安全实践(6)
前端·javascript·安全
m0_7482463536 分钟前
Spring Web MVC:功能端点(Functional Endpoints)
前端·spring·mvc
CodeClimb37 分钟前
【华为OD-E卷 - 猜字谜100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
SomeB1oody1 小时前
【Rust自学】6.4. 简单的控制流-if let
开发语言·前端·rust
云只上1 小时前
前端项目 node_modules依赖报错解决记录
前端·npm·node.js
程序员_三木1 小时前
在 Vue3 项目中安装和配置 Three.js
前端·javascript·vue.js·webgl·three.js
lxw18449125141 小时前
vue 基础学习
前端·vue.js·学习