【ahooks源码解析07】React也可以使用响应式对象-useReactive

544 阅读6分钟

引言

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

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

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

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

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

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

等下期!这期先聊这个

useReactive 概述

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

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函数可以将一个普通对象转换为响应式对象:

import { reactive } from 'vue';

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

ref

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

import { ref } from 'vue';

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

区别与适用场景

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

实战案例

使用 useReactive 实现一个计数器
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 组件代码:

<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 实现:

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 不支持这样使用:

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

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

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

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都是我们前端工程师手上的一把剑,你就得知道它的锋利之处在哪里,在不同的场景用好它才是关键。