理解 Redux 原始碼(番外篇)- 用 useSyncExternalStore hook 讓一個陽春版的 redux store 與元件同步

  在讀完 Redux 後就很好奇 React-Redux 是如何搭配 Redux 觸發元件在呼叫 dispatch 後達到重新渲染的,去了解後發現目前版本是用一個 useSyncExternalStore hook 來實現,也就是本篇的主角,於是就突發奇想想用之前所學去寫一個非常陽春的 Redux store (當然要直接抓 Redux 下來用也OK,自己寫純粹是想練習)並搭配這個 hook 來做出類 React-Redux 的感覺(但當然 React-Redux 沒有這麼簡單),所以這篇會先介紹該 hook 基本的用法後,仿刻一個 Redux 當作外部的 store 寫一個簡單的例子。


useSyncExternalStore 用法

  React 元件重新渲染機制會仰賴 state, propscontext,有時也會需要仰賴由外部管理的第三方 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() 更新而重新渲染

// App.js
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>
</>
);
}

// todoStore.js

// This is an example of a third-party store
// that you might need to integrate with React.

// If your app is fully built with React,
// we recommend using React state instead.

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 進行初始化。

// createStore.js
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
}
}

接著定義 usersReducercounterReducer 並建立 store,而為了要組合兩個 reducer 也寫一個簡單的 combineReducer

// combineReducers.js
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
}
}

// store.js
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)

這樣就大功告成!接著將 UserCounter 元件寫出來並呼叫 useSyncExternalStore 傳入 store.subscribestore.getState 即可!

// Counter.js
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>
)
}

// Users.js
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>
)
}

// App.js
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