开篇
在一些 toc 类面向个人用户的网站中,都会为用户提供自由选择上传头像功能。前端可以借助 canvas
和 Canvas 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 如何实现呢?我们以正方形容器为例(正方形便于理解),要实现图片自适应居中展示分两个步骤:
- 将图片短的一边(宽/高 取一边)完全展示在容器内,让图片长的一边按照短边与容器的比例,以等比例的方式计算出新的尺寸;
- 长的一边经过计算后会有多出的部分,让长边居中展示在容器内,上/下(或者 左/右)平分多出的部分。
首先拿到图片的实际宽高:
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 下的相关属性。主要的事件参数信息:
WheelEvent.deltaX
,返回一个浮点数(double),表示水平方向的滚动量。WheelEvent.deltaY
,返回一个浮点数(double),表示垂直方向的滚动量。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 绘制用户头像裁剪区域
头像裁剪区域由三部分组成:
- 方形图像展示区域;
- 圆形头像裁剪区域及圆形边框;
- 在裁剪区域绘制头像图片。
- 绘制方形图像展示区域
首先定义一个 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);
- 绘制圆形头像裁剪区域及圆形边框
在这里会用到 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();
效果图如下:
- 绘制头像
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 个是定义图像在「画布」中的位置和大小。
在本文中并为使用到,感兴趣的同学可以进一步了解。