React + Canvas = 💜(译文)

React + Canvas = 💜(译文)

Tags
React.js
Canvas
Published
September 9, 2021
与常规的 DOM 或 SVG 相比,使用 <canvas /> 绘制图形可以提供更加精细的控制。不过在 React 中, canvas 绘制并不直观,且其提供的 API 非常不同。React 每个组件拥有属于自身的 node,截然不同的是,canvas 提供了唯一的 node 给予我们进行绘制。让我们来看下如何在 React 中使用 canvas 可视化绘制。
The <canvas/> HTML element can be used to draw graphics with a finer control than the usual DOM or SVG. But with React, trying to draw on a canvas is not intuitive as their interfaces are quite different. With React, each component owns their node, as opposed to canvas where there is only one shared node that we can use for drawing. Let's see how we can make a canvas visualization with React components !

🧑‍🏫 Canvas 101

canvas 就像一张纸。在现实生活中,你会拿一支笔,把你的手移动到第一个位置,然后通过移动你的手到另一个位置来画一条线。在 canvas 上绘制的浏览器API实际上非常类似。我们首先需要为我们想要绘制的形状做一个蓝图——就像使用真正的铅笔一样——稍后可以着色。
The canvas element is like a sheet of paper. To draw in real life you would take a pen, move your hand to a first position, and draw a line by moving your hand to another position. The browser API to draw on a canvas is actually very similar. We first need to make a blueprint of the shape we want to draw – like using a real pencil – that can later be colored in.
// moving our hand to the starting position canvasContext.moveTo(x1, y1); // drawing a blueprint line to the finishing position canvasContext.lineTo(x2, y2); // taking a purple pen and coloring the line canvasContext.strokeStyle = "purple"; canvasContext.stroke();
在面向组件化时编码可能比较棘手!我们需要在页面上创建一个 canvas 并调用 moveTolineTo 绘制 line。实际上,做到这两点是比较困难的。我们需要使用 React reference 获取 canvas 节点,之后使用 canvas 节点获取 2D context;之后我们便可以调用它的绘制方法,代码如下:
Having imperative code like this in a component-oriented codebase can be tricky! We would need to create a component that renders a <canvas/> on the page and then call the moveTo and lineTo methods on it to draw a line. In practice, it's a bit more complicated to bridge those two. We need to use a React reference to access the canvas DOM node, and to retrieve a 2D context from it; we are then able to call our drawing methods. The code would look like this:
const Canvas = () => { // we use a ref to access the canvas' DOM nodeconst canvasRef = React.useRef(null); React.useEffect(() => { const context = canvasRef.current.getContext("2d"); // ...drawing using the context}, [canvasRef]); return <canvas ref={canvasRef} />; };
但如果我们想要绘制一些东西时还是会有一点困难,Canvas 组件可以非常大。通常,大组件会拆分为一些子组件。可在这里是不可能的,因为这里只有一个 canvas 节点。
But if we want to draw something a bit more complex, the Canvas component can become quite large. Usually, big components are split into several child components. Yet here this is not possible as there is only one canvas node。
 

🎨 Hexagons

下面展示如何编写一个 canvas 子组件,让我们话一些更有趣的东西 ✨
To show how to make child components with canvas, let's draw something more fancy ✨
 
一个简单的六边形组件需要定义以下数据:
A single hexagon is defined with the folowing data:
1. 定义其在画布上的位置 — xy 的值
  • A position on the screen — two x and y number values.
2. 表示 radius 范围的大小
radius to represent its size.
3. 使所有六边形看起来不对齐的 rotation
rotation so that all hexagons don't look aligned.
4. color
color.
我们需要一个函数生成一些随机的六边形。随机函数的编码和本文无关;让我们假设存在一个获取六边形数组的方式。至于如何绘制一个六边形 — 我们需要在所有角之间绘制一条线,之后用一种颜色进行填充。
We need a function that is able to generate some random hexagons. The randomisation code is not relevant here; let's just assume we have a way to get an array of hexagons. As for how to draw the shape of an hexagon – we need to draw a line between all its corners and then fill it with a color:
// This article explains all the math behind hexagons// https://www.redblobgames.com/grids/hexagons/const corners = getHexagonCorners(x, y, radius, rotation); context.beginPath(); corners.forEach((corner, index) => { if (index === 0) { context.moveTo(corner.x, corner.y); } else { context.lineTo(corner.x, corner.y); } }); context.fillStyle = color; context.fill();
如何扩展这个逻辑到我们的六边形组件中?该组件为了绘制一些东西需要一个 canvas 的 context。这可以通过传递一个 prop 到所有子组件中,但如果子组件嵌套很深的话,则该方式将很不实用。因此另一种方式是通过在所有组件中传递全局的 React context。
How could we extract this logic into its own Hexagon component? The component would need the canvas's context in order to draw anything. This could be passed via a prop to all child components, but this approach can become tedious when children are deeply nested. Another way of doing this is by using a React context to share "global" values between components.
 

📦 A context in a context

我们尝试通过 React context 共享 canvas context,此时命令就有点棘手了。首先创建一个 React context,我们需要使用 context Provider 共享值。在 Canvas 组件中看起来就像下面这样:
At this point the naming gets a bit tricky as we are trying to share a canvas' context via a React context. Once we create a React context, we need to use the context's Provider to share a value. In the case of our Canvas component it would look like this:
// we create a React context with a _null_ default valueconst SharingContext = React.createContext(null); const Canvas = (props) => { const canvasRef = React.useRef(null); const [ renderingContext, setRenderingContext, ] = React.useState(null); // the canvas rendering context is not immediately avalaible// the canvas node first needs to be added to the DOM by reactReact.useEffect(() => { const context2d = canvasRef.current.getContext("2d"); setRenderingContext(context2d); }, []); return ( <SharingContext.Provider value={renderingContext}> <canvas ref={canvasRef} /> {/* hexagons are passed through the `children` prop */} {props.children} </SharingContext.Provider>); };
六边形组件需要消费当前 React context 去读取值 — 这里通过 useContext hook。
The Hexagon component needs to consume this React context to read its value – here with the useContext hook.
const Hexagon = (props) => { // we get the rendering context by comsuming the React contextconst renderingContext = React.useContext(SharingContext); if (renderingContext !== null) { // hexagon drawing logic} };
现在我们的 CanvasHexagon 组件都准备好了,我们能够显示随机的六边形:
Now that both our Canvas and Hexagon components are ready we are able to display randomly-generated hexagons:
const App = () => ( <Canvas> {getRandomHexagons().map((hexagon) => ( <Hexagon {...hexagon} />))} </Canvas>);
最后我们可以为六边形添加一些动画,使它们旋转起来。
The last thing we need is to animate the hexagons so that they rotate.
 

🎬 Animations

正如我们看到的,canvas 就像一张纸 — 一旦绘制后就不能更改!然而,为了可以绘制新的东西,canvas 可以被清空。这方面 canvas 动画有点像老式的卡通动画 — 我们绘制,清除,绘制,清除并重复这个过程直到达到我们预期的效果。要让图形移动,我们需要将动画分解为一个个小步骤,并将它们一个接一个绘制出来,同时清除每次产生的 canvas。这些步骤我们称为 。浏览器为我们带来了一个 API requestAnimationFrame,这样就可以画出每一帧。
As we saw, the canvas is like a sheet of paper – once it's been drawn on, it can't be changed! However, a canvas can be cleared in order that something new can be draw on it. In that respect animating a canvas is somewhat like old-fashioned cartoon animation - we draw, clean, draw, clean and repeat until we achieve the desired effect. To make a shape move, you need to split the movement into small steps, draw them one by one, while clearing the canvas in-between. Those steps are called frames. Browsers come with an API requestAnimationFrame so that you can draw in each frame.
 

🖼 Creating a frame loop

第一步 — canvas 应该在每一帧开始前被清除。最简单的方式维护一个内部状态对帧进行计数。在该方法中,组件将重绘每个帧:
First things first - the canvas should be cleared at the beginning of each frame. The easiest way to do this is to have an internal state counting the frames. This way, the component re-renders at each frame:
 
const [frameCount, setFrameCount] = React.useState(0); // this effect increments frameCount by one at the next frame// as it's called every time frameCount changes// this makes the Canvas component re-render at every frameReact.useEffect(() => { const frameId = requestAnimationFrame(() => { setFrameCount(frameCount + 1); }); return () => { cancelAnimationFrame(frameId); }; }, [frameCount, setFrameCount]); // here's the clearing at every render — at every frame.if (context !== null) { context.clearRect(0, 0, actualWidth, actualHeight); }
但...canvas 现在是空白的!这是因为六边形只被渲染了一次 — 当 <RandomHexagons/> 第一次被重渲染。但,canvas 在每一帧中被清除,一旦下一帧被渲染后则六边形被清除。子组件必须在每一帧中强制重新渲染。一个解决方法是将 frameCount 状态值传递给 Hexagon 组件。这是通过 React context 实现的,和之前共享 canvas context 是类似的:
But... the canvas is now white! This is because the hexagons are only rendered once - when <RandomHexagons/> is first rendered. But, as the canvas is cleared on each frame, the hexagons are erased once the next render occurs. Child components must be forced to re-render and draw in the canvas on every frame. One solution is to share the frameCount from the canvas with the Hexagon component. This is achieved via a React context, like we did with SharingContext:
const FrameContext = React.createContext(0); const Canvas = (props) => { // [...]return ( <SharingContext.Provider value={renderingContext}> <FrameContext.Provider value={frameCount}> <canvas /> {props.children} </FrameContext.Provider> </SharingContext.Provider>); };
const Hexagon = (props) => { const renderingContext = React.useContext(SharingContext); const frameCount = React.useContext(FrameContext); // drawing logic};
现在我们的六边形又回到了画布上了!要使这个方法生效,FrameContext 必须被添加到每个子组件。有两种选择可以确保 SharingContext 在每个地方被使用到:
And now our hexagons are back on the screen! 🎉 While this method works, the FrameContext has to be added to every child component. Two options are available to make sure that this context is used everywhere that SharingContext is:
1. 我们可以将它们重新组合成单个 context,并共享 renderingContextframeCount。不过 frameCount 没有被子组件使用到,所以共享它的值是没有意义的。
We can regroup them into a single context that shares both the renderingContext and the frameCount. But the frameCount variable is not used in the child components, so it does not make sense to share its value.
2. 或者,我们可以创建 useCanvas hook 隐藏这种复杂性!当我们消费 React contexts 时,hook 可以只返回 canvas context 给子组件:
Or, we can create a useCanvas hook to hide this complexity away! Even when consuming both React contexts, the hook can only return the canvas rendering context to the child components:
export const useCanvas = () => { React.useContext(FrameContext); const renderingContext = React.useContext(CanvasContext); return renderingContext; };
Hexagon 组件的逻辑现在需要更新一个以使用该 hook:
The Hexagon component logic now needs a small update to use this new hook:
const Hexagon = (props) => { const renderingContext = useCanvas(); // drawing logic};

🔄 Making the hexagons move

如果我们想要让六边形旋转起来,所有六边形应该在每个帧改变它的角度 — 即每次加 1,在下面例子中,六边形需要记住上一次渲染时的角度。我们将通过 React ref 做到:
If we want the hexagons to rotate, each hexagon should change its rotation angle at every frame - by incrementing it by 1, for example. To do this, the hexagons needs to remember the rotation from the previous render. We will use a React ref to achieve this:
const animatedRotation = React.useRef(props.rotation); animatedRotation.current = animatedRotation.current + 1;
Hexagon 组件在每一帧被重新渲染,代码中使它每一帧中都改变了角度:它们旋转起来了! 😍
As the Hexagon component re-renders at every frame, this code makes its rotation change at every frame: they rotate! 😍
就像我们使用 useCanvas,我们可以在 hook 中隐藏实现细节,以提高代码的可读性:
Like we did with useCanvas, we can also improve the readability of this code by hiding the implementation details – here, using a React ref – in a hook:
const useAnimation = (initialValue, valueUpdater) => { const animatedValue = React.useRef(initialValue); animatedValue.current = valueUpdater( animatedValue.current ); return animatedValue.current; };
Hexagon 代码现在看起来更棒了:
The Hexagon code now looks a bit better:
const Hexagon = (props) => { // [...]const animatedRotation = useAnimation( props.rotation, (angle) => angle + 1 ); // drawing logic};
👏 Congratulations 👏 我们完成了一个 canvas 动画 — 基于 React 组件!我们创建两个自定义 hook 让我们的代码更加美观。
👏 Congratulations 👏 We now have animated canvas-based React components! We even created two custom hooks along the way to make our code nicer.
 

👀 Going further

1. 许多情况下使用 canvas 绘制动画会使我们的 CPU 负担过重。在这个观点中,还有另一种更高效的绘制方式:WebGL。这是一个更大的主题!如果你想写基于 WebGL 的组件,我推荐使用一些库如 react-three-fiber — 剧透,它们编写了它们自己的 React reconciler
At some point it can become quite CPU heavy to try to animate a lot of shapes in a canvas. At this point, there's another more performant way to draw things: WebGL. This is a huge subject on its own! If you want to write WebGL-based components, I would recommend using a library like react-three-fiber — spoiler alert, they made their own React 协调器
2. 除了让数字变化,useAnimation hook 无法帮助我们实现让其他物体动起来。若要创建更加复杂的动画,react-spring 是一个很好的选择。它需要做一些工作让我们的帧循环一起工作 — here's how to get it working with react-three-fiber for example
Our useAnimation hook does not help if we want to animate stuff other than infinitely changing numbers. To create more complex things, react-spring is the library to go to. It needs a bit of wiring to make it work with our own frame loop – here's how to get it working with react-three-fiber for example.