Hooks & Mobx 只需额外知道两个 Hook,便能体验到如此简单的开发方式

概要

本文主要讲解了下我平时在工做开发中遇到的关于 Hooks 的一些缺点和问题,并尝试配合 Mobx 解决这些问题的经历。我以为二者的配合能够极大的下降开发过程当中有可能出现的问题以及极大的提升开发体验,并且学习成本也是很是的低。若是你对 Hooks 以及 Mobx 有兴趣,想知道更进一步的了解,那么这篇文章适合你。这篇文章会介绍以下内容,方便你决定是否要仔细阅读,节省时间:html

  • 本文不会介绍太过于基础的内容,你须要对 Mobx 以及 Hooks 有基础的了解
  • 本文介绍了平时开发中的一些最佳实践,方便小伙伴们对二者有更加深刻的认识
  • 若是你使用过一部分 Mobx,可是不太了解如何和 Hooks 更好的合做,能够尝试来看看

另外 Hooks 自己真的就是一个理解上很是简单的东西,因此本文也不长,我也不喜欢去写什么万字长文,又不是写教程,并且读者看着标题就失去兴趣了。前端

Hooks 究竟有什么问题?

首先,在这里我再也不说 Hooks 的优势,由于他的优势用过的人都清楚是怎么回事,这里主要讲解一下他存在的缺点,以及如何用 Mobx 来进行改进。node

  • 依赖传染性 —— 这致使了开发复杂性的提升、可维护性的下降
  • 缓存雪崩 —— 这致使运行性能的下降
  • 异步任务下没法批量更新 —— 这也会致使运行性能的下降

换句话说,形成这种缘由主要是由于 Hooks 每次都会建立一个全新的闭包,而闭包内全部的变量其实都是全新的。而每次都会建立闭包数据,而从性能角度来说,此时缓存就是必要的了。而缓存又会牵扯出一堆问题。react

说到底,也就是说没有一个公共的空间来共享数据,这个在 Class 组件中,就是 this,在 Vue3 中,那就是 setup 做用域。而 Hooks 中,除非你愿意写 useRef + ref.current 不然是没有办法找到共享做用域。设计模式

而 mobx 和 Hooks 的结合,能够很方便在 Hooks 下提供一个统一的做用域来解决上面遇到的问题,所谓双剑合并,剑走天下。api

Hook1 useObserver

在传统的使用 mobx 的过程当中,你们应该都知道 observer 这个 api,对须要可以响应式的组件用这个包裹一下。一样,这个 api 直接在 hooks 中依旧能够正常使用。 可是 hooks 并不推荐 hoc 的方式。天然,mobx 也提供了 hookify 的使用方式,那就是 useObserver缓存

const store = observable({})
function App() {
	return useObserver(() => {
		return <div>{store.count}</div>
	})
}
复制代码

看到这里,相信使用过 mobx 的应该能够发现,useObserver 的使用几乎和 Class 组件的 render 函数的使用方式一致。事实上也确实如此,并且他的使用规则也很简单,直接把须要返回的 Node 用该 hooks 包裹后再返回就能够了。闭包

通过这样处理的组件,就能够成功监听数据的变化,当数据变化的时候,会触发组件的重渲染。至此,第一个 api 就了解完毕了框架

Hook2 useLocalStore

简单来说,就是在 Hooks 的环境下封装的一个更加方便的 observable。就是给他一个函数,该函数返回一个须要响应式的对象。能够简单的这样理解异步

const store = useLocalStore(() => ({key: 'value'}))
// equal
const [store] = useState(() => obserable({key: 'value'}))
复制代码

而后就没有了,极其简单的一个 api 使用。然后面要讲的一些最佳实践更多的也是围绕这个展开,后文简化使用 local store 代指。

这两个 API 能带来什么?

简单来说,就是在保留 Hooks 的特性的状况下,解决上面 hooks 所带来的问题。

第一点,因为 local store 的存在,做为一个不变的对象存储数据,咱们就能够保证不一样时刻对同一个函数的引用保持不变,不一样时刻都能引用到同一个对象或者数据。再也不须要手动添加相关的 deps。由此能够避免 useCallback 和 useRef 的过分使用,也避免不少 hooks 所面临的的闭包的坑(老手请自动忽略)。依赖传递性和缓存雪崩的问题均可以获得解决

