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

相关推荐
To_OC7 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic7 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy7 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志8 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble9 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯9 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜10 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司10 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
kaiking_g11 小时前
vue项目中,静态导入 vs 动态导入 详解
前端·javascript·vue.js
江华森11 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http