[FE302] React 基礎 - hooks 版本:再戰 todo list 與其他 hooks


中場休息時間

一、寫 code 的秘密武器:prettier

code 排版團隊不同,但可以靠工具決定排版。

React 官方文件Formatting Code Automatically

sublime: https://packagecontrol.io/packages/JsPrettier

二、忘記提的 JSX 特性

js 自動 escape,不用擔心 xss。在 React 裡你還是可以直接注入 HTML,但是你必須使用 dangerouslySetInnerHTML,然後傳入一個有 __html 為 key 的 object,藉此來提醒你自己這樣做具有風險。例如:

function createMarkup() {
  return {__html: 'First · Second'};
}

function MyComponent() {
  return <div dangerouslySetInnerHTML={createMarkup()} />;
}

對 click based 的 xss,要特別留意:<a href={todo.content //使用者輸入}></a>,他就會執行一個 js。不管是否寫 react 都要特別注意。舉例:使用者輸入:javascript:alert(1),因為冒號不是特殊字串所以不會經過特殊處理

防範方式:

  1. 不要接收使用者輸入
  2. 輸出之前先經過特殊編碼:<a href={window.encodeURIComponent(todo.content)}></a>

再戰 todo list 與其他 hooks

一、初探 useEffect

render 完後想做甚麼,就寫在這個 hook 裡面。

一般的 hook 都是傳值進去,useEffect 是傳 function 進去。

現在這個 App 這個 component 就只是一個 function,每次 render 就是重新再執行這個 function,然後 return 他想 render 的東西。但如果 render 完想做一些事情,該寫在哪裡 ?

舉例:todo app 的資料初始值從 api 來,要做一個 api call,要在哪邊做 ? 如果寫在 App 裡面,每次 render 都會發一次 request,而非 render 完的第一次才做這件事情。

因為在目前的 function component 裡面沒有地方做這件事情,所以 React 提供了一個 hook 讓我們做這件事。這個 hook 就是 useEffect。

import './App.css';
import styled from 'styled-components'
import {useState, useRef} from 'react'
import TodoItem from './TodoItem';
//let id = 2 // 每次 render 都會重新呼叫 App 所以 id 要放外面
function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }
  const handleInputChange = (e) => {
    setValue(e.target.value)
  }
  const handleDeleteTodo = id => {
    setTodos(todos.filter(todo => todo.id !== id)) // todo.id 不等於要刪除的 id 就會留下來
  }
  const hadnleToggleIsDone = id => {
    setTodos(todos.map(todo => {
      if (todo.id !== id) return todo // 這個 id 不是我要修改的 id 我就將原本的 todo 回傳
      return {
        ...todo, // todo 原本的東西
        isDone: !todo.isDone // 我要修改的屬性
      }
    }))
  }
  return (
    <div className="App">
      <input type="text" placeholder="todo" value={value} onChange={handleInputChange}/>
      <button onClick={handleButtonClick}>Add todo</button>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}
export default App;

每次 render 完都做事

function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  useEffect(() => {
    console.log('after render')
  })

只想在某些 state 改變才做事。

舉例:想寫某個功能,將 todo 的 app 存到 local storage 裡面,讓他跟 local storage 同步。

因為 local storage 只能存字串,所以要先 JSON.stringify。storage.setItem(keyName, keyValue);

呼叫完 setTodos 不會即時更新,因為他是非同步的。

其他功能以此類推,讓每次 todos 改變都去做這件事情,新增、刪除、isDone 改變、編輯的時候都會做這件事

現在這種作法類似以前用 jquery 的想法,當我們變動他時就做些甚麼事情,所以在不同地方變動就要在不同地方做事。

