Skip to content

响应式系统的前世今生

Posted on:2023年4月19日 at 08:07

翻译自https://www.builder.io/blog/history-of-reactivity 原作者:MIŠKO HEVERY

本文并非权威的响应式历史,而是作者的个人经历和感受

Flex

我的经历从Macromedia Flex开始说起,后来被 Adobe 收购了。

Flex是一个基于FlashActionScript框架。ActionScript是一种与JavaScript非常类似的语言,但是ActionScript通过注释来让编译器包装字段用于事件订阅,大概是类似这样:

class MyComponent {
  [Bindable] public var name: String;
}

[Bindable]注释创建一个getter/setter,可以触发属性变化的事件。然后你就可以监听属性的变化。Flex使用.mxml模板文件来渲染 UI。如果属性通过监听属性的变化而变化,在.mxml中的任何数据绑定都是细粒度的响应。

<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml">
  <mx:MyComponent>
    <mx:Label text="{name}"/></mx:Label>
  </mx:MyComponent>
</mx:Applicatio>

我对FlexReactivity(响应式)的起源的说法存疑,但是确实是我第一次接触。

Reactivity实际上对Flex来说是一个痛苦,因为它很容易制造出update stormsupdate storms是指当一个属性发生变化时触发许多其他属性发生变化,而这些变化的属性会进一步触发其他属性改变,这时就会进入一个无尽的循环当中。由于Flex没有将update propertyupdate UI区分开,以至于会出现频繁的UI抖动,也就是会对中间值渲染。

虽然经过事后分析,我明白是哪个(架构上的)决策导致这种欠佳的结果。但是,在当时,我是不清楚的,并且我带着对响应式系统的怀疑离开了。

AngularJS

AngularJS最初的目的就是去拓展HTML的语法,为了让那些非开发者(设计师)来建立简单的 web 应用。这就是 AngularJS 以html标签为结束符号的原因。因为 AngularJS 是对 html 的拓展,所以需要将其绑定到任意一个JavaScript对象上。在当时,Proxygetter/settersObject.observe()都还不是备选项。所以唯一可行的方案是做一个dirty checking 脏检查。

每当浏览器执行异步任务,dirty checking 将通过读取绑定到模板上的所有属性进行工作。

<!DOCTYPE html>
<html ng-app>
  <head>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.8.2/angular.min.js"></script>
  </head>
  <body>
    <div>
      <label>Name:</label>
      <input type="text" ng-model="yourName" placeholder="Enter a name here" />
      <hr />
      <h1>Hello {{yourName}}!</h1>
    </div>
  </body>
</html>

这种方法的好处是,任何JavaScript对象都可以在模板中作为数据绑定源,并且 update 能正常进行。

缺点是,JS 必须执行每个 update,并且AngularJS无法知道变化是什么时候发生的,所以AngularJS会比理论上需要更频繁的dirty checking

因为 AngularJS 仅与 JS 对象一起工作,并且是 html 语法的拓展,所以 AngularJS 从来没有提供任何类型的状态管理

React

React是在 AngularJS 之后出现的,并且做了很多提升。

首先,React 引入了 setState(),允许 React 知道什么时候应该执行vDOMdirty-checking。这样的好处是,不像 AngularJS 会在每次异步任务执行 dirty-checking,React 只有在开发者"告诉"它进行 dirty-checking 时才会执行。所以虽然 React vDOM dirty-checking 在计算上要比 Angular 更昂贵,但是执行次数会更少。

