实现一个用于 Canvas 实时绘画的钩子 useRTDraw

399 阅读3分钟

用于 Canvas 实时绘画的钩子,大概为每秒 60 次的刷新。

主要采用 requestAnimationFrame 来进行实时绘画,并对高刷屏和高分辨率屏做了一定兼容。对于不支持 requestAnimationFrame 的采用 setInterval 替代。

useRTDraw 源码

使用方式

npm i lhh-ui

只需导入 useRTDraw 钩子,通过参数中的 onDraw 回调即可对 Canvas 进行实时绘画。其中的 onDraw 大概为每秒触发 60 次。

import React, { useEffect, useState } from "react"
import { useRTDraw } from "lhh-ui";

export default () => {
  const {drawState, canvasRef, canvasInfo, setCanvasInfo, startAnimation} = useRTDraw({
    onDraw() {
      // 注意此时 canvas 内部的尺寸,已经根据分辨率等比例缩放。
      drawState.ctx.clearRect(0, 0, canvasInfo.w * drawState.ratio, canvasInfo.h * drawState.ratio);
      // ... 对 canvas 进行绘画
    },
  })

  useEffect(() => {
    startAnimation()
  }, [])

  return (
    <canvas 
      ref={canvasRef}
      // 使 canvas 绘画区域增大
      width={canvasInfo.w * drawState.ratio}
      height={canvasInfo.h * drawState.ratio}
      style={{
        width: canvasInfo.w + 'px',
        height: canvasInfo.h + 'px',
      }}
    />
  )
}

使用代码可以参考码上掘金

注意事项

  1. 需要和 canvasref 进行绑定
  2. 需要根据屏幕实际分辨率,对 canvas 的宽高进行对应比例增加(主要是为了增加每 1px 中的像素点,使绘画更清晰)。
  3. onDraw 绘画时,记得对尺寸与 ratio 进行乘积。

例: 绘画一个宽高为 100px,在 canvas 中为横向 20px 纵向 50px 位置的矩形。

onDraw({ctx, ratio}) {
  // ...
  const rect = {
    w: 100 * ratio,
    x: 20 * ratio,
    y: 50 * ratio,
  }
  ctx.fillStyle = 'green';
  ctx.fillRect(rect.x, rect.y, rect.w, rect.w);
}

源码解析

useRTDraw 源码

钩子挂载获取数据

const [ratio, setRatio] = useState(1);
const drawState = useRef({
  ctx: void 0 as CanvasRenderingContext2D | undefined,
  isHighRefreshScreen: void 0 as boolean | undefined,
})

useEffect(() => {
  getCtx()
  // 获取分辨率
  setRatio(window.devicePixelRatio)
  getScreenFps().then(fps => {
    drawState.current.isHighRefreshScreen = fps > 65
  })
  return () => {
    cancelAnimation()
  }
}, [])

获取 canvas 上下文

注意,如果你的 canvas 为异步创建的,在挂载阶段没获取到也没关系,当调用 startAnimation 时也会获取一遍,只需确保调用 startAnimationcanvas 已经挂载好即可。

function getCtx() {
  if(drawState.current.ctx) return
  const ctx = canvasRef.current?.getContext('2d')
  if(ctx) {
    drawState.current.ctx = ctx
  }
}

判断是否为高刷屏

根据传参,计算出 60 次的累加,requestAnimationFrame 需要的用时是多少,非高刷屏该值一般在 60 左右。上方用 65 来比较是否为高刷屏即可。

/** 经过多少次计算后,获取fps */
export const getScreenFps = (total: number = 60): Promise<number> => {
  return new Promise(resolve => {
    if(typeof requestAnimationFrame === 'undefined') {
      return resolve(60)
    }
    const begin = Date.now();
    let count = 0;
    (function run() {
      requestAnimationFrame(() => {
        if (++count >= total) {
          const fps = Math.ceil((count / (Date.now() - begin)) * 1000)
          return resolve(fps)
        }
        run()
      })
    })()
  })
}

startAnimation 开始绘画

前面几行只是一些判断,没什么好说的;从 runDraw 开始触发判断,如果是高刷屏调用 startAnimationLockFrame 锁定 60 帧触发回调,否则就是根据 requestAnimation 正常触发函数回调来进行绘画。

function onDrawFn() {
  latest.current.onDraw({ctx: drawState.current.ctx!, ratio});
}
/** 开启动画绘画 */
function startAnimation() {
  getCtx()
  if(!canvasRef.current) {
    console.warn('useRTDraw: Please bind the ref of canvas')
    return
  }
  if(!drawState.current.ctx) {
    console.warn('useRTDraw: Canvas context retrieval failed, can call getCtx to retrieve again')
    return
  }
  // 兼容性处理
  if(typeof requestAnimationFrame === 'undefined') {
    clearInterval(timer.current)
    timer.current = setInterval(() => {
      onDrawFn()
    }, 16.6)
    return
  }
  function runDraw() {
    if(timer.current) {
      cancelAnimationFrame(timer.current as number);
    }
    if(drawState.current.isHighRefreshScreen) {
      startAnimationLockFrame()
    } else {
      (function go() {
        timer.current = requestAnimationFrame(go);
        onDrawFn()
      })();
    }
  }
  // 等待是否为高刷屏的判断
  if(drawState.current.isHighRefreshScreen === void 0) {
    sleep(1200).then(() => {
      runDraw()
    })
    return
  }
  runDraw()
}

startAnimationLockFrame 锁帧绘画

用于高刷屏的锁帧操作,但是锁帧的同时会使绘画出现掉帧;简单来说就是某一时段的绘画函数不执行了,到下一时段再执行,所以感观上就是掉帧。

function startAnimationLockFrame() {
  const fps = 60;
  let fpsInterval = 1000 / fps;
  let then = Date.now();
  (function go() {
    timer.current = requestAnimationFrame(go);
    const now = Date.now();
    const elapsed = now - then;
    if (elapsed > fpsInterval) {
      onDrawFn()
      then = now - (elapsed % fpsInterval);
    }
  })();
}

返回参数

属性名描述类型
canvasRef用于和 canvas 的 ref 相绑定HTMLCanvasElement
drawState用于绘画的 state 包含 上下文,设备像素比和是否为高刷屏的判断{ctx: CanvasRenderingContext2D | null; ratio: number; isHighRefreshScreen: booleanundefined;}
canvasInfo用于设置 canvas 的宽高{w: number, h: number}
setCanvasInfo设置 canvas 的宽高信息(p: {w: number, h: number}) => void
getCtx用于获取 canvas 的上下文() => void
startAnimation开启动画的回调() => void
cancelAnimation结束动画的回调() => void

后言

该实现主要是根据我的 塔防小游戏 ,以及平时使用 Canvas 做绘画时总结封装而来。虽然很一般,但要是感兴趣可以前往玩玩。