Hello!大家好,我是Spring Cat。这里分享的不仅是巧妙交织的代码逻辑,还有生动演绎的思考过程。我想给大家的不仅是一个答案,更是一个它为何从何而来将向何去的故事。
React x Svelte 跨界联动来啦!最近了解和尝试了一下 svelte,感觉 svelte 做的真不错。简单高效,没有什么繁文缛节般的规矩和约定,用起来很顺手,既有一些 React 的东西也有 Vue 的影子,体验可以说很不错。于是有了在 React 里用一些 svelte 语法糖的想法,着手稍微尝试了一下。
API-$state的设计
我们先看一下 svelte 中 $state 在一个 svelte 文件中是如何使用:
<script>
let count = $state(0)
</scritp>
<button onclick={()=> count++}>clicks: {count}</button>
这里可以看见几个特点:
1. 首先,这里没有使用 import 导入而是直接使用$state
2. 然后,count++ 指通过直接赋值的方式触发重新渲染
3. 最后,$state 的参数可以直接使用原始值类型
在React中能实现到什么地步?
首先,仅在逻辑代码层面,我们无法避免使用 import 导入。然后,通过直接赋值变量的方式触发渲染,这一点应该是可以做到的。因为在 JS 中有代理 API,我们可以对变量的访问和赋值操作做拦截,然后在拦截的时候触发渲染,代理一种是 Object.defineProperty,另一种是 Proxy,Proxy 目前更新接受度也更高我们使用 Proxy。但在 JS 中使用代理的缺点是代理对象必须是一个 “对象” 类型,对于基础原始值类型 number、string、boolean 等等这些即便是使用 Proxy + Symbol.toPrimitive 也没办法做到对访问和赋值的拦截,如果我们的实现要兼容这些基础类型,那就得对这些类型做判断然后再包裹一层然后做代理。那不如我们就约定这个将要实现的 API 就支持对象类型,因为最后的效果也是一样的,没必要对这些原始类型做特殊判断和额外逻辑。
另一个问题是,在 React 的世界中,你能触发渲染逻辑的方式是有限的,只有通过 props、state、context(实际也是props)、useSyncExternalStore 这几种 API 才可以触发。那目前留给我们的方式仔细一看,就只有利用 useSyncExternalStore 这一种,而它又是一个 React Hook,那意味着我们最终要实现的是一个 React Hook。所以通过以上分析,我们可以得出要实现的 API 大致的形状:它是一个需要用 import 导入的 React Hook,它的参数得是一个“对象类型”,它返回的值是一个代理,并且通过这个代理可以对访问的值进行访问赋值拦截,在拦截的时候通过 useSyncExternalStore 触发渲染,最后完美闭环。此时,我们就可以直接写出它实现后使用的样子:
import $state from './your-path/$stateBrige'
export default App = () => {
const count = $state({ value: 0 })
return <div>
<p>{ count.value }<p>
<button onClick={()=> { count.value++ }}> +1 </button>
</div>
}
以上代码的效果就是:点击按钮会对 count.value 进行自加赋值,然后直接触发渲染。不需要经过 reducer store 类型的dispatch,也不需要使用 context 经过 props,也不需要通过组件内的 state 来触发渲染。虽然看起来是个很简单的语法糖,但是却方便极了!
如何触发渲染和代理
那么接下来我们就开始着手实现它。在具体实现之前我们先要了解之前提到 useSyncExternalStore 这个 React Hook 具体是怎么使用的:
const snapshot = useSyncExternalStore(subscribe, getSnapshot),
它这两个参数十分重要。首先 subscribe 是一个函数,React 调用它来向你注册触发渲染的函数,也就是说它的参数是一个可以触发渲染的函数,你需要做的就是把这个参数自己存起来,然后在代理对象赋值拦截时调用它触发渲染。然后是 getSnapshot,它是每次 useSyncExternalStore 触发渲染时要调用的一个函数,这个函数在对代理赋值后,必须返回一个与之前不同的用来渲染的值,并且 useSyncExternalStore 首次使用时就会调用一次 getSnapshot,将值返回给上面的 “const snapshot ”。为什么 getSnapshot 要返回一个 “与之前不同的用来渲染的值”?因为在 React 的源码里它使用了 "return !objectIs(inst, nextValue)" 判断新老值来确定是否需要重新渲染。
接来下我们先通过代码来实现两个事。第一个,是一个函数,它返回包含 subscribe、getSnapshot 的对象,提供给 useSyncExternalStore 使用,保留 React 传给它的触发渲染的函数。第二个是通过 Proxy 代理对象,代理值保存在第一个实现的方法里,进行赋值拦截时调用触发渲染的函数。
// your-path/$stateProxy.ts
type Subscriber = () => void
function isObject(o: any) {
return o !== null && typeof o === 'object'
}
export function createStore<T extends object>(initial: T) {
let subscribers = new Set<Subscriber>()
let root = initial
let proxyContainerRoot: { proxy: any } = { proxy: null };
let scheduled = false
function notify() {
if (scheduled) return
scheduled = true
Promise.resolve().then(() => {
scheduled = false
for (const s of Array.from(subscribers)) s()
})
}
// cache of proxies to avoid rewrapping same object
let proxyCache = new WeakMap<object, any>()
function makeProxy(obj: any) {
if (!isObject(obj)) return obj
const cached = proxyCache.get(obj)
if (cached) return cached
const proxy = new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// lazily wrap nested objects
return isObject(res) ? makeProxy(res) : res
},
set(target, key, value, receiver) {
const old = (target as any)[key]
if (old === value) return true
const result = Reflect.set(target, key, value, receiver)
proxyContainerRoot = { proxy: proxyContainerRoot.proxy }
notify()
return result
},
deleteProperty(target, key) {
const existed = key in target
const result = Reflect.deleteProperty(target, key)
if (existed && result) notify()
return result
}
})
proxyCache.set(obj, proxy)
return proxy
}
proxyContainerRoot.proxy = makeProxy(root)
return {
subscribe(cb: Subscriber) {
subscribers.add(cb)
return () => subscribers.delete(cb)
},
getSnapshot() {
return proxyContainerRoot as T
}
}
}
这段代码来解释一下。首先,这个函数返回了subscribe、getSnapshot 函数,subscribe 的参数是 React 传递给我们用来触发渲染的函数,我们将它们保存起来。然后 getSnapshot 返回一个用于渲染的数据,需要注意的是,如果这个数据与之前的值相同则不会触发渲染,而我们内部保存的值是一个代理后的值,它是一个 Proxy 对象类型,如果每次都返回这个值,那始终会是同一个引用而不会触发渲染,所以我们需要给这个代理值包裹一层随时可以替换掉的 “外壳”, 也就是代码中的 let proxyConainterRoot = { proxy: null },这样每次代理拦截到赋值操作,就将这个外壳换掉,但内部仍然是同一个代理引用。
代理的访问和赋值拦截
然后我们对参数传递的值进行了代理,分别实现了对代理进行访问和赋值的拦截。这里我们进行了嵌套处理,也就是要代理的对象内又有嵌套的对象,那么就对这个嵌套的对象进行代理处理。这里有一点处理细节需要注意,我们并没有一开始就整个深入遍历要代理的对象,来对所有嵌套对象进行代理,而只是在访问到某一个值的时候才会判断需不需要进行代理,这是一种出于性能考量的 “延迟处理”。
对于赋值操作的拦截,我们先做了简单的等值判断,在之后才更新代理的值,然后马上替换掉当前整个代理的 “外壳”,也就是 “proxyContainerRoot = { proxy: proxyContainerRoot.proxy }”,给原来的值重新套上一个新的“外壳”。这样当 React 调用你提供的 getSnapshot 时,就可以得到赋值操作后的新值,进而在触发渲染时通过 React 的新老值的判断真正的再触发渲染。
还需要注意的地方是我们在每次真正代理前,先从 proxyCache 中尝试取值,这是为什么呢?因为嵌套值也可能指向之前的父值。为了避免这样的重复代理,我们把每次代理后的值先保存在 proxyCache,在之后的代理过程中如果它里面已经包含了当前要代理的值,那说明它指向的是已经代理过的父值,不需要再做代理处理。
最后一个需要注意的地方是,我们在触发渲染时,不是直接同步调用 React 传递的渲染函数,而是将它放在一个 Promise 中异步处理,也就是 notify 中的处理方式。其实除此之前,你还可以将它放进浏览器的 requestIdleCallback API 中,以在浏览器空闲时调用,这其实也是 React 内部调用渲染逻辑时使用的方式之一。我们还使用了 WeakMap,如果你还不了解,我可以简单解释一下,WeakMap 的 key 可以是一个对象,但这个 key 保留的是这个对象的弱应用,当这个对象被垃圾回收后,这个 key 对应的 entry 就不复存在了。
将值连接到 React 的世界
前面我们实现了对值的代理,接下来我们就可以顺利的通过 useSyncExternalStore 将我们的值连接到 React 的世界了。我们先实现一个 Hook,这个 Hook 的作用就是直接利用 useSyncExternalStore 获取新值和渲染的。
// your-path/$stateBrige.ts
import { useSyncExternalStore } from 'react';
type ExternalStoreType<T> = {
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => T,
}
function useTodoStore<T extends { proxy?: any }>(externalStore?: ExternalStoreType<T>) {
if(!externalStore) return {};
const pc = useSyncExternalStore(
externalStore.subscribe,
() => externalStore.getSnapshot()
)
return pc?.proxy
}
我们通过类型和泛型对参数和返回值做了明确的约束,然后它的参数就是我们用实现的 createStore 产生的值,并返回 “外壳” 里真正的代理对象。最后就可以实现我们的 $state 了,如之前所说,它也是一个 Hook:
// your-path/$stateBrige.ts
import { useRef } from 'react';
import { createStore } from './your-path/$stateProxy';
const $state = (value: any) => {
let createStoreRef = useRef<ExternalStoreType<{ proxy?: any }> | undefined>(undefined)
if (!createStoreRef.current) {
createStoreRef.current = createStore(value)
}
return useTodoStore(createStoreRef.current)
}
这个 $state 利用 useRef 创建的对象来持久化持有我们通过 createStore 创建的对象,这样在每次渲染时就可以不必重复调用 createStore。最后通过之前实现的 useTodoStore 来将我们的目标值连接进 React 的世界里。
但是还没完!事情到这里其实还没有结束,它还有一个令人惊喜的功能可以通过以上做好的工具简单的就实现。想象一下,如果我们可以导出数据,并在多个组件中使用,同时,在任何一个组件内更改这个数据其它的组件都可以做出响应,那不就更方便了么!没错,这就是数据共享!
数据共享
之前我们提到过,在 React 的世界里,触发渲染的方式只有那几种,我们实现的 $state 本质上也是通过 Hook 调用 useSyncExternalStore 来实现触发渲染。因此,这次我们不是直接导出数据,而是导出这个数据对应的 Hook 来实现数据的共享。我们之前已经实现了 useTodoStore 和 creataStore,到这里我们就可以进一步实现:动态生成并导出一个 Hook,而这个 Hook 在被调用的时候内部 "复用了数据",就是我们在动态生成 Hook 时 createStore(value) 生成的数据:
const $contextState = (value: any) => () => useTodoStore(createStore(value))
export const CountHook = $contextState({ value: 0 })
为什么它可以做到数据共享呢,因为这个 Hook 无论在哪里被调用它的数据源都是统一的。接下来在使用它的时候:
// Count1.tsx
import { CountHook } from "./your-path/$stateBrige"
import Count2 from "./your-path/Count2"
export default function Count1() {
const count = CountHook()
return (
<div>
<p>count: {count.value}</p>
<button
onClick={() => {
count.value++;
}}
>
+1
</button>
<Count2 />
</div>
);
}
// Count2.tsx
import { CountHook } from "./your-path/$stateBrige"
export default function Count2 = () => {
const count = CountHook()
return <div>count2: {count.value}</div>
}
当 Count1 这个组件在页面渲染的时候,Count1 和 Count2 同时渲染了同样的数据。当点击在 Count1 中渲染的 button 后,Count1 和 Count2 同时更新并渲染了同样的数据!这既没有通过 context 的组件层层嵌套配合 state 或者 reducer dispatch,也没有通过 reducer store 这样类型的库来共享并更新数据。从功能上来说,至少对于简单的数据共享场景,确实方便不少!
到这里,如果你了解 Preact 的话,你会知道 Preact 有着类似的 signals 和专用于 React 的 useSignals,它们也面临一些同样的问题:必须 import 显示导入功能组件,无法直接代理原始值类型,并且提供了相应的的插件在编译时对你使用 signals 的地方帮你插入触发渲染的代码。其实这些问题完全可以通过插件来解决,比如特定的字符串直接使用插件替换掉,而复杂的情况则可以通过构建 AST 语法树,然后精准的将内容替换成你想要的内容来解决。而使用插件的同时,也面临一些额外的问题,比如编译性能、热重载性能、热重载数据更新等等,你可能需要先检查判断哪些文件需要处理,需要处理的方式是直接使用字符串替换还是构建 AST 语法树等等。
最后,还有一个十分方便的语法糖,那就是 $effect,它不需要显示的在参数列表里列出依赖项,它会自动将注册的函数内,访问的 $state 变量列为依赖项,当这些变量更新时,这个通过 $effect 注册的函数会自动执行。那么,接下来的文章我们就来探究如何在 React 里完成一个 $effect 的基础实现。最后也希望 React 在今后能实现一些官方的语法糖,毕竟使用起来确实好使!
作者原文:https://blog.hiou.top/dollar-state