[實戰] 爲了學好 React Hooks, 我抄了 Vue Composition API, 真香

前幾篇文章都在講 React 的 Concurrent 模式, 不少讀者都看懵了,這一篇來點輕鬆的,蹭了一下 Vue 3.0 的熱度。講講如何在 React 下實現 Vue Composition API(下面簡稱VCA),只是個玩具,別當真。html

實現 'React' Composition API?看起來很吊,確實也是,經過本文你能夠體會到這兩種思想的碰撞, 你能夠深刻學習三樣東西:React HooksVue Composition APIMobx。篇幅很長(主要是代碼),固然乾貨也不少。vue


目錄react



Vue Composition API 是 Vue 3.0 的一個重要特性,和 React Hooks 同樣,這是一種很是棒的邏輯組合/複用機制。儘管初期受到很多爭議我我的仍是比較看好這個 API 提案,由於確實解決了 Vue 以往的不少痛點, 這些痛點在它的 RFC 文檔中說得很清楚。動機和 React Hooks 差很少,無非就是三點:git


  • ① 邏輯組合和複用
  • ② 更好的類型推斷。完美支持 Typescript
  • ③ Tree-shakable 和 代碼壓縮友好

若是你瞭解 React Hooks 你會以爲 VCA 身上有不少 Hooks 的影子, 畢竟官方也認可 React Hooks 是 VCA 的主要靈感來源,可是 Vue 沒有徹底照搬 React Hooks,而是基於本身的數據響應式機制,建立出了本身特點的邏輯複用原語, 辨識度也是很是高的。github



對比 React Hooks 和 Vue Composition API

對於 React 開發者來講, VCA 還解決了 React Hooks 的一些有點稍微讓人難受、新手不友好的問題。這是驅動我寫這篇文章緣由之一,來嘗試把 VCA 抄過來, 除了學習 VCA,還能夠加深對 React Hooks 的理解。typescript

VCA 官方 RFC 文檔已經很詳細列舉了它和 React Hooks 的差別:express


① 總的來講,更符合慣用的 JavaScript 代碼直覺。這主要是 Immutable 和 Mutable 的數據操做習慣的不一樣。npm

// Vue: 響應式數據, 更符合 JavaScript 代碼的直覺, 就是普通的對象操做
const data = reactive({count: 1})
data.count++

// React: 不可變數據, JavaScript 原生不支持不可變數據,所以數據操做會 verbose 一點
const [count, setCount] = useState(1)
setCount(count + 1)
setCoung(c => c + 1)

// React: 或者使用 Reducer, 適合進行一些複雜的數據操做
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。 例如:編程

// React
const [state, setState] = useState(0)
const [startTransition] = useTransition()

setState(1)              // 高優先級變動
startTransition(() => {  // 低優先級狀態變動
  setState(2)
})
複製代碼

React 中狀態變動能夠有不一樣的優先級,實際上這些變動會放入一個隊列中,界面可能先顯示 1, 而後纔是 2你能夠認爲這個隊列就是這個狀態的歷史快照,由 React 來調度進行狀態的前進,有點相似於 Redux 的'時間旅行'。若是是可變數據,實現這種‘時間旅行’會相對比較麻煩。api




② 不關心調用順序和條件化。React Hooks 基於數組實現,每次從新渲染必須保證調用的順序,不然會出現數據錯亂。VCA 不依賴數組,不存在這些限制。

// React
function useMyHooks(someCondition, antherCondition) {
  if (someCondition) {
    useEffect(() => {/* ... */}, []) // 💥
  }

  if (anotherCondition) {
    return something      // 提早返回 💥
  }

  const [someState] = useState(0)
}
複製代碼



③ 不用每次渲染重複調用,減低 GC 的壓力。 每次渲染全部 Hooks 都會從新執行一遍,這中間可能會重複建立一些臨時的變量、對象以及閉包。而 VCA 的setup 只調用一次。

// React
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 中咱們能夠安全地引用對象,隨時能夠存取最新的值。

// React
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 中,使用 useCallbackuseMemouseEffect 這些 Hooks,都須要手動維護一個數據依賴數組。當這些依賴項變更時,才讓緩存失效。

