这是何等的亵渎!在 React 里面使用 Vue!

17,969 阅读6分钟

reactivue

翻 antfu 大佬的 repo 时发现了 reactivue 这个库。我将其称为我最喜爱的库排行第一!将 Vue 用在 React 里面,我感觉我整个人都混乱邪恶了起来!

import React from 'react'
import { useSetup, ref, computed, onUnmounted } from 'reactivue'

interface Props {
  value: number
}

function MyCounter(Props: Props) {
  const state = useSetup(
    (props: Props) => { // props is a reactive object in Vue
      const counter = ref(props.value)
      const doubled = computed(() => counter.value * 2)
      const inc = () => counter.value += 1

      onUnmounted(() => console.log('Goodbye World'))

      return { counter, doubled, inc }
    },
    Props // pass React props to it
  )

  // state is a plain object just like React state
  const { counter, doubled, inc } = state

  return (
    <div>
      <div>{counter} x 2 = {doubled}</div>
      <button onClick={inc}>Increase</button>
    </div>
  )
}

正好最近想要学习一下 React ,从这个库一窥 React 与 Vue 的差异也是相当不错。

React 和 Vue 的核心差异之一

得益于 Vue reactivity system ,在 React 里面结合 Vue 的响应式系统这一部分成为可能。

要在 React 中使用 Vue 响应式系统,首先需要理解 React 和 Vue 的核心差异之一:也就是同样作为组件,在 Vue 中不止有渲染函数,而在 React 中只有渲染函数。(仅讨论 React 的 functional component)。

为了方便说明,我们分别用 Vue 和 React 写一个 Counter 的例子:

Vue

import { defineComponent, ref } from 'vue'

const Button = defineComponent({
  emits: ['click'],
  setup(_, { emit }) {
    return () => {
      return <button onClick={ $event => emit('click', $event) }>+1</button>
    }
  },
})

export const Counter = defineComponent({
  setup() {
    const count = ref(0)
    const onClick = () => {
      count.value++
    }
    return () => {
      return <div><p>{ count.value }</p><Button onClick={ onClick } /></div>
    }
  },
})

React(由于需要考虑在复用角度和Vue等价,这里加上了 memo 等 api)

import { memo, useCallback, useEffect, useRef, useState } from "react";

const Button = memo(function Button({ onClick }: { onClick: () => void }) {
  return <button onClick={onClick}>+1</button>;
});

export function Counter(): JSX.Element {
  const [count, setCount] = useState(0);
  const countRef = useRef<number>(count); // 这个也可以不用

  useEffect(() => {
    countRef.current = count;
  }, [count]);

  const handleClick = useCallback(() => {
    setCount(countRef.current + 1);
  }, []);

  return (
    <div>
      <p>{count}</p>
      <Button onClick={handleClick} />
    </div>
  );
}

可以看出,同样作为组件,在 Vue 中不止有渲染函数,而在 React 中只有渲染函数。

在 Vue 中:

  1. 声明了 setup ,类似于 class 的 new 过程。会初始化所有 ref function 等。
  2. 然后返回了 render 函数,也就是返回了 () => VNodeChild。而返回的是函数,就使得返回的 render 函数天然的就可以闭包地获取上面声明的变量。
  3. 通过 vue 自身的响应式系统,render 可以在第一次执行后收集到它所依赖的响应式数据。就可以在这些响应式数据变更时重新执行。

而在 React 中:

  1. 直接返回了 JSX.Element 也就是组件本身就是一个渲染函数,也只是渲染函数。
  2. 是否执行该渲染函数是由上层确认的。
  3. 函数本身是没有状态的,所以在每次执行此渲染函数的过程中,会利用 hook 去获取藏在外部闭包中的当前组件状态,从而进行渲染。

可以这么理解,Vue 中将组件所有的属性初始化/声明提前写出来,并统一放到了setup。就像是一个使用闭包和 ender 数组成的 class 。

而 React 仅仅是 render 函数,函数本身是无法拥有状态的,所以需要借助 hook ,也就是如 setState 等利用闭包去存储当前组件的状态。或许理解为第一次渲染时,执行函数的过程中完成了所有状态数据的初始化。而后在每次重新渲染时,去获取、比较、利用 hook 存储的状态,来完成组件的渲染。

如何在 React 中使用 @vue/reactivity

思路是在 vue reactivity 数据发生变更时(使用 watch 检测),触发当前组件重新渲染。

既然 React 仅仅是一个 render 函数,那我们就模仿 setState 等方法,让 React 组件拥有一个利用闭包存储状态在外部的响应式数据!而这个生存响应式数据的函数,就是 setup 函数。