function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    console.log('todos', todos) // 不會即時更新
    writeTodosLocalStorage([{ // 因為非同步不會即時更新,所以要將東西放到這裡面來
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

我們發現這幾個功能的共同點都是 todos 會改變,因為 useEffect 會在每次 render 完執行,有 render 就代表 state 有改變。

寫法一:每次 render 完都將最新的 todos 寫到 localStorage 裡面去

function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  useEffect(() => {
    writeTodosLocalStorage(todos)
    console.log(JSON.stringify(todos))
  })
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

輸入不該新增 todo,因為 todo 是一樣的。

React 在 useEffect 裡面提供第二個參數,這個參數是一個陣列,陣列中放的是我們所關注的資料。

當 todos 改變,我才要執行這個 effect。第一次 render 都會執行這個 effect。除了第一次外,todos 有改變才會執行這個 effect。

  useEffect(() => {
    writeTodosLocalStorage(todos)
    console.log(JSON.stringify(todos))
  }, [todos])

以記憶功能而言,希望在頁面第一次 render 結束時,將 local storage 的 todo 撈出來,然後把它放到 todos 裡面去。

傳空的陣列,表示當陣列裡面東西改變,就重新執行 effect。因為是空陣列,所以裡面的東西不會變,所以只有第一次 render 完會執行這個 effect。第二次、第三次...就都不會。

  useEffect(() => {
    alert(1)
  }, [])

所以可以在這裡面做初始化的動作。

  useEffect(() => {
    const todoData = window.localStorage.getItem('todos') || ''
    if (todoData) {
      setTodos(JSON.parse(todoData)) // 將 todoData 放回 setTodos 裡面去
    }
  }, [])

重新整理畫面會閃一下,原因是第一次 render 我的 todo 是長這個樣子

  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])

但 useEffect 是在瀏覽器畫出畫面後才執行。render 只是 react 將這個 component return 回去,靠 react set innerhtml 達到改變畫面的效果,畫面還未顯示到使用者眼中。useEffect 會在畫面顯示在使用者眼中,才去執行。

因為第一次會顯示上面那個 todos 的畫面,然後才新增我的 data,把 data 放回去。下一次 render 使用者才會看到畫面。所以會閃一下。

二、初探 useLayoutEffect 與 lazy initializer

useEffect 更精確地說是 render 完,瀏覽器 paint 以後你想做甚麼

針對上述閃一下的問題,怎麼解決呢?

解法一、useLayoutEffect:瀏覽器 paint 以前你想做甚麼

重新整理就沒有一閃的問題

import {useState, useRef, useEffect, useLayoutEffect} from 'react'

function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

function App() {
  const [todos, setTodos] = useState([
    {id: 1, content: 'done', isDone: true},
    {id:2, content: 'not done', isDone: false}
  ])
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  useLayoutEffect(() => {
    const todoData = window.localStorage.getItem('todos') || ''
    if (todoData) {
      setTodos(JSON.parse(todoData)) // 將 todoData 放回 setTodos 裡面去
    }
  }, [])

這個圖非常重要,請記住

Mount 就是把 component 放到畫面上。

Mount 會執行 run lazy inItializers => render => react update DOM => Run LayoutEffect => brower paints screen => run effect

所以在瀏覽器畫畫面前就提早改變 state,提早讓畫面更新,所以在畫面真的更新前再提早更新一次 DOM,他就會顯示最新的那一次而非第一次的結果。

Update 就是更新 state

update 會先執行 render => react update DOM => cleanup layoutEffect (將上一個 effect 給清掉) => Run LayoutEffect => brower paints screen => cleanup Effects=> run effect

Unmount 將 effect 清掉


解法二、useState 傳初始值

function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

function App() {
  const todoData = window.localStorage.getItem('todos') || ''
  const [todos, setTodos] = useState(JSON.parse(todoData) || []) // 需要 json parse 變成一個陣列
  const [value, setValue] = useState('')
  const id = useRef(3) // 他的初始值
  useEffect(() => {
    writeTodosLocalStorage(todos)
  }, [todos])
  const handleButtonClick = () => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('') // 將 todo 清空
    //id ++
    id.current ++
  }

碰到問題,我的 useState 放的值只有第一次有效,舉例:application 裡面的 local storage 改東西,新增 todo 後,我改動的東西並沒有相對應的變動。

因為 useState 傳的是第一次 render 吃的值,所以看到 mount run lazy initializer,只有第一次會跑這個東西,然後才去把這個值給撈出來。但事實上每次 render,const todoData = window.localStorage.getItem('todos') || '' 都會將相對應的 todoData 給拿出來,他的程式碼還是 const [todos, setTodos] = useState(JSON.parse(todoData) || []),但因為 state 已經有初始值了,所以 React 會忽略後面的初始值。

因此造成效能上的浪費,因為明明只有第一次要用這個東西 const [todos, setTodos] = useState(JSON.parse(todoData) || []),但每次 render 都要再重做這件事情。

useState 後面的參數也可以傳 function,function return 的東西就會是你的初始值,這個 function 只會執行一次。useState 後面傳的就叫做 lazy initializer。

對一些比較複雜的運算,要把他們當作 state 的初始值就可以這樣寫。

function App() {
  const id = useRef(1)
  const [todos, setTodos] = useState(() => {
    console.log('init')
    let todoData = window.localStorage.getItem('todos') || ''
    if (todoData) {
      todoData = JSON.parse(todoData)
      id.current = todoData[0].id + 1
    } else {
      todoData = []
    }
    return todoData
  }) // 需要 json parse 變成一個陣列
  const [value, setValue] = useState('')

大部分情況下只會用到 useEffect,如果不加第二個參數就是每次 render 完就會執行;加第二個參數 [ ] 表示第一次 render 完會執行,等於是 mount 之後執行。react 把東西放到 DOM 上就是 Mount;第二個參數放東西 [todos] 表示當東西 todos 更新時才會執行這個 effect。

三、hooks 重要觀念補充

因為 React 背後的機制,所以 hook 只能寫在 component 的第一層。他不能被 if-else 包起來。不能依據條件決定要否使用這個 hook,用了就是用了。可以在 useEffect 裡面判斷,例如 if (!todos) return,無法用 if 包住整個 useEffect。

每次 call 這些東西時順序要一樣、不能在 if else 裡面寫

四、再探 useEffect

hook 的生命週期

甚麼是 cleanup ? 清掉上個 effect 再執行下個 effect

當 todos 改變就會去執行裡面這個 function。但其實這個 function 還可以 return 另一個 function。這個 function 裡面就是你想在這個 effect 被清掉前做的事情。

  useEffect(() => {
    writeTodosLocalStorage(todos)
  }, [todos])
  useEffect(() => {
    writeTodosLocalStorage(todos)
    console.log('useEffect: todos')
    // clean up function
    return () => {
      console.log('clearEffect: todos')
    }
  }, [todos])
  1. 第一次跑初始化
  2. 第一次 render 完後執行 useEffect,所以 log 出 console.log('useEffect: todos')
  3. 當今天改了 todos,會先把上一個 effect 清掉,所以 console.log('clearEffect: todos'),然後再執行下一個 effect。
  4. 重點:兩邊的 todos 其實不一樣。清上一個的 todos 會是第一次 render 的 todos 而非第二次的,他的 todos 會是舊的。
  useEffect(() => {
    writeTodosLocalStorage(todos)
    console.log('useEffect: todos', JSON.stringify(todos))
    return () => {
      console.log('clearEffect: todos', JSON.stringify(todos))
    }
  }, [todos])

前面一再強調:每次 render 就是一再執行這個 function
詳細版流程:

  1. 第一次 render 執行 App() 這個 function,App() 裡的 todos 就會是一開始初始的值,也就是 useEffect: todos [{"id":4,"content":"3","isDone":false},{"id":3,"content":"1"}]
  2. 第一次執行完後,呼叫 useEffect。在第一次的 App() 底下呼叫 useEffect
  3. 執行第二次 render,會第二次呼叫一次 App() 這個 function。這個時候因為 todos 已經更新所以 todos 就會不一樣了,他的 todos 就會是我第二次已經更新過的 todos useEffect: todos [{"id":4,"content":"3","isDone":true},{"id":3,"content":"1"}],但是我 clean up 的是第一次 App 裡面的 todos,clearEffect: todos [{"id":4,"content":"3","isDone":false},{"id":3,"content":"1"}]clean up function 會在執行下次 effect 之前執行,先清掉上次的 effect,清完後再執行 useEffect

有點 closure 的概念,每次都重新 render 這個 function。第一次 render App(),我的 todos 東西就不會變了,第一次 render 的 todos 長相永遠一樣。setTodos 只是在調整下次 render 的 todos 的長相,他不改變我第一次 render 的 todos。

所以每次 render 都是一次重新的 function call,他會有自己的 todos 自己的 setTodos,因此第一次 render 的 todos 與第二次 render 的 todos,並不相等。

甚麼時候會幫 useEffect 加 clean up 的 function ?

用途一、清東西

useEffect(() => {
  // 每次 userId 進來會去聽一個東西
  WebSocket.CONNECTING(userId).subscribe(() => {
    // ...
  }) // 連到某個 userId 去拿資料然後再去做處理
  return () => {
    // 當 userId 改變,需要將 WebSocket 的連結給斷掉
    WebSocket.disconnect(userId)
  }
}, [userId])

用途二、unmount
useEffect 只有在陣列東西改變才會重新執行,因為陣列是空的所以東西不會改變,所以這個 useEffect 只會執行一次,因為沒有執行第二次,這裡面的東西就不會因為執行新的 effect 被清掉,所以就只會在 component unmount 時被清掉

useEffect(() => {
  return () => { // 確保 unmount 會執行
    console.log('unmount')
  }
}, []) // 只有在第一次 render 會執行

clear out function 執行時機:執行下個 effect 的時候要把上一個 effect 清掉、component unmount 會把 effect 給清掉

延伸閱讀:A Complete Guide to useEffect、How Are Function Components Different from Classes? Personal blog by Dan Abramov.

五、寫一個自己的 hook!

客製化 hook 一定要是 use 開頭

可以寫一個自己的客製化 hook。舉例:input 的東西,不管如何只要有 input 就會有 value 和 setValue,在 onChange 都會做這件事情。

所以,其實可以把這個行為包成一個 hook。

// 開一個檔案 useInput.js
import {useState} from 'react'

export default function useInput() {
  const [value, setValue] = useState('')
  const handleChange = e => {
    setValue(e.target.value)
  }
  // 回傳一個物件
  return {
    value,
    setValue,
    handleChange,
  }
}
// App.js 
import './App.css';
import styled from 'styled-components'
import React, {useState, useRef, useEffect, useLayoutEffect, memo, useCallback, useMemo} from 'react'
import TodoItem from './TodoItem';
import useInput from './useInput'

function App() {
 略
 const {value, setValue, handleChange} = useInput()
 略
   return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}

好處:將共同邏輯給抽出,對於類似的東西可以比照辦理

  const {value, setValue, handleChange} = useInput()
  const {value: todoName, setValue: setTodoName, handleChange: handleTodoNameChange} = useInput()

ex:todos 與 localStorage 相關,我們可以 1. 抽成共同邏輯 2. 將這套邏輯放在客製化的 hook 中

用自己寫的 hook 將其中的邏輯抽到這邊來,將 todo 相關邏輯抽到這部分來。如此一來,在 App.js 裡面就少了很多 code

// useTodo.js
import {useState, useEffect, useRef} from 'react'

function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

export default function useTodos() {
  const id = useRef(1)
  const [todos, setTodos] = useState(() => {
    let todoData = window.localStorage.getItem('todos') || ''
    if (todoData) {
      todoData = JSON.parse(todoData)
      id.current = todoData[0].id + 1
    } else {
      todoData = []
    }
    return todoData
  })
  useEffect(() => {
    writeTodosLocalStorage(todos)
  }, [todos])
  return {
    todos,
    setTodos,
    id
  }
}
// App.js

import './App.css';
import styled from 'styled-components'
import React, {useState, useRef, useEffect, useLayoutEffect, memo, useCallback, useMemo} from 'react'
import TodoItem from './TodoItem';
import useInput from './useInput'
import useTodos from './useTodos'

class Button extends React.Component {
  render() {
    const {onClick, children} = this.props
    return <button onClick={onClick}>{children}</button>
  }
}
const MemoButton = memo(Button)

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}

const redStyle = {
  color: 'red'
}
const blueStyle = {
  color: 'blue'
}
function App() {
  const {todos, setTodos, id} = useTodos()
  const {value, setValue, handleChange} = useInput()
  const handleButtonClick = useCallback(() => {
    console.log(value)
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('')
    id.current ++
  }, [setValue, setTodos, value, todos])
  const handleDeleteTodo = id => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  const hadnleToggleIsDone = id => {
    setTodos(todos.map(todo => {
      if (todo.id !== id) return todo
      return {
        ...todo,
        isDone: !todo.isDone
      }
    }))
  }
  const s = useMemo(() => {
    console.log('calculate s')
    return {
      color: value ? redStyle : blueStyle,
    }
  }, [value])
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}
export default App;

還可以更進一步地拆解,將邏輯拆到 hook 裡面去讓我的 App.js 介面與邏輯是分開。我們只是將東西拆出來用 return 的方式 return 回去。

優點:功能與介面分開。功能都被包裝在 hook 裡面。

// useTodo.js

import {useState, useEffect, useRef, useCallback} from 'react'
import useInput from './useInput'
function writeTodosLocalStorage(todos) {
  window.localStorage.setItem('todos', JSON.stringify(todos))
}

export default function useTodos() {
  const id = useRef(1)
  const {value, setValue, handleChange} = useInput()
  const [todos, setTodos] = useState(() => {
    let todoData = window.localStorage.getItem('todos') || ''
    if (todoData) {
      todoData = JSON.parse(todoData)
      id.current = todoData[0].id + 1
    } else {
      todoData = []
    }
    return todoData
  })
  const handleButtonClick = useCallback(() => {
    setTodos([{
      id: id.current, 
      content: value
    }, ...todos])
    setValue('')
    id.current ++
  }, [setValue, setTodos, value, todos])
  const hadnleToggleIsDone = id => {
    setTodos(todos.map(todo => {
      if (todo.id !== id) return todo
      return {
        ...todo,
        isDone: !todo.isDone
      }
    }))
  }
  const handleDeleteTodo = id => {
    setTodos(todos.filter(todo => todo.id !== id))
  }
  useEffect(() => {
    writeTodosLocalStorage(todos)
  }, [todos])
  return {
    todos,
    setTodos,
    id,
    handleButtonClick,
    hadnleToggleIsDone,
    handleDeleteTodo,
    value,
    setValue,
    handleChange
  }
}
// App.js

import './App.css';
import styled from 'styled-components'
import React, {useState, useRef, useEffect, useLayoutEffect, memo, useCallback, useMemo} from 'react'
import TodoItem from './TodoItem';
import useTodos from './useTodos'

class Button extends React.Component {
  render() {
    const {onClick, children} = this.props
    return <button onClick={onClick}>{children}</button>
  }
}
const MemoButton = memo(Button)

function Test({style}) {
  console.log('test render')
  return <div style={style}>test</div>
}

const redStyle = {
  color: 'red'
}
const blueStyle = {
  color: 'blue'
}
function App() {
  const {
    todos, 
    setTodos, 
    id,
    handleButtonClick,
    hadnleToggleIsDone,
    handleDeleteTodo,
    value,
    setValue,
    handleChange} = useTodos()
  const s = useMemo(() => {
    console.log('calculate s')
    return {
      color: value ? redStyle : blueStyle,
    }
  }, [value])
  return (
    <div className="App">
      <Test style={s} />
      <input type="text" placeholder="todo" value={value} onChange={handleChange}/>
      <MemoButton onClick={handleButtonClick}>Add todo</MemoButton>
      {
        todos.map((todo) => <TodoItem key={todo.id} todo={todo} handleDeleteTodo={handleDeleteTodo} hadnleToggleIsDone={hadnleToggleIsDone}/>)
      }
    </div>
  );
}
export default App;

六、hooks 總結

hooks 可以分成幾種

  1. useState:讓 function component 擁有 state,可以管理內部的狀態
  2. useEffect:在 component render 完之後要做甚麼事情,在 render 完在 browser 畫出畫面後要做甚麼事情。(非常常用,因為直接寫在 function 裡面每次 render 都會做這件事情,但很多事情並不希望在 render 時做,而且予其在 render 時做,不如在 render 結束才做,這樣可以節省效能。)
  3. useLayoutEffect:render 完但 browser 還沒畫出畫面之前要做甚麼事情
  4. 自己將邏輯抽出來變成 hook:邏輯與 ui 分開,測試會更好測、共同部分可以抽出來、程式碼會更簡潔

建議將官方提供的十個 hook 看過。

可以做自己的 hook,也可以用別人寫好的 hook。基本上在 react 裡面甚麼都可以包成一個 hook。usehooks.com

現在只要先學習怎麼用 useState 管理狀態、useEffect 管理 side effect,

#useEffect #useLayoutEffect #lazy initializer #custom hooks #Hooks






Related Posts

110 研究所找教授玩踩地雷

110 研究所找教授玩踩地雷

this 與 call() / apply() / bind()

this 與 call() / apply() / bind()

[day-7] JS 陣列與物件混合應用

[day-7] JS 陣列與物件混合應用






Comments