Skip to content

React 也可以使用响应式对象-useReactive

引言

大家好,欢迎来到 ahooks 系列博客的最新一期。在前几期博客中,我们详细介绍了 ahooks 中的各种 hook,并对它们的使用场景进行了深入分析。本期,我们将介绍 ahooks 中的 useReactive,并对比 Vue 3reactive ref,帮助大家更好地理解这几个工具的异同与应用场景。

【在React中,玩响应式数据?】

你会Vue吗,你玩过吗,你玩过就知道爽了呀

【那你干嘛不去直接使用Vue啊!】

大哥,用啥工具看什么项目

【你有本事,在Vue里面写React吗,在React里面写Vue吗?】

等下期!这期先聊这个

useReactive 概述

useReactiveahooks 提供的一个用于创建响应式状态的 hook。它能够使对象状态具备响应性,并在状态变化时自动更新 UI。让我们来看一下 useReactive 的基本使用方法。

jsx
import { useReactive } from "ahooks";

const MyComponent = () => {
  const state = useReactive({
    count: 0,
    message: "Hello",
  });

  return (
    <div>
      <p>{state.message}</p>
      <button onClick={() => state.count++}>Count: {state.count}</button>
    </div>
  );
};

在这个示例中,useReactive 接受一个初始状态对象,并返回该对象的响应式副本。每当对象的属性发生变化时,组件会自动重新渲染以反映最新的状态。

Vue 3reactiveref 概述

接下来,我们来看看 Vue 3 提供的 reactive 和 ref,它们分别用于创建响应式对象和单个响应式值。

reactive

Vue 3reactive函数可以将一个普通对象转换为响应式对象:

js
import { reactive } from "vue";

const state = reactive({
  count: 0,
  message: "Hello",
});

ref

Vue 3 的 ref 用于定义单个响应式值,可以是基本类型或对象:

js
import { ref } from "vue";

const count = ref(0);
const message = ref("Hello");

区别与适用场景

  • reactive 适用于创建复杂的嵌套对象,并使整个对象具备响应性。
  • ref 更适合简单的基本类型或需要单独处理的响应式对象。

实战案例

使用 useReactive 实现一个计数器
jsx
import { useReactive } from "ahooks";

const Counter = () => {
  const state = useReactive({ count: 0 });

  return (
    <div>
      <button onClick={() => state.count++}>Count: {state.count}</button>
    </div>
  );
};
使用 Vue 3 的 reactive 和 ref 实现同样的功能

为了便于大家在 Vue 3 的官方 playground 上直接运行,这里给出完整的 Vue 3 组件代码:

vue
<template>
  <div>
    <button @click="state.count++">Count: {{ state.count }}</button>
    <button @click="count++">Count: {{ count }}</button>
  </div>
</template>

<script setup>
import { reactive, ref } from "vue";

const state = reactive({ count: 0 });
const count = ref(0);
</script>

这个示例展示了如何使用 Vue 3 的 reactive 和 ref 来实现同样的计数器功能。

useReactive 源码分析

官方实现解析

以下是 ahooks 官方的 useReactive 实现:

js
import { useRef } from 'react';
import isPlainObject from 'lodash/isPlainObject';
import useCreation from '../useCreation';
import useUpdate from '../useUpdate';

// k:v 原对象:代理过的对象
const proxyMap = new WeakMap();
// k:v 代理过的对象:原对象
const rawMap = new WeakMap();

function observer<T extends Record<string, any>>(initialVal: T, cb: () => void): T {
  const existingProxy = proxyMap.get(initialVal);

  // 添加缓存 防止重新构建proxy
  if (existingProxy) {
    return existingProxy;
  }

  // 防止代理已经代理过的对象
  if (rawMap.has(initialVal)) {
    return initialVal;
  }

  const proxy = new Proxy<T>(initialVal, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      const descriptor = Reflect.getOwnPropertyDescriptor(target, key);
      if (!descriptor?.configurable && !descriptor?.writable) {
        return res;
      }

      // 只能代理简单对象和数组,
      return isPlainObject(res) || Array.isArray(res) ? observer(res, cb) : res;
    },
    set(target, key, val) {
      const ret = Reflect.set(target, key, val);
      cb();
      return ret;
    },
    deleteProperty(target, key) {
      const ret = Reflect.deleteProperty(target, key);
      cb();
      return ret;
    },
  });

  proxyMap.set(initialVal, proxy);
  rawMap.set(proxy, initialVal);

  return proxy;
}

