在讀完 Redux 後就很好奇 React-Redux 是如何搭配 Redux 觸發元件在呼叫 dispatch 後達到重新渲染的,去了解後發現目前版本是用一個 useSyncExternalStore
hook 來實現,也就是本篇的主角,於是就突發奇想想用之前所學去寫一個非常陽春的 Redux store (當然要直接抓 Redux 下來用也OK,自己寫純粹是想練習)並搭配這個 hook 來做出類 React-Redux 的感覺(但當然 React-Redux 沒有這麼簡單),所以這篇會先介紹該 hook 基本的用法後,仿刻一個 Redux 當作外部的 store 寫一個簡單的例子。
useSyncExternalStore 用法
React 元件重新渲染機制會仰賴 state
, props
或 context
,有時也會需要仰賴由外部管理的第三方 store,這時候 useSyncExternalStore
就是個很好的入口。
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
useSyncExternalStore
是 React18 出現的 hook,接受兩個必填參數
subscribe
- 外部 store 的訂閱函式,且 store 更新時必須呼叫被訂閱的函式,React 會讓元件本身被這個函式訂閱,從而在 store 更新時達到 rerender。
getSnapShot
- 返回 store 內部的資料,用 redux 來舉例的話就是 store.getState 函式,如果 store 更新前後返回的值一樣的話(用 Object.is
比較)不 rerender,不同才 rerender。
getServerSnapshot
- optional,返回 store 的初始快照,只在 SSR 場景使用這裡暫不討論。
先看官網上的例子可以比較好理解,這個例子讓 TodosApp
元件訂閱一個外部的 todoStore
,元件隨著 todoStore
狀態因呼叫 todosStore.addTodo()
更新而重新渲染
import { useSyncExternalStore } from 'react'; import { todosStore } from './todoStore.js';
export default function TodosApp() { const todos = useSyncExternalStore(todosStore.subscribe, todosStore.getSnapshot); return ( <> <button onClick={() => todosStore.addTodo()}>Add todo</button> <hr /> <ul> {todos.map(todo => ( <li key={todo.id}>{todo.text}</li> ))} </ul> </> ); }
let nextId = 0; let todos = [{ id: nextId++, text: 'Todo #1' }]; let listeners = [];
export const todosStore = { addTodo() { todos = [...todos, { id: nextId++, text: 'Todo #' + nextId }] emitChange(); }, subscribe(listener) { listeners = [...listeners, listener]; return () => { listeners = listeners.filter(l => l !== listener); }; }, getSnapshot() { return todos; } };
function emitChange() { for (let listener of listeners) { listener(); } }
|
官網或者這裡都可以動手玩看看,如果在 emitChange 函式 log 出 listener 並呼叫 addTodo 時會看到一個名為 handleStoreChange
的函式,呼叫他就能讓元件重新渲染。
而另個例子中讓我覺得讚嘆的地方是 useSyncExternalStore
還能夠追蹤瀏覽器 API 的狀態
import { useSyncExternalStore } from 'react';
export default function ChatIndicator() { const isOnline = useSyncExternalStore(subscribe, getSnapshot); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; }
function getSnapshot() { return navigator.onLine; }
function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; }
|
navigator.onLine
可以追蹤瀏覽器是否目前有連上網路,而連上線時會觸發 window 的 online
事件,斷線時會觸發 offline
事件,React 就是通過這一點讓元件觸發重新渲染。
到此 useSyncExternalStore
的基本用法就比較清楚,接著就來簡單寫一個 Redux store 並套用看看吧!
實作 Redux
先將 dispatch
, subscribe
, getState
寫出來,因為是陽春版所以省略非常多細節XD,僅留必要的功能,且不要忘記在 createStore
最後呼叫 dispatch
進行初始化。
const createStore = (reducer) => { let listeners = [] let currentState = {}
const dispatch = (action) => { currentState = reducer(currentState, action)
listeners.forEach((listener) => listener()) }
const getState = () => { return currentState }
const subscribe = (cb) => { let isSubscribe = true listeners.push(cb)
return function unsubscribe() { if (!isSubscribe) return isSubscribe = false
listeners = listeners.filter((listener) => listener !== cb) } }
dispatch({type: Math.random()})
return { subscribe, getState, dispatch } }
|
接著定義 usersReducer
及 counterReducer
並建立 store,而為了要組合兩個 reducer 也寫一個簡單的 combineReducer
const combineReducer = (reducersObj) => { const reducersObjKeys = Object.keys(reducersObj)
return function combinedReducer(currentState, action) { let isStateChanged let nextState = {}
for (let i = 0; i < reducersObjKeys.length; i++) { const key = reducersObjKeys[i] const reducer = reducersObj[key] let prevStateForKey = currentState[key] let nextStateForKey = reducer(prevStateForKey, action)
nextState[key] = nextStateForKey isStateChanged = isStateChanged || prevStateForKey !== nextStateForKey }
return isStateChanged ? nextState : currentState } }
import combineReducer from './mockRedux/combineReducer' import createStore from './mockRedux/createStore'
const counterReducer = (state = {count: 0}, action) => { switch (action.type) { case 'increment': return { count: state.count + 1 } case 'decrement': return { count: state.count - 1 } default: return state } }
const userReducer = (state = {users: []}, action) => { switch (action.type) { case 'register': return { users: [...state.users, action.payload] } default: return state } }
const combinedReducer = combineReducer({ counter: counterReducer, user: userReducer })
const store = createStore(combinedReducer)
|
這樣就大功告成!接著將 User
及 Counter
元件寫出來並呼叫 useSyncExternalStore
傳入 store.subscribe
及 store.getState
即可!
import {useSyncExternalStore} from 'react' import store from './store'
const Counter = () => { const storeValue = useSyncExternalStore(store.subscribe, store.getState)
const increment = () => { store.dispatch({type: 'increment'}) }
const decrement = () => { store.dispatch({type: 'decrement'}) }
return ( <div> <div>{storeValue.counter.count}</div> <button onClick={increment}>increment</button> <button onClick={decrement}>decrement</button> </div> ) }
import {useState} from 'react' import {useSyncExternalStore} from 'react' import store from './store'
const Users = () => { const storeValue = useSyncExternalStore(store.subscribe, store.getState) const [input, setInput] = useState('')
const register = () => { store.dispatch({type: 'register', payload: input}) }
return ( <div> <ul> {storeValue.user.users.map((user, idx) => ( <li key={idx}>{user}</li> ))} </ul> <input value={input} onChange={(e) => setInput(e.target.value)} /> <button onClick={register}>register</button> </div> ) }
import Counter from './Counter' import User from './Users'
function App() { return ( <div className='App'> <Counter /> <User /> </div> ) }
|
如果有興趣可以在這裡 clone 下來玩看看。
這裡必需補充一下,實際上 React-Redux
並不是像這邊直接把 store 方法抓進 useSyncExternalStore
與元件產生互動的,而是底層有一套邏輯去判斷當 store 改變時,有使用 connect
方法的元件需不需要重複渲染。
所以除非想被老闆罵,否則自己沒事千萬不要這麼寫!這麼做會導致只要 store 狀態改變了,所有使用同樣的 store 且呼叫到這個 hook 的元件都會跟著重新渲染,堪稱災難!這種寫法還是自己私底下玩玩就好XD