直接上代码,主要关注注释部分

// 要实现一个方法,只有当鼠标移动超过多少像素以后,才会触发组件的更新
// props.size 控制移动多少像素才触发回调
function MouseEventListener(props) {
	const [pos, setPos] = useState({x: 0, y: 0})
	const posRef = useRef()
	const propsRef = useRef()
	// 这里须要用 Ref 存储最新的值,保证回调里面用到的必定是最新的值
	posRef.current = pos
	propsRef.current = propsRef

	useEffect(() => {
		const handler = (e) => {
			const newPos = {x: e.xxx, y: e.xxx}
			const oldPos = posRef.current
			const size = propsRef.current.size
			if (
				Math.abs(newPos.x - oldPos.x) >= size
				|| Math.abs(newPos.y - oldPos.y) >= size
			) {
				setPos(newPos)
			}
		}
		// 当组件挂载的时候,注册这个事件
		document.addEventListener('mousemove', handler)
		return () => document.removeEventListener('mousemove', handler)
		// 固然这里也能够监听 [pos.x, pos.y],可是性能很差
	}, [])

	return (
		props.children(pos.x, pos.y)
	)
}

// 用 mobx 改写以后,这种使用方式远比原生 hooks 更加符合直觉。
// 不会有任何 ref,任何 current 的使用,任何依赖的变化
function MouseEventListenerMobx(props) {
	const state = useLocalStore(target => ({
		x: 0,
		y: 0,
		handler(e) {
			const nx = e.xxx
			const ny = e.xxx
			if (
				Math.abs(nx - state.x) >= target.size ||
				Math.abs(ny - state.y) >= target.size
			) {
				state.x = nx
				state.y = ny
			}
		}
	}), props)
	
	useEffect(() => {
		document.addEventListener('mousemove', state.handler)
		return () => document.removeEventListener('mousemove', state.handler)
	}, [])

	return useObserver(() => props.children(state.x, state.y))
}
复制代码

第二,就是针对异步数据的批量更新问题,mobx 的 action 能够很好的解决这个问题

// 组件挂载以后,拉取数据并从新渲染。不考虑报错的状况
function AppWithHooks() {
	const [data, setData] = useState({})
	const [loading, setLoading] = useState(true)
	useEffect(async () => {
		const data = await fetchData()
		// 因为在异步回调中,没法触发批量更新,因此会致使 setData 更新一次,setLoading 更新一次
		setData(data)
		setLoading(false)
	}, [])
	return (/* ui */)
}

function AppWithMobx() {
	const store = useLocalStore(() => ({
		data: {},
		loading: true,
	}))
	useEffect(async () => {
		const data = await fetchData()
		runInAction(() => {
			// 这里借助 mobx 的 action,能够很好的作到批量更新,此时组件只会更新一次
			store.data = data
			store.loading = false
		})
	}, [])
	return useObserver(() => (/* ui */))
}
复制代码

不过也有人会说,这种状况下用 useReducer 不就行了么?确实,针对这个例子是能够的,可是每每业务中会出现不少复杂状况,好比你在异步回调中要更新本地 store 以及全局 store,那么就算是 useReducer 也要分别调用两次 dispatch ,一样会触发两次渲染。而 mobx 的 action 就不会出现这样的问题。// 若是你强行 ReactDOM.unstable_batchedUpdates 我就不说啥了,勇士受我一拜

Quick Tips

知道了上面的两个 api,就能够开始愉快的使用起来了,只不过这里给你们一下小 tips,帮助你们更好的理解、更好的使用这两个 api。(不想用并且也不敢用「最佳实践」这个词,感受太绝对,这里面有一些我本身也没有打磨好,只能算是 tips 来帮助你们拓展思路了)

no this

对于 store 内的函数要获取 store 的数据,一般咱们会使用 this 获取。好比

const store = useLocalStore(() => ({
	count: 0,
	add() {
		this.add++
	}
}))

const { add } = store
add() // boom
复制代码

这种方式通常状况下使用彻底没有问题,可是 this 依赖 caller,并且没法很好的使用解构语法,因此这里并不推荐使用 this,而是采用一种 no this 的准则。直接引用自身的变量名

