把 React 改造成 Vue 3 ,这是什么操作?

1,966 阅读7分钟

大家好,我是anuoua,今天我们来聊聊把 React 改造成 Vue 3 这件事情,虽然有点标题党,但不完全是,因为这就是 anuoua/unis 框架诞生的过程之一。

从原子化 API 潮流

自从 React hooks 发布后,前端社区就掀起了潮流,这个好,那个好,反正就是好。那么hooks到底好在哪里?

我认为最重要的一点就是“原子化”,说白了就是拆,把原先一体化的组件 API 拆成自由度更高的细粒度 API,于是 React 把 state 拆成了 useState, useRef...,把生命周期(拆/抽象)成了 useEffect,useLayoutEffect...。前段时间大家还记得 FB 还发布的 Recoil 这个状态管理框架吗?它的设计也体现了“原子”这个概念。

Untitled.png

好处

原子化 API 带给开发者更高的自由度,除了方便自由组合创造外,它还有更好的 TS 类型支持,逻辑封装的灵活性得到巨大提升。

既然原子化 API 这么好,那么 Vue 3 选择这种形式是可以理解的,但是为什么 Composition API 和 hooks 使用方式不同,且心智模型完全不一样呢?

为了搞明白这个问题,我将通过以下内容,进行一次头脑风暴,通过从设计上把 React 改造到 Vue 3 的整个过程来深入理解 Composition API 和 hooks 为什么是现在这幅面貌。

改造 React

如果让我设计一个理想中完美开发体验的 React,那么得先列几个设计目标,得益于 React 的槽点并不多,所以设计目标是这样的,在当前特性的基础上:

  1. 组件函数只执行一次
  2. 没有useCallback,没有deps

只有两条,貌似挺简单。嘿嘿。

小心试探

从一个基础例子说起。

function App() {
  const [hello, setHello] = useState('hello')
  return (
    <div>{hello}</div>
  )
}

如上的代码,如果 App 函数只在初始化的时候执行一次,那么意味着这个组件永远不会更新,因为JSX 视图部分只有初始化的时候才执行生成 vdom,后续不会有任何变化。

而在 React 中却能够更新,是因为 App 这个函数在视图更新的时候会多次执行,每一次执行, JSX 视图部分就会重新生成 vdom,用于组件 diff 。而正是应为多次执行,才造就了 hooks 的现状。

Untitled 1.png

鱼和熊掌

如何做到 App 函数只在初始化的时候执行,又能做到组件更新呢?鱼和熊掌就不能兼得吗?答案是有的,那就是使用高阶函数,我们改一下组件形态。

function App() {
  const [hello, setHello] = useState('hello')
  
  const handleClick = () => {}

  return () => ( // 返回一个函数
    <div onClick={handleClick}>{hello}</div>
  )
}

当初始化的时候执行一次 App 函数,其返回的结果是一个闭包函数,这个闭包函数是可以被组件实例存起来的,它可以在需要的时候重新执行并生成最新的 vdom 供组件渲染。

Untitled 2.png

问题解决,同时由于 App 函数只执行一次,根本不需要 useCallback 和 deps 来解决 handleClick 缓存问题,如代码中所看到的 handleClick 引用不会改变,这种方式看起来如此完美?

缺陷

有人说了:都让你懂完了哈,那为啥 React 不设计及成这样。

因为这样有缺陷!

function useCount() {
  const [count, setCount] = useState(0);
  setTimeout(() => {
    setCount(count++)
  }, 1000);
  return [count]
}

function App() {
  const [count] = useCount()
  return () => (
    <div>{count}</div>
  )
}

仔细看看这个组件能更新吗?

不行,原因就是 App 函数只在初始化的时候执行,useCount 的结果 count 变量在第一次执行函数更新值后,不会再有任何更新,后续无论 setTimeout 怎么卖力运行,App 的 JSX 视图部分获取到的 count 值永远是 0。

Untitled 3.png

我们发现如果在 React 中,那么 App 函数是多次运行的,这个 count 值可以得到更新!

卧槽,这下完蛋了,在要求只运行一次组件函数的情况下,没办法像原来一样大胆的使用基础类型的值!

办法总比困难多

改,继续改,没有什么不能改的!

