再探 mobx,換個口味的寫法

蠻有意思的。

簡述

附註:後來發現其實這篇的精髓在於「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({
// state
todos: [] as Todo[],

// computed
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'

// 別忘了一樣要用 observer 來包住
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>
)

就是一個很基本的導覽列和路由,長得像這樣:

target

我這邊想做的事情很簡單,就是在 store 中建立一個 isInit 的 state,然後我希望上面紅色框框的兩個按鈕只有在 isInit=true 時才顯示出來。

所以這邊的 store 跟 action 就會這樣寫:

1
2
3
4
5
6
7
8
9
10
// store
import { observable } from 'mobx'

export const AuthStorage = observable({
isInit: false,

get getIsinit() {
return this.isInit
}
})
1
2
3
4
5
6
7
// action
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
// 注意這邊有用 observer 來觀測
const App = observer(() => {
console.log('render app')
useEffect(() => {
// set isInit to true
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(() => {
// set isInit be true
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)的元件):

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
// 加上 observer
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({
// 存在 store 中的 state
todos: [
{
id: 1,
name: 'Test',
completed: true
}
] as Todo[],

// 把 state 直接當作 computed 傳出去
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)

Ant Design-Table React router dom 相關的 hook
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×