const store = useLocalStore(() => ({
	count: 0,
	add() {
		store.count++
	}
}))

const { add } = store
add() // correct,不会致使 this 错误
复制代码
  • 避免 this 指向的混乱
  • 避免在使用的时候直接解构从而致使 this 丢失
  • 避免使用箭头函数直接定义 store 的 action,一是没有必要,二是能够将职责划分的更加清晰,那些是 state 那些是 action

source

在某些状况下,咱们的 local store 可能须要获取 props 上的一些数据,而经过 source 能够很方便的把 props 也转换成 observable 的对象。

function App(props) {
	const store = useLocalStore(source => ({
		doSomething() {
			// source 这里是响应式的,当外界 props 发生变化的时候,target 也会发生变化
			if (source.count) {}
			// 若是这里直接用 props,因为闭包的特性,这里的 props 并不会发生任何变化
			// 而 props 每次都是不一样的对象,而 source 每次都是同一个对象引用
			// if (props.count) {}
		}
	// 经过第二个参数,就能够完成这样的功能
	}), props)
	// return Node
}
复制代码

固然,这里不只仅能够用于转换 props,能够将不少非 observable 的数据转化成 observable 的,最多见的好比 Context、State 之类,好比

const context = useContext(SomeContext)
const [count, setCount] = useState(0)
const store = useLocalStore(source => ({
	getCount() {
		return source.count * context.multi
	}
}), {...props, ...context, count})
复制代码

自定义 observable

有的时候,默认的 observable 的策略可能会有一些性能问题,好比为了避免但愿针对一些大对象所有响应式。能够经过返回自定义的 observable 来实现。

const store = useLocalStore(() => observable({
	hugeObject: {},
	hugeArray: [],
}, {
	hugeObject: observable.ref,
	hugeArray: observable.shallow,
}))
复制代码

甚至你以为自定义程度不够的话,能够直接返回一个自定义的 store

const store = useLocalStore(() => new ComponentStore())
复制代码

类型推导

默认的使用方式下,最方便高效的类型定义就是经过实例推导,而不是经过泛型。这种方式既能兼顾开发效率也能兼顾代码可读性和可维护性。固然了,你想用泛型也是能够的啦

// 使用这种方式,直接经过对象字面量推导出类型
const store = useLocalStore(() => ({
	todos: [] as Todo[],
}))

// 固然你能够经过泛型定义,只要你不以为烦就行
const store = useLocalStore<{
	todos: Todo[]
}>(() => ({todos: []}))
复制代码

可是这个仅仅建议用做 local store 的时候,也就是相关的数据是在本组件内使用。若是自定义 Hooks 话,建议仍是使用预约义类型而后泛型的方式,能够提供更好的灵活性。

memo?

当使用 useObserver api 以后,就意味着失去了 observer 装饰器默认支持的浅比较 props 跳过渲染的能力了,而此时须要咱们本身手动配合 memo 来作这部分的优化

另外,memo 的性能远比 observer 的性能要高,由于 memo 并非一个简单的 hoc

export default memo(function App(){
	const xxx = useLocalStore(() => ({}))
	return useObserver(() => {
		return (<div/>)
	})
})
复制代码

再也不建议使用 useCallback/useRef/useMemo 等内置 Hooks

上面的这几个 Hooks 均可以经过 useLocalStore 代替,内置 Hooks 对 Mobx 来讲是毫无必要。并且这几个内置 api 的使用也会致使缓存的问题,建议作以下迁移

  • useCallback 有两种作法
    • 若是函数不须要传递给子组件,那么彻底没有缓存的必要,直接删除掉 useCallback 便可,或者放到 local store 中也能够
    • 若是函数须要传递给子组件,直接放到 local store 中便可。
  • useMemo 直接放到 local store,经过 getter 来使用

useEffect or reaction?

常用 useEffect 知道他有一个功能就是监听依赖变化的能力,换句话说就是能够当作 watcher 使用,而 mobx 也有本身的监听变化的能力,那就是 reaction,那么究竟使用哪一种方式更好呢?

这边推荐的是,两个都用,哈哈哈,没想到吧。

