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 的(防槓聲明)。

相關文章
相關標籤/搜索