初探mobx-react-lite

Mobx-react-lite —— Lightweight React bindings for MobX based on React 16.8 and Hooks

最近项目开发使用到了mobx-react-lite这个轻量级的状态管理库,在使用的过程中碰到了不少的问题,这里做一个小小的总结。

mobx介绍

Mobx是一个状态管理库,与Redux不同,它更加简单且可扩展性强,推崇任何源自应用状态的东西都应该自动地获得。它的核心原理是通过action触发state的变化,进而触发state的衍生对象(computed value & Reactions)。
Mobx flow
Mobx的几个核心概念:

  • Observable state
    可观察的状态,存放应用数据。默认情况下,observable 是递归应用的,所以如果状态的某个值是一个对象或数组,那么该值也会变成可观察的。
  • Computed values
    计算值,在相关state数据发生变化时自动更新的值。
  • Reactions
    Reactions 和计算值很像,但它不是产生一个新的值,而是会产生一些副作用,比如打印到控制台、网络请求、递增地更新 React 组件树以修补DOM、等等。
  • Actions
    动作是任何用来修改状态的东西。在严格模式下,必须通过action来更改状态。

mobx-react-lite介绍

直至2018年底,React项目中统一使用mobx的方式都是mobx-react。然而随着react hooks的诞生,mobx-react-lite也出现了,它是专门服务于react hooks的mobx-react轻量级版本。虽然mobx-react@6已经包含了mobx-react-lite,但官方是推荐在没有类组件的react项目中直接使用mobx-react-lite的。

如何管理状态?

Mobx中的状态用observable object表示,而在mobx-react-lite中,有一个新的API用于创建可观察状态—— useLocalStore。这可以看作是一个自定义的react hook,它运行其初始化函数一次以创建可观察的store并在组件的生命周期内维护store。

初始化函数返回对象的所有属性将自动可观察化,getter将转换为计算属性,方法将绑定到store并自动封装成mobx transaction。什么是transaction呢?我们知道,观察者(react组件)会监听Observable变量的改变从而触发视图渲染,如果action中改变了多个状态,那么是不是会触发多次渲染呢?为了避免这种情况,mobx提供了transaction功能,可以将对多个应用状态(Observable)的更新封装成一个事务,只有在事务执行完成后,才会触发一次对应的监听者(Reactions),减少组件渲染次数。如果初始化函数返回的是类实例,那么也是同样的操作。

在mobx中,状态是可以修改的,但一般推荐通过action来修改状态,便于跟踪状态变化,同时减少相关依赖的更新。

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
import React from 'react'
import { useLocalStore, useObserver } from 'mobx-react-lite'

export const SmartTodo = () => {
// 注意不要使用解构赋值语法
const todo = useLocalStore(() => ({
// state
title: 'Click to toggle',
done: false,

// action
toggle() {
todo.done = !todo.done
},

// getter
get emoji() {
return todo.done ? '😜' : '🏃'
},
}))

return useObserver(() => (
<h3 onClick={todo.toggle}>
{todo.title} {todo.emoji}
</h3>
))
}

useLocalStore源码:

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
export function useLocalStore<TStore extends Record<string, any>, TSource extends object = any>(
initializer: (source: TSource) => TStore,
current?: TSource
): TStore {
const source = useAsObservableSourceInternal<TSource | undefined>(current, true)

return React.useState(() => {
const local = observable(initializer(source as TSource))
if (isPlainObject(local)) {
runInAction(() => {
Object.keys(local).forEach(key => {
const value = local[key]
if (typeof value === "function") {
local[key] = wrapInTransaction(value, local)
}
})
})
}
return local
})[0]
}


function wrapInTransaction(fn: Function, context: object) {
return (...args: unknown[]) => {
return transaction(() => fn.apply(context, args))
}
}

从源码可以看出,useLocalStore的逻辑其实是比较简单的,它先是通过observable(initializer())将初始化函数返回的对象中的属性转换成可观察的,为计算属性创建代理。接着遍历对象中的方法,将其封装成事务,再使用useState返回处理后的store,而这个store是不可变的。

如何观察状态变更?

有三种方式可以让组件监听到状态变化:

  • observer HOC
  • observer component
  • useObserver hook

组件正确监听后,就会被通知组件有关更改的信息(只要状态可以被观察到),并且在父级不知道的情况下进行重新渲染。这种观察者模式正是mobx的一大特征。

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
import { observable } from 'mobx'
import { Observer, useObserver, observer } from 'mobx-react-lite'
import ReactDOM from 'react-dom'

const person = observable({
name: 'John',
})

// observer HOC
// named function is optional (for debugging purposes)
const P1 = observer(function P1({ person }) {
return <h1>{person.name}</h1>
})

// observer component
const P2 = ({ person }) => <Observer>{() => <h1>{person.name}</h1>}</Observer>

// useObserver hook
const P3 = ({ person }) => {
return useObserver(() => <h1>{person.name}</h1>)
}

ReactDOM.render(
<div>
<P1 person={person} />
<P2 person={person} />
<P3 person={person} />
</div>,
)

setTimeout(() => {
person.name = 'Jane'
}, 1000)

