Canvas优雅入门以及苹果官网案例

1,636 阅读6分钟

自我介绍

大家好,我叫JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。

前言

Canvas技术可以说是web技术花样最多的了。 根据mdn,它是由HTML5<canvas>标签和Canvas API所构成。主要聚焦于2d图形,当然也可以结合webgl作3d图形。

那么它可以做什么呢?

  1. 协同文档(也有基于svg的)
  2. 游戏(系兄弟就来砍我)
  3. 可视化图表(echarts、antV)
  4. 网页的动画(苹果官网某些部分)
  5. 图片编辑(在线ps)
  6. 实时视频处理

下面来学习一下吧

边上手边认识API

搭建环境

新建一个canvas画布元素

<template>
    <canvas id="canvas" width="500" height="500">
        
        <div> 你的浏览器不支持Canvas </div>

    </canvas>
</template>

注意: 这里别同时用css和width/height来设置画布大小,否则会出现缩放,导致后续画图的时候坐标对应不上。

canvas上下文

这里的上下文可以理解为生成一个画笔

const canvas = document.querySelector('#canvas')
// 拿到渲染上下文
const ctx = canvas.getContext('2d')

iShot_2023-03-20_19.33.28.png 可以看到拥有一些基本信息,方法在原型CanvasRenderingContext2D上。

画一条直线吧

画画肯定需要告诉它你需要画在什么地方吧? 就像初中物理课上的坐标系一样,需要一个标准去描述你的画笔🖌️应该在什么位置。 Canvas的坐标系跟position布局的差不多啊

iShot_2023-03-20_19.38.04.png

那么开始画画吧

// 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
ctx.beginPath()
// 起点,类比于下笔
ctx.moveTo(100,50)
// 经过的路径点
ctx.lineTo(100,100)
// 经过的路径点
ctx.lineTo(50,100)

// 有路径之后,需要通过线条来绘制图形轮廓。
ctx.stroke()

从起点(100,50)-->路径点1(100,100)-->路径点2(50,100)三个点组成半个正方形。

没错,画完是这样子的

iShot_2023-03-20_19.53.32.png 这里是我自己加了背景色。

三角形

同样的可以画个三角形 在刚才的基础之上,使用closePath()方法即可

。。。刚才的代码
闭合路径,使得图形绘制命令又重新指向到上下文中。
ctx.closePath()
// 注意需要在填充线条之前闭合路径
ctx.stroke()

image.png

如果需要颜色的话可以使用fill()去填充。注意,比如刚刚未closePath()时也可以填充,此时会自动填充

image.png

正方形

rect(x,y,width,height) 生成矩形路径 (注意了,路径就需要stroke()绘制) 也可以这样 strokeRect(x,y,width,height) = rect(x,y,width,height) + stroke()

矩形擦除(清除画布) clearRect(x,y,width,height) 擦除范围内的矩形

矩形路径填充 fillReact(x,y,width,height) = rect(x,y,width,height) + stroke()(可有可无) + fill()

圆形

arc(x,y,r,startAngle,endAngle,anticlockwise) r -- 当然是 半径啦 x,y -- 当然是组成 圆心 坐标点啦 startAngle 、endAngle -- 开始结束角度 从x正半轴到y正半轴 (弧度制) anticlockwise -- boolean 是否逆时针 ,默认为flse

ctx.arc(100,150,30,0,Math.PI/2,true)
ctx.fill()

image.png

ctx.arc(100,150,30,0,Math.PI/2,false)
ctx.fill()

image.png

贝塞尔曲线

贝塞尔曲线就是PhotoShop上面到钢笔工具那个 它有两种

2次贝塞尔曲线 1个控制点 1个结束点

  • quadraticCurveTo(cp1x, cp1y, x, y) cp1x,cp1y 为一个控制点,x,y 为结束点

3次贝塞尔曲线 2个控制点 1个结束点

  • bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) cp1x,cp1y为控制点一,cp2x,cp2y为控制点二,x,y为结束点。

image.png 图中蓝点为起始点 ,红点为控制点

有看官可能会问,那起点呢?可别忘了我们的moveTo(x,y)

一般来说用photoshop话会比较直观,靠想象太难了

文字

对没错,文字同样可以在画布上显示 同样的fill和stroke两兄弟搭配 fillText(text,x,y,[, maxWidth]) maxWidth最大宽度 ,超过会被横向缩放

strokeText(text,x,y,[, maxWidth])

ctx.font = "bold 48px serif";

文本对齐全 ctx.textAlign = 'left'|'right'|'center'

文字方向

ctx.direction = 'ltr'|'rtl'|'inherit'

图片

drawImage(image, dx, dy)
drawImage(image, dx, dy, dWidth, dHeight)
// 前四代表裁剪的部分
drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

// 示例
const img = new Image()

img.src = '../xxx.png'

ctx.drawImage(img,200,200,100,100)

可以看到,这方法可以重载为三种方法,简单区分就是 dx,dy是根据目标画布的坐标点,sx,sy代表image 的矩形(裁剪)选择框 基于(左上角) 坐标点

image.png

style

通过fill stroke 两兄弟搭配style来设置上下文的颜色 ctx.strokeStyle ='blue' ctx.fillStyle ='blue'


ctx.fillStyle = color;
ctx.fillStyle = gradient;
ctx.fillStyle = pattern;

同时可以设置lineCaps末端颜色 lineWidth路径宽度 setLineDash([实线长度,间隔长度])

保存和重置

设置了上下文的颜色啊,字体大小之类的会影响我们后续要画的内容,因此需要一个重置的步骤 但这样不太优雅,因此可以使用save()和restore()方法去存档和恢复 save() restore()