useEffect(() =>
	reaction(() => store.count, () => console.log('changed'))
, [])
复制代码

说正经的,针对非响应式的数据使用 useEffect,而响应式数据优先使用 reaction。固然若是你全程抛弃原生 hooks,那么只用 reaction 也能够的。

组合?拆分?

逻辑拆分和组合,是 Hooks 很大的一个优点,在 mobx 加持的时候,这个有点依旧能够保持。甚至在还更加简单。

function useCustomHooks() {
	// 推荐使用全局 Store 的规则来约束自定义 Hooks
	const store = useLocalStore(() => ({
		count: 0,
		setCount(count) {
			store.count = count
		}
	}))
	return store
}

function App() {
	// 此时这个 store 你能够从两个角度来思考
	// 第一,他是一个 local store,也就是每个都会初始化一个新的
	// 第二,他能够做为全局 store 的 local 化,也就是你能够将它按照全局 store 的方式来使用
	const store = useCustomHook()
	return (
		// ui
	)
}
复制代码

App Store

Mobx 自己就提供了做为全局 Store 的能力,这里只说一下和 Hooks 配合的使用姿式

当升级到 mobx-react@6 以后,正式开始支持 hooks,也就是你能够简单的经过这种方式来使用

export function App() {
	return (
		<Provider sa={saStore} sb={sbStore}> <Todo/> </Provider>
	)
}

export function Todo() {
	const {sa, sb} = useContext(MobxProviderContext)
	return (
		<div>{sa.foo} {sb.bar}</div>
	)
}
复制代码

Context 永远是数据共享的方案,而不是数据托管的方案,也就是 Store

这句话怎么理解数据共享和组件通信呢?举个例子

  • 有一些基础的配置信息须要向下传递,好比说 Theme。而子组件一般只须要读取,而后作对应的渲染。换句话说数据的控制权在上层组件,是上层组件共享数据给下层组件,数据流一般是单向的,或者说主要是单向的。这能够说是数据共享
  • 而有一些状况是组件之间须要通信,好比 A 组件须要修改 B 组件的东西,这种状况下常见的作法就是将公共的数据向上一层存放,也就是托管给上层,可是使用控制权却在下层组件。其实这就是全局 Store,也就是 Redux 这类库作的事情。能够看出来数据流一般是双向的,这就能够算做数据托管

曾经关注过 Hooks 的发展,发现不少人在 Hooks 诞生的时候开始尝试用 Context + useReducer 来替换掉 Redux,我以为这是对 Context 的某种曲解。

缘由就是 Context 的更新问题,若是做为全局 Store,那么必定要在根组件上挂载,而 Context 检查是否发生变化是经过直接比较引用,那么就会形成任意一个组件发生了变化,都会致使从 Provider 开始的整个组件树发生从新渲染的状况。

function App() {
	const [state, dispatch] = useReducer(reducer, init)
	return (
		// 每次当子组件调用 dispatch 以后,会致使 state 发生变化,从而致使 Provider 的 value 变化
		// 进而让全部的子组件触发刷新
		<GlobalContext.Provider value={{...state, dispatch}}>
			{/* child node */}
		</GlobalContext.Provider>
	)
}
复制代码

而若是你想避免这些问题,那就要再度封装一层,这和直接使用 Redux 也就没啥区别了。

主要是 Context 的更新是一个性能消耗比较大的操做,当 Provider 检测到变化的时候,会遍历整颗 Fiber 树,比较检查每个 Consumer 是否要更新。

专业的事情交给专业的来作,使用 Redux Mobx 能够很好的避免这个问题的出现。

如何写好一个 Store

知道 Redux 的应该清楚他是如何定义一个 Store 吧,官方其实已经给出了比较好的最佳实践,但在生产环境中,使用起来依旧不少问题和麻烦的地方。因而就诞生了不少基于 Redux 二次封装的库,基本都自称简化了相关的 API 的使用和概念,可是这些库其实大大增长了复杂性,引入了什么 namespace/modal 啥的,我也记不清了,反正看到这些就自动劝退了,不喜欢在已经很麻烦的东西上为了简化而作的更加麻烦。

而 Mobx 这边,官方也有了一个很好的最佳实践。我以为是颇有道理,并且是很是易懂易理解的。