這每每是新手接觸 React Hooks 的第一道坎。你要理解好閉包,理解好 Memoize 函數 ,才能理解這些 Hooks 的行爲。這還不是問題,問題是這些數據依賴須要開發者手動去維護,很容易漏掉什麼,致使bug。

// React
function MyComp({anotherCount, onClick}) {
  const [count, setState] = useState(0)

  const handleClick = useCallback(() => {
    onClick(anotherCount + count)
  }, [count]) // 🐞漏掉了 antherCount 和 onClick
}
複製代碼

所以 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) or useReducer const state = reactive({value: 0}) or ref(0)
狀態變動 setValue(1) orsetValue(n => n + 1) or dispatch state.value = 1 or 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(() => {/*掛載*/; return () => {/*卸載*/}}, []) onBeforeUnmount(() => {/*卸載前*/}) + onUnmounted(() => {/*卸載後*/})
從新渲染 useEffect(() => {/*更新*/}) onBeforeUpdate(() => {/*更新前*/}) + onUpdated(() => {/*更新後*/})
異常處理 目前只有類組件支持(componentDidCatchstatic 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 ImmutableReactive vs Diff
  • 更新響應方面。React Hooks 和其組件思惟一脈相承,它依賴數據的比對來肯定依賴的更新。而Vue 則基於自動的依賴訂閱。這點能夠經過對比 useEffect 和 watch 體會。
  • 生命週期鉤子。React Hooks 已經弱化了組件生命週期的概念,類組件也廢棄了componentWillMountcomponentWillUpdatecomponentWillReceiveProps 這些生命週期方法。 一則咱們確實不須要這麼多生命週期方法,React 作了減法;二則,Concurrent 模式下,Reconciliation 階段組件可能會被重複渲染,這些生命週期方法不能保證只被調用一次,若是在這些生命週期方法中包含反作用,會致使應用異常, 因此廢棄會比較好。Vue Composition API 繼續沿用 Vue 2.x 的生命週期方法.

其中第一點是最重要的,也是最大的區別(思想)。這也是爲何 VCA 的 'Hooks' 只須要初始化一次,不須要在每次渲染時都去調用的主要緣由: 基於Mutable 數據,能夠保持數據的引用,不須要每次都去從新計算



API 設計概覽

先來看一下,咱們的玩具(隨便取名叫mpos吧)的大致設計:

// 就隨便取名叫 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 });

// 自定義 Hooks
function useTitle(title: Box<string>) {
  watch(() => document.title = title.value)
}

// createComponent 建立組件
export default createComponent<CounterProps>({
  // 組件名
  name: 'Counter',
  // ⚛️ 和 Vue Composition API 同樣的setup,只會被調用一次
  // 接受組件的 props 對象, 這也是響應式對象, 能夠被watch,能夠獲取最新值
  setup(props) {
    /** * ⚛️建立一個響應式數據 */
    const data = reactive({ count: props.initial, tick: 0 });

    /** * ⚛️等價於 Vue Composition API 的 ref * 因爲reactive 不能包裝原始類型,box 能夠幫到咱們 */
    const name = box('kobe')
    name.set('curry')
    console.log(name.get()) // curry

    /** * ⚛️衍生數據計算 */
    const derivedCount = computed(() => data.count * 2);
    console.log(derivedCount.get()) // 0

    /** * ⚛️等價於 React.createRef(),用於引用Virtual DOM 節點 */
    const containerRef = createRef<HTMLDivElement>();

    /** * ⚛️依賴注入,獲取 React.Context 值, 相似於 useContext,只不過返回一個響應式數據 */
    const ctx = inject(MultiplyContext);

    /** * ⚛️能夠複合其餘 Hooks,實現邏輯組合 */
    useTitle(computed(() => `title: ${data.count}`))
    const awesome = useYourImagination()

    /** * ⚛️生命週期方法 */
    onMounted(() => {
      console.log("mounted", container.current);

      // 支持相似 useEffect 的方式,返回一個函數,這個函數會在卸載前被調用
      // 由於通常資源獲取和資源釋放邏輯放在一塊兒,代碼會更清晰
      return () => {
        console.log("unmount");
      }
    });

    onUpdated(() => {
      console.log("update", data.count, props);
    });

    // 注意這裏是 onUnmount,而 VCA 是 onUnmounted
    onUnmount(() => {
      console.log("unmount");
    });

    /** * ⚛️監聽數據變更, 相似於 useEffect * 返回一個disposer,能夠用於顯式取消監聽,默認會在組件卸載時自動取消 */
    const stop = watch(
      () => [data.count], // 可選
      ([count]) => {
        console.log("count change", count);

        // 反作用
        const timer = setInterval(() => data.tick++, count)

        // 反作用清理(可選), 和useEffect 保持一致,在組件卸載或者當前函數被從新調用時,調用
        return () => {
          clearInterval(timer)
        }
      }
    );

    // props 是一個響應式數據
    watch(() => {
      console.log("initial change", props.initial);
    });

    // context 是一個響應式數據
    watch(
      () => [ctx.value],
      ([ctxValue], [oldCtxValue]) => {
        console.log("context change", ctxValue);
      }
    );

    /** * ⚛️方法,不須要 useCallback,永久不變 */
    const add = () => {
      data.count++;
    };

    /** * ⚛️返回一個渲染函數 */
    return () => {
      // 在這裏你也能夠調用 React Hooks, 就跟普通函數組件同樣
      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

reactivecomputed 以及 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, 簡單設置個別名:

// mpos.ts

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        // 等價於 VCA 的 ref
export const isBox = isBoxedObservabl
export { computed }

// 等價於 VCA 的 toRefs, 見下文
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'

/** * ⚛️ reactive 能夠用於轉換 Map、Set、數組、對象爲響應式數據 */
const data = reactive({foo: 'bar'})
data.foo = 'baz'

// reactive 內部使用Proxy 實現數據響應,他會返回一個新的對象,不會影響原始對象
const initialState = { firstName: "Clive Staples", lastName: "Lewis" }
const person = reactive(initialState)
person.firstName = 'Kobe'
person.firstName // "Kobe"
initialState.firstName // "Clive Staples"

// 轉換數組
const arr = reactive([])
arr.push(1)
arr[0]

/** * ⚛️ 通常狀況下都使用reactive,若是你要轉換原始類型爲響應式數據 * 或者進行數據傳遞,能夠用 box */
const temperature = box(20)
temperature.set(37)
temperature.get() // 37


/** * ⚛️ 衍生數據計算, 它們也具備響應特性。 */
const fullName = computed(() => `${person.firstName} ${person.lastName}`)
fullName.get() // "Kobe Lewis"
複製代碼


關於 Vue Composition API ref

上面說了,VCA 的 ref 函數等價於 Mobx 的 box 函數。能夠將原始類型包裝爲'響應式數據'(本質上就是建立一個reactive對象,監聽getter/setter方法), 所以 ref 也被 稱爲包裝對象(Mobx 的 box 命名更貼切):

// Vue

const count = ref(0)
console.log(count.value) // 0
複製代碼

你能夠這樣理解, ref 內部就是一個 computed 封裝(固然是假的):

// Vue

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, 也稱自動展開), 不過目前自動解包,僅限於讀取。 例如:

// 1️⃣ 做爲reactive 值時
const state = reactive({
  count                  // 能夠賦值給 reactive 屬性
})
console.log(state.count) // 0 等價於 state.count.value

// 自動展開有時候會讓人困惑,這裏有個陷阱,會致使原有的 ref 對象被覆蓋
state.count = 1          // 被覆蓋掉了, count 屬性如今是 1, 而不是 Ref<count>
console.log(count.value) // 0

// 2️⃣ 傳遞給模板時,模板能夠自動解包
// 
// <button @click="increment">{{ count }}</button>
// 等價於
// <button @click="increment">{{ count.value }}</button>
//

// 3️⃣ 支持直接 watch
watch(count, (cur, prev) => { // 等價於 watch(() => count.value, (cur, prev) => {})
  console.log(cur) // 直接拿到的是 ref 的值,因此不須要 cur.value 這樣獲取
})
複製代碼

另外 VCA 的 computed 實際上就是返回 ref 對象:

const double = computed(() => state.count * 2)
console.log(double.value) // 2
複製代碼

🤔 VSA 和 Mobx 的 API 驚人的類似。想必 Vue 很多借鑑了 Mobx.



爲何須要 ref?

響應式對象有一個廣爲人知的陷阱,若是你對響應式對象進行解構、展開,或者將具體的屬性傳遞給變量或參數,那麼可能會致使響應丟失。 看下面的例子, 思考一下響應是怎麼丟失的:

const data = reactive({count: 1})

// 解構, 響應丟失了.
// 這時候 count 只是一個普通的、值爲1的變量.
// reactive 對象變更不會傳導到 count
// 修改變量自己,更不會影響到本來的reactive 對象
let { count } = data
複製代碼

由於 Javascript 原始值按值傳遞的,這時候傳遞給變量、對象屬性或者函數參數,引用就會丟失。爲了保證 ‘安全引用’, 咱們才須要用'對象'來包裹這些值,咱們老是能夠經過這個對象獲取到最新的值:


關於 VCA 的 ref,還有 toRefs 值得提一下。 toRefs 能夠將 reactive 對象的每一個屬性都轉換爲 ref 對象,這樣能夠實現對象被解構或者展開的狀況下,不丟失響應:

// Vue 代碼

// 使用toRefs 轉換
const state = reactive({count: 1})
const stateRef = toRefs(state) // 轉換成了 Reactive<{count: Ref<state.count>}>

// 這時候能夠安全地進行解構和傳遞屬性
const { count } = stateRef

count.value    // 1
state.count    // 1 三者指向同一個值
stateRef.count.value // 1

state.count++ // 更新源 state
count.value   // 2 響應到 ref
複製代碼

簡單實現一下 toRefs, 沒什麼黑魔法:

// Vue 代碼

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 只返回一個狀態:

// Vue 代碼

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)
  })

  // 返回一個 ref
  // 若是這時候返回一個 reactive 對象,會顯得有點奇怪
  return online
}
複製代碼