function useReactive<S extends Record<string, any>>(initialState: S): S {
  const update = useUpdate();
  const stateRef = useRef<S>(initialState);

  const state = useCreation(() => {
    return observer(stateRef.current, () => {
      update();
    });
  }, []);

  return state;
}

export default useReactive;

一眼看下这个源码,从长度上看就知道重点就是上面这个observer函数,因此我们一起看下

observer 函数的核心逻辑解析

  • proxyMaprawMap 用于缓存已代理的对象和它们的原始对象,防止重复代理。
  • observer 函数接受一个初始值 initialVal 和一个回调 cb。如果 initialVal 已经代理过,直接返回缓存的代理对象。
  • Proxy 处理对象的 getsetdeleteProperty 操作。在 get 操作中,如果属性值是普通对象或数组,则递归代理它们。在 setdeleteProperty 操作中,调用回调 cb,触发组件更新。

通过这个官方实现,我们可以看到 useReactive 如何利用 WeakMap 来缓存代理对象,从而提升性能,并确保每个对象只被代理一次。

源码中用到了useCreationuseUpdate这两个自定义 hook,暂时简单了解下,useCreation类似useMemo,而useUpdate则用来强制组件重新渲染

关于 Proxy 大家可以看我之前的一篇,Proxy

提问环节

【别叭叭干讲啊,我问你,为什么用了proxyMaprawMap缓存就能防止重复代理了】

你看呀,它们的键值对正好相反,可以相互查找,通过原对象方便找到代理过后的,也可以通过代理过后的对象找到原对象

【github 上有个 issue,你解释下?】

目前 useReactive 不支持这样使用:

js
const App = () => {
  const state = useReactive(new Map());

  // ❌ will throw: "TypeError: Method Map.prototype.size called on incompatible receiver #<Map>"
  return <div>{state.size}</div>;
};

下面这种使用方式不会报错,但是没法引起组件重新渲染,所以还是不会正常工作:

jsx
const App = () => {
  const state = useReactive({
    a: new Map(),
  });

  return (
    <div>
      {/* ✅ no error thrown */}
      {state.a.size}

      {/* ❌ can't cause the component to re-render */}
      <button onClick={() => state.a.set("a", 1)}>update</button>
    </div>
  );
};

综上,目前 useReactive 不兼容 Map, Set。我看了下,想要兼容的话, 处理起来很麻烦,需要实现类似 observer-util 这个包的能力,暂时先不考虑了,我在文档里加上 FAQ

Vue 3 的 reactive 支持 MapSet 对象的,Vue YYDS 哈哈

【就这?原因是啥】

React 使用浅比较来决定是否重新渲染组件。对于 MapSet 这样的复杂数据结构,浅比较并不能有效地检测出内部数据的变化,导致组件不能正确响应状态变化,所以点了没反应

【这个嘛,才有点样子。点了后调用useUpdate的返回函数不就好了 🐔】

你一边去

总结

其实从个人的经验来看,仅代表个人哈,勿喷

我是不推荐在React中使用这个hook的,简单来说React本身就是希望引起组件刷新的Update是显性的,怎么理解这个话呢,不管是hook时代的useState,还是class时代的this.setState,都是希望开发者明确你告诉它,你的什么动作将更新数据,更新哪些数据,然后在源码的Update阶段,将更新组成链表,进行对比和计算。useReactive虽然好用,但是数据的变化,将脱离原先的显性的更新动作,变得隐秘,因此可能在多人协作的中大型前端应用中,造成理解上的困难,可能要查很久才发现,这边用了响应式,因为这个变量改了就直接刷新了。

这里并不是说响应式不好,相反我觉得很优秀,Vue的成功也证明了这一点。既然我们说VueReact都是我们前端工程师手上的一把剑,你就得知道它的锋利之处在哪里,在不同的场景用好它才是关键。