要解决这个问题,方法也是有的,那就是把这种基础类型的值包裹成对象,在传递过程中就能使用引用传递,useRef 搞起!

function useCount() {
  const count = useRef(0);
  const update = useForceUpdate();
  setTimeout(() => {
    count.current++
    update();
  }, 1000);
  return [count]
}

function App() {
  const [count] = useCount()
  return () => (
    <div>{count.current}</div>
  )
}

如上代码,我们这样使用 ref 就可以更新了,问题搞定。但是使用 ref 存在一个问题,它有值的包裹和解包的心智负担,不是那么爽,这...感觉在哪里见过?嗯,我们继续。

强烈的既视感

而且在处理 props 的时候也会有同样的问题,props 是外部传入的参数,props 本身引用不变,但是内部值会变,在这种条件下基础类型的值是不能解构的,一旦解构,值就不能更新。

function App(props) {
  const { hello } = props; // hello 是个基本类型
  return () => (
    <div>{hello}</div>
  )
}

如上代码 hello 值是不会更新的,所以我改成这样,添加一个 toRefs 函数。

function toRefs(props) {
  const data = {}
  Object.keys(props).forEach((key) => {
    data[key] = {
      get current: () => props[key]
    }
  })
  return data;
}

function App(props) {
  const { hello } = toRefs(props)
  const [count] = useCount()
  return () => (
    <div>{hello.current}{count.current}</div>
  )
}

改动后这里 hello 被包装成了类 ref 对象,无论 props 中 hello 怎么变,组件仍然能够通过 hello.current 获取到最新的值,问题解决。

但是...这个 toRefs 怎么这么熟悉?!

再仔细看看,这熟悉的 API ,这特色的限制条件,这不就是 Vue 3 嘛!

我们把上面的代码换成近似的 Composition API,一目了然。

function useCount() {
  const count = ref(0)
  setTimeout(() => {
    count.value++
  }, 1000);
  return [count]
}

defineComponent({
  name: 'App',
  setup(props) {
    const { hello } = toRefs(props);
    const [count] = useCount();
    return () => (
      <div>{hello.value}{count.value}</div>
    )
  }
})

造孽啊!我改造 React 最后的结果居然是 Vue 3!

宇宙的尽头是 Vue 3?我麻了!

关于 Composition API 和 hooks

经过上面的改造全程,我们发现最后改造的 React 哪怕不使用 Composition API,在不借助编译手段、只运行一次组件函数的前提下,值的包裹和解包这个心智负担始终是绕不过去的,因为js中基础类型值的传递方式是复制,不是引用!

改造后的 React 在 API 形态和使用限制上和 Vue 3 几乎一致,如果理解了整个过程,那么我相信你也能理解 Composition API 形态上的设计思路和使用限制的无奈了,尤大为了解决这种问题,又是搞 setup 代码块,又是搞 ref 提案,搞的焦头烂额,归根结底是因为 js 还不够强啊!

运行多次/运行一次似乎成了两个框架设计上的岔路,React 走在运行多次的路上诞生了 hooks,Vue 3 走在运行一次的路上诞生了 Composition API!

两种截然不同的理念,造就了最流行的两个前端框架。

同时也感慨那句名言:没有银弹!因为它们都不完美。

说说 Unis

写 Unis 的想法来自于之前看到 Vue 3 的发布,由于它要向下兼容 Vue 2,导致尤大在设计上有很多无奈之举(尤大难啊),没有达到我个人的偏好预期,而同时对 React hooks 使用体验并不满意,于是萌生了自己写一个框架的念头,满足自己对框架的想法。

上面的改造过程,是我去实现类 React 框架的时候真实进行过的思考,最后 Unis 框架的形态也大差不差,说白了它是一个改版的 Vue 3 或者 Composition API 版的 React,从 React 而来,最后止于 Vue 的响应式。

我把上面例子的代码用 Unis 写一遍,和 Vue 3 的 setup 几乎一致。

function useCount() {
  const count = ref(0)
  setTimeout(() => {
    count.value++
  }, 1000);
  return [count]
}

function App(props) {
  const { hello } = toRefs(props);
  const [count] = useCount();
  return () => (
    <div>{hello.value}{count.value}</div>
  )
}

render(<App />, document.body);

最后欢迎一键三连 anuoua/unis

谢谢