React 如何不具备响应式特性,以及为什么你不必在意
什么是响应式编程?
常见的反应类型
React难道不是响应式的吗?
稻草人谬误
概括
如果标题与你的观点一致,你可以立即停止阅读,直接跳到下一篇文章。在科技领域,我们往往会抓住差异不放,以此来构建易于识别的讨论点,即便真相并非如此泾渭分明。
所以,如果你不想把一些基本没用的信息塞进脑子里,那就省省时间,直接跳过吧。但如果你对这类事情感兴趣,那我就试着解释一下。
什么是响应式编程?
这就是问题的关键所在。如果说有什么术语比“响应式编程”更含义繁杂的话……它涵盖的内容非常广泛,而且大多数定义都相当糟糕。要么过于具体地指向某种机制,要么过于学术化。所以我打算再尝试一下。
响应式编程是一种基于数据中心事件发射器的声明式编程范式。
这包含两个方面。“声明式编程范式”意味着代码描述的是行为,而不是如何实现行为。常见的例子是HTML/模板,你描述的是将要看到的内容,而不是如何更新它。另一个例子是SQL查询语言,你描述的是你想要的数据,而不是如何获取数据。
SELECT name FROM customers
WHERE city = "Dallas"
ORDER BY created_at DESC
这种范式同样适用于数据转换,并且通常与函数式编程相关。例如,这种映射/过滤操作描述的是输出结果,而不是获取结果的过程。
const upperCaseOddLengthWords = words
.filter(word => word.length % 2)
.map(word => word.toUpperCase());
第二部分是“以数据为中心的事件发射器”。我们都曾在使用事件的系统中工作过。DOM 会在用户与元素交互时触发事件。操作系统也使用事件队列。它们的作用是将系统中的变更处理与触发变更的参与者解耦。
响应式系统的关键在于将参与者视为数据。每条数据都负责发出自己的事件,以便在其值发生变化时通知订阅者。实现方式多种多样,从数据流和操作符到信号和计算,但其核心始终是这个以数据为中心的事件发射器。
常见的反应类型
JavaScript 中有两种常见的响应式类型。它们是为了解决不同的问题而发展起来的。它们具有相同的核心属性,但在建模方式上略有不同。
1. 响应式流
这可能是你听过最多但未必最常用的方法。它基于异步流,并使用运算符处理这些流。这是一个转换系统,非常适合模拟随时间推移的变化传播。
它在 JavaScript 中最著名的形式是 RxJS,并为 Angular 等项目提供支持。
const listener = merge(
fromEvent(document, 'mousedown').pipe(mapTo(false)),
fromEvent(document, 'mousemove').pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, 'mouseup')))
.subscribe(isDragging => {
console.log('Were you dragging?', isDragging);
});
你可以看到这个数据流在你眼前逐渐建立起来。你可以用极少的代码描述一些极其复杂的行为。
2. 精细信号
这种技术通常与电子表格或数字电路联系在一起。它的开发是为了解决同步问题。它本身对时间的概念比较模糊,但能确保数据无误地传输,从而使所有数据保持同步。
它基于信号和自动跟踪计算构建,而非数据流和运算符。信号代表单个数据点,其变化会通过一系列推导过程传播,最终产生副作用。
你经常会在不知不觉中使用这些系统。它是 Vue、MobX、Alpine、Solid、Riot 和 Knockout 的核心组成部分。
import { observable, autorun } from "mobx"
const cityName = observable.box("Vienna")
autorun(() => {
console.log(cityName.get())
})
// Prints: 'Vienna'
cityName.set("Amsterdam")
// Prints: 'Amsterdam'
仔细观察,你会发现cityName它的值似乎是被拉取而不是推送的。而且在初始执行时确实如此。这些系统使用混合推送/拉取机制,但原因可能与你想象的不同。这样做是为了保持同步。
无论我们采用何种方法,计算都需要按一定顺序执行,因此有可能在派生值更新之前就对其进行读取。鉴于计算表达式的高度动态性,在追求最优执行时,拓扑排序并非总是可行。因此,有时我们会采用拉取(pull)而非推送(push)的方式来确保在触发读取信号时数据的一致性。
另外值得一提的是:有些人误以为简单的代理设置就一定是响应式的标志。这是个误解。你可能看到响应式,city.name = "Firenze"但实际情况并非如此city.setName("Firenze")。React 完全可以将类组件state对象设置为代理,而不会对行为产生任何影响。
这就引出了……
React难道不是响应式的吗?
嗯,我们来看看。React 组件由状态驱动,setState调用有点像数据事件。而且 React 的 Hooks 和 JSX 本质上都是声明式的。那么问题出在哪里呢?
实际上差别很小。只有一个关键区别:React 将数据事件与组件更新解耦。它使用调度器来处理数据事件。虽然你可能已经执行过setState很多次更新操作,但 React 会记录哪些组件已被安排更新,并且只有在组件准备就绪后才会执行更新。
但这其实是一种缓冲机制。队列不仅会因状态更新事件而填充,处理该队列的调度也会如此。React 并没有设置一个持续轮询机制来检测状态变化。整个系统都由相同的事件驱动。
那么 React 就不是响应式的吗?只有当你把响应式理解为仅推送机制时,它才不是。诚然,React 的调度机制通常与基于推送的响应式系统兼容性不如某些人所期望的那样好,但这很难成为证据。它似乎符合一般的响应式标准。但它绝对不是典型的响应式。你知道还有什么不是吗?Svelte。
稻草人谬误
在 Svelte 中,如果你在事件处理程序中更新一个值,而恰好在下一行代码中读取了一个派生值,那么该派生值不会更新。这显然不是同步的。
<script>
let count = 1;
$: doubleCount = count * 2;
</script>
<button on:click={() => {
count = count + 1;
console.log(count, doubleCount); // 2, 2
}}>Click Me</button>
实际上,Svelte 的更新是分批进行的,其调度方式与 React 类似。虽然不像时间切片那样可中断,但仍然是按计划进行的。事实上,大多数框架都采用这种分批方式。Vue 在处理 DOM 更新时也是如此。同步和顺序地设置两次计数不会导致 Svelte 多次更新组件。
更进一步说,你看到编译后的输出结果了吗?其中重要的部分如下所示:
let doubleCount;
let count = 1;
const click_handler = () => {
$$invalidate(0, count = count + 1);
console.log(count, doubleCount); // 2, 2
};
$$self.$$.update = () => {
if ($$self.$$.dirty & /*count*/ 1) {
$: $$invalidate(1, doubleCount = count * 2);
}
};
不出所料,它和 React$$invalidate非常相似setState。猜猜它的作用是什么?告诉组件调用它的update函数。基本上和 React 的工作原理一模一样。
此后,由于记忆化模式和是否使用虚拟 DOM 的差异,执行过程会有所不同。但无论如何,Svelte 都提供了一个setState重新评估组件的函数。与 React 类似,它是组件级别的,执行基于标志的简单差异比较,而不是基于引用值检查的比较。
所以 Svelte 就不是响应式的吗?它具备我们之前认为 React 不具备的所有特性。
概括
这种争论基本上毫无意义,就像 JSX 与自定义模板 DSL 之争一样。执行模型上的差异可能很显著,但 Svelte 的独特之处并非在于响应式,而在于其编译器将创建/更新路径分离,从而允许跳过虚拟 DOM。
React 团队承认它并非完全响应式。虽然这听起来似乎很有价值,但实际上它与许多声称是响应式的库并没有太大区别。诚然,React Fiber 将调度机制发挥到了极致,但大多数 UI 框架都会自动进行一定程度的调度。
响应式并非针对特定问题的解决方案,而是一种数据变更传播的建模方式,它是一种编程范式。几乎任何问题都可以用响应式方法进行建模。我们越早将其视为一种范式,就能越早专注于真正重要的问题。
文章来源:https://dev.to/this-is-learning/how-react-isn-t-reactive-and-why-you-shouldn-t-care-152m