蠻有意思的。
簡述
附註:後來發現其實這篇的精髓在於「mobx 提供的不同 API」讓你能用不同的方法來建立 store。總之推薦去看這份 官方文件,我覺得寫得還蠻清楚的。
在 來點不一樣的狀態管理 mobx 中已經介紹過基本的 mobx 觀念,忘記的話可以回去複習一下。
當時我們是透過 class 來建立一個 store,把所有 action、computed、state 都寫在一起,像這樣:
| 12
 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,而是要拆成這樣的結構:
| 12
 3
 4
 5
 
 | └── state├── action
 │   └── todo.tsx
 └── storage
 └── todo.tsx
 
 | 
簡單來說就是把 action 和 state 給拆開來寫,action/todo.ts 裡面只會放跟 action 有關的東西,而 storage/todo.ts 則會放跟 state 有關的東西。
接著先來看 state 的部分:
| 12
 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:
| 12
 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 串起來而已,我來貼一段:
| 12
 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 的基本結構長這樣:
| 12
 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>
 )
 
 | 
就是一個很基本的導覽列和路由,長得像這樣:

target
我這邊想做的事情很簡單,就是在 store 中建立一個 isInit 的 state,然後我希望上面紅色框框的兩個按鈕只有在 isInit=true 時才顯示出來。
所以這邊的 store 跟 action 就會這樣寫:
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 
 | import { observable } from 'mobx'
 
 export const AuthStorage = observable({
 isInit: false,
 
 get getIsinit() {
 return this.isInit
 }
 })
 
 | 
| 12
 3
 4
 5
 6
 7
 
 | import { action } from 'mobx'
 import { AuthStorage } from '../store/auth'
 
 export const checkStorage = action(() => {
 AuthStorage.isInit = true
 })
 
 | 
附註:如果你串過 JWT 的話大概就會知道這是起手式,只是這邊為了簡化所以不會真的去檢查 storage。
接下來,為了在進入頁面時完成「初始化」的動作,我們會用 useEffect 來處理,像這樣:
| 12
 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 拿出來用,像這樣:
| 12
 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,像這樣:
| 12
 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 了:

re-render
附註:範例可以參考這邊
就跟我們剛剛說的一樣,現在因為 App 中出現了 observable,所以一旦這個 observable 「被改變了」,App 就應該要 re-render,這就是背後的道理而已,別想得太複雜了。
所以回到一開始的問題,如果我想讓 <Header /> 能正確的被重新渲染的話該怎麼做?
只要幫他加上 observer 就好(因為 observable 是出現在它裡面的,這個才是我們真正該觀察(observer)的元件):
| 12
 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,像這樣:
| 12
 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
 }
 })
 
 | 
用的時候會像這樣:
| 12
 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」 裡面的東西,像這樣:
| 12
 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 這個方法了,來把一開始講的方法改成這樣:
| 12
 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);
 }
 });
 
 | 
| 12
 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)