总结在使用canvas时遇到的一些通用效果实现与相关示例。

透底(剪裁)效果

使用clip API

See the Pen Clip target area by yrq110 (@yrq110) on CodePen.

示例中的蓝色小方块即为剪裁的区域,无透明效果的原始图案。

主要使用了如下三个元素:

  1. save()&restore()

    save()restore()是为了保留裁剪前的上下文状态,不然在需要交互(如移动透底框体)场景下,透底区域的图像就不会随着外部canvas内容的变化而改变了。

  2. Path2D

    region为一个Path2D对象,用于声明路径,可以直接在canvas2d上下文中使用。

    首先在region上添加目标区域矩形,其次添加整体区域矩形,则两者相交叉的部分即为目标的透底(剪裁)区域。之后在上下文上使用clip API应用该region区域即可对目标区域进行透底(裁剪)。

  3. clip()

    clip有两种填充模式:nonzero模式和evenodd模式,默认为nonzero。详情可以看张鑫旭的这篇文章了解。

    本例中使用的是evenodd模式(奇偶判断规则)。若想剪裁相关区域可以直接通过例子中targetArea所代表的目标区域来进行剪裁

相关QA:https://stackoverflow.com/questions/7821384/html-canvas-clip-area-context-restore

笔画效果

在canvas上想实现一个可以自由绘制的画笔效果,需要两个步骤

  1. 监听鼠标事件得到坐标数据
  2. 使用处理后的坐标进行绘制

在第2步中有两种方法进行绘制:一种是在监听到鼠标事件并处理坐标后立即绘制,第二种是通过一个标志位(开关)在requestAnimationFrame所执行的函数中进行绘制,区别在于体验时的流畅度、执行的频率以及性能。

鼠标事件

See the Pen bhiKl by Juriy Zaytsev (@kangax) on CodePen.

设置开始与结束绘制的标志位

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};
el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

在移动时通过标志点进行绘制,示例中为了使路径光滑使用了二次贝塞尔曲线函数

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);
  for (var i = 1, len = points.length; i < len; i++) {
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

鼠标事件 + requestAnimationFrame

同样根据判断鼠标事件中设置标志位绘制,不过是在requestAnimationFrame的动画关键帧函数中执行

See the Pen draw-stroke by yrq110 (@yrq110) on CodePen.

路径坐标绘制

同样可以使用Path2D来绘制一个给定一系列坐标的轨迹线,需要先将这些坐标点转换成SVG路径,直接传入Path2D构造函数即可。笔者这里用了svg-points这个库来将坐标点转换成SVG路径。

let points = contour.map(p => {
    let [x, y] = p;
    return { x, y };
});
points[0].moveTo = true;
let svgPath = toPath(points);
ctx.beginPath();
var p = new Path2D(svgPath);
ctx.stroke(p);
ctx.closePath();

See the Pen draw-svg-path by yrq110 (@yrq110) on CodePen.

图形或图像的透明效果

RGBA

在绘制时给填充或笔触颜色设置alpha值,就可以实现透明图形或图像的绘制。

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(255,0,255,0.2)';
ctx.fillRect(0, 0, 75, 75);

See the Pen Overlaying transparent shapes-4 by yrq110 (@yrq110) on CodePen.

globalAlpha

在canvas context上设定globalAlpha属性可以用来设置之后图形或图像绘制在canvas上的alpha值。

ctx.globalAlpha = 0.5;
ctx.fillStyle = '#FD0';
ctx.fillRect(0, 0, 75, 75); // 该矩形为半透明的

See the Pen Overlaying transparent shapes by yrq110 (@yrq110) on CodePen.

层叠图形的加深效果

若设置globalAlpha后绘制层叠图形,那么层叠的部分会出现透明度加深的效果

See the Pen Overlaying transparent shapes by yrq110 (@yrq110) on CodePen.

全局统一透明度

若想保持图形的透明度统一,可以采用加一层offscreen canvas(通过document.createElement('canvas')创建)的方式。

offscreen层为不透明的,将其绘制到onscreen层,设置onscreen层的globalAlpha属性即可。

See the Pen Overlaying transparent shapes-3 by yrq110 (@yrq110) on CodePen.

相关QA: https://stackoverflow.com/questions/33723384/how-to-reset-transparency-when-drawing-overlapping-content-on-html-canvas

擦除操作

设置globalCompositeOperation属性进行绘制即可,会将绘制图形的形状未覆盖的部分保留。

ctx.globalCompositeOperation = "destination-out";

在下面这个例子中,绘制的路径形状会被去除,显示为背景的白色。

See the Pen erase-effect by yrq110 (@yrq110) on CodePen.

图片绘制与导出

图片绘制

使用drawImage API进行绘制。

需要注意的是,表示图像源的第一个参数可以是多种类型:CSSImageValue, HTMLImageElement, SVGImageElement, HTMLVideoElement, HTMLCanvasElement, ImageBitmap, OffscreenCanvas。

即在常见的场景中,可以将图像绘制到画布,也可以将另一个画布的内容绘制到该画布上。

通过调整表示尺寸的source与destination参数可以在绘制时进行一定比例的缩放。

跨域问题

若跨域使用图片资源,并在画布上进行如下三种操作时:

  1. 在canvas上下文中调用getImageData()
  2. 在<canvas>元素上调用toBlob()
  3. 在canvas对象上调用toDataURL()

会提示Tainted canvases may not be exported的错误,此时需要给Image对象设置一个crossOrigin属性来解决。

如下是个简单封装的加载image方法:

export const loadImage = imgPath => {
  return new Promise((resolve, reject) => {
    let img = new Image();
    img.setAttribute("crossOrigin", "anonymous"); // to solve "Tainted canvases may not be exported" error
    img.onload = () => {
      resolve(img);
    };
    img.onerror = e => {
      reject(new Error(e));
    };
    img.src = imgPath;
  });
};

图片导出成base64

使用toDataURL API

let img = await loadImage(imagePath)
let { width, height } = img;
let canvas = document.createElement("canvas");
let ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height);
let imageData = canvas.toDataURL("image/png");
console.log('imageData: ', imageData)
// imageData:  data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAg4AAAIOCAYAAADQu4U5AAAgAElEQVR4Xu3dd5zdVZ3/8de5M5M6k4QmLqKsIhYgE4orsjbcddcVewklE5pYsLA2VBQRfooFFxXsNEWSCWBYy+qK7loQFREJGxKwF1BBBYEkM5M6c8/v8Z077EYI8J3J/Z45c+/r+6ee7/mc8zwn4Z3v/ZaAhwIKKKCAAgooUFIglGxnMwUUUEABBRRQAIO...

原始图像与mask遮罩合成

将mask层的alpha通道值赋予原始图像的alpha通道进行结果图的合成

// resultMaskData: { width, height, values }
...
let resultMaskLayer = document.createElement("canvas");
let resultMaskCtx = resultMaskLayer.getContext("2d");
resultMaskLayer.width = img.width;
resultMaskLayer.height = img.height;
resultMaskCtx.drawImage(img, 0, 0);
if (resultMaskData) {
  let { width: maskWidth, height: maskHeight, values } = resultMaskData;
  let maskData = resultMaskCtx.getImageData(0, 0, maskWidth, maskHeight);
  let size = maskWidth * maskHeight;
  for (let i = 0; i < size; i++) {
    if (values[i] !== 255) {
      maskData.data[(i + 1) * 4 - 1] = values[i];
    }
  }
  resultMaskCtx.putImageData(maskData, 0, 0);
}