微信搜索 【大迁世界】, 我会第一时间和你分享前端行业趋势,学习途径等等。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

这篇文章并不是关于响应式的权威历史,而是关于我个人在这方面的经历和观点。

Flex

我的旅程始于 Macromedia Flex,后来被 Adobe 收购。Flex 是基于 Flash 上的 ActionScript 的一个框架。ActionScript 与 JavaScript 非常相似,但它具有注解功能,允许编译器为订阅包装字段。我不记得确切的语法了,也在网上找不到太多信息,但它看起来是这样的:

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

[Bindable] 注解会创建一个 getter/setter,当属性发生变化时,它会触发事件。然后你可以监听属性的变化。Flex 附带了用于渲染 UI 的 .mxml 文件模板。如果属性发生变化,.mxml 中的任何数据绑定都是细粒度的响应式,因为它通过监听属性的变化。

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

我怀疑 Flex 并不是响应式最早出现的地方,但它是我第一次接触到响应式。

在 Flex 中,响应式有点麻烦,因为它容易创建更新风暴。更新风暴是指当单个属性变化触发许多其他属性(或模板)变化,从而触发更多属性变化,依此类推。有时,这会陷入无限循环。Flex 没有区分更新属性和更新 UI,导致大量的 UI 抖动(渲染中间值)。

事后看来,我可以看到哪些架构决策导致了这种次优结果,但当时我并不清楚,我对响应式系统有点不信任。

AngularJS

AngularJS 的最初目标是扩展 HTML 词汇,以便设计师(非开发人员)可以构建简单的 Web 应用程序。这就是为什么 AngularJS 最终采用了 HTML 标记的原因。由于 AngularJS 扩展了 HTML,它需要绑定到任何 JavaScript 对象。那时候既没有 Proxy、getter/setters,也没有 Object.observe() 这些选项可供选择。所以唯一可用的解决方案就是使用脏检查。

脏检查通过在浏览器执行任何异步工作时读取模板中绑定的所有属性来工作。

<!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 对象都可以在模板中用作数据绑定源,更新也能正常工作。

缺点是每次更新都要执行大量的 JavaScript。而且,因为 AngularJS 不知道何时可能发生变化,所以它运行脏检查的频率远远超过理论上所需。

因为 AngularJS 可以与任何对象一起工作,而且它本身是 HTML 语法的扩展,所以 AngularJS 从未将任何状态管理形式固化。

React

React在AngularJS(Angular之前)之后推出,并进行了几项改进。

首先,React引入了setState()。这使得React知道何时应该对vDOM进行脏检查。这样做的好处是,与每个异步任务都运行脏检查的AngularJS不同,React只有在开发人员告诉它要运行时才会执行。因此,尽管React vDOM的脏检查比AngularJS更耗费计算资源,但它会更少地运行。

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

其次,React引入了从父组件到子组件的严格数据流。这是朝着框架认可的状态管理迈出的第一步,而AngularJS则没有这样做。

粗粒度响应性

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

细粒度响应性

解决上述问题的方法是细粒度响应性,状态改变只更新与状态绑定的 UI 部分。

难点在于如何以良好的开发体验(DX)来监听属性变化。

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');

我认为冗长的语法是像 AngularJS 和后来的 React 这样的框架取而代之的原因之一,因为开发者可以简单地使用点符号来访问和设置状态,而不是一组复杂的函数回调。在这些较新的框架中开发应用程序更容易,也更快。

Knockout

Knockout 和 AngularJS 出现在同一时期。我从未使用过它,但我的理解是它也受到了更新风暴问题的困扰。虽然它在 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 tracks dependencies automatically.
    // It knows that fullName depends on firstName and lastName,
    // because these get called when evaluating fullName.
    return this.firstName() + " " + this.lastName();
  }, this);
};

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

Svelte

Svelte使用编译器实现了响应式。这里的优势在于,有了编译器,语法可以是任何你想要的。你不受JavaScript的限制。对于组件,Svelte具有非常自然的响应式语法。但是,Svelte并不会编译所有文件,只会编译以.svelte结尾的文件。如果你希望在未经过编译的文件中获得响应性,则Svelte提供了一个存储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 和 MobX

大约在同一时间,Vue 和 MobX 都开始尝试基于代理的响应式。代理的优势在于,你可以使用开发者喜欢的干净的点表示法语法,同时可以像 Knockout 一样使用相同的技巧来创建自动订阅 —— 这是一个巨大的胜利!

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

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

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

在上面的示例中,模板在渲染期间通过读取 count 值自动创建了一个对 count 的订阅。开发者无需进行任何额外的工作。

SolidJS

