蠻有意思的。
簡述
附註:後來發現其實這篇的精髓在於「mobx 提供的不同 API」讓你能用不同的方法來建立 store。總之推薦去看這份 官方文件,我覺得寫得還蠻清楚的。
在 來點不一樣的狀態管理 mobx 中已經介紹過基本的 mobx 觀念,忘記的話可以回去複習一下。
當時我們是透過 class 來建立一個 store,把所有 action、computed、state 都寫在一起,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import { action, computed, makeObservable, observable } from 'mobx'
interface TodoItem { id: number name: string completed: boolean }
export class TodoStoreImpl { todos: TodoItem[] = []
constructor() { makeObservable(this, { todos: observable, addTodo: action, toggleTodo: action, states: computed }) }
addTodo(name: string): void { const item: TodoItem = { id: new Date().getTime(), name, completed: false } this.todos.push(item) }
toggleTodo(id: number): void { const index = this.todos.findIndex((item) => item.id === id) if (index > -1) { this.todos[index].completed = !this.todos[index].completed } }
get states() { let completed: number = 0 let remaining: number = 0 this.todos.forEach((item) => { if (item.completed) { completed++ } else { remaining++ } }) return { completed, remaining } } }
export const TodoStore = new TodoStoreImpl()
|
簡單來說,我們會先寫好一個 store 的藍圖,接著再把 instance 輸出到外面。
雖然這樣也不錯,不過其實也有不需要透過 class 的寫法,下面就來介紹一下。
範例
這邊一樣會拿 Todo list 來當範例,不過在那之前要先介紹一下資料夾結構。
我們現在不會寫成一個 todoStore.ts
,而是要拆成這樣的結構:
1 2 3 4 5
| └── state ├── action │ └── todo.tsx └── storage └── todo.tsx
|
簡單來說就是把 action 和 state 給拆開來寫,action/todo.ts
裡面只會放跟 action 有關的東西,而 storage/todo.ts
則會放跟 state 有關的東西。
接著先來看 state 的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { observable } from 'mobx'
type Todo = { id: number name: string completed: boolean }
type State = { completed: number remaining: number }
export const TodoStorage = observable({ todos: [] as Todo[],
get state(): State { let completed = 0 let remaining = 0 this.todos.forEach((item) => { if (item.completed) { completed++ } else { remaining++ } }) return { completed, remaining } } })
|
我們會有一個 todos 的 state,跟一個已完成 / 未完成的 computed。
其實就跟 class 的寫法很像,只是現在把東西全部包成一個 object 在丟到 observable
裡面而已。
接著來看 action:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { action } from 'mobx' import { TodoStorage } from '../storage/todo'
export const addTodo = action((name: string): void => { TodoStorage.todos.push({ id: new Date().getTime(), name, completed: false }) })
export const toggleTodo = action((id: number): void => { const index = TodoStorage.todos.findIndex((item) => item.id === id) if (index > -1) { TodoStorage.todos[index].completed = !TodoStorage.todos[index].completed } })
|
我們會有新增 todo 跟更新 todo 的兩個 action。
跟剛剛的做法差不多,我們把用來執行的 function 放到 action
裡面去,這樣就建立完成了。
最後就是跟 Component 串起來而已,我來貼一段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import React from 'react' import { TodoStorage } from '../state/storage/todo' import { toggleTodo } from '../state/action/todo' import { observer } from 'mobx-react-lite'
const TodoList: React.FC = observer(() => { return ( <> Completed: {TodoStorage.state.completed} <br /> Remaining: {TodoStorage.state.remaining} <ul> {TodoStorage.todos.map((item) => { return ( <li onClick={() => toggleTodo(item.id)} key={item.id}> [{item.completed ? 'X' : ' '}] {item.name} </li> ) })} </ul> </> ) })
export default TodoList
|
以上就是第二種寫法的示範。
我覺得這種寫法在有些時候會更直覺一些,因為透過這種方式在 Component 裡面就不需要把整個 store 引進來,可以只把需要的 action 或 state 拿來用就好,所以會更簡潔一點。
最後老樣子,需要範例的話來 這邊 看。
關於 observer
實在不知道這段怎麼下標題,總之這邊想來談談「關於重新渲染的問題」,這跟 mobx 中的 observer 有一些關聯。
我先講結論,就是:
- observer 無法直接觸發子元件 re-render
- observer 無法直接觸發子元件 re-render
- observer 無法直接觸發子元件 re-render
好,我知道這樣講一定聽不懂,所以又到了我們的範例時間。假設我有一段 code 的基本結構長這樣:
1 2 3 4 5 6 7 8 9 10 11
| return ( <div className='app'> <HashRouter> <Header /> <Routes> <Route path='' element={<div>hello</div>} /> <Route path='login' element={<Login />} /> </Routes> </HashRouter> </div> )
|
就是一個很基本的導覽列和路由,長得像這樣:
我這邊想做的事情很簡單,就是在 store 中建立一個 isInit
的 state,然後我希望上面紅色框框的兩個按鈕只有在 isInit=true
時才顯示出來。
所以這邊的 store 跟 action 就會這樣寫:
1 2 3 4 5 6 7 8 9 10
| import { observable } from 'mobx'
export const AuthStorage = observable({ isInit: false,
get getIsinit() { return this.isInit } })
|
1 2 3 4 5 6 7
| import { action } from 'mobx' import { AuthStorage } from '../store/auth'
export const checkStorage = action(() => { AuthStorage.isInit = true })
|
附註:如果你串過 JWT 的話大概就會知道這是起手式,只是這邊為了簡化所以不會真的去檢查 storage。
接下來,為了在進入頁面時完成「初始化」的動作,我們會用 useEffect
來處理,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const App = observer(() => { console.log('render app') useEffect(() => { setTimeout(() => { checkStorage() }, 1000) }, [])
return ( <div className='app'> <HashRouter> <Header /> <Routes> <Route path='' element={<div>hello</div>} /> <Route path='login' element={<Login />} /> </Routes> </HashRouter> </div> ) })
export default App
|
接下來你可能就很開心的去 <Header />
中把 isInit
拿出來用,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const Header = () => { return ( <div style={styles.container}> <h1 style={styles.title}> <Link to='/' style={styles.link}> Home </Link> </h1> <ul style={styles.list}> <li> <Link style={styles.link} to='login'> default </Link> </li> {/* 加上條件渲染 */} {AuthStorage.getIsinit && ( <> <li> <Link style={styles.link} to='login'> Link1 </Link> </li> <li> <Link style={styles.link} to='login'> Link2 </Link> </li> </> )} </ul> </div> ) }
export default Header
|
附註:可以到這邊看 範例 會更清楚
然後你就會發現一秒過後什麼事情也沒發生,跟你想的不一樣。
為什麼?我明明已經用 observer 來觀測整個 App 了啊,照理來說只要有任何 state 改變了不就應該重新渲染嗎?
要解開這個問題,首先要釐清一件事情:「observer 的觀察範圍在哪裡?」
以上面的例子來說,observer 的觀察範圍就僅限於 App
這個元件本身而已。既然如此,那麼思考一件事情,App
中有沒有用到任何「observable(被觀察)」的值?
答案是沒有,忘記的話你可以拉上去看一下。
也就是說,只有當 observer 中存在 observable 時,才會再更新時觸發 re-render。(這一句請搞清楚 observer 跟 observable 的差的差別,一個是觀察者,一個是被觀察者)
所以如果要讓 App
觸發 re-render,最簡單的做法就是讓 App 裡面出現 observable,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| const App = observer(() => { console.log('render app') console.log('check observable', AuthStorage.getIsinit) useEffect(() => { setTimeout(() => { checkStorage() }, 1000) }, [])
return ( <div className='app'> <HashRouter> <Header /> <Routes> <Route path='' element={<div>hello</div>} /> <Route path='login' element={<Login />} /> </Routes> </HashRouter> </div> ) })
export default App
|
這時候神奇的事情就發生了,真的觸發 re-render 了:
附註:範例可以參考這邊
就跟我們剛剛說的一樣,現在因為 App 中出現了 observable,所以一旦這個 observable 「被改變了」,App 就應該要 re-render,這就是背後的道理而已,別想得太複雜了。
所以回到一開始的問題,如果我想讓 <Header />
能正確的被重新渲染的話該怎麼做?
只要幫他加上 observer
就好(因為 observable 是出現在它裡面的,這個才是我們真正該觀察(observer)的元件):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| const Header = observer(() => { return ( <div style={styles.container}> <h1 style={styles.title}> <Link to='/' style={styles.link}> Home </Link> </h1> <ul style={styles.list}> <li> <Link style={styles.link} to='login'> default </Link> </li> {AuthStorage.getIsinit && ( <> <li> <Link style={styles.link} to='login'> Link1 </Link> </li> <li> <Link style={styles.link} to='login'> Link2 </Link> </li> </> )} </ul> </div> ) })
export default Header
|
附註:範例參考這裡
總之,一定要搞清楚 observer 的涵蓋範圍在哪裡?它跟 observable 的關係又是什麼?是一段想講的重要觀念。
關於 toJS(深拷貝)
這邊是我後來在做專案時碰到的一個問題,所以記錄一下。
簡單來說上面的範例其實可以再利用 computed
的方式來取出 state,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| export const TodoStorage = observable({ todos: [ { id: 1, name: 'Test', completed: true } ] as Todo[],
get getTodos(): Todo[] { return this.todos } })
|
用的時候會像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13
| const TodoList: React.FC = observer(() => { return ( <> <ul> {TodoStorage.getTodos.map((item) => ( <li onClick={() => toggleTodo(item.id)} key={item.id}> {item.name} </li> ))} </ul> </> ) })
|
你可能會想說明明能用 TodoStorage.todos
拿出來就好了,為什麼要這樣寫?
這是因為單用 TodoStorage.todos
拿出來的話:
- 他是有被修改的風險在的
- 他是有被修改的風險在的
- 他是有被修改的風險在的
因為我們拿出來的東西其實是「reference」,所以我只要在拿出來的時候亂改,就會改到「原本放在 store」 裡面的東西,像這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| import React from 'react' import { TodoStorage } from '../state/storage/todo' import { toggleTodo } from '../state/action/todo' import { observer } from 'mobx-react-lite'
const TodoList: React.FC = observer(() => { TodoStorage.todos[0].name = 'yoyoyo'
return ( <> <ul> {TodoStorage.todos.map((item) => { return ( <li onClick={() => toggleTodo(item.id)} key={item.id}> {/* 原本是 test,但會被改成 yoyoyo */} {item.name} </li> ) })} </ul> </> ) })
export default TodoList
|
附註:不懂的話可以到這邊看範例
所以比較保險的方式是先把 state 做深拷貝以後再拿出來,這樣子就算之後意外的改到 state,那也不會影響到 store 裡面的東西(這個就是深拷貝的原理,不懂的話回去複習)。
這時候就會用到 toJS
這個方法了,來把一開始講的方法改成這樣:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| export const TodoStorage = observable({ todos: [ { id: 1, name: "Test", completed: true } ] as Todo[],
get getTodosWithToJS(): Todo[] { return toJS(this.todos); } });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import React from 'react' import { TodoStorage } from '../state/storage/todo' import { toggleTodo } from '../state/action/todo' import { observer } from 'mobx-react-lite'
const TodoList: React.FC = observer(() => { TodoStorage.getTodosWithToJS[0].name = 'yoyoyo' return ( <> <ul> {TodoStorage.todos.map((item) => { return ( <li onClick={() => toggleTodo(item.id)} key={item.id}> {/* 不會影響到原本的 todos */} {item.name} </li> ) })} </ul> </> ) })
export default TodoList
|
不懂的話一樣來這邊看範例。
總之呢,toJS
通常是用在你「想要拿 state 來做一些計算時」會拿來用的東西,為的就是避免在途中不小心改到 store 裡的東西,所以才會用這種方式來盡可能避免掉。
如果你的 state 只是純粹拿來 Read 的話,那不用這種方式也沒關係,但要知道要有這種作法就是了。
最後的附註:雖然準確一點來說 toJS
的用途是「取消 observable」,不過我覺得用「深拷貝」的方式來理解會更好懂一點(當初完全聽不懂什麼叫取消 observable XD)