function Counter() {
  const [count, setCount] = useState();
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

其次,React 引入了从parentchild严格数据流。这些是迈向框架认可的状态管理的第一步,而 AngularJS 没有。

粗粒度响应式

React 和 AngularJS 都是粗粒度的响应式。这意味着数据的变化会触发大量 JavaScript 的执行。该框架最终会将所有更改协调到 UI 中。这意味着快速变化的属性(例如动画)会导致性能问题。

细粒度响应式

上述问题的解决方案是细粒度的反应性,其中状态更改仅更新状态绑定到的 UI 部分。难点是知道如何以具有好的 DX 的方式监听属性变化。(DX:Developer experience,describes how developers feel about a system while working on it)

Backbone.js

Backbone 是早于 AngularJS 的一种框架,它是具有细粒度响应性的,但是语法非常繁琐。

var MyModel = Backbone.Model.extend({
  initialize: function () {
    // Listen to changes on itself.
    this.on("change:name", this.onAsdChange);
  },
  onNameChange: function (model, value) {
    console.log("Model: Name was changed to:", value);
  },
});
var myModel = new MyModel();
myModel.set("name", "something");

我认为冗长的语法是 Backbone 被像 AngularJS 和后来的 React 接管的部分原因。因为开发人员可以简单地使用点符号来访问和设置状态,而不是一组复杂的函数回调。在这些更新的框架中开发应用程序更加容易和快捷。

Knockout

Knockout 是一个与 AngularJS 同时出现的框架。虽然我没有使用过,但是以我的理解,但是它同样被update storms困扰。虽然,Knockout 是对 Backbone.js 的改进,但是它使用可观测属性依旧非常笨重。这也是为什么我认为开发者更喜欢“点符号”框架(AngularJS 和 React 等)的原因。

但是 Knockout 有一个有趣的创新——计算属性,也许在之前就出现了,但确实是我第一次听说。计算属性会根据输入数据自动创建一个订阅。

var ViewModel = function (first, last) {
  this.firstName = ko.observable(first);
  this.lastName = ko.observable(last);
  this.fullName = ko.pureComputed(function () {
    // Knockout自动追踪依赖
    // 知道fullName依赖firstName,lastName
    // 因为在计算fullName时会调用他们
    return this.firstName() + " " + this.lastName();
  }, this);
};

ko.pureComputed()调用this.firstName()时,该值会隐式创建一个订阅。这是通过ko.pureComputed()设置一个全局变量实现的,允许this.firstName()ko.pureComputed()进行通信,并且无需开发者做任何额外的工作就可以将订阅信息传递给ko.pureComputed()

Svelte

Svelte 使用一个编译器整合了响应式。这么做的好处是,语法可以更加灵活,不必限制于 JavaScript。 Svelte 具有非常自然的响应式组件的语法。但是 Svelte 无法编译所有文件,只能处理.svelte文件。如果你想在未编译文件中使用响应式,Svelte 提供了一个store API,它没有编译文件的响应式特性,而是要求使用subscribeunsubscribe来进行明确的注册。

const count = writable(0);
const unsubscribe = count.subscribe(value => {
  countValue = value;
});

我认为用两种不同的方式做同一件事并不理想,因为你必须在头脑中保留两种不同的心智模型并在它们之间进行选择。因此,更加推荐只使用一种方法。

RxJS

RxJS 是一个响应式库,没有与任何其他底层渲染系统进行捆绑。这似乎是一个优势,但是它有一个缺陷。导航到一个新页面时,需要将旧 UI 清除,再构建新的 UI。对于 RxJS,这意味着需要进行许多取消订阅订阅。这种额外的工作意味着粗粒度的响应式系统在这种情况下更快,因为清除只是丢弃 UI(垃圾收集),而构建不需要任何注册/分配监听器。我们需要的是一种批量取消订阅/订阅的方法。

const observable1 = interval(400);
const observable2 = interval(300);
const subscription = observable1.subscribe(x =>
  console.log("[first](https://rxjs.dev/api/index/function/first): " + x)
);
const childSubscription = observable2.subscribe(x =>
  console.log("second: " + x)
);
subscription.add(childSubscription);
setTimeout(() => {
  // Unsubscribes BOTH subscription and childSubscription
  subscription.unsubscribe();
}, 1000);

Vue and Mobx

大约在同一时间,Vue 和 MobX 都开始尝试基于Proxy的响应式。

Proxy 的优点是你可以使用点符号语法(开发者更喜欢),并且可以使用与 Knockout 相同的技巧来创建自动订阅——这是一个巨大的胜利!

<template>
  <button @click="count = count + 1">{{ count }}</button>
</template>

<script setup>
  import { ref } from "vue";

  const count = ref(1);
</script>

在上面的示例中,模板(template)通过在渲染期间读取count,自动创建对count的订阅。这期间,开发人员不需要额外的工作。

SolidJS

Proxy 的缺点是不能传递对 getter/setter 的引用。您可以传递整个 Proxy属性的值,但你可以从 store 中剥离一个 getter 并传递它。以这个问题为例。

function App() {
  const state = createStateProxy({ count: 1 });
  return (
    <>
      <button onClick={() => state.count++}>+1</button>\
      <Wrapper value={state.count} />
    </>
  );
}

function Wrapper(props) {
  return <Display value={state.value} />;
}
function Display(props) {
  return <span>Count: {props.value}</span>;
}

当我们读取 state.count 时,得到的数字是初始值的,并且不再可观察。这意味着 MiddleChild 都需要在 state.count 更改时重新渲染。我们失去了细粒度的反应性。理想情况下,只有 Count: 应该被更新。我们需要一种方法,传递对值的引用而不是值本身。

Enter Signals

Signal 允许你引用该值,还允许你引用该值的 getter/setter。所以你可以用 Signal 解决上面的问题:

function App() {
  const [count, setCount] = createSignal(1);
  return (
    <>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <Wrapper value={count} />
    </>
  );
}
function Wrapper(props: { value: Accessor<number> }) {
  return <Display value={props.value} />;
}
function Display(props: { value: Accessor<number> }) {
  return <span>Count: {props.value}</span>;
}

这种解决方案的好处是,我们没有传递而是传递了一个Accessor(一个getter),这意味着当count值改变时,我们不必通过WrapperDisplay,而是直接对DOM进行一个更新。在工作方式上与Knockout非常相似,但是在语法上与Vue/Mobx非常相似.

但是这存在一个 DX 问题,作为组件的使用者,假设我们想要绑定一个常量

<Display value={10} />

由于Display被定义为Accessor(访问器),所以这是不起作用的。

function Display(props: {value: Accessor<number>});

这很不幸,因为组件的作者现在定义了消费者是否可以发送 getter。无论作者选择什么,总会有未涵盖的用例。这两件事都是合理的。

<Display value={10}/>
<Display value={createSignal(10)}/>

以上是使用 Display 组件的两种有效方式,但它们都不是对的!我们需要的是一种将类型声明为原始类型但同时适用于原始类型访问器的方法。

function App() {
  const [count, setCount] = createSignal(1);
  return (
    <>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <Wrapper value={count()} />
    </>
  );
}
function Wrapper(props: { value: number }) {
  return <Display value={props.value} />;
}
function Display(props: { value: number }) {
  return <span>Count: {props.value}</span>;
}

请注意,现在我们声明的是数字,而不是访问器。这意味着这段代码可以正常工作。

<Display value={10}/>
<Display value={createSignal(10)()}/> // Notice the extra ()

但这是否意味着我们现在已经打破了反应性?答案是肯定的,除非我们可以让编译器执行一个技巧来恢复我们的反应性。问题是这一行:

<Wrapper value={count()} />

调用 count()访问器转换为原始属性并创建订阅。所以编译器做了这个把戏。

Wrapper({
  get value() {
    return count();
  },
});

count() 作为一个 prop 传递给子组件时,通过将其包装在一个getter中,使得编译器可以延长 count() 的执行时机,直到 DOM 需要使用这个值。

这样的好处是:

响应式与渲染

让我们想象一个场景,一个带有购买按钮和购物车的页面。

在上面的示例中,有一系列组件组成的树。用户可能的行为是点击购买按钮,这时需要更新购物车。存在两种不同的结果。

在粗粒度的响应式系统中,它是这样的:

我们必须找到 BuyCart 组件之间的公共根,因为这是最有可能存放状态的地方。然后,在更改状态时,与该状态关联的树必须重新渲染。使用记忆化(memoization),可以将树修剪成两条最小路径,如上所示。许多代码仍然需要执行,尤其是当应用程序变得越来越复杂时。

在一个细粒度的响应式系统中,它看起来像这样:

可以观察到只有目标 Cart 组件需要执行。无需查看声明状态的位置或共同祖先是什么。也不必担心数据记忆化并而去剪枝。细粒度响应式系统的好处在于,无需开发人员的任何努力,运行时只执行最少量的代码!

细粒度响应式系统的精度使其非常适合延迟执行代码,因为系统只需要执行状态的侦听器(在我们的例子中是 Cart )。

但是细粒度的响应式系统有一个意想不到的极端情况。为了让系统至少建立一次响应式图,必须执行所有组件才能了解这些关系!一旦建立起来,该系统就可以进行外科手术。这是初始执行的样子:

这个问题就是我们想 lazy 下载和执行,但是响应图的初始化强制应用程序完全执行(下载)

Qwik

这就是 Qwik 的用武之地。Qwik 是细粒度的反应式,类似于 SolidJS,这意味着状态的变化会直接更新 DOM。(在某些极端情况下,Qwik 可能需要执行整个组件。)但是 Qwik 有一个小窍门。还记得细粒度的反应性要求所有组件至少执行一次以创建反应性图吗?Qwik 利用了组件已经在 SSR/SSG 期间在服务器上执行的事实。Qwik 可以将该图序列化为 HTML。这允许客户端完全跳过最初的“执行全部组件以了解响应图”。我们称之为可恢复性(resumability)。因为组件不在客户端执行或下载,所以 Qwik 的好处是应用程序的即时启动。应用程序运行后,反应性就像外科手术一样,就像 SolidJS 一样。

总结

作为一个行业,我们经历了多次反应性迭代。我们从粗粒度反应系统开始,因为它们对开发人员更友好。但我们一直关注拥有细粒度反应系统的价值,并且一直在努力解决这个问题一段时间。最新一代框架解决了开发者体验、更新风暴、状态管理等诸多问题。我不知道您接下来会选择哪个框架,但我敢打赌它会是细粒度的响应式框架!