一文看懂状态管理工具Valtio

7,810 阅读4分钟

什么是 Valtio?

Valtio makes proxy-state simple for React and Vanilla

翻译成中文,Valtio是一个基于 Proxy 实现,更容易上手的状态管理工具。

Valtio 怎么发音?

[ˈvæltioʊ]

常用的有哪些状态管理工具?

市面上的 React 状态管理工具非常多,例如:Redux、Mobx、Valtio、Recoil。

  • Redux 属于单向数据流方式

  • Mobx 和Valtio 都属于双向绑定的响应式的实现

  • Recoil 以原子化方式对状态进行分离管理

  • 也可以直接用 props 或者 context,不需要任何三方状态管理工具

首先,我们分别用 Svelte, Vue, React 实现如下的效果:

Svelte 在线编辑

<script>
  let count = 0;
  function handleClick() {
    count++;
  }
</script>
<div>
  {count}
  <button on:click={handleClick}>+1</button>
</div>

Vue 在线编辑

<template>
  {{count}}
  <button @click="handleClick">+1</button>
</template>

<script>
export default {
  name: 'App',
  data() {
    return {
      count: 0
    }
  },
  methods: {
    handleClick() {
      this.count++
    },
  }
}
</script>

React 在线编辑

import { useState } from 'react'

function App() {
  const [count, setCount] = useState(0)
  const handleClick = () => {
    setCount((count) => ++count)
  }
  return (
    <div>
      {count}
      <button onClick={handleClick}>+1</button>
    </div>
  )
}

export default App

React (引入 Valtio 后)在线编辑

import { proxy, useSnapshot } from 'valtio'
const state = proxy({ count: 0 })

function App() {
  const snap = useSnapshot(state)
  const handleClick = () => {
    ++state.count
  }
  return (
    <div>
      {snap.count}
      <button onClick={handleClick}>+1</button>
    </div>
  )
}

export default App

可以看出,引入 Valtio 后,React 状态管理变得和 Svelte/Vue 一样简洁

接下来我们一起来看看 Valtio 有什么优势在什么场景下使用 Valtio?以及Valtio源码分析

基于 Valtio 做状态管理有什么优势?

上手会更加简单

使用体验和 mobx 基本一致。支持:

  • 响应式对象

  • 细粒度 render

  • 计算属性

适用于需要简单的自动更新的场景

Valtio源码分析

Valtio 的实现就两个核心方法 proxy 与 useSnapshot

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

在组件中,使用 useSnapshot(state) 返回一个不可变的对象 snap - 用于组件渲染,当需要对状态变更时,需操作 state 反映到原始对象上。

Valtio 状态管理本质上是订阅发布模式,如图:

1 创建可观察的状态 ( observable state )

在 Valtio 中封装 observable state 的函数是 proxy,demo 中所示:

const state = proxy({ count: 0 })

外界对目标对象 { count: 0 } 的访问,会通过 handler 层拦截,对于 set/deleteProperty 的操作,会触发 notifyUpdate 函数,最终更新视图。

每个 state 都会有 [PROXY_STATE] 属性,以数组的形式保存着。

[
  target,  // 目标对象 { count: 0 }
  receiver, // Proxy的实例对象,本例中是 state
  version, // 版本号,每次set/deleteProperty操作,都会使版本号+1,以此来区分新老数据
  createSnapshot, // 创建只读的对象,用于组件的渲染
  listeners, // 存储着所有和该state相关的组件的forceUpdate函数调用,用于rerender组件
]

state 属性操作如图所示:

为了便于理解,将 proxy 函数的代码进行简化,点我看源码

const proxy = (initialObject) => {
  const PROXY_STATE = Symbol()

  const handler: ProxyHandler<T> = {
      get(target: T, prop: string | symbol, receiver: object) {
        if (prop === PROXY_STATE) {
          const state: ProxyState = [
            target,
            receiver,
            version,
            createSnapshot,
            listeners,
          ]
          return state
        }
        return Reflect.get(target, prop, receiver)
      },
      deleteProperty(target: T, prop: string | symbol) {
        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)) {
          return true
        }
        let nextValue = value
        Reflect.set(target, prop, nextValue, receiver)
        notifyUpdate(['set', [prop], value, prevValue])
        return true
      },
    }
    return new Proxy(target, handler)
}

2 订阅 subscribe

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

  • proxyObject: 实参为上文中定位的的 Proxy 实例 state

  • callback: React 组件重新渲染函数 forceUpdate

将 callback 函数加入到 proxyObject 对象的 listeners 集合中,proxyObject 对象属性的增删改操作会触发 listeners 函数执行,精准的触发对应组件重新渲染,做到了只 render 真正需要 render 的组件。

简化代码如下:点我看源码

function subscribe<T extends object>(
  proxyObject: T,
  callback: (ops: Op[]) => void,
) {
  let promise: Promise<void> | undefined
  const ops: Op[] = []
  const listeners = ((proxyObject as any)[PROXY_STATE] as ProxyState)[4]
  const listener: Listener = (op) => {
    ops.push(op)
    if (!promise) {
      promise = Promise.resolve().then(() => {
        promise = undefined
        if (listeners.has(listener)) {
          callback(ops.splice(0))
        }
      })
    }
  }
  listeners.add(listener)
  return () => listeners.delete(listener)
}

3 建立关联

React v18.2.0 新增了 useSyncExternalStore hook【用于读取和订阅外部数据源】,通过使用该hook,将定义在 React 外部的可观察的状态 ( observable state ) 和 React Component Render 建立关联,state 上任意属性的变化都会触发组件的重绘。

​语法:

const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);

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

  • subscribe:用于注册一个回调函数,当存储值发生更改时被调用。

  • getSnapshot: 返回当前存储值的函数。

  • getServerSnapshot:返回服务端渲染期间使用的存储值的函数

由于兼容性的原因,作者引入了 shim

import {useSyncExternalStore} from 'react';
  or
// Backwards compatible shim
import {useSyncExternalStore} from 'use-sync-external-store/shim';

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

const snap = useSnapshot(state)

函数源码如下:

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() // Note: do we really need this?
        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)
}

4 通知 notify

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))
  }
}

整个流程描述为:

onDataChange -> notifyUpdata -> callListeners -> recreateSnap -> forceUpdate

更多API

除了 proxy 和 useSnapshot 外,还会有比如 ref、watch、proxyWithHistory、proxySet 等等,感兴趣的同学可以在官网查看使用。

参考:

Meet the new hook useSyncExternalStore, introduced in React 18 for external stores

useSyncExternalStore

use