但仍是那个问题,官方在有些地方仍是没有进行太多的约束,而在开发中也遇到了相似的问题,因此这里在基于官方的框架下有几点意见和建议:

  • 保证全部修改 store 的操做都只能在 store 内部操做,也就是说你要经过调用 store 上的 action 方法更新 store,坚定不能在外部直接修改 store 的 property 的值。
  • 保证 store 的可序列化,方便 SSR 的使用以及一些 debug 的功能
    • 类构造函数的第一个参数永远是初始化的数据,而且类型保证和 toJSON 的返回值的类型一致
    • 若是 store 不定义 toJSON 方法,那么要保证 store 中的数据不存在不可序列化的类型,好比函数、DOM、Promise 等等类型。由于不定义默认就走 JSON.stringify 的内置逻辑了
  • store 之间的沟统统过构造函数传递实现,好比 ThemeStore 依赖 GlobalStore,那么只须要在 ThemeStore 的构造参数中传入 GlobalStore 的实例便可。不过说到这里,有的人应该会想到,这不就是手动版本的 DI 么。没错,DI 是一个很好的设计模式,可是在前端用的比较轻,就不必引入库来管理了,手动管理下就行了。也经过这种模式,能够很方便的实现 Redux 那种 namespace 的概念以及子 store
  • 若是你使用 ts 开发,那么建议将实现和定义分开,也就是说分别定义一个 interface 和 class,class 继承 Interface,这样对外也就是组件内只须要暴露 interface 便可。这样能够很方便的隐藏一些你不想对外部暴露的方法,但内部却依旧要使用的方法。仍是上面的例子,好比 GlobalStore 有一个属性是 ThemeStore 须要获取的,而不但愿组件获取,那么就能够将方法定义到 class 上而非 interface 上,这样既能有良好的类型检查,又能够保证必定的隔离性。

是的,基本上这样就能够写好一个 Store 了,没有什么花里胡哨的概念,也没有什么乱七八糟的工具,约定俗成就足以。我向来推崇没有规则就是最大的规则,没有约束就是最大的约束。不少东西能约定俗成就约定俗成,落到纸面上就足够了。彻底不必作一堆 lint/tools/library 去约束,既增长了前期开发成本,又增长了后期维护成本,就问问你司内部有多少 dead 的工具和库?

俗话说的话,「秦人不暇自哀然后人哀之,后人哀之而不鉴之,亦使后人而复哀后人也」,这就是现状(一巴掌打醒)

不过以上的前提是要求大家的开发团队有足够的开发能力,不然新手不少或者同步约定成本高的话,搞个库去约束到也不是不行(滑稽脸)

缺点?

说了这么多,也不是说是万能的,有这个几个缺点

  • 针对一些就带状态的小组件,性能上还不如原生 hooks。能够根据业务状况酌情针对组件使用原生 hooks 仍是 mobx hooks。并且针对小组件,代码量可能相应仍是增多。由于每次都要包裹 useObserver 方法。
  • mobx 就目前来看,没法很好在将来使用异步渲染的功能,虽然我以为这个功能意义不大。某种程度上说就是一个障眼法,不过这个思路是值得一试的。
  • 须要有必定 mobx 的使用基础,若是新手直接上来写,虽然能避免不少 hooks 的坑,可是可能会踩到很多 mobx 坑

总结

Mobx 在我司的项目中已经使用了好久了,但 Hooks 也是刚刚使用没多久,但愿这个能给你们帮助。也欢迎你们把遇到的问题一块儿说出来,你们一块儿找解决办法。

我始终以为基于 Mutable 的开发方式永远是易于理解、上手难度最低的方式,而 Immutable 的开发方式是易维护、比较稳定的方式。这二者不必非此即彼,而 Mobx + React 能够认为很好的将二者整合在一块儿,在须要性能的地方能够采用 Immutable 的方式,而在不须要性能的地方,能够用 Mutable 的方式快速开发。

固然了,你就算不用 Mobx 也彻底没有问题,毕竟原生的 Hooks 的坑踩多了以后,习惯了也没啥问题,一些小项目,我也会只用原生 Hooks 的(防杠声明)。

相关文章
相关标签/搜索