React 状态管理 valtio 解析

valtio 是什么

valtio是一个很轻量级的响应式状态管理库,它基于 Proxy 实现,类似于 vue 的数据驱动视图的理念,使用外部状态代理去驱动 React 视图更新,不管在react组件内部还是外面都可以使用。下面提供 valtio 基本用法例子:

codesandbox.io/embed/5x592...

valtio 优势在哪里

这里我们选用老牌状态管理 redux 和 valtio 进行对比,下面通过如何实现数字累加功能的例子来探讨它们的用法差异。

Redux 实现数字累加

首先我们需要定义一系列的模版:Action类型、Action创建函数、State类型、Reducer函数。

tsx 复制代码
// 定义Action类型
enum ActionType {
  INCREMENT = 'INCREMENT',
  DECREMENT = 'DECREMENT',
}

// 定义Action创建函数
const increment = () => ({ type: ActionType.INCREMENT });
const decrement = () => ({ type: ActionType.DECREMENT });

// 定义State类型
type State = { count: number };

// 定义Reducer函数
const counterReducer = (state: State = { count: 0 }, action: { type: ActionType }): State => {
  switch (action.type) {
    case ActionType.INCREMENT:
      return { count: state.count + 1 };
    case ActionType.DECREMENT:
      return { count: state.count - 1 };
    default:
      return state;
  }
};

然后通过 Redux 的createStore创建 store。

tsx 复制代码
import { createStore } from 'redux';

// 创建Redux store
const store = createStore(counterReducer);

根组件 App 通过 Provider 组件 包裹将store传递给所有子组件,在子组件我们使用 connect 高阶组件将 Redux 的状态映射到组件props,这样在子组件里能够访问 Redux 的状态和触发 Action。

tsx 复制代码
import React from 'react';
import { Provider, connect } from 'react-redux';

// 定义React组件
type CounterProps = {
  count: number;
  increment: () => void;
  decrement: () => void;
};

const Counter: React.FC<CounterProps> = ({ count, increment, decrement }) => (
  <div>
    <h2>Counter: {count}</h2>
    <button onClick={increment}>+</button>
    <button onClick={decrement}>-</button>
  </div>
);

// 将Redux state映射到React组件的props
const mapStateToProps = (state: State) => ({
  count: state.count,
});

// 将Action创建函数映射到React组件的props
const mapDispatchToProps = {
  increment,
  decrement,
};

// 使用connect高阶组件包裹Counter组件
const ConnectedCounter = connect(mapStateToProps, mapDispatchToProps)(Counter);

// 使用Provider组件将store传递给所有子组件
const App: React.FC = () => (
  <Provider store={store}>
    <ConnectedCounter />
  </Provider>
);

export default App;

Redux包括View、Actions和State三个核心元素。每当 view 需要更新时,会触发一个 action,然后 dispatch 携带 action, 交给 store 的 reducer 处理,更新 state 并反馈到 view。这种设计使得数据改变遵循统一流程,使代码更易于理解和调试。但 Redux 也有缺点,如需要编写大量样板代码,导致项目文件结构复杂和代码冗余,新开发者需要时间去理解这些概念。此外,Redux 强制单一数据源和同步状态更新,可能限制某些应用的开发效率。

valtio 实现数字累加

我们再来看看使用 valtio 是如何实现数字累加功能。

首先,我们使用 valtio 的 proxy 函数创建了一个响应式的状态代理对象 state,该对象包含 count 属性。

tsx 复制代码
import { proxy } from 'valtio';

// 使用 valtio 定义 state
const state = proxy({ count: 0 });

然后在组件里使用 useSnapshot 钩子获取状态快照(snapshot),并在状态变化时触发组件的重新渲染 (valtio做到了细粒度re-render,下面的源码小节将会解释)

tsx 复制代码
import React from 'react';
import { useSnapshot } from 'valtio';

// 定义 React 组件
const Counter: React.FC = () => {
  // 使用 useSnapshot 获取 state 的快照
  const snap = useSnapshot(state);

  return (
    <div>
      <h2>Counter: {snap.count}</h2>
      <button onClick={()=>{ state.count += 1; }}>+</button>
      <button onClick={()=>{ state.count -= 1; }}>-</button>
    </div>
  );
};

// 根组件
const App: React.FC = () => {
  return <Counter />;
};

export default App;

上面只是为了做演示,并不推荐在组件内部直接修改state。相反,我们可以按照官方文档中提到的方式,通过操作 actions 来管理状态(不唯一)。

valtio.pmnd.rs/docs/how-to...

与 Redux 相比,valtio 提供了一种更简洁、直观的状态管理方式,我们无需定义一系列的模板代码,而是通过直接操作 proxy 对象来改变状态,无需使用 dispatch 函数或连接函数(如connect)。这样做的优点是代码更加精简,学习曲线更低,特别是在小型到中型的项目中,或者在需要快速原型开发时。

小结