SolidJS 的缺点是无法将引用传递给 getter/setter。你要么传递整个代理,要么传递属性的值,但是你无法从存储中剥离一个 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 时,得到的数字是原始的,不再是可观察的。这意味着 Middle 和 Child 都需要在 state.count 改变时重新渲染。我们失去了细粒度的响应性。理想情况下,只有 Count: 应该被更新。我们需要的是一种传递值引用而不是值本身的方法。

signals

signals 允许你不仅引用值,还可以引用该值的 getter/setter。因此,你可以使用信号解决上述问题:

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>});

这是令人遗憾的,因为组件的作者现在定义了使用者是否可以发送gettervalue。无论作者选择什么,总会有未涵盖的用例。这两者都是合理的事情。

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

以上是使用 Display 的两种有效方式,但它们都不能同时成立!我们需要一种方法来将类型声明为基本类型,但可以同时与基本类型和 Accessor 一起使用。这时编译器就出场了。

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>
}

请注意,现在我们声明的是 number,而不是 Accessor。这意味着这段代码将正常工作

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

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

<Wrapper value={count()}/>

count()的调用会将访问器转换为原始值并创建一个订阅。因此编译器会执行这个技巧。

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

通过在将count()作为属性传递给子组件时,在getter中包装它,编译器成功地延迟了对count()的执行,直到DOM实际需要它。这使得DOM可以创建基础信号的订阅,即使对开发人员来说似乎是传递了一个值。

好处有:

  • 清晰的语法
  • 自动订阅和取消订阅
  • 组件接口不必选择原始类型或Accessor。
  • 响应性即使开发人员将Accessor转换为原始类型也能正常工作。

我们还能在此基础上做出什么改进吗?

响应性和渲染

让我们想象一个产品页面,有一个购买按钮和一个购物车。

image.png

在上面的示例中,我们有一个树形结构中的组件集合。用户可能采取的一种可能的操作是点击购买按钮,这需要更新购物车。对于需要执行的代码,有两种不同的结果。

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

image.png

我们必须找到 BuyCart 组件之间的共同根,因为状态很可能附加在那里。然后,在更改状态时,与该状态相关联的树必须重新渲染。使用 memoization 技术,可以将树剪枝成仅包含上述两个最小路径。尤其是随着应用程序变得越来越复杂,需要执行大量代码。

在细粒度反应式系统中,它看起来像这样:

image.png

请注意,只有目标 Cart 需要执行。无需查看状态是在哪里声明的或共同祖先是什么。也不必担心数据记忆化以修剪树。精细的反应式系统的好处在于,开发人员无需任何努力,运行时只执行最少量的代码!

精细的反应式系统的手术精度使它们非常适合懒惰执行代码,因为系统只需要执行状态的侦听器(在我们的例子中是 Cart)。

但是,精细的反应式系统有一个意外的角落案例。为了建立反应图,系统必须至少执行所有组件以了解它们之间的关系!一旦建立起来,系统就可以进行手术。这是初始执行的样子:

image.png

你看出问题了吗?我们想懒惰地下载和执行,但反应图的初始化强制执行应用程序的完整下载。

Qwik

这就是 Qwik 发挥作用的地方。Qwik 是精细的反应式,类似于 SolidJS,意味着状态的变化直接更新 DOM。(在某些角落情况下,Qwik 可能需要执行整个组件。)但是 Qwik 有一个诡计。记得精细的反应性要求所有组件至少执行一次以创建反应图吗?好吧,Qwik 利用了组件在 SSR/SSG 期间已经在服务器上执行的事实。Qwik 可以将这个图形序列化为 HTML。这使得客户端完全可以跳过最初的“执行世界以了解反应图”的步骤。我们称这种能力为可恢复性。由于组件在客户端上不会执行或下载,因此 Qwik 的好处是应用程序的即时启动。一旦应用程序正在运行,反应就像 SolidJS 一样精确。

总结

本文介绍了响应式编程的历史和发展,响应式编程是一种编程范式,它强调了数据流和变化的传递。文章从早期的编程语言开始讲述,比如Lisp和Smalltalk,它们的数据结构和函数式编程的特性促进了响应式编程的发展。然后,文章提到了响应式编程框架的出现,如React和Vue.js等。这些框架使用虚拟DOM(Virtual DOM)技术来跟踪数据变化,并更新界面。文章还讨论了响应式编程的优点和缺点,如可读性和性能等。最后,文章预测了未来响应式编程的发展方向。

总的来说,本文很好地介绍了响应式编程的历史和发展,深入浅出地讲述了它的优点和缺点。文章提到了很多实际应用和框架的例子,让读者更好地理解响应式编程的概念和实践。文章还预测了未来响应式编程的发展方向,这对读者和开发者有很大的启示作用。

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

原文:https://www.builder.io/blog/history-of-reactivity

交流

有梦想,有干货,微信搜索 【大迁世界】 关注这个在凌晨还在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。


王大冶
68k 声望104.9k 粉丝