这三个的区别是什么呢?

  • observer HOC:
    observer<P>(baseComponent: React.FC<P>, options?: IObserverOptions): React.FC<P>
    使用了高阶组件的模式,并在内部封装了React.memo,将传入的函数组件转换为监听者。
  • observer component
    <Observer>{renderFn}</Observer>
    只跟踪函数中的渲染并在需要时自动渲染,更颗粒化地管控变化, 通常为了优化性能而使用。
  • useObserver hook
    useObserver<T>(fn: () => T, baseComponentName = "observed", options?: IUseObserverOptions): T
    使用这个钩子去包裹return的内容,它相对更加自由,可以让我们以多种方式优化组件(e.g. using memo with a custom areEqual, using forwardRef, etc.), 并准确声明观察的部分。
    关于这一点的一个好处是,如果任何钩子由于某种原因改变了一个observable,那么该组件将不会不必要地重新渲染两次。

与React Context协作

useLocalStore的虽然是从名称上看只创建组件本地的store,但是借助react context,就可以建立全局的store。

先来设计store

1
2
3
4
5
6
7
8
9
10
11
12
export function createStore() {
// note the use of this which refers to observable instance of the store
return {
friends: [],
makeFriend(name, isFavorite = false, isSingle = false) {
this.friends.push({ name, isFavorite, isSingle })
},
get singleFriends() {
return this.friends.filter(friend => friend.isSingle)
},
}
}

然后创建一个context,使用useLocalStore传入刚刚的函数创建本地的store,通过context将store挂载上去

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from 'react'
import { createStore } from './createStore'
import { useLocalStore } from 'mobx-react-lite'

const storeContext = React.createContext(null)

export const StoreProvider = ({ children }) => {
const store = useLocalStore(createStore)
return <storeContext.Provider value={store}>{children}</storeContext.Provider>
}

export const useStore = () => {
const store = React.useContext(storeContext)
if (!store) {
// this is especially useful in TypeScript so you don't need to be checking for null all the time
throw new Error('You have forgot to use StoreProvider, shame on you.')
}
return store
}

接着将StoreProvider作为父组件放在最外层,这样我们就可以在页面组件中获取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
import React from 'react'
import { useStore } from '../../../store'

export const MatchMaker = observer(() => {
const store = useStore()
const singleAndFavoriteFriends = store.singleFriends.filter(
friend => friend.isFavorite,
)
return <div>{singleAndFavoriteFriends.map(renderFriend)}</div>
})

export const FriendsMaker = observer(() => {
const store = useStore()
const onSubmit = React.useCallback(({ name, favorite, single }) => {
store.makeFriend(name, favorite, single)
}, [])
return (
<Form onSubmit={onSubmit}>
Total friends: {store.friends.length}
<input type="text" id="name" />
<input type="checkbox" id="favorite" />
<input type="checkbox" id="single" />
</Form>
)
})

如何处理异步请求和副作用?

这也是我在项目中遇到的一个问题。首先,mobx与redux不同,它的异步请求是可以写在action里面的,因此在实际项目中,就产生了两种写法:

  1. 将请求写在action中。这样写的好处是在组件中的逻辑更加清晰,只需直接调用action执行即可。但是无法根据请求状态返回loading给组件。

    1
    2
    3
    4
    5
    6
    7
    getTitle() {
    this.pendingRequestCount++;
    fetch(url).then(action(resp => {
    this.title = resp.title;
    this.pendingRequestCount--;
    }))
    }
  2. 将请求写在组件中,在action中只做修改状态的事情。这样写的好处是可以直接在组件内部做异步请求,更方便地管理请求进度并处理结果。缺点是组件内部请求逻辑变重,没有在store中统一维护请求,会因此增加一些额外的代码来维护该请求。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    useEffect(
    () => {
    if (!store.finishFetch) {
    fetch(url).then(action(resp => {
    store.getTitle(resp.title)
    }))
    }
    },
    [store]
    )

目前采用的是第二种写法,是否有更好的方法来优化呢?

在项目中,我们经常会用到useEffect去处理一次副作用(如首次进入页面做异步请求),如果其中依赖到了observable value那怎么办呢?这里也是有2种写法:

  1. 借助autorun。这是官方推荐的写法,这里注意永远不需要指定useEffect的依赖项,因为我们只希望运行一次且运行结果不依赖于store,但这样写会引起eslint报warning,所以需要让eslint去ignore这个警告。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import React from 'react'
    import { autorun } from 'mobx'

    function useDocumentTitle(store: TStore) {
    React.useEffect(
    () =>
    autorun(() => {
    document.title = `${store.title} - ${store.sectionName}`
    }),
    // eslint-disable-next-line
    [], // note empty dependencies
    )
    }
  2. 直接在useEffect中编写副作用。但是这样组件重新渲染可能会执行多次副作用,需要额外的代码去控制。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    useEffect(
    () => {
    setLoading(true)
    requestTeamList().then((res) => {
    store.setList(res.teams)
    setLoading(false)
    })
    },
    [store]
    )

mobx-react-lite目前还是比较新的一个库,需要更多的探索。