前言
看 2022 年 react 生态,大家都用啥 中提到 valtio, 考察了一番, 在最近的 side project B 站首页推荐脚本 中也有使用, API 比较简单, 也比较轻量.
这里做一个简单的源码解析.
环境
- valtio@1.6.1
- proxy-compare@2.1.0
- react@18
valtiio
proxy(object: T): T
生成一个 proxy, proxy 可以简化成下面这样
1 | function proxy(initialObject) { |
即
- 新建一个 initialObject 的副本 baseObject, 作为
new Proxy
的第一个参数 new Proxy
- 将 initialObject 中的值, 通过 proxy 拷贝到 baseObject 中,
Object.defineProperty(baseObject, key, desc)
/proxyObjectp[key] = initialObject[key]
proxyObject[key] =
会走到 proxyHandlers 的 set trap 中去
proxyHandlers
target === baseObject
,new Proxy()
第一个参数reciver === proxyObject
new Proxy()
返回值
1 | const handler = { |
set
set trap 比较重要, 给 proxyObject[key] = val
赋值会走到这里, proxy() 中拷贝 initialObject 中的值也会走到这里
1 | // 3 proxy object |
这里可以看到, 对于 nested object 对 proxy() 来说, 其实是一样的. 即
1 | // form 1: match 3 proxy object |
- form 2,
set
trap 中,canProxy({ key: 'val' })
为true
, 则会自动调用nextValue = proxy(value)
, 余下和 form 1 一致
listeners
1 | const LISTENERS = __DEV__ ? Symbol('LISTENERS') : Symbol() |
getPropListener(prop: string | symbol)
返回一个Listener
- 如果 prop 对应的 child proxy object 发生任何变更操作, 调用当前 proxyObject 的
notifyUpdate
op[1] = [prop, ...op[1]]
path 中 prepend 当前 prop- 调用时机在上面说到的
set
trap
- 如果 prop 对应的 child proxy object 发生任何变更操作, 调用当前 proxyObject 的
- popPropListener 删除一个 prop listener
- 调用时机为
set
trap 剔除掉旧值的 prop listener, 或者deleteProperty
trap
- 调用时机为
1 | const propListeners = new Map<string | symbol, Listener>() |
subscribe()
1 | export function subscribe<T extends object>( |
即是
state[LISTENERS].add(listener)
callback
参数是ops: Op[]
需要注意, 用于批处理 op, 文档未标注, 一般无需使用
snapshot()
1 | export function snapshot<T extends object>(proxyObject: T): Snapshot<T> { |
- 该方法用于根据
proxyObject
创建一个快照 useSyncExternalStore
在 render 里连续调用两次getSnapshot
, 以提示 getSnapshot 在没有变化时应该返回缓存的版本.snapshot()
自带根据版本的缓存, 所以这个getSnapshot
可以通过useSyncExternalStore
的检查.
react
react.useSyncExternalStore
https://github.com/magicdawn/magicdawn/issues/8#issuecomment-1135633629
可以简写成以下代码
1 | useSyncExternalStore(subscribe, getSnapshot) { |
- 通过
subscribe
感知外部 store 的变化 - 通过
getSnapshot
获取外部 store 的最新快照 - 感知到变化之后, 通过对比之前的
snapshot
与 新获得的nextSnapshot
是否是同一个对象, 不是就通过setState
触发 re-render getSnapshot
会在render 中
orsubscribe 回调中
调用, 要求外部没有变化时返回同一个 snapshot
valtio.useSnapshot
1 | export function useSnapshot<T extends object>(proxyObject: T, options?: Options): Snapshot<T> { |
inRender
前面提到 getSnapshot 会在 render 函数 或者 subscribe 回调中被调用, inRender 用于区分这两种情况- inRender 直接返回
snapshot()
的结果 - 在 subscribe 回调里使用时, 根据
isChanged
结果, 如果没有改变, 就会返回 lastSnapshot 缓存的版本, 也就不会 re-render.
- inRender 直接返回
createProxy
&isChanged
是 valtio 相比 zustand / redux 等库从机制上优越的地方createProxy
/isChanged
需要传入一个 affected 参数, 表示在 render 函数中哪些 prop 被访问了- 比如
const {a, b} = useSnapshot(state)
, 等 render 函数执行完, affected 会记录 Set(a,b) 已被访问 - 在
isChanged
里需要传入 affected, 只会判断 a,b 是否变化, 如果是另外的字段如 c 变化了, 此处还是可以使用缓存的版本. - 而传统的 store(redux / zustand) 等需要先 select(a,b), 再使用 shallowEqual 判断, 以避免多余的 render, 而 valtio 可以直接使用解构
proxy-compare
Types
1 | type Affected = WeakMap<object, Set<string | symbol>> |
Affected
表示一个object
有哪些 key 被访问过了ProxyCache
用于proxy()
的 cache, key 是originalObject
ProxyHandler
是new Proxy(target, handlers)
第二个参数, 再加上一些自定义属性
proxy-coampre.createProxy
createProxy
1 | export const createProxy = <T>( |
- 最后返回了
Proxy
- 该方法比较直观, 查 cache, 没有 fallback 到
new Proxy
, 逻辑在下面的createProxyHandler
useSnapshot
在最后使用了该方法
createProxyHandler
1 | const createProxyHandler = <T extends object>(origObj: T, frozen: boolean) => { |
关于 trackMemo
/ TRACK_MEMO_SYMBOL
在单测看到用法
1 | it('should fail without trackMemo', () => { |
- 注意看最后一行, 使用了
trackMemo(p1.a)
后, 就算s1.a.b
和s2.a.b
相同, 结果 isChanged 还是 true, 也就是改变了 trackMemo()
调用链trackMemo(obj)
- 实现中有
return TRACK_MEMO_SYMBOL in obj
- 走到 Proxy
has
trap, 即上面createProxyHandler
第 40 行 recordObjectAsUsed(this)
- 会调用
affected.delete(originalObject)
, 也就是没有 used 了
- 文档未标注, 不太确定用途, 表现就是不会存 affected, 没有 used set, isChanged 返回总是返回
true
get
trap
可以看到这里有递归调用, 对于嵌套 object = { nested: { key: 'val' } }
访问 proxy.nested
, 会返回 createProxy(proxy.nested)
1 | const o = { nested: { key: 'val' } } |
proxy-compare.isChanged
API
1 | export const isChanged = ( |
调用流程
- 抛开 cache 不看, 就是
- 根据
origObj
从 affected 拿到 used set, 没有表示 changed.- 这里需要注意
useSnapshot
的用法, 如果没有使用useSnapshot()
中的值, 只是用这个 hook, 那么 affected 中会是空, 最后每次 isChanged 都是 true, 每次都 re-render
- 这里需要注意
- 遍历 used keys
- 如果是
OWN_KEYS_SYMBOL
, 则比较两个 object 所拿到的 ownKeys 是否 deep equal - 不是, 则继续递归
isChanged(origObj[key], nextObj[key], affected, cache)
- 如果是
Tips
- 这里 cache 可以给个默认值,
cache = new WeakMap()
, 方便外部使用
END
差不多了.