若是 useOnline 返回一個 reactive 對象, 會顯得有點怪:

// Vue 代碼

// 這樣子? online 可能會丟失響應
const { online } = useOnline() // 返回 Reactive<{online: boolean}>

// 怎麼肯定屬性命名?
const online = useOnline()
watch(() => online.online)

// 因此咱們須要規範,這個規範能夠幫咱們規避陷阱,也統一了使用方式
// 更規範的返回一個 ref,使用 value 來獲取值
watch(() => online.value)
// 能夠更方便地進行監聽
wacth(online, (ol) => {
  // 直接拿到 online.value
})
複製代碼

再看另外一個返回多個值的例子:

// Vue 代碼

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))
  // 返回多個值,可使用 toRefs 批量轉換
  return toRefs(pos)
}

// 使用
function useMyHook() {
  // 安全地使用解構表達式
  const { x, y } = useMousePosition()

  // ... do something

  // 安全地輸出
  return { x, y }
}
複製代碼

所以官方也推薦使用 ref 對象來進行數據傳遞,同時保持響應的傳導。就到這吧,否則寫着寫着就變成 VCA 的文檔了🌚。



ref 和 useRef

VCA ref 這個命名會讓 React 開發者將其和 useRef 聯想在一塊兒。的確,VCA 的 ref 在結構、功能和職責上跟 React 的 useRef 很像。例如 ref 也能夠用於引用 Virtual DOM的節點實例:

