在Canvas中使用React Hooks

Ioodu發表於2019-03-24

線上體驗地址han-hooks.netlify.com/

在Canvas中使用React Hooks

在本文中,我將使用React Hooks建立一個html canvas 畫圖網站,我將使用create-react-app腳手架從零開始構建專案。最後這個應用程式有諸如清除、撤銷和使用localStorage基本功能。

本文我將向您展示任何構建自定義Hooks和在普通的Hooks中重用有狀態邏輯。

基本設定

我們首先使用create-react-app建立一個新的React應用程式。

$ npx create-react-app canvas-and-hooks
$ cd canvas-and-hooks/
$ yarn start
複製程式碼

您的瀏覽器會開啟http://localhost:3000/,然後您會看到一個旋轉的React logo圖片,那麼,您現在可以開始了...

第一個hook:useRef

用您喜歡的編輯器開啟src/App.js檔案?,然後替換成以下內容:

import React from 'react'
function App() {
  return (
    <canvas
      width={window.innerWidth}
      height={window.innerHeight}
      onClick={e => {
        alert(e.clientX)
      }}
    />
  )
}
export default App
複製程式碼

在瀏覽器視窗中點選任意一處,如果會彈出一個彈出框:顯示您滑鼠?️點選的x座標,很好!應用程式跑起來了。

現在,我們真正的畫一些東西。這樣的話我們就需要canvas 元素的ref,所以,開始使用今天的第一個hook useRef吧:

import React from 'react'
function App() {
  const canvasRef = React.useRef(null)
  return (
    <canvas
      ref={canvasRef}
      width={window.innerWidth}
      height={window.innerHeight}
      onClick={e => {        
         const canvas = canvasRef.current        
         const ctx = canvas.getContext('2d')        
         // implement draw on ctx here
      }}
    />
  )
}
export default App
複製程式碼

通常,在React中你不需要一個ref來做更新的操作。但是canvas不像其它的DOM元素。大多數DOM元素都有一個屬性,比如說:value,你可以直接更新它。在canvas中允許✅您使用context(本?:ctx)來畫一些東西。為此,我們不得不使用ref,它是對實際canvas DOM元素的引用。

現在我們有了canvas上下文,是時候畫一些東西了。為此,貼上複製以下程式碼繪製一個SVG hook。它與hooks無關,如果您不理解它也不需要擔心?。

import React from 'react'
const HOOK_SVG =  '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'
const HOOK_PATH = new Path2D(HOOK_SVG)
const SCALE = 0.3
const OFFSET = 80
function draw(ctx, location) {
  ctx.fillStyle = 'deepskyblue'
  ctx.shadowColor = 'dodgerblue'
  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()
}
function App() {
  const canvasRef = React.useRef(null)
  return (
    <canvas
      ref={canvasRef}
      width={window.innerWidth}
      height={window.innerHeight}
      onClick={e => {
        const canvas = canvasRef.current
        const ctx = canvas.getContext('2d')
        draw(ctx, { x: e.clientX, y: e.clientY })
      }}
    />
  )
}
export default App
複製程式碼

上面的程式碼是為了在座標(x,y)繪製一個SVG形狀(一個魚鉤)。

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

第二個hook:useState

我們要新增的下一個功能是Clean和Undo按鈕?。為此,我們將使用useState hook來跟蹤使用者互動。

import React from 'react'
// ...
// canvas draw function
// ...
function App() {
  const [locations, setLocations] = React.useState([])
  const canvasRef = React.useRef(null)
  return (
    <canvas
      ref={canvasRef}
      width={window.innerWidth}
      height={window.innerHeight}
      onClick={e => {
        const canvas = canvasRef.current
        const ctx = canvas.getContext('2d')
        const newLocation = { x: e.clientX, y: e.clientY }
        setLocations([...locations, newLocation])
        draw(ctx, newLocation)
      }}
    />
  )
}
export default App
複製程式碼

所以,我們為app新增了state。您可以在return語句上面新增console.log(locations)來驗證一下。隨著使用者點選,您會看到列印的陣列。

第三個hook:useEffect

目前,我們對state沒有任何操作。我們還是像以前一樣繪製了hooks。我們來看看用useEffect hook如何修復這個問題。