对比 Redux 并结合 valtio 官网给出的优点,小结 valtio 以下几点优势:

  1. 概念和使用简单,无需繁琐的配置,基于 proxy,只有两个核心方法 proxy 和 useSnapshot。proxy 函数创建状态代理对象, useSnapshot 钩子获取状态快照
  2. 细粒度渲染,valtio 提供细粒度的渲染机制,使用 useSnapshot 可以只在状态变化的部分触发组件的重新渲染
  3. 官网文档友好,各种应用场景都有举例
  4. 提供 devtools api,支持使用 Redux DevTools Extension 调试
  5. 完全支持 TypeScript,无需额外的类型配置

valtio 源码分析

valtio 的实现主要依赖两个核心方法:proxy 和 useSnapshot。

proxy 用于包装原始对象(initialObject),生成一个可监听修改操作的 proxy state。

在组件中,我们使用 useSnapshot来获取这个proxy state,并返回一个不可变的snapshot。这个snapshot用于组件渲染,当状态需要变更时,我们可以操作proxy state 获取新的 snapshot,触发组件 rerender。

这其实就是一种发布-订阅模式的实现:创建代理对象并监听操作,通过内部机制通知所有依赖这些状态的组件进行更新。

创建可观察的状态 ( proxy state )

在 valtio 中封装 proxy state 的方法是proxy(proxyFunction) --- 基于原生 proxy的封装。外界对目标对象 { count: 0 } 的访问,会通过 handler 层拦截,对于 set/deleteProperty 的操作,会触发 notifyUpdate 方法,最终更新视图。

tsx 复制代码
const state = proxy({ count: 0 })

我们知道js里的proxy只能实现浅层次的代理,对于深层次的代理需要用到递归。

tsx 复制代码
if (!proxyStateMap.has(value) && canProxy(value)) {
  nextValue = proxyFunction(value)
}

同时为了避免循环引用问题,在代理的过程中需要追踪代理过的对象,这里使用到WeakMap。

tsx 复制代码
proxyCache = new WeakMap<object, ProxyObject>()

proxyFunction = <T extends object>(initialObject: T): T => {
  if (!isObject(initialObject)) {
    throw new Error('object required')
  }
  const found = proxyCache.get(initialObject) as T | undefined
  if (found) {
    return found
  }
  // ......
}

使用proxyStateMap收集所有的代理对象,key 是代理对象,value 是每个代理对象都会有 [ProxyState] 属性,以数组的形式保存着。

tsx 复制代码
[
  baseObject,  // 目标对象 { count: 0 }
  ensureVersion, // 获取版本号,每次set/deleteProperty操作,都会使版本号+1,以此来区分新老数据
  createSnapshot, // 创建只读的对象,用于组件的渲染
  addListener, // 用于添加监听方法,存储着所有和该state相关的组件的forceUpdate函数调用,用于rerender组件
]

为了便于理解,将 proxyFunction 方法的代码进行简化:

tsx 复制代码
const proxyFunction = (initialObject) => {
  const handler: ProxyHandler<T> = {
    deleteProperty(target: T, prop: string | symbol) {
      const prevValue = Reflect.get(target, prop)
      removePropListener(prop)
      const deleted = Reflect.deleteProperty(target, prop)
      if (deleted) {
        notifyUpdate(['delete', [prop], prevValue])
      }
      return deleted
    },
    set(target: T, prop: string | symbol, value: any, receiver: object) {
      const hasPrevValue = Reflect.has(target, prop)
      const prevValue = Reflect.get(target, prop, receiver)
      if (
        hasPrevValue &&
        (objectIs(prevValue, value) ||
         (proxyCache.has(value) &&
          objectIs(prevValue, proxyCache.get(value))))
      ) {
        return true
      }
      removePropListener(prop)
      if (isObject(value)) {
        value = getUntracked(value) || value
      }
      let nextValue = value
      if (value instanceof Promise) {
        value
          .then((v) => {
            value.status = 'fulfilled'
            value.value = v
            notifyUpdate(['resolve', [prop], v])
          })
          .catch((e) => {
            value.status = 'rejected'
            value.reason = e
            notifyUpdate(['reject', [prop], e])
          })
      } else {
        if (!proxyStateMap.has(value) && canProxy(value)) {
          nextValue = proxyFunction(value)
        }
        const childProxyState =
          !refSet.has(nextValue) && proxyStateMap.get(nextValue)
        if (childProxyState) {
          addPropListener(prop, childProxyState)
        }
      }
      Reflect.set(target, prop, nextValue, receiver)
      notifyUpdate(['set', [prop], value, prevValue])
      return true
    },
  }
  return new Proxy(initialObject, handler)
}

订阅 subscribe

该函数有两个重要参数,proxyObject 和 callback

  • proxyObject: 实参为上文中定位的的 Proxy 实例 state
  • callback: React 组件重新渲染函数 forceUpdate

将 callback 函数加入到 proxyObject 对象的 listeners 集合中,proxyObject 对象属性的增删改操作会触发 listeners 函数执行。简化代码如下:

