作为响应式编程中最为基础的部分,信号(Signals)和行为(Reactions)为响应式系统提供了核心的元对象,但实际的系统中往往更加复杂。
Derivations
在很多场景下,多个行为可能同时需要读取同一个信号的值,但是往往是以不同的方式对信号的原始值进行处理之后各自表达。具体实现上,可以是在行为函数内部实现,也可以通过独立抽取为一个帮助函数来实现。
JavaScript
const [name, setName] = createSignal("Mike")
const fullName = () => {
return `${name()} More`;
}
createEffect(() => {
console.log("This is my name:", fullName())
})
setName("Jack")
上述示例伪代码中,将 fullName 实现为一个函数,这是因为为了在行为中读取到信号最新的值,需要延迟执行以等待信号的值获得更新。如果fullName仅仅为另外一个值,那么就没有机会进行跟踪或再次执行。
考虑到某些情况下,通过计算获取派生值是昂贵的,应该尽可能避免在程序中反复多次计算。正是在这种情况下,我们需要第三种基本的元对象,它提供类似函数记忆体的能力,将中间计算存储为自己的内部信号。这被称为派生,在部分框架中也有时被叫做Memos, Computeds, Pure Computeds。
JavaScript
const fullName = createMemo(() => {
return `${name()} More`;
})
相对于之前的实现,此时的fullName会在初始化时立即执行一次,并在内部存储计算获得的初始值。然而,其他的行为使用到该派生时,不会导致派生再次执行,而是直到作为源数据的信号发生变更时,派生再次执行,这仅仅只会在改变冒泡至相应的行为时发生一次。
当计算fullName所导致的代价十分昂贵时,派生通过内部缓存计算的中间结果而避免重复执行所带来的优势会凸显,派生通过独立的可执行表达式来存储中间结果的行为有时也被称之为trackable。更重要的是,当它们被派生时,它们可以保证是同步的。在任何时候都可以确定它们的依赖关系,并评估是否可能已过时。聪明如你可能想到,也可以通过在行为中改写其他的信号来模拟这一种场景,这看似是等效的,却并不能总是保证同步和依赖的正确性,因为行为并不是信号的显式依赖(信号本身的实现不包含依赖关系)。
有的库实现中会通过延迟执行来产生派生,因为派生仅需要在读取时进行计算,同时也可能允许对还未被计算的派生触发主动计算,这可被看做另外一种实现,从核心来讲并未超出派生的基本定义。
响应式生命周期
细粒度响应式在内部维护了大量的响应式节点之间的链接。任何既定的变更可能导致整个响应式系统所构成的图在某些部分进行重新计算,这会产生或者删除链接。
考虑条件的变更如何影响用于派生值的数据:
JavaScript
const [firstName, setFirstName] = createSignal("Dick")
const [lastName, setLastName] = createSignal("Smith")
const [showFullName, setShowFullName] = createSignal(true)
const displayName = createMemo(() => {
if (!showFullName()) {
return firstName()
}
return `${firstName()} ${lastName()}`
})
createEffect(() => {
console.log("This is my name:", displayName())
})
setShowFullName(false)
setLastName("Yang")
setShowFullName(true)
在上面这段代码中,通过setLastName修改lastName的值时,不会导致displayName派生值被重计算。这是由于每次当响应式表达式被重新执行时,所有的依赖会重新收集。只有当我们重新修改 showFullName
的值为 true之后,displayName
派生值才重新与lastName
信号再次建立依赖并响应式的追踪它的变更。 依赖是一个响应式表达式在执行求值期间所读取的所有的信号。反过来,这些信号同样持有了所有的响应式表达式的订阅,当信号的值发生变更,它们得以通知所有依赖的订阅者。这些依赖关系在每次执行时被构建,并在下一次重新执行前或者是最终被析构时释放,有的框架实现中提供了显式的清理函数以手动释放所有的依赖。
同步执行
细粒度响应式系统通过立即执行的方式执行变更,所有的改变都是同步发生的。这确保了整个系统的一致性和有效性,也使得任意的既定变更都是可预测的,因为任意变更的代码只会运行一次。 状态的不一致性可能带来非常多的不可预测的行为,因为当系统内部持有依赖和监听时,整个流程会变得无序。为了避免这些无谓的问题,最简单的办法就是同时应用两个变更,使得运行在特定行为中的派生值同步的获得最新的信号。 通过一个batch
包装器可以容易的模拟出事务性的更新,使得所有的变更仅仅只在整个表达式执行完成之后应用更改。
JavaScript
const [a, setA] = createSignal(1)
const [b, setB] = createSignal(2)
const c = createMemo(() => {
return b() * 2
})
createEffect(() => {
console.log("The sum is:", a() + c())
})
batch(() => {
setA(2)
setB(3)
})
在上述的示例程序中,尽管对于a
和b
的修改是几乎同时发生,但程序执行上仍然是遵照一定的先后,从理解上来说,可以认为对于a
的变更首先发生,进而执行关于a
的依赖,但随之监测到信号a
的依赖项中关联了c
派生值,而这个值已经被标记为过时。因此,c
的派生表达式被立即执行并获取新的派生值,所有的这些变更都在同步的环境中执行一次并都得到了正确的计算。 在业务层当然也能通过特定的方法确保这些情况运行良好,但是所有的依赖都可能在任意表达式执行期间发生变更,随着程序规模的扩大,几乎难以通过业务层的编排来确保正确性。现代的细粒度响应式库设计中采用了一种混合的push/pull
双向模型确保一致性,这并非简单的类似事件或流的推模式,也不仅是类似于迭代器的拉模式。
以上我们已经涵盖了细粒度响应式系统设计的关键部分,包括核心元对象、依赖收集以及同步执行。
接下来让我们看看ArcGIS Maps SDK for JavaScript中是如何设计和实现一套响应式系统,而我们又能如何更好的去使用这些特性。