// Vue 代碼
export default {
  setup() {
    const root = ref(null)

    // with JSX
    return () => <div ref={root}/>
  }
}
複製代碼

爲了不和現有的 useRef 衝突,並且在咱們也不打算實現 ref 自動解包諸如此類的功能。所以在咱們會沿用 Mobx 的 box 命名,對應的還有isBox, toBoxes 函數。


那怎麼引用 Virtual DOM 節點呢? 咱們可使用 React 的 createRef() 函數:

// React 代碼
import { createRef } from 'react'

createComponent({
  setup(props => {
    const containerRef = createRef()

    // ...

    return () => <div className="container" ref={containerRef}>?...?</div>
  })
})
複製代碼


生命週期方法

接下來看看怎麼實現 useMounted 這些生命週期方法。這些方法是全局、通用的,怎麼關聯到具體的組件上呢?

這個能夠借鑑 React Hooks 的實現,當 setup() 被調用時,在一個全局變量中保存當前組件的上下文,生命週期方法再從這個上下文中存取信息。

來看一下 initial 的大概實現:

// ⚛️ 全局變量, 表示當前正在執行的 setup 的上下文
let compositionContext: CompositionContext | undefined;

/** * initial 方法接受一個 setup 方法, 返回一個 useComposition Hooks */
export function initial<Props extends object, Rtn, Ref>( setup: (props: Props) => Rtn, ) {
  return function useComposition(props: Props, ref?: React.RefObject<Ref>): Rtn {
    // ⚛️ 使用 useRef 用來保存當前的上下文信息。 useRef,能夠保證引用不變
    const context = useRef<CompositionContext | undefined>();

    // 若是當前上下文爲空,則開始初始化
    // ⚛️ 咱們這樣實現了 setup 只被調用一次!
    if (context.current == null) {
      // 建立 Composition 上下文
      const ctx = (context.current = createCompositionContext(props));

      // ⚛️ 進入當前組件的上下文做用域
      const prevCtx = compositionContext;
      compositionContext = ctx;

      // ⚛️ **調用 setup, 並緩存返回值**
      ctx._instance = setup(ctx._props);

      // ⚛️ 離開當前組件的上下文做用域, 恢復
      compositionContext = prevCtx;
    }

    // ... 其餘,下文展開

    // 返回 setup 的返回值
    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;
  // 註冊 React.Context 下文會介紹
  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
}
複製代碼

addMountedaddUpdater 這些方法實現都很簡單, 只是簡單添加到隊列中:

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) {
      // 初始化....
    }

    // ⚛️ 每次從新渲染, 調用 onUpdated 生命週期鉤子
    useEffect(() => {
      const ctx = context.current;
      // 首次掛載時不調用
      if (ctx._isMounted) executeCallbacks(ctx._updater);
    });

    // ⚛️ 掛載
    useEffect(() => {
      const ctx = context.current;
      ctx._isMounted = true;

      // ⚛️ 調用 useMounted 生命週期鉤子
      if (ctx._mounted.length) {
        ctx._mounted.forEach(cb => {
          // ⚛️ useMounted 若是返回一個函數,則添加到disposer中,卸載前調用
          const rt = cb();
          if (typeof rt === "function") {
            ctx.addDisposer(rt);
          }
        });
        ctx._mounted = EMPTY_ARRAY; // 釋放掉
      }

      // ⚛️ 調用 onUnmount 生命週期鉤子
      return () => executeCallbacks(ctx._disposers);
    }, []);

    // ...
  };
}
複製代碼