tsx 复制代码
function subscribe<T extends object>(
  proxyObject: T,
  callback: (ops: Op[]) => void,
  notifyInSync?: boolean,
) {
  const proxyState = proxyStateMap.get(proxyObject as object)
  let promise: Promise<void> | undefined
  const ops: Op[] = []
  const addListener = (proxyState as ProxyState)[3]
  let isListenerActive = false
  const listener: Listener = (op) => {
    ops.push(op)
    if (notifyInSync) {
      callback(ops.splice(0))
      return
    }
    if (!promise) {
      promise = Promise.resolve().then(() => {
        promise = undefined
        if (isListenerActive) {
          callback(ops.splice(0))
        }
      })
    }
  }
  const removeListener = addListener(listener)
  isListenerActive = true
  return () => {
    isListenerActive = false
    removeListener()
  }
}

建立关联

React v18.2.0 新增了 useSyncExternalStore hook【用于读取和订阅外部数据源】(在React低版本中,官方提供了一个水合版:use-sync-external-store/shim),通过使用该hook,将定义在 React 外部的可观察的状态 ( observable state ) 和 React Component Render 建立关联,状态上任意属性的变化都会触发组件的重绘。

tsx 复制代码
const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);

此方法返回存储的值并接受三个参数:

  • subscribe:用于注册一个回调函数,当存储值发生更改时被调用。
  • getSnapshot: 返回当前存储值的函数。
  • getServerSnapshot:返回服务端渲染期间使用的存储值的函数。

基于 useSyncExternalStore 作者封装了 useSnapshot hook,使用方式:

tsx 复制代码
const snap = useSnapshot(state)

每次使用 useSnapshot 钩子时,都会订阅并添加监听器。当监听器被触发时,会调用 forceUpdate ,重新获取快照。只有在通过 getSnapshot 获取的快照发生变化时,才会触发重新渲染。作者在这里引入了 proxy-compare 库,用于更精确地比较代理对象的差异,以实现只在真正需要重新渲染的组件上触发重新渲染。代码如下:

tsx 复制代码
import {
  createProxy as createProxyToCompare,
  isChanged,
} from 'proxy-compare'

export function useSnapshot<T extends object>(
  proxyObject: T,
  options?: Options,
): Snapshot<T> {
  const notifyInSync = options?.sync
  const lastSnapshot = useRef<Snapshot<T>>()
  const lastAffected = useRef<WeakMap<object, unknown>>()
  let inRender = true
  const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback()
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject, use)
      try {
        if (
          !inRender &&
          lastSnapshot.current &&
          lastAffected.current &&
          !isChanged(
            lastSnapshot.current,
            nextSnapshot,
            lastAffected.current,
            new WeakMap(),
          )
        ) {
          // not changed
          return lastSnapshot.current
        }
      } catch (e) {
        // ignore if a promise or something is thrown
      }
      return nextSnapshot
    },
    () => snapshot(proxyObject, use),
  )
  inRender = false
  const currAffected = new WeakMap()
  useEffect(() => {
    lastSnapshot.current = currSnapshot
    lastAffected.current = currAffected
  })

  const proxyCache = useMemo(() => new WeakMap(), []) // per-hook proxyCache
  return createProxyToCompare(
    currSnapshot,
    currAffected,
    proxyCache,
    targetCache,
  )
}

通知 notify

tsx 复制代码
versionHolder = [1] as [number]
let version = versionHolder[0]
const notifyUpdate = (op: Op, nextVersion = ++versionHolder[0]) => {
  if (version !== nextVersion) {
    version = nextVersion
    listeners.forEach((listener) => listener(op, nextVersion))
  }
}

小结

valtio 整个流程描述为:

buildProxy -> onDataChange -> notifyUpdata -> callListeners -> forceUpdate -> recreateSnap -> re-render

除了 proxy 和 useSnapshot 外,还会有比如 ref、watch、proxyWithHistory、proxySet 等等,感兴趣可以查看使用:

valtio.pmnd.rs/

项目实践

当 valtio 应用于页面或组件级别时,需要特别注意页面或组件卸载时需要重置state。这里我封装了 useStore

tsx 复制代码
function useStore<T extends object>(state:T, initialState:T, options?:{
  resetStateWhenUnmout?:boolean
}) {
  useEffect(()=>{
    if(options?.resetStateWhenUnmout !== false) {
      const resetObj = _.cloneDeep(initialState)
      Object.keys(resetObj).forEach((key) => {
        state[key] = resetObj[key]
      })
    }
  },[])
  return useSnapshot(state)
}

参考

当面试官直接问:你是如何理解单向数据流的? - 掘金

Proxy代理深层属性 - 掘金

一文看懂状态管理工具Valtio

你了解过 React 的 useSyncExternalStore 吗?

proxy-compare

How to avoid rerenders manually --- Valtio, makes proxy-state simple for React and Vanilla

相关推荐
吃杠碰小鸡5 分钟前
commitlint校验git提交信息
前端
虾球xz36 分钟前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇41 分钟前
HTML常用表格与标签
前端·html
疯狂的沙粒1 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员1 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐1 小时前
前端图像处理(一)
前端
程序猿阿伟1 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒1 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪1 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背1 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript