在本文中,我将使用React Hooks创建一个html canvas 画图网站,我将使用create-react-app脚手架从零开始构建项目。最后这个应用程序有诸如清除、撤销和使用localStorage基本功能。

本文我将向您展示任何构建自定义Hooks和在普通的Hooks中重用有状态逻辑。

基本设置

我们首先使用create-react-app创建一个新的React应用程序。

$ npx create-react-app canvas-and-hooks
$ <span>cd</span> canvas-and-hooks/
$ yarn start
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span>

您的浏览器会打开 http://localhost:3000/,然后您会看到一个旋转的React logo图片,那么,您现在可以开始了...

第一个hook:useRef

用您喜欢的编辑器打开 src/App.js文件📃,然后替换成以下内容:

import React from <span>'react'</span>
<span>function</span> <span><span>App</span></span>() {
  <span>return</span> (
    <canvas width="{window.innerWidth}" height="{window.innerHeight}" onclick="{e" => {
        alert(e.clientX)
      }}
    />
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

在浏览器窗口中点击任意一处,如果会弹出一个弹出框:显示您鼠标🖱️点击的x坐标,很好!应用程序跑起来了。

现在,我们真正的画一些东西。这样的话我们就需要canvas 元素的ref,所以,开始使用今天的第一个hook useRef吧:

import React from <span>'react'</span>
<span>function</span> <span><span>App</span></span>() {
  const canvasRef = React.useRef(null)
  <span>return</span> (
    <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{e" => {
         const canvas = canvasRef.current
         const ctx = canvas.getContext(<span>'2d'</span>)
         // implement draw on ctx here
      }}
    />
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

通常,在React中你不需要一个ref来做更新的操作。但是canvas不像其它的DOM元素。大多数DOM元素都有一个属性,比如说:value,你可以直接更新它。在canvas中允许✅您使用context(本🌰:ctx)来画一些东西。为此,我们不得不使用ref,它是对实际canvas DOM元素的引用。

现在我们有了canvas上下文,是时候画一些东西了。为此,粘贴复制以下代码绘制一个SVG hook。它与hooks无关,如果您不理解它也不需要担心😓。

import React from <span>'react'</span>
const HOOK_SVG =  <span>'m129.03125 63.3125c0-34.914062-28.941406-63.3125-64.519531-63.3125-35.574219 0-64.511719 28.398438-64.511719 63.3125 0 29.488281 20.671875 54.246094 48.511719 61.261719v162.898437c0 53.222656 44.222656 96.527344 98.585937 96.527344h10.316406c54.363282 0 98.585938-43.304688 98.585938-96.527344v-95.640625c0-7.070312-4.640625-13.304687-11.414062-15.328125-6.769532-2.015625-14.082032.625-17.960938 6.535156l-42.328125 64.425782c-4.847656 7.390625-2.800781 17.3125 4.582031 22.167968 7.386719 4.832032 17.304688 2.792969 22.160156-4.585937l12.960938-19.71875v42.144531c0 35.582032-29.863281 64.527344-66.585938 64.527344h-10.316406c-36.714844 0-66.585937-28.945312-66.585937-64.527344v-162.898437c27.847656-7.015625 48.519531-31.773438 48.519531-61.261719zm-97.03125 0c0-17.265625 14.585938-31.3125 32.511719-31.3125 17.929687 0 32.511719 14.046875 32.511719 31.3125 0 17.261719-14.582032 31.3125-32.511719 31.3125-17.925781 0-32.511719-14.050781-32.511719-31.3125zm0 0'</span>
const HOOK_PATH = new Path2D(HOOK_SVG)
const SCALE = 0.3
const OFFSET = 80
<span>function</span> draw(ctx, location) {
  ctx.fillStyle = <span>'deepskyblue'</span>
  ctx.shadowColor = <span>'dodgerblue'</span>
  ctx.shadowBlur = 20  ctx.save()
  ctx.scale(SCALE, SCALE)  ctx.translate(location.x / SCALE - OFFSET, location.y / SCALE - OFFSET)
  ctx.fill(HOOK_PATH)
  ctx.restore()
}
<span>function</span> <span><span>App</span></span>() {
  const canvasRef = React.useRef(null)
  <span>return</span> (
    <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{e" => {
        const canvas = canvasRef.current
        const ctx = canvas.getContext(<span>'2d'</span>)
        draw(ctx, { x: e.clientX, y: e.clientY })
      }}
    />
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

上面的代码是为了在坐标(x,y)绘制一个SVG形状(一个鱼钩)。

试一试,看看它是否起作用。

第二个hook:useState

我们要添加的下一个功能是Clean和Undo按钮🔘。为此,我们将使用useState hook来跟踪用户交互。

import React from <span>'react'</span>
// ...

// canvas draw <span>function</span>
// ...

<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = React.useState([])
  const canvasRef = React.useRef(null)
  <span>return</span> (
    <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{e" => {
        const canvas = canvasRef.current
        const ctx = canvas.getContext(<span>'2d'</span>)
        const newLocation = { x: e.clientX, y: e.clientY }
        <span>set</span>Locations([...locations, newLocation])
        draw(ctx, newLocation)
      }}
    />
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

所以,我们为app添加了state。您可以在return语句上面添加 console.log(locations)来验证一下。随着用户点击,您会看到打印的数组。

第三个hook:useEffect

目前,我们对state没有任何操作。我们还是像以前一样绘制了hooks。我们来看看用useEffect hook如何修复这个问题。

import React from <span>'react'</span>
// ...

// canvas draw <span>function</span>
// ...

<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext(<span>'2d'</span>)
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  <span>return</span> (
    <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{e" => {
        const newLocation = { x: e.clientX, y: e.clientY }
        <span>set</span>Locations([...locations, newLocation])
      }}
    />
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

这里做了很多事情我们来一一拆解一下。我们把onClick事件处理函数的绘制函数移动到useEffect回掉里。这很重要,因为在画布上绘制由app的状态决定,这是个副作用。后面我们会使用localStorage来保持持久化,在state更新的时候这也会是个副作用。

我也对canvas本身的实际绘制做了一些更改,在当前实现中,每次render渲染先清除canvas然后再绘制所有位置,我们可以做的比这聪明一点。但为了保持简单,就留给读者去优化吧。

我们已经完成了所有最难的部分,现在添加新功能应该很简单了。我们来创建清除按钮吧。

import React from <span>'react'</span>
// ...

// canvas draw <span>function</span>
// ...

<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext(<span>'2d'</span>)
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  <span>function</span> handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    <span>set</span>Locations([...locations, newLocation])
  }
  <span>function</span> <span><span>handleClear</span></span>() {
    <span>set</span>Locations([])
  }
  <span>return</span> (
    <>
      <button onclick="{handleClear}">Clear</button>
      <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{handleCanvasClick}">
    </>
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

清除功能只是一个简单的state更新:我们通过设置它为一个空数组来清除state,这很简单,对吗?

进一步,我也把canvas onClick事件处理移动到一个单独的函数里。

我们来添加另外一个功能:撤销。同样的原则,即使这种状态更新有点棘手。

import React from <span>'react'</span>
// ...

// canvas draw <span>function</span>
// ...

<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext(<span>'2d'</span>)
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  <span>function</span> handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    <span>set</span>Locations([...locations, newLocation])
  }
  <span>function</span> <span><span>handleClear</span></span>() {
    <span>set</span>Locations([])
  }
  <span>function</span> <span><span>handleUndo</span></span>() {
    <span>set</span>Locations(locations.slice(0, -1))
  }
  <span>return</span> (
    <>
      <button onclick="{handleClear}">Clear</button>
      <button onclick="{handleUndo}">Undo</button>
      <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{handleCanvasClick}">
    </>
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

因为React中任何state更新都必须是不可变的,所以我们不能使用像 locations.pop()来清除数组中最近的一项。我们的操作不能改变原始的locations数组。方法是使用slice,复制所有项直到最后一个。你可以使用 locations.slice(0, locations.length - 1),但是slice有个更聪明的操作数组最后一位的-1。

在我们开始之前,我们整理一下html,然后添加一个css样式文件。在buttons按钮外面添加如下的div。

import React from <span>'react'</span>
import <span>'./App.css'</span>
// ...

// canvas draw <span>function</span>
// ...

<span>function</span> <span><span>App</span></span>() {
  // ...

  <span>return</span> (
    <>
      <div classname="<span">"controls">
        <button onclick="{handleClear}">Clear</button>
        <button onclick="{handleUndo}">Undo</button>
      </div>
      <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{handleCanvasClick}">
    </>
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

css样式如下:

*,
*:before,
*:after {
  box-sizing: border-box;
}
body {
  background-color: black;
}
.controls {
  position: absolute;
  top: 0;
  left: 0;
}
button {
  height: 3em;
  width: 6em;
  margin: 1em;
  font-weight: bold;
  font-size: 0.5em;
  text-transform: uppercase;
  cursor: pointer;
  color: white;
  border: 1px solid white;
  background-color: black;
}
button:hover {
  color: black;
  background-color: <span>#00baff;</span>
}
button:focus {
  border: 1px solid <span>#00baff;</span>
}
button:active {
  background-color: <span>#1f1f1f;</span>
  color: white;
}

<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span>

看起来不错,我们来看看下一个功能:持久化。

添加localStorage

我们之前提过,我们也想要我们的绘制保存在localStroage中,这也是另外一个副作用,我们将添加另外一个useEffect。

import React from <span>'react'</span>
import <span>'./App.css'</span>
// ...draw <span>function</span>
<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = React.useState(
    JSON.parse(<span>local</span>Storage.getItem(<span>'draw-app'</span>)) || []  )
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext(<span>'2d'</span>)
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  React.useEffect(() => {
    <span>local</span>Storage.setItem(<span>'draw-app'</span>, JSON.stringify(locations))
  })
  <span>function</span> handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    <span>set</span>Locations([...locations, newLocation])
  }
  <span>function</span> <span><span>handleClear</span></span>() {
    <span>set</span>Locations([])
  }
  <span>function</span> <span><span>handleUndo</span></span>() {
    <span>set</span>Locations(locations.slice(0, -1))
  }
  <span>return</span> (
    <>
      <div classname="<span">"controls">
        <button onclick="{handleClear}">Clear</button>
        <button onclick="{handleUndo}">Undo</button>
      </div>
      <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{handleCanvasClick}">
    </>
  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span></canvas>

现在我们已经完成了我们要构建的所有功能,但还不够。 关于books最酷的一件事是您可以使用现有的hooks来组建新的自定义hooks。我创建一个自定义的usePersistentState hook来展示这一点。

第一个自定义hook:usePersistentState

import React from <span>'react'</span>
import <span>'./App.css'</span>
// ...draw <span>function</span>
// our first custom hook!

<span>function</span> usePersistentState(init) {
  const [value, <span>set</span>Value] = React.useState(
    JSON.parse(<span>local</span>Storage.getItem(<span>'draw-app'</span>)) || init
  )
  React.useEffect(() => {
    <span>local</span>Storage.setItem(<span>'draw-app'</span>, JSON.stringify(value))
  })
  <span>return</span> [value, <span>set</span>Value]}
<span>function</span> <span><span>App</span></span>() {
  const [locations, <span>set</span>Locations] = usePersistentState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext(<span>'2d'</span>)
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  <span>function</span> handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    <span>set</span>Locations([...locations, newLocation])
  }
  <span>function</span> <span><span>handleClear</span></span>() {
    <span>set</span>Locations([])
  }
  <span>function</span> <span><span>handleUndo</span></span>() {
    <span>set</span>Locations(locations.slice(0, -1))
  }
  <span>return</span> (
    // ...

  )
}
<span>export</span> default App
<span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span>

这里,我们创建了第一个自定义hook并且从App组件中提取了与从localStorage保存和获取状态相关的所有逻辑。我们这样做的方式是usePersistentState hook可以被其它组件重用。这里没有任何特定于此组件的内容。

我们重复这个技巧来操作canvas相关的逻辑。

第二个自定义hook:usePersistentCanvas

import React from <span>'react'</span>
import <span>'./App.css'</span>
// ...draw <span>function</span>
// our first custom hook
<span>function</span> usePersistentState(init) {
  const [value, <span>set</span>Value] = React.useState(
    JSON.parse(<span>local</span>Storage.getItem(<span>'draw-app'</span>)) || init
  )
  React.useEffect(() => {
    <span>local</span>Storage.setItem(<span>'draw-app'</span>, JSON.stringify(value))
  })
  <span>return</span> [value, <span>set</span>Value]
}
// our second custom hook: a composition of the first custom hook // and React<span>'s useEffect + useRef
function usePersistentCanvas() {
  const [locations, setLocations] = usePersistentState([])

  const canvasRef = React.useRef(null)

  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('</span>2d<span>')
    ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
    locations.forEach(location => draw(ctx, location))
  })
  return [locations, setLocations, canvasRef]
}
function App() {
  const [locations, setLocations, canvasRef] = usePersistentCanvas()
  function handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    setLocations([...locations, newLocation])
  }
  function handleClear() {
    setLocations([])
  }
  function handleUndo() {
    setLocations(locations.slice(0, -1))
  }
  return (
    <>
      <div classname="controls">
        <button onclick="{handleClear}">Clear</button>
        <button onclick="{handleUndo}">Undo</button>
      </div>
      <canvas ref="{canvasRef}" width="{window.innerWidth}" height="{window.innerHeight}" onclick="{handleCanvasClick}">
    </>
  )
}
export default App
</canvas></span><span class="copy-code-btn">&#x590D;&#x5236;&#x4EE3;&#x7801;</span>

正如您所看到的,我们的App组件变得非常小。 在localStorage中存储状态和在canvas上绘图相关的所有逻辑都被提取到自定义hooks。 您可以通过将hooks移动到hooks文件中来进一步清理此文件。 这样,其他组件可以重用这种逻辑,例如构成更好的hooks。

总结

如果将hooks与生命周期方法(如componentDidMount,componentDidUpdate)进行比较,是什么让hooks如此特别? 看看上面的例子:

  • hooks允许你在不同的组件 重用生命周期钩子逻辑
  • 你可以 合成hooks来构建更丰富的自定义hooks,就像你可以合成更丰富的UI组件一样。
  • hooks更小更简洁,不再臃肿,生命周期方法有时很困惑。

现在判断hooks是否真的要解决所有这些问题还为时尚早 - 以及可能会出现什么新的不良做法 - 但看看上面我对React的未来感到非常兴奋和乐观!