沒錯,這些生命週期方法,最終仍是用 useEffect 來實現。



watch

接下來看看 watch 方法的實現。watch 估計是除了 reactive 和 ref 以外調用的最頻繁的函數了。

watch 方法能夠經過 Mobx 的 authrunreaction 方法來實現。咱們進行簡單的封裝,讓它更接近 Vue 的watch 函數的行爲。

這裏有一個要點是: watch 便可以在setup 上下文中調用,也能夠裸露調用。在setup 上下文調用時,支持組件卸載前自動釋放監聽。 若是裸露調用,則須要開發者本身來釋放監聽:

/** * 在 setup 上下文中調用,watch 會在組件卸載後自動解除監聽 */
function useMyHook() {
  const data = reactive({count: 0})
  watch(() => console.log('count change', data.count))

  return data
}

/** * 裸露調用,須要手動管理資源釋放 */
const stop = watch(() => someReactiveData, (data) => {/* reactiveData change */})
dosomething(() => {
  // 手動釋放
  stop()
})

/** * 另外watch 回調內部也能夠獲取到 stop 方法 */ 
wacth((stop) => {
  if (someReactiveData === 0) {
    stop()
  }
})
watch(() => someReactiveData, (data, stop) => {/* reactiveData change */})
複製代碼

另外 watch 的回調支持返回一個函數,用來釋放反作用資源,這個行爲和 useEffect 保持一致。VCA 的 watch 使用onClean 回調來釋放資源,由於考慮到 async/await 函數。

useEffect(() => {
  const timer = setInterval(() => {/* do something*/}, time)
  return () => {
    clearInterval(timer)
  }
}, [time])