function Counter() {
  const state = useSetup(() => {
      const counter = ref(props.value)
      const doubled = computed(() => counter.value * 2)
      const inc = () => counter.value += 1

      return { counter, doubled, inc }
  })

  const { counter, doubled, inc } = state

  return (
    <div>
      <div>{counter} x 2 = {doubled}</div>
      <button onClick={inc}>Increase</button>
    </div>
  )
}

简化了一下 reactivue 中的示例。useSetupCounter 第一次渲染时,闭包地保存了使用 @vue/reactivity 生成的 state。每一次 state 发生变动,将会触发这个组件的重新渲染(和一个普通的 useState 的 setter 如何触发组件重新渲染的流程相同)。而让 state 发生变动和根据 state 变动触发重新渲染的部分,则交给了 vue 的响应式系统。

下面是简化过的 reactivue useSetup 实现。去掉了其中 lifecycle props传入和__DEV__ 等相关功能。

export function useSetup<State extends Record<any, any>, Props = {}>(
  setupFunction: (props: Props) => State,
  ReactProps?: Props,
): UnwrapRef<State> {
  const id = useState(getNewInstanceId)[0]

  const setTick = useState(0)[1]

  const createState = () => {
    const instance = createNewInstanceWithId(id)

    useInstanceScope(id, () => {
      const setupState = setupFunction() ?? {}
      const data = ref(setupState)

      instance.data = data
    })

    return instance.data.value
  }

  // run setup function
  const [state, setState] = useState(createState)

  // trigger React re-render on data changes
  useEffect(() => {

    useInstanceScope(id, (instance) => {
      if (!instance)
        return

      const { data } = instance
      watch(
        data,
        () => {
          setTick(+new Date())
        },
        { deep: true, flush: 'post' },
      )
    })

    return () => {
      unmountInstance(id)
    }
  }, [])

  return state
}

第一步是通过 useState 的特性,给当前组件赋予一个自增的 id,如果是同一个组件,此id不会发生变化:

const id = useState(getNewInstanceId)[0]

这个 id 会用在下面的 createNewInstanceWithIduseInstanceScope 中,目的是生成一个和该组件伴随的 instance 来存储如 state 和生命周期相关等各种事项。

useInstanceScope 的设计让 callback 能获取到当前 instance 从而获取到当前 instance 的状态,比如处于 lifecycle 哪个阶段。

然后利用 useState 生成一个 setTick 函数,每次发生变更时 setTick(+new Date()) 去触发 React 组件的 re-render。

const setTick = useState(0)[1]

通过 useState 执行 setupFunction 进行相应式数据的初始化。

这里的 setState 其实是用不着的,触发更新使用的是 setTick 。不过可以处理 hmr 导致的一些问题。

const createState = () => {
  const instance = createNewInstanceWithId(id)

  useInstanceScope(id, () => {
    const setupState = setupFunction() ?? {}
    const data = ref(setupState)

    instance.data = data
  })

  return instance.data.value
}

const [state, setState] = useState(createState)

接下来通过 useEffect(() => { ... }, []) 去构造了一个仅执行一次的 function 。在里面 watch 了 instance.data 也就是 setupFunction 的返回值。每当 instance.data 变动时会触发 setTick 从而触发 re-render。

useSetup 传入 props

分为两步:

  1. 初始化时将 props 转为 reactive 并放到 instance 上
  2. 使用 useEffect 在 props 变化时,将 instance 上的 reactive props 重新赋值

具体可以看 reactivue 源码。

构造 lifecycle

vue 中有这么几种生命周期 hook:

  • onBeforeMount
  • onMounted
  • onBeforeUpdate
  • onUpdated
  • onBeforeUnmount
  • onUnmounted

缺少了 lifecycle 是不行的,我们必须要在组件 unmount 的时候将 vue reactivity 生成的 effects 给 stop 掉。

  • onBeforeMount 在 setup function 执行后,第一次 render 返回之前。
  • onMounted 利用 useEffect 的执行时机,设置在 useEffect(() => { ... }, []) 内。
  • onBeforeUpdate onUpdated 设置在 setTick 周围
  • onBeforeUnmount onUnmounted 利用 useEffect(() => { ... }, []) 的 cleanup , return 一个 unmout callback 在其中进行这两个生命周期的触发,设置在 effects stop 的周围。

为什么可以利用 useEffect 来作为 Unmount 生命周期看这里

具体可以看 reactivue 源码。

总结

用这个库,作为 Vue 用户,有种“虽然已经属于别人(React)了,但是还是爱你(Vue)的”的感觉。

React 用户会不会有种 “虽然还没离婚(和 React),但是心已经属于别人(Vue)了”的感觉呢?

这真是太刺激糟糕啦!!!