注意:这里的restore可以恢复到上次保存的状态(这里只包括颜色,线宽之类的)

变形

  1. 移动坐标系原点 translate(x,y)
  2. 旋转坐标系 rotate(angle)
  3. 伸缩 scale(x,y) 水平/垂直伸缩因子 注意: save 和restore方法也是可以恢复这些状态的

实战

实战部分分为两个小例子

签名版

利用mouseMove mouseDown mouseUp 3个事件,结合刚刚路径和线条方法可以实现一个具有清除/保存功能的签名版

image.png

苹果官网小动画

可以看到MacbookPro的M2芯片这里的动画有个滚动时才会触发的动画

QQ20230501-153921-HD.gif

是不是很神奇?

通过观察代码可以发现,这是用Canvas来绘制帧动画实现的

思路就是scroll滚动时候触发对应帧的动画渲染,渲染则是通过canvas2d 上下文的drawImage方法

下面来实现一下

构建DOM页面结构

// 设定一个高度,模拟苹果官网
<body style="height: 3000px;">

<div id="app">

    <div>

    这是苹果官网的其他内容

    </div>

    <div id="container">

        <canvas id="pro" width="308" height="308">

            <div> 你的浏览器不支持Canvas!</div>

        </canvas>

        <canvas id="max" width="308" height="308">

            <div> 你的浏览器不支持Canvas!</div>

        </canvas>

    </div>
</div>
</body>

设定一下style

body {

    margin: 0;

    padding: 20px;

}

  


#container {

    display: flex;

    flex-wrap: wrap;

    justify-content: space-around;

    position: relative;

    top: 500px;

    padding: 5%;

    background-image: url('./03_source/m2_bg__e4dkdscoyaaa_small_2x.jpg');

}

  


#container canvas {

    margin-bottom: 20px;

}

利用gasp库实现滚动悬浮的效果

// 利用gsap这个库控制滚动到经过盒子时,盒子悬浮在视口上

// 当滚动条滚动超过盒子的范围时,取消悬浮效果

// 注册ScrollTrigger插件

gsap.registerPlugin(ScrollTrigger);

gsap.to("#container", {
    opacity: 1,
    scrollTrigger: {
        trigger: "#container",
        duration: 2,
        start: `top ${TOP}px`, // 从盒子顶部15px处开始固定
        scrub: true, // 表示动画可以重复执行改成false表示只执行一次
        // markers: true, // 绘制开始位置和结束位置的线条 (开发的时候可以打开更直观)
        pin: true, // 动画执行期间,页面不进行滚动,动画执行结束后
    },
});

接下来的思路就是

拿到渲染上下文 --> 封装好渲染函数 --> 在scroll事件的回调中实现关键逻辑

下面看看回调当中的关键逻辑


// 记录下初次滑动到悬停位置

let firstScrollTop

const handleScroll = ()=>{

    const top = container.getBoundingClientRect().top // 容器距离视口的高度

    const height = container.getBoundingClientRect().height // 432px 容器的高度
    
    // 此时容器开始滚动停滞
    if(top === TOP){
        // 拿到此时滚动的距离
        const scrollTop = document.body.scrollTop
        // 记录下初次停滞时候的滚动距离
        if(firstScrollTop == undefined){
            firstScrollTop = document.body.scrollTop
        }
        // 求出当前滚动和初次触发停滞时候的差值 
        // 比上容器本身的高度获得百分比(当滚动的长度超过容器高度时,容器不再停滞)
        const percent = (scrollTop - firstScrollTop) / height
        // 百分比和需要渲染的帧相乘,得出当前这个滚动距离应该渲染的帧(即图片)
        const renderIndex = Math.ceil(percent * TOTAL_PICS)

        // 借助window.requestAnimationFrame来渲染
        requestAnimationFrame(()=>{
            renderImg(renderIndex)
        })

    }

}

其余函数

渲染对应的帧

const renderImg = (index)=>{

    if(index == undefined ) return

    if(IMG_MAP.pro[index]){

        proCtx.clearRect(0,0,CANVAS_HEIGHT,CANVAS_HEIGHT)

        proCtx.drawImage(IMG_MAP.pro[index],0,0)

    }

    if(IMG_MAP.max[index]){

        maxCtx.clearRect(0,0,CANVAS_HEIGHT,CANVAS_HEIGHT)

        maxCtx.drawImage(IMG_MAP.max[index],0,0)

    }
}


// canvas 容器的宽高

const CANVAS_HEIGHT = 308

const TOTAL_PICS = 52 // 总共的img、

// 存放Image对象

const IMG_MAP = {

    max: new Array(TOTAL_PICS).fill(null),

    pro: new Array(TOTAL_PICS).fill(null),

}

// 从静态资源当中引入图片,并且包装成Image对象

const loadImages = ()=>{

    return new Promise((resolve,reject)=>{

        let count = 0

        for(let i = 0 ;i <=TOTAL_PICS ; i ++ ){

            const n = i < 10 ? `0${i}` : `${i}`

            const pro = new Image()

            pro.src = `./03_source/m2pro/small_00${n}.jpg`

            const max = new Image()

            max.src = `./03_source/m2max/small_00${n}.jpg`

            // 异步加载需要控制下

            pro.onload = ()=>{

                IMG_MAP.pro[i] = pro

                count ++

                if(count == 2*(TOTAL_PICS+1)){

                    resolve()

                }

            }

            max.onload = ()=>{

                IMG_MAP.max[i] = max

                count ++

                if(count == 2*(TOTAL_PICS+1)){

                    resolve()

                }

            }

            // 处理错误

            pro.onerror = max.onerror = ()=>{

                reject()

            }

        }

    })

}

具体的实现代码可以参考我的github

参考资料

MDN-Canvas_tutorial