前几篇文章都在讲 React 的 Concurrent 模式, 很多读者都看懵了,这一篇来点轻松的,蹭了一下 Vue 3.0 的热度。讲讲如何在 React 下实现 Vue Composition API
(下面简称VCA),只是个玩具,别当真。
实现 ‘React’ Composition API?看起来很吊,确实也是,通过本文你可以体会到这两种思想的碰撞, 你可以深入学习三样东西:React Hooks
、Vue Composition API
、Mobx
。篇幅很长(主要是代码),当然干货也很多。
目录
Vue Composition API 是 Vue 3.0 的一个重要特性,和 React Hooks 一样,这是一种非常棒的逻辑组合/复用机制。尽管初期受到不少争议,我个人还是比较看好这个 API 提案,因为确实解决了 Vue 以往的很多痛点, 这些痛点在它的 RFC 文档中说得很清楚。动机和 React Hooks 差不多,无非就是三点:
- ① 逻辑组合和复用
- ② 更好的类型推断。完美支持 Typescript
- ③ Tree-shakable 和 代码压缩友好
如果你了解 React Hooks 你会觉得 VCA 身上有很多 Hooks 的影子, 毕竟官方也承认 React Hooks 是 VCA 的主要灵感来源,但是 Vue 没有完全照搬 React Hooks,而是基于自己的数据响应式机制,创建出了自己特色的逻辑复用原语, 辨识度也是非常高的。
对比 React Hooks 和 Vue Composition API
对于 React 开发者来说, VCA 还解决了 React Hooks 的一些有点稍微让人难受、新手不友好的问题。这是驱动我写这篇文章原因之一,来尝试把 VCA 抄过来, 除了学习 VCA,还可以加深对 React Hooks 的理解。
VCA 官方 RFC 文档已经很详细列举了它和 React Hooks 的差异:
① 总的来说,更符合惯用的 JavaScript 代码直觉。这主要是 Immutable 和 Mutable 的数据操作习惯的不同。
const data = reactive({count: 1}) data.count++
const [count, setCount] = useState(1) setCount(count + 1) setCoung(c => c + 1)
const initialState = {count: 0, };
function reducer(state, action) { switch (action.type) { case 'increment': return {...state, count: state.count + 1}; case 'decrement': return {...state, count: state.count - 1}; default: return state } } const [state, dispatch] = useReducer(reducer, initialState) dispatch({type: 'increment'})
|
不过, 不能说可变数据就一定好于不可变数据, 反之亦然。 不可变数据也给 React 发挥和优化的空间, 尤其在 Concurrent 模式下, 不可变数据可以更好地被跟踪和 reduce。 例如:
const [state, setState] = useState(0) const [startTransition] = useTransition()
setState(1) startTransition(() => { setState(2) })
|
React 中状态变更可以有不同的优先级,实际上这些变更会放入一个队列中,界面可能先显示 1
, 然后才是 2
。你可以认为这个队列就是这个状态的历史快照,由 React 来调度进行状态的前进,有点类似于 Redux 的’时间旅行’。如果是可变数据,实现这种‘时间旅行’会相对比较麻烦。
② 不关心调用顺序和条件化。React Hooks 基于数组实现,每次重新渲染必须保证调用的顺序,否则会出现数据错乱。VCA 不依赖数组,不存在这些限制。
function useMyHooks(someCondition, antherCondition) { if (someCondition) { useEffect(() => {}, []) }
if (anotherCondition) { return something }
const [someState] = useState(0) }
|
③ 不用每次渲染重复调用,减低 GC 的压力。 每次渲染所有 Hooks 都会重新执行一遍,这中间可能会重复创建一些临时的变量、对象以及闭包。而 VCA 的setup 只调用一次。
function MyComp(props) { const [count, setCount] = useState(0) const add = () => setCount(c => c+1) const decr = () => setCount(c => c-1)
useEffect(() => { console.log(count) }, [count])
return (<div> count: {count} <span onClick={add}>add</span> <span onClick={decr}>decr</span> </div>) }
|
④ 不用考虑 useCallback/useMemo 问题。 因为问题 ③ , 在 React 中,为了避免子组件 diff 失效导致无意义的重新渲染,我们几乎总会使用 useCallback 或者 useMemo 来缓存传递给下级的事件处理器或对象。
VCA 中我们可以安全地引用对象,随时可以存取最新的值。
function MyComp(props) { const [count, setCount] = useState(0) const add = useCallback(() => setCount(c => c+1), []) const decr = useCallback(() => setCount(c => c-1), [])
useEffect(() => { console.log(count) }, [count])
return (<SomeComplexComponent count={count} onAdd={add} onDecr={decr}/>) }
// Vue: 没有此问题, 通过对象引用存取最新值 createComponent({ setup((props) => { const count = ref(0) const add = () => count.value++ const decr = () => count.value-- watch(count, c => console.log(c))
return () => <SomeComplexComponent count={count} onAdd={add} onDecr={decr}/> }) })
|
⑤ 不必手动管理数据依赖。在 React Hooks 中,使用 useCallback
、useMemo
、useEffect
这些 Hooks,都需要手动维护一个数据依赖数组。当这些依赖项变动时,才让缓存失效。
这往往是新手接触 React Hooks 的第一道坎。你要理解好闭包,理解好 Memoize 函数 ,才能理解这些 Hooks 的行为。这还不是问题,问题是这些数据依赖需要开发者手动去维护,很容易漏掉什么,导致bug。
function MyComp({anotherCount, onClick}) { const [count, setState] = useState(0)
const handleClick = useCallback(() => { onClick(anotherCount + count) }, [count]) }
|
因此 React 团队开发了 eslint-plugin-react-hooks插件,辅助检查 React Hooks 的用法, 可以避免漏掉某些依赖。不过这个插件太死了,搞不好要写很多 //eslint-disable-next-line
😂
VCA 由于不存在 ④ 问题,当然也不存在 ⑤问题。 Vue 的响应式机制可以自动、精确地跟踪数据依赖,而且基于对象引用的不变性,我们不需要关心闭包问题。
如果你长期被这些问题困扰,你会觉得 VCA 很有吸引力。而且它简单易学, 这简直是 Vue 开发者的‘福报‘啊! 是不是也想自己动手写一个?把 VCA 搬到 React 这边来,解决这些问题?那请继续往下读
基本 API 类比
首先,你得先了解 React Hooks 和 VCA。最好的学习资料是它们的官方文档。下面简单类比一下两者的 API:
|
React Hooks |
Vue Composition API |
状态 |
const [value, setValue] = useState(0) useReducer |
const state = reactive({value: 0}) ref(0) |
状态变更 |
setValue(1) setValue(n => n + 1) dispatch |
state.value = 1 state.value++ |
状态衍生 |
useMemo(() => derived, [deps]) |
computed(() => derived) |
对象引用 |
const foo = useRef(0); foo.current = 1 |
const foo = ref(0) foo.value = 1 |
挂载 |
useEffect(() => {/*挂载*/}, []) |
onBeforeMount(() => {/*挂载前*/}) onMounted(() => {/*挂载后*/}) |
卸载 |
useEffect(() => () => {/*卸载*/}}, []) |
onBeforeUnmount(() => {/*卸载前*/}) onUnmounted(() => {/*卸载后*/}) |
重新渲染 |
useEffect(() => {/*更新*/}) |
onBeforeUpdate(() => {/*更新前*/}) onUpdated(() => {/*更新后*/}) |
异常处理 |
目前只有类组件支持(componentDidCatch , static getDerivedStateFromError ) |
onErrorCaptured((err) => {/*异常处理*/}) |
依赖监听 |
useEffect(() => {/*依赖更新*/}, [deps]) |
const stop = watch(() => {/*自动检测数据依赖, 更新...*/}) |
依赖监听 + 清理 |
useEffect(() => {/*...*/; return () => {/*清理*/}}, [deps]) |
watch(() => [deps], (newVal, oldVal, clean) => {/*更新*/; clean(() => {/* 清理*/})}) |
Context 注入 |
useContext(YouContext) |
inject(key) provider(key, value) |
对比上表,我们发现两者非常相似,每个功能都可以在对方身上找到等价物。 React Hooks 和 VCA 的主要差别如下:
- 数据方面。
Mutable
vs Immutable
,Reactive
vs Diff
。
- 更新响应方面。React Hooks 和其组件思维一脉相承,它依赖数据的比对来确定依赖的更新。而Vue 则基于自动的依赖订阅。这点可以通过对比 useEffect 和 watch 体会。
- 生命周期钩子。React Hooks 已经弱化了组件生命周期的概念,类组件也废弃了
componentWillMount
、 componentWillUpdate
、 componentWillReceiveProps
这些生命周期方法。 一则我们确实不需要这么多生命周期方法,React 做了减法;二则,Concurrent 模式下,Reconciliation 阶段组件可能会被重复渲染,这些生命周期方法不能保证只被调用一次,如果在这些生命周期方法中包含副作用,会导致应用异常, 所以废弃会比较好。Vue Composition API 继续沿用 Vue 2.x 的生命周期方法.
其中第一点是最重要的,也是最大的区别(思想)。这也是为什么 VCA 的 ‘Hooks’ 只需要初始化一次,不需要在每次渲染时都去调用的主要原因: 基于Mutable 数据,可以保持数据的引用,不需要每次都去重新计算。
API 设计概览
先来看一下,我们的玩具(随便取名叫mpos吧)的大体设计:
import { reactive, box, createRef, computed, inject, watch, onMounted, onUpdated, onUnmount, createComponent, Box } from 'mpos' import React from 'react'
export interface CounterProps { initial: number; }
export const MultiplyContext = React.createContext({ value: 0 });
function useTitle(title: Box<string>) { watch(() => document.title = title.value) }
export default createComponent<CounterProps>({ name: 'Counter', setup(props) {
const data = reactive({ count: props.initial, tick: 0 });
const name = box('kobe') name.set('curry') console.log(name.get())
const derivedCount = computed(() => data.count * 2); console.log(derivedCount.get())
const containerRef = createRef<HTMLDivElement>();
const ctx = inject(MultiplyContext);
useTitle(computed(() => `title: ${data.count}`)) const awesome = useYourImagination()
onMounted(() => { console.log("mounted", container.current);
return () => { console.log("unmount"); } });
onUpdated(() => { console.log("update", data.count, props); });
onUnmount(() => { console.log("unmount"); });
const stop = watch( () => [data.count], ([count]) => { console.log("count change", count);
const timer = setInterval(() => data.tick++, count)
return () => { clearInterval(timer) } } );
watch(() => { console.log("initial change", props.initial); });
watch( () => [ctx.value], ([ctxValue], [oldCtxValue]) => { console.log("context change", ctxValue); } );
const add = () => { data.count++; };
return () => { useEffect(() => { console.log('hello world') }, [])
return ( <div className="counter" onClick={add} ref={containerRef}> {data.count} : {derivedCount.get()} : {data.tick} </div> ); } }, })
|
我不打算完全照搬 VCA,因此略有简化和差异。以下是实现的要点:
- ① 如何确保 setup 只初始化一次?
- ② 因为 ①,我们需要将 Context、Props 这些对象进行包装成响应式数据, 确保我们总是可以拿到最新的值,避免类似 React Hook 的闭包问题.
- ③ 生命周期钩子, watch 如何绑定到组件上?我们要实现一个调用上下文
- ④ watch 数据监听和释放
- ④ Context 支持, inject 怎么实现?
- ⑤ 如何触发组件重新渲染?
我们带着这些问题,一步一步来实现这个 ‘React Composition API’
响应式数据和 ref
如何实现数据的响应式?不需要我们自己去造轮子,现成最好库的是 MobX
。
reactive
和 computed
以及 watch
都可以在 Mobx 中找到等价的API。以下是 Mobx API 和 VCA 的对照表:
Mobx |
Vue Composition API |
描述 |
observable(object/map/array/set) |
reactive() |
转换响应式对象 |
box(原始类型) |
ref() |
转换原始类型为响应式对象 |
computed() + 返回 box 类型 |
computed() + 返回 ref 类型 |
响应式衍生状态计算 |
autorun(), reaction() |
watch() |
监听响应式对象变动 |
所以我们不需要自己去实现这些 API, 简单设置个别名:
import { observable, computed, isBoxedObservable } from 'mobx'
export type Box<T> = IObservableValue<T> export type Boxes<T> = { [K in keyof T]: T[K] extends Box<infer V> ? Box<V> : Box<T[K]> }
export const reactive = observable export const box = reactive.box export const isBox = isBoxedObservabl export { computed }
export function toBoxes<T extends object>(obj: T): Boxes<T> { const res: Boxes<T> = {} as any Object.keys(obj).forEach(k => { if (isBox(obj[k])) { res[k] = obj[k] } else { res[k] = { get: () => obj[k], set: (v: any) => (obj[k] = v), } } })
return res }
|
下面是它们的简单用法介绍(详细用法见官方文档)
import { reactive, box, computed } from 'mpos'
const data = reactive({foo: 'bar'}) data.foo = 'baz'
const initialState = { firstName: "Clive Staples", lastName: "Lewis" } const person = reactive(initialState) person.firstName = 'Kobe' person.firstName initialState.firstName
const arr = reactive([]) arr.push(1) arr[0]
const temperature = box(20) temperature.set(37) temperature.get()
const fullName = computed(() => `${person.firstName} ${person.lastName}`) fullName.get()
|
关于 Vue Composition API ref
上面说了,VCA 的 ref 函数等价于 Mobx 的 box 函数。可以将原始类型包装为’响应式数据’(本质上就是创建一个reactive对象,监听getter/setter方法), 因此 ref 也被 称为包装对象(Mobx 的 box 命名更贴切):
const count = ref(0) console.log(count.value)
|
你可以这样理解, ref 内部就是一个 computed
封装(当然是假的):
function ref(value) { const data = reactive({value}) return computed({ get: () => data.value, set: val => data.value = val }) }
// 或者这样理解也可以 function ref(value) { const data = reactive({value}) return { get value() { return data.value }, set value(val) { data.value = val } } }
|
只不过它们需要通过 value
属性来存取值,有时候代码显得有点啰嗦。因此 VCA 在某些地方支持对 ref 对象进行自动解包(Unwrap, 也称自动展开)
, 不过目前自动解包,仅限于读取。 例如:
const state = reactive({ count }) console.log(state.count)
state.count = 1 console.log(count.value)
watch(count, (cur, prev) => { console.log(cur) })
|
另外 VCA 的 computed 实际上就是返回 ref 对象:
const double = computed(() => state.count * 2) console.log(double.value)
|
🤔 VSA 和 Mobx 的 API 惊人的相似。想必 Vue 不少借鉴了 Mobx.
为什么需要 ref?
响应式对象有一个广为人知的陷阱,如果你对响应式对象进行解构、展开,或者将具体的属性传递给变量或参数,那么可能会导致响应丢失。 看下面的例子, 思考一下响应是怎么丢失的:
const data = reactive({count: 1})
let { count } = data
|
因为 Javascript 原始值是按值传递的,这时候传递给变量、对象属性或者函数参数,引用就会丢失。为了保证 ‘安全引用’, 我们才需要用’对象’来包裹这些值,我们总是可以通过这个对象获取到最新的值:
关于 VCA 的 ref,还有 toRefs
值得提一下。 toRefs 可以将 reactive 对象的每个属性都转换为 ref 对象,这样可以实现对象被解构或者展开的情况下,不丢失响应:
const state = reactive({count: 1}) const stateRef = toRefs(state)
const { count } = stateRef
count.value state.count stateRef.count.value
state.count++ count.value
|
简单实现一下 toRefs, 没什么黑魔法:
function toRefs(obj) { const res = {} Object.keys(obj).forEach(key => { if (isRef(obj[key])) { res[key] = obj[key] } else { res[key] = { get value() { return obj[key] }, set value(val) { obj[key] = val } } } })
return res }
|
toRefs 解决 reactive 对象属性值解构和展开导致响应丢失问题。配合自动解包,不至于让代码变得啰嗦(尽管有限制).
对于 VCA 来说,① ref 除了可以用于封装原始类型,更重要的一点是:② 它是一个’规范’的数据载体,它可以在 Hooks 之间进行数据传递;也可以暴露给组件层,用于引用一些对象,例如引用DOM组件实例。
举个例子, 下面的 useOnline
Hook, 这个 Hooks 只返回一个状态:
function useOnline() { const online = ref(true)
online.value = navigator.onLine
const handleOnline = () => (online.value = true) const handleOffline = () => (online.value = false) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline)
onUnmounted(() => { window.removeEventListener('online', handleOnline) window.removeEventListener('offline', handleOffline) })
return online }
|
如果 useOnline 返回一个 reactive 对象, 会显得有点怪:
const { online } = useOnline()
const online = useOnline() watch(() => online.online)
watch(() => online.value)
wacth(online, (ol) => { })
|
再看另一个返回多个值的例子:
function useMousePosition() { const pos = reactive({x: 0, y: 0}) const update = e => { pos.x = e.pageX pos.y = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return toRefs(pos) }
function useMyHook() { const { x, y } = useMousePosition()
return { x, y } }
|
因此官方也推荐使用 ref 对象来进行数据传递,同时保持响应的传导。就到这吧,不然写着写着就变成 VCA 的文档了🌚。
ref 和 useRef
VCA ref 这个命名会让 React 开发者将其和 useRef
联想在一起。的确,VCA 的 ref 在结构、功能和职责上跟 React 的 useRef 很像。例如 ref 也可以用于引用 Virtual DOM的节点实例:
export default { setup() { const root = ref(null)
return () => <div ref={root}/> } }
|
为了避免和现有的 useRef 冲突,而且在我们也不打算实现 ref 自动解包诸如此类的功能。因此在我们会沿用 Mobx 的 box 命名,对应的还有isBox, toBoxes 函数。
那怎么引用 Virtual DOM 节点呢? 我们可以使用 React 的 createRef()
函数:
import { createRef } from 'react'
createComponent({ setup(props => { const containerRef = createRef()
return () => <div className="container" ref={containerRef}>?...?</div> }) })
|
生命周期方法
接下来看看怎么实现 useMounted 这些生命周期方法。这些方法是全局、通用的,怎么关联到具体的组件上呢?
这个可以借鉴 React Hooks 的实现,当 setup() 被调用时,在一个全局变量中保存当前组件的上下文,生命周期方法再从这个上下文中存取信息。
来看一下 initial 的大概实现:
let compositionContext: CompositionContext | undefined;
export function initial<Props extends object, Rtn, Ref>( setup: (props: Props) => Rtn, ) { return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn { const context = useRef<CompositionContext | undefined>();
if (context.current == null) { const ctx = (context.current = createCompositionContext(props));
const prevCtx = compositionContext; compositionContext = ctx;
ctx._instance = setup(ctx._props);
compositionContext = prevCtx; }
return context.current._instance!; }; }
|
Ok,现在生命周期方法实现原理已经浮出水面, 当这些方法被调用时,只是简单地在 compositionContext 中注册回调, 例如:
export function onMounted(cb: () => any) { const ctx = assertCompositionContext(); ctx.addMounted(cb); }
export function onUnmount(cb: () => void) { const ctx = assertCompositionContext(); ctx.addDisposer(cb); }
export function onUpdated(cb: () => void) { const ctx = assertCompositionContext(); ctx.addUpdater(cb); }
|
assertCompositionContext 获取 compositionContext,如果不在 setup
作用域下调用则抛出异常.
function assertCompositionContext(): CompositionContext { if (compositionContext == null) { throw new Error(`请在 setup 作用域使用`); }
return compositionContext; }
|
看一下 CompositionContext 接口的外形:
interface CompositionContext<P = any, R = any> { addMounted: (cb: () => any) => void; addUpdater: (cb: () => void) => void; addDisposer: (cb: () => void) => void; addContext: <T>(ctx: React.Context<T>) => T; // 添加通过ref暴露给外部的对象, 下文会介绍 addExpose: (value: any) => void
/** 私有属性 **/ // props 引用 _props: P; // 表示是否已挂载 _isMounted: boolean; // setup() 的返回值 _instance?: R; _disposers: Array<() => void>; _mounted: Array<() => any>; _updater: Array<() => void>; _contexts: Map<React.Context<any>, { value: any; updater: () => void }> _exposer?: () => any }
|
addMounted
、addUpdater
这些方法实现都很简单, 只是简单添加到队列中:
function createCompositionContext<P, R>(props: P): CompositionContext<P, R> { const ctx = { addMounted: cb => ctx._mounted.push(cb), addUpdater: cb => ctx._updater.push(cb), addDisposer: cb => ctx._disposers.push(cb), addContext: c => {} , _isMounted: false, _instance: undefined, _mounted: [], _updater: [], _disposers: [], _contexts: new Map(), _props: observable(props, {}, { deep: false, name: "props" }) _exposer: undefined, };
return ctx; }
|
关键实现还是得回到 initial 方法中:
export function initial<Props extends object, Rtn, Ref>( setup: (props: Props) => Rtn, ) { return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn { const context = useRef<CompositionContext | undefined>();
if (context.current == null) { }
useEffect(() => { const ctx = context.current; if (ctx._isMounted) executeCallbacks(ctx._updater); });
useEffect(() => { const ctx = context.current; ctx._isMounted = true;
if (ctx._mounted.length) { ctx._mounted.forEach(cb => { const rt = cb(); if (typeof rt === "function") { ctx.addDisposer(rt); } }); ctx._mounted = EMPTY_ARRAY; }
return () => executeCallbacks(ctx._disposers); }, []);
}; }
|
没错,这些生命周期方法,最终还是用 useEffect 来实现。
watch
接下来看看 watch 方法的实现。watch 估计是除了 reactive 和 ref 之外调用的最频繁的函数了。
watch 方法可以通过 Mobx 的 authrun
和 reaction
方法来实现。我们进行简单的封装,让它更接近 Vue 的watch 函数的行为。
这里有一个要点是: watch 即可以在setup 上下文中调用,也可以裸露调用。在setup 上下文调用时,支持组件卸载前自动释放监听。 如果裸露调用,则需要开发者自己来释放监听:
function useMyHook() { const data = reactive({count: 0}) watch(() => console.log('count change', data.count))
return data }
const stop = watch(() => someReactiveData, (data) => {}) dosomething(() => { stop() })
wacth((stop) => { if (someReactiveData === 0) { stop() } }) watch(() => someReactiveData, (data, stop) => {})
|
另外 watch 的回调支持返回一个函数,用来释放副作用资源,这个行为和 useEffect 保持一致。VCA 的 watch 使用onClean 回调来释放资源,因为考虑到 async/await 函数。
useEffect(() => { const timer = setInterval(() => {}, time) return () => { clearInterval(timer) } }, [time])
watch(() => { const timer = setInterval(() => {}, time) return () => { clearInterval(timer) } })
|
看看实现代码:
import {reaction, autorun} from 'mobx' export type WatchDisposer = () => void;
export function watch(view: (stop: WatchDisposer) => any, options?: IAutorunOptions): WatchDisposer; export function watch<T>(expression: () => T, effect: (arg: T, stop: WatchDisposer) => any, options?: IReactionOptions): WatchDisposer; export function watch(expression: any, effect: any, options?: any): WatchDisposer { let nativeDisposer: WatchDisposer; let effectDisposer: WatchDisposer | undefined; let disposed = false;
const stop = () => { if (disposed) return; disposed = true; if (effectDisposer) effectDisposer(); nativeDisposer(); };
const effectWrapper = (effect: (...args: any[]) => any, argnum: number) => ( ...args: any[] ) => { if (effectDisposer != null) effectDisposer(); const rtn = effect.apply(null, args.slice(0, argnum).concat(stop)); effectDisposer = typeof rtn === "function" ? rtn : undefined; };
if (typeof expression === "function" && typeof effect === "function") { nativeDisposer = reaction(expression, effectWrapper(effect, 1), options); } else { nativeDisposer = autorun(effectWrapper(expression, 0)); }
if (compositionContext) { compositionContext.addDisposer(stop); }
return stop; }
|
DONE!
包装 Props 为响应式数据
React 组件每次重新渲染都会生成一个新的 Props 对象,所以无法直接在 setup 中使用,我们需要将其转换为一个可以安全引用的对象,然后在每次重新渲染时更新这个对象。
import { set } from 'mobx'
export function initial<Props extends object, Rtn, Ref>(setup: (props: Props) => Rtn) { return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn { const context = useRef<CompositionContext | undefined>();
if (context.current == null) { const ctx = (context.current = createCompositionContext(props)); const prevCtx = compositionContext; compositionContext = ctx; ctx._instance = setup(ctx._props); compositionContext = prevCtx; }
set(context.current._props, props);
return context.current._instance!; }; }
|
支持 Context 注入
和 VCA 一样,我们通过 inject
支持依赖注入,不同的是我们的 inject
方法接收一个 React.Context
对象。inject
可以从 Context 对象中推断出注入的类型。
另外受限于 React 的 Context 机制,我们没有实现 provider 函数,用户直接使用 Context.Provider 组件即可。
实现 Context 的注入还是得费点事,我们会利用 React 的 useContext
Hook 来实现,因此必须保证 useContext
的调用顺序。
和生命周期方法一样,调用 inject 时,将 Context 推入队列中, 只不过我们会立即调用一次 useContext 获取到值:
export function inject<T>(Context: React.Context<T>): T { const ctx = assertCompositionContext(); return ctx.addContext(Context); }
|
为了避免重复的 useContext 调用, 同时保证插入的顺序,我们使用 Map
来保存 Context 引用:
function createCompositionContext<P, R>(props: P): CompositionContext<P, R> { const ctx = { _isMounted: false, _contexts: new Map(),
addContext: c => { if (ctx._contexts.has(c)) { return ctx._contexts.get(c)!.value }
let value = useContext(c) const wrapped = observable(value, {}, { deep: false, name: "context" })
ctx._contexts.set(c, { value: wrapped, updater: () => { const newValue = useContext(c) if (newValue !== value) { set(wrapped, newValue) value = newValue } }, })
return wrapped as any }, // .... };
return ctx; }
|
回到 setup 函数,我们必须保证每一次渲染时都按照一样的次序调用 useContext:
export function initial<Props extends object, Rtn, Ref>( setup: (props: Props) => Rtn, ) { return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn { const context = useRef<CompositionContext | undefined>()
if (context.current == null) { const ctx = (context.current = createCompositionContext(props)) const prevCtx = compositionContext compositionContext = ctx ctx._instance = setup(ctx._props) compositionContext = prevCtx }
if (context.current._contexts.size && context.current._isMounted) { for (const { updater } of context.current._contexts.values()) { updater() } }
} }
|
DONE!
跟踪组件依赖并触发重新渲染
基本接口已经准备就绪了,现在如何和 React 组件建立关联,在响应式数据更新后触发组件重新渲染?
Mobx 有一个库可以用来绑定 React 组件, 它就是 mobx-react-lite
, 有了它, 我们可以监听响应式变化并触发组件重新渲染。用法如下:
import { observer } from 'mobx-react-lite' import { initial } from 'mpos'
const useComposition = initial((props) => {})
const YouComponent = observer(props => { const state = useComposition(props) return <div>{state.data.count}</div> })
|
How it work? 如果这样一笔带过,估计很多读者会很扫兴,自己写一个 observer
也不难。我们可以参考 mobx-react 或者 mobx-react-lite 的实现。
它们都将渲染函数放在 track
函数的上下文下,track函数可以跟踪渲染函数依赖了哪些数据,当这些数据变动时,强制进行组件更新:
import React, { FC , useRef, useEffect } from 'react' import { Reaction } from 'mobx'
export function createComponent<Props extends {}, Ref = void>(options: { name?: string setup: (props: Props) => () => React.ReactElement forwardRef?: boolean }): FC<Props> { const { setup, name, forwardRef } = options const useComposition = initial(setup)
const Comp = (props: Props, ref: React.RefObject<Ref>) => { const forceUpdate = useForceUpdate() const reactionRef = useRef<{ reaction: Reaction, disposer: () => void } | null>(null)
const render = useComposition(props, forwardRef ? ref : null)
if (reactionRef.current == null) { reactionRef.current = { reaction: new Reaction(`observer(${name || "Unknown"})`, () => forceUpdate()), disposer: () => { if (reactionRef.current && !reactionRef.current.reaction.isDisposed) { reactionRef.current.reaction.dispose() reactionRef.current = null } }, } }
useEffect(() => () => reactionRef.current && reactionRef.current.disposer(), [])
let rendering let error
reactionRef.current.reaction.track(() => { try { rendering = render(props, inst) } catch (err) { error = err } })
if (error) { reactionRef.current.disposer() throw error }
return rendering } }
|
接着,我们将 Comp 组件包裹在 React.memo 下,避免不必要重新渲染:
export function createComponent<Props extends {}, Ref = void>(options: { name?: string setup: (props: Props) => () => React.ReactElement forwardRef?: boolean }): FC<Props> { const { setup, name, forwardRef } = options const useComposition = initial(setup)
const Comp = (props: Props, ref: React.RefObject<Ref>) => {}
Comp.displayName = `Composition(${name || "Unknown"})`
let finalComp if (forwardRef) { finalComp = React.memo(React.forwardRef(Comp)) } else { finalComp = React.memo(Comp) }
finalComp.displayName = name
return finalComp }
|
forwardRef 处理
最后一步了,有些时候我们的组件需要通过 ref 向外部暴露一些状态和方法。在Hooks 中我们使用 useImperativeHandle
来实现:
function FancyInput(props, ref) { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => { inputRef.current.focus(); } })); return <input ref={inputRef} ... />; } FancyInput = forwardRef(FancyInput);
|
在我们的玩具中,我们自定义一个新的函数 expose
来暴露我们的公开接口:
function setup(props) { expose({ somePublicAPI: () => {} })
}
|
实现如下:
export function expose(value: any) { const ctx = assertCompositionContext(); ctx.addExpose(value); }
|
关键是 useComposition 的处理:
export function initial<Props extends object, Rtn, Ref>( setup: (props: Props) => Rtn, ) { return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn { const context = useRef<CompositionContext | undefined>() if (context.current == null) { }
if (ref && context.current._exposer != null) { useImperativeHandle(ref, context.current._exposer, [context.current._exposer]); }
|
🎉🎉 搞定,所有代码都在这个 CodeSandbox 中,大家可以自行体验. 🎉🎉
总结
最后,这只是一个玩具🎃!整个过程也不过百来行代码。
就如标题所说的,通过这个玩具,学到很多奇淫巧技,你对 React Hooks 以及 Vue Composition API 的了解应该更深了吧? 之所以是个玩具,是因为它还有一些缺陷,不够 ’React‘, 又不够 ‘Vue’!只能以学习的目的自个玩玩! 而且搞这玩意, 搞不好可能在两个社区都会被喷。所以我话就撂这了,你们就不要在评论区喷了。
如果你了解过 React Concurrent 模式,你会发现这个架构是 React 自身的状态更新机制是深入绑定的。React 自身的setState 状态更新粒度更小、可以进行优先级调度、Suspense、可以通过 useTransition + Suspense 配合进入 Pending 状态、在’平行宇宙’中进行渲染。 React 自身的状态更新机制和组件的渲染体系是深度集成。
因此我们现在监听响应式数据,然后粗暴地 forceUpdate
,会让我们丢失部分 React Concurrent 模式带来的红利。除此之外、开发者工具的集成、生态圈、Benchmark…
说到生态圈,如果你将这个玩具的 API 保持和 VCA 完全兼容,那么以后 Vue 社区的 Hooks 库也可以为你所用,想想脑洞挺大。
搞这一套还不如直接上 Vue 是吧?毕竟 Vue 天生集成响应式数据,跟 React 的不可变数据一样, Vue 的响应式更新机制和其组件渲染体系是深度集成的。 整个工作链路自顶向下, 从数据到模板、再到底层组件渲染, 对响应式数据有更好、更高效地融合。
尽管如此,React 的灵活性、开放、多范式编程方式、创造力还是让人赞叹。(仅代表我作为React爱好者的立场)
另外响应式机制也不是完全没有心智负担,最起码你要了解响应式数据的原理,知道什么可以被响应,什么不可以:
function useMyHook() { const { count } = reactive({count: 0})
return { count } }
|
还有响应式数据转换成本,诸如此类的,网上也有大量的资料, 这里就不赘述了。 关于响应式数据需要注意的东西可以参考这些资料:
除此之外,你有时候会纠结什么时候应该使用 reactive,什么时候应该使用 ref…
没有银弹,没有银弹。
最后的最后, useYourImagination, React Hooks 早已在 React 社区玩出了花🌸,Vue Composition API 完全可以将这些模式拿过来用,两个从结构和逻辑上都是差不多的,只不过换一下 ‘Mutable’ 的数据操作方式。安利 2019年了,整理了N个实用案例帮你快速迁移到React Hooks
我是荒山,觉得文章可以,请点个赞,下篇文章见!
参考/扩展