import React from 'react'
// ...
// canvas draw function
// ...
function App() {
  const [locations, setLocations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  return (
    <canvas
      ref={canvasRef}
      width={window.innerWidth}
      height={window.innerHeight}
      onClick={e => {
        const newLocation = { x: e.clientX, y: e.clientY }
        setLocations([...locations, newLocation])
      }}
    />
  )
}
export default App
複製程式碼

這裡做了很多事情我們來一一拆解一下。我們把onClick事件處理函式的繪製函式移動到useEffect回掉裡。這很重要,因為在畫布上繪製由app的狀態決定,這是個副作用。後面我們會使用localStorage來保持持久化,在state更新的時候這也會是個副作用。

我也對canvas本身的實際繪製做了一些更改,在當前實現中,每次render渲染先清除canvas然後再繪製所有位置,我們可以做的比這聰明一點。但為了保持簡單,就留給讀者去優化吧。

我們已經完成了所有最難的部分,現在新增新功能應該很簡單了。我們來建立清除按鈕吧。

import React from 'react'
// ...
// canvas draw function
// ...
function App() {
  const [locations, setLocations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  function handleCanvasClick(e) {
    const newLocation = { x: e.clientX, y: e.clientY }
    setLocations([...locations, newLocation])
  }
  function handleClear() {
    setLocations([])
  }
  return (
    <>
      <button onClick={handleClear}>Clear</button>
      <canvas
        ref={canvasRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onClick={handleCanvasClick}
      />
    </>
  )
}
export default App
複製程式碼

清除功能只是一個簡單的state更新:我們通過設定它為一個空陣列來清除state,這很簡單,對嗎?

進一步,我也把canvas onClick事件處理移動到一個單獨的函式裡。

我們來新增另外一個功能:撤銷。同樣的原則,即使這種狀態更新有點棘手。

import React from 'react'
// ...
// canvas draw function
// ...
function App() {
  const [locations, setLocations] = React.useState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  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 (
    <>
      <button onClick={handleClear}>Clear</button>
      <button onClick={handleUndo}>Undo</button>
      <canvas
        ref={canvasRef}
        width={window.innerWidth}
        height={window.innerHeight}
        onClick={handleCanvasClick}
      />
    </>
  )
}
export default App
複製程式碼

因為React中任何state更新都必須是不可變的,所以我們不能使用像locations.pop()來清除陣列中最近的一項。我們的操作不能改變原始的locations陣列。方法是使用slice,複製所有項直到最後一個。你可以使用locations.slice(0, locations.length - 1),但是slice有個更聰明的運算元組最後一位的-1。

在我們開始之前,我們整理一下html,然後新增一個css樣式檔案。在buttons按鈕外面新增如下的div。

import React from 'react'
import './App.css'
// ...
// canvas draw function
// ...
function App() {
  // ...
  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
複製程式碼

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: #00baff;
}
button:focus {
  border: 1px solid #00baff;
}
button:active {
  background-color: #1f1f1f;
  color: white;
}

複製程式碼

看起來不錯,我們來看看下一個功能:持久化。

新增localStorage

我們之前提過,我們也想要我們的繪製儲存在localStroage中,這也是另外一個副作用,我們將新增另外一個useEffect。

import React from 'react'
import './App.css'
// ...draw function
function App() {
  const [locations, setLocations] = React.useState(
    JSON.parse(localStorage.getItem('draw-app')) || []  )
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  React.useEffect(() => {
    localStorage.setItem('draw-app', JSON.stringify(locations))
  })
  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
複製程式碼

現在我們已經完成了我們要構建的所有功能,但還不夠。關於books最酷的一件事是您可以使用現有的hooks來組建新的自定義hooks。我建立一個自定義的usePersistentState hook來展示這一點。

第一個自定義hook:usePersistentState

import React from 'react'
import './App.css'
// ...draw function
// our first custom hook!
function usePersistentState(init) {
  const [value, setValue] = React.useState(
    JSON.parse(localStorage.getItem('draw-app')) || init
  )
  React.useEffect(() => {
    localStorage.setItem('draw-app', JSON.stringify(value))
  })
  return [value, setValue]}
function App() {
  const [locations, setLocations] = usePersistentState([])
  const canvasRef = React.useRef(null)
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, window.innerHeight, window.innerWidth)
    locations.forEach(location => draw(ctx, location))
  })
  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 (
    // ...
  )
}
export default App
複製程式碼

這裡,我們建立了第一個自定義hook並且從App元件中提取了與從localStorage儲存和獲取狀態相關的所有邏輯。我們這樣做的方式是usePersistentState hook可以被其它元件重用。這裡沒有任何特定於此元件的內容。

我們重複這個技巧來操作canvas相關的邏輯。

第二個自定義hook:usePersistentCanvas

import React from 'react'
import './App.css'
// ...draw function
// our first custom hook
function usePersistentState(init) {
  const [value, setValue] = React.useState(
    JSON.parse(localStorage.getItem('draw-app')) || init
  )
  React.useEffect(() => {
    localStorage.setItem('draw-app', JSON.stringify(value))
  })
  return [value, setValue]
}
// our second custom hook: a composition of the first custom hook // and React's useEffect + useRef
function usePersistentCanvas() {
  const [locations, setLocations] = usePersistentState([])
  
  const canvasRef = React.useRef(null)
  
  React.useEffect(() => {
    const canvas = canvasRef.current
    const ctx = canvas.getContext('2d')
    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
複製程式碼

正如您所看到的,我們的App元件變得非常小。 在localStorage中儲存狀態和在canvas上繪圖相關的所有邏輯都被提取到自定義hooks。 您可以通過將hooks移動到hooks檔案中來進一步清理此檔案。 這樣,其他元件可以重用這種邏輯,例如構成更好的hooks。

總結

如果將hooks與生命週期方法(如componentDidMount,componentDidUpdate)進行比較,是什麼讓hooks如此特別? 看看上面的例子:

  • hooks允許你在不同的元件重用生命週期鉤子邏輯
  • 你可以合成hooks來構建更豐富的自定義hooks,就像你可以合成更豐富的UI元件一樣。
  • hooks更小更簡潔,不再臃腫,生命週期方法有時很困惑。

現在判斷hooks是否真的要解決所有這些問題還為時尚早 - 以及可能會出現什麼新的不良做法 - 但看看上面我對React的未來感到非常興奮和樂觀!

相關文章