Hello!大家好,我是Spring Cat。这里分享的不仅是巧妙交织的代码逻辑,还有生动演绎的思考过程。我想给大家的不仅是一个答案,更是一个它为何从何而来将向何去的故事。
这次,我们深入探索svelte的响应式系统,分析理解它设计思想和实现思路,并将$effect也带进React的世界中。
React x Svelte 跨界联动又来啦!随着对svelte的了解越来越深入,越是发现svelte真的挺有意思,它代表了下一代前端框架,有着自己独特的理念和运行方式,并且表现的很好。在之前的文章中,我们对 $state在React做了实现,而这一次,我们继续将svelte中的另一个重要的rune: $effect引入React。如果你已经读过之前的svelte的$state在React中的基础实现这篇文章,那么你将会很轻松的理解接下来内容。
你肯定发现我使用了一个新词“rune”,这是什么呢?这个被svelte称之为“响应式原语”,这么说你肯定还是觉得很抽象。简单来说,svelte中的 $state、 $effect都是“rune”,除此之外svelte还有很多这种“原语”,比如:$derived、inspect、$template等等。这些“原语”创建的变量被称之为“响应式变量”。
你又会发现,我们开始介绍一些新概念了。不要着急,我们先耐心搞懂这些svelte的概念,接下来的内容阅读起来才会更清晰。因为随着对svelte的深入了解,我们的知识已经触及到svelte的核心理念,此时我们不能直接从代码开始,而是要先理解它的设计理念和运行方式,再按图索骥,事半功倍!就像一盘美味摆在你面前,如果你直接塞进嘴里,却想不经咀嚼大口吞下,那么结果就是既尝不到口中美味,又难以下咽,此时又难以咀嚼,就是这个道理,那么我们就开始吧!
svelte响应式系统
svelte通过“响应式原语”和“响应式变量”构建了一套完整的“响应式系统”,这套系统的基本运行逻辑就是,当这些变量发生变化时,对应访问这些变量的部分(Dom、$effect、$derived etc.)也会随着更新和执行,并且只有自己才会更新不会影响其它部分。
举个UI更新的例子对比:React的渲染基本单位是以组件为单位,组件里任意state内一个值发生变化你都要更新整个state,进而更新整个组件。而在svelte里,没有虚拟Dom,代码经过编译后,所有的标签都被直接转化为dom和对dom的访问与操作。此时响应式变量如果发生变化,那么访问了该变量的dom就会更新其内容。也就是说,svelte的UI更新是“精准的dom操作”。
回到$effect,作为这个系统的重要参与者,它是怎么发挥作用的呢?我们先从了解其基本使用方法开始:
<script>
let count = $state(0);
$effect(() => {
console.log(count)
return () => {
console.log("teardown function")
};
});
</script>
<h1>{count}</h1>
<button onclick={() => (count *= 2)}>count *= 2</button>
这段代码的效果是:使用$effect注册了一个函数,这个函数和h1标签访问了一个响应式变量 count。页面渲染时注册函数会立即执行一次。
点击按钮会对这个count进行自加赋值,进而$effect内返回的函数会先执行一次,然后注册的这个函数会执行并打印变量值,h1标签对应的dom更新。
你会注意到这个$effect和React的useEffect相似但又不同,不同的重点其实就是,它不需要你显示的在参数列表里列出依赖项,而是自动将注册的函数内,访问的响应式变量,作为依赖。
$effect如何得知它的依赖
这是一个很有意思的现象,$effect是如何得知,使用它注册的函数里面,访问了哪些响应式变量呢?答案其实可能比你想的要简单,就如同“高端的食材往往只需要最简单的烹饪方式”:
我们还是以上面的示例代码为例:
当$effect执行的时候,先立马向全局的某一个变量设置一个值,这个值至少包含了自己身的引用。然后执行注册的函数,注册函数内的console语句访问了count变量,此时如果count变量可以进行访问拦截,那么拦截的时候就可以去那个全局变量取出当前正在执行的$effect。好的,到了这里你会发现,被访问count其实是可以通过那个全局变量,知道自己当前在哪个effect内被访问的,如果那个全局变量里还有一个方法,这个方法就是记录保存响应式变量的呢?哇!我猜你一定豁然开朗了,爽!
也就是说$effect的依赖不是自己主动去找到的,而是注册的函数内,访问的响应式变量自己主动交待的!count被访问的时候,通过一个全局变量得知自己当前在哪个effect内被访问,然后主动把自己加入这个effect对应的依赖列表里!此时,count变量还可以将当前这个effect加入自己的订阅者列表里!
进一步,如果count还可以做赋值拦截,那么被赋值更新的时候,岂不就可以通过自己的订阅者列表,通知所有订阅者了!原来这么简单!
effect基础流程
到了这你可能激动的想要看看具体怎么实现的了,不要着急,咱们一步一步来。这只是最原始的逻辑,我们还需要为这个逻辑补充一些流程上的细节。此时,我们考虑一种情况:
let condition = $state(false);
let value = $state(0);
$effect(() => {
if (condition) {
console.log(value); // 依赖 value
}
// 如果condition变为false,value的依赖应该被清除
});
如果我们每次建立依赖关系前,不先清理依赖,当condition为false时,value的变化仍然会触发effect,这种情况显然错误的,我们称之为“悬挂依赖”。同时里面某些响应式变量可能会在某些情况系已经被销毁了,也不应该做记录,为了避免过多的边界情况,保持每次的依赖是干净清晰的,我们需要在建立依赖前先彻底清理掉双方的依赖订阅关系,正所谓“旧的不去,新的不来”。
现在我们重新整理一下这个过程:
1.$effect执行,设置全局变量 2.清理双方的依赖订阅关系 3.执行注册函数上次执行后返回的函数 4.执行注册函数,在每次执行过程中,首次或重新建立双方的依赖订阅关系 5.移除全局变量中设置的值
你可能会问,怎保证,每次在这个设置了effect的全局变量期间,访问的响应式变量就是这个effect的依赖呢,原因还是非常的质朴,因为js是单线程的。
effect执行分析
好了,有了以上的逻辑,咱们就可以开始按码索骥了:
export type RunnerType = {
(): void;
__run: Function;
deps: Set<RunnerDepsType>;
cleanup: Function | null | never;
stop: Function;
running: boolean;
}
type RunnerDepsType = {
_subs: Set<RunnerType>
}
type EffectFnResult = Function | null | never | void
type EffectFn = () => EffectFnResult
let RUNNER_STACK: RunnerType[] = []
export const getCurrentRunner = () => RUNNER_STACK[RUNNER_STACK.length - 1]
function pushRunner(runner: RunnerType) {
RUNNER_STACK.push(runner)
}
// helper: safely pop runner and validate it's the same one
function popRunnerExpect(runner: RunnerType) {
const topRunner = RUNNER_STACK[RUNNER_STACK.length - 1]
if (topRunner === runner) {
RUNNER_STACK.pop()
return
} else if (RUNNER_STACK.length > 0) {
// use splice to keep the original variable reference unchanged
const runnerIndex = RUNNER_STACK.findIndex(r => r === runner)
RUNNER_STACK.splice(runnerIndex, 1)
// throw new Error("sync pop runner error")
}
}
function makeRunner(fn: EffectFn): RunnerType {
function runner() {
// Clean previous deps: remove runner from each previous signal's subs
runner._cleanupDependencies()
// Enter runner global context
pushRunner(runner)
try {
(runner.cleanup as EffectFnResult) = fn(); // rebuild dependencies
runner.running = false;
} finally {
popRunnerExpect(runner)
}
}
runner.running = false
runner.stopped = false
runner.cleanup = null
runner.deps = new Set<RunnerDepsType>()
// wrapped version used by scheduler to ensure no direct external calls bypassing __run
runner.__run = function() {
if (runner.stopped) return
if (!runner.running) {
runner.running = true // merge all execution requests during this period before the actual start
queueMicrotask(() => {
if (runner.running) {
runner();
}
});
}
}
runner._cleanupDependencies = () => {
for (const sig of runner.deps) {
sig._subs.delete(runner)
}
runner.deps.clear()
if (runner.cleanup) {
(runner.cleanup as Function)()
runner.cleanup = null
}
}
runner.stop = function() {
// remove from current dependencies
for (const sig of runner.deps) sig._subs.delete(runner)
runner.deps.clear()
runner.stopped = true
};
return runner
}
export function effect(fn: EffectFn) {
// pop all runners which in stack if it is in a effect context
if (RUNNER_STACK.length) {
var _copy_runner_stack = RUNNER_STACK.splice(0)
fn()
RUNNER_STACK.push(..._copy_runner_stack)
return
}
const r = makeRunner(fn)
// initial sync run to collect deps
r()
// return stop
return () => r.stop()
}
代码看起来有些长,但实际上就是对以上过程的直观复现。我们来分析一下:我们首先直接定位到makeRunner这个函数,它负责的就是返回一个真正执行effect过程的函数,这个函数的执行完成了我们以上提到的流程。
同时我们直接将effcet的状态、一些方法、依赖集合挂在这个函数上,因此这个函数就完全代表了当前这个effect。比如running表示这个函数已经进入执行流程、deps表示依赖集合(里面存储这它依赖的响应式变量)、cleanup表示注册函数返回的函数。
在这个函数的执行过程中我们使用了全局的栈来弹入弹出当前effect,这个effect就是makeRunner返回的这个函数本身的引用,它依赖的响应式变量会将它加入自己的订阅者列表里。
同时它还提供了一个 __run方法,当依赖变量更新时就调用这个方法触发effect执行。你可以看到它并没有直接执行,而是通过 queueMicrotask 将这次的任务推入到当前的微任务队列中,相比Promise 更直接,这也是React使用的异步任务方式之一。
_cleanupDependencies则是清理上次双方建立的依赖订阅关系,它首先从依赖列表里取出依赖变量,依赖变量直接提供了一个自己的订阅者列表_subs,然后effect将自己从这个订阅者列表移除,最后清空自己的依赖列表,双方上次的恩怨纠葛就此了结。
我们还要注意一个细节,那就是状态running,它实际上起到了非常重要的作用,一旦被推入微任务队列就,在执行的过程中就不再接受重复的推入任务。这就相当于将这之前所有的更新执行请求都合并了,为什么呢?因为在这之前变量已经更新了,那么这次执行就会访问到变量的最新值,在这期间所有的变量更新执行请求其实都是重复的,举个直观的例子:你接收到第一个叫你起跑的指令,并将在3秒后起跑,指期间剩余所有的起跑指令都是已经是重复的了。
接下来将注意力转移到effect函数,就是它负责调用makeRunner来创建此次的effect任务,并且为了避免嵌套,它也将读取全局的effect记录栈来判断当前执行是否处在一个effect中,如果是则直接弹出effect栈内所有内容,在执行一次注册函数,避免在这次执行中建立依赖订阅关系,只当将注册函数当作一个普通函数执行,执行完后接着恢复栈所有内容。
还需要注意一个细节,那就是每次对全局栈的操作使用了splice、pop、push,它们有一个共同的特点,那就是保持当前引用不变!
将$effect引入React的世界中
如之前[svelte的state在React中的基础实现](https://juejin.cn/post/7572437226022469642)的文章中一样,我们在React引入\effect本质上仍是实现一个React Hook,因此:
export const $effect = (fn: EffectFn) => {
let __fn_ref = useRef<EffectFn>(null);
if (!__fn_ref.current) {
__fn_ref.current = fn
effect(__fn_ref.current)
}
}
因此我们还是采用相同的手法,将$effect引入到React的世界中。
好了,能读到了这里我由衷的赞叹你的耐心和求知欲并对你表示出大大的👍。但还没结束,我们似乎忘记了什么?哦!$state创建的响应式变量是怎么在被访问到的时候,记录双方的依赖订阅关系的?
追踪记录依赖订阅关系
之前的[svelte的state,现在我们仅需在它里面新增一丢丢逻辑便可轻松实现双方依赖订阅关系的建立:
// ./your-path/stateProxy.ts
import { getCurrentRunner } from "./your-path/$effect"
export function createStore<T extends object>(initial: T) {
......
// notify all react subscribers (effect derived etc.)
function notifyReactSubRun(reactSubs?: Set<RunnerType>) {
reactSubs?.forEach(reactSub => {
reactSub.__run()
});
}
let targetSubs = new TargetSubs()
function makeProxy(obj: any) {
......
const proxy = new Proxy(obj, {
get(target, key, receiver) {
......
const currentRunner = getCurrentRunner()
// sync get current effect runner if it is running
if (currentRunner) {
// add runner deps
currentRunner.deps.add({
_subs: targetSubs.addSubsIfNeed(target, key, currentRunner)
})
}
......
},
set(target, key, value, receiver) {
......
const subs = targetSubs.getSubs(obj, key)
notifyReactSubRun(subs)
......
}
......
})
......
}
}
这里仅展示出新增的逻辑。在get访问拦截里,我们判断当前处在effect上下文中,则通过targetSubs.addSubsIfNeed方法来记录追踪当前订阅者,同时向effect的依赖列表中加入代表自身的依赖项(它提供了自己当前的订阅者列表,effect在清理关系时可以将它自身从这个列表中移除),这就完成了双方依赖订阅关系的建立。
而在set赋值拦截中,通过targetSubs.getSubs方法取出当前的订阅者,并通知它们,这里直接调用effect提供的 __run方法。
最后是我们的TargetSubs类,它专门追踪记录每个被访问对象的订阅者。我们为这个过程单独实现了这个类:
import { RunnerType } from "./your-path/$effect"
export default class TargetSubs {
// target -> prop map
private targetPropSubsMap = new WeakMap<object, Map<any, Set<RunnerType>>>()
addSubsIfNeed(target: object, prop: any, sub: RunnerType) {
let targetMap = null
let targetPropSet = null
if (!this.targetPropSubsMap.has(target)) {
targetPropSet = new Set([sub])
this.targetPropSubsMap.set(target, new Map([[prop, targetPropSet]]))
return targetPropSet
}
targetMap = this.targetPropSubsMap.get(target)!
if (!targetMap.has(prop)) {
targetPropSet = new Set([sub])
targetMap.set(prop, targetPropSet)
console.log('[TargetSubs] [add] 初始化 Prop Set', targetMap)
return targetPropSet
}
targetPropSet = targetMap.get(prop)!
if (!targetPropSet.has(sub)) {
targetPropSet.add(sub)
}
return targetPropSet
}
getSubs(target: object, prop: any) {
return this.targetPropSubsMap.get(target)?.get(prop)
}
}
需要非常注意的是,我们使用addSubsIfNeed和getSubs方法时提供的参数。在实现中,我们为代理(Proxy)中每个target的prop单独做了订阅者追踪记录,这是为了针对每个prop的变化,不会影响同一个代理下的,其它值的订阅者。
你看,这些其实本质上仍然是一套基于观察者订阅者模式的系统。“高端的食材往往只需要最简单的烹饪方式”,再加上我们细腻的操作,一道足以让味蕾持续为之疯狂的美味佳肴诞生了!此时由衷地感谢你能耐心的读到这里,相信你已收获颇丰,这是代表下一代前端框架的核心设计理念和实现思路,当然不止于此,仍有许多内容值得我们去探索,但你已超越了许多人,走在了前面,为此我们加油鼓掌👏👏👏。
接来下还有一个React不曾拥有的但Vue和其它语言却经常用到的rune,那就是计算变量: $derived。那么下次,我们将继续深入探索并将 $derived带进React的世界!