// watch
watch(() => {
  const timer = setInterval(() => {/* do something*/}, 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 {
  // 放置 autorun 或者 reactive 返回的釋放函數
  let nativeDisposer: WatchDisposer;
  // 放置上一次 watch 回調返回的反作用釋放函數
  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") {
    // reaction
    nativeDisposer = reaction(expression, effectWrapper(effect, 1), options);
  } else {
    // autorun
    nativeDisposer = autorun(effectWrapper(expression, 0));
  }

  // 若是在 setup 上下文則添加到disposer 隊列,在組件卸載時自動釋放
  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) {
      // ⚛️ createCompositoonContext 會將props 轉換爲一個響應式數據, 並且這裏是淺層轉換
      // _props: observable(props, {}, { deep: false, name: "props" })
      const ctx = (context.current = createCompositionContext(props));
      const prevCtx = compositionContext;
      compositionContext = ctx;
      ctx._instance = setup(ctx._props);
      compositionContext = prevCtx;
    }

    // ...

    // ⚛️ 每次從新渲染時更新, props 屬性
    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,
    // ⚛️ 使用 Map 保存
    _contexts: new Map(),
    // ...

    // ⚛️ 註冊Context
    addContext: c => {
      // ⚛️ 已添加
      if (ctx._contexts.has(c)) {
        return ctx._contexts.get(c)!.value
      }

      // ⚛️ 首次使用當即調用 useContext 獲取 Context 的值
      let value = useContext(c)
      // ⚛️ 和 Props 同樣轉換爲 響應式數據, 讓 setup 能夠安全地引用
      const wrapped = observable(value, {}, { deep: false, name: "context" })

      // ⚛️ 插入到隊列
      ctx._contexts.set(c, {
        value: wrapped,
        // ⚛️ 更新器,這個會在組件掛載以後的每次從新渲染時調用
        // 咱們須要保證 useContext 的調用順序
        updater: () => {
          // ⚛️ 依舊是調用 useContetxt 從新獲取 Context 值
          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
    }

    // ⚛️ 必定要在其餘 React Hooks 以前調用
    // 由於在 setup 調用的過程當中已經調用了 useContext,因此只在掛載以後的從新渲染中才調用更新
    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) => {/* setup */})

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
  // ⚛️ 建立 useComposition Hook
  const useComposition = initial(setup)

  const Comp = (props: Props, ref: React.RefObject<Ref>) => {
    // 用於強制更新組件, 實現很簡單,就是遞增 useState 的值
    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 = {
        // ⚛️ 在依賴更新時,調用 forceUpdate 強制從新渲染
        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

    // ⚛️ 將 render 函數放在track 做用域下,收集 render 函數的數據依賴
    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
  // 建立 useComposition Hook
  const useComposition = initial(setup)

  const Comp = (props: Props, ref: React.RefObject<Ref>) => {/**/}

  Comp.displayName = `Composition(${name || "Unknown"})`

  let finalComp
  if (forwardRef) {
    // 支持轉發 ref
    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) {
      // 初始化...
    }

    // ... useContext

    // ⚛️ 若是傳遞了ref 且 調用了 expose 函數
    // 則使用useImperativeHandle 暴露給 ref
    if (ref && context.current._exposer != null) {
      // 只在 _exposer 變更後更新
      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() {
  // 將count 拷貝給(按值傳遞) count變量,這會致使響應丟失,下游沒法響應count 的變化
  const { count } = reactive({count: 0})

  return { count }
}
複製代碼

還有響應式數據轉換成本,諸如此類的,網上也有大量的資料, 這裏就不贅述了。 關於響應式數據須要注意的東西能夠參考這些資料:

除此以外,你有時候會糾結何時應該使用 reactive,何時應該使用 ref...

沒有銀彈,沒有銀彈。


最後的最後, useYourImagination, React Hooks 早已在 React 社區玩出了花🌸,Vue Composition API 徹底能夠將這些模式拿過來用,兩個從結構和邏輯上都是差很少的,只不過換一下 'Mutable' 的數據操做方式。安利 2019年了,整理了N個實用案例幫你快速遷移到React Hooks


我是荒山,以爲文章能夠,請點個贊,下篇文章見!



參考/擴展


相關文章
相關標籤/搜索