现代JS框架的重要特征就是MVVM模式。MVVM是一种架构模式,它将应用程序分为三个部分:模型、视图和视图模型。其中,视图模型是连接模型和视图的关键组件,它负责将模型的数据转换为视图可以使用的格式,并将视图的用户输入转换为模型可以使用的格式。
MVVM和数据响应式是密切相关的概念。数据响应式是指当数据发生变化时,系统能够自动更新相关的界面元素。在MVVM中,视图模型通常会使用数据绑定技术来实现数据响应式。例如,可以使用绑定表达式将视图模型的属性与视图中的元素绑定在一起,当属性值发生变化时,相应的元素就会自动更新。

实现数据响应式,在JS发展史上有过几种实现方式,值得我们回顾和探讨。

AngularJS的脏检查

AngularJS是以HTML为模板的,它的数据绑定采用HTML标记的形式,并且数据可以绑定到任何的JS对象。它的数据响应式是通过脏检查来实现的。

1
2
3
<div ng-controller="firstController">
<input type="text" ng-model="abc" value="" />
</div>
1
2
3
angular.module('app', []).controller('firstController', function($scope) {
$scope.abc = 'input value';
});

启动时,它会创建一个$rootScope对象,该对象包含应用程序的所有作用域。每个作用域都包含一个$$watchers数组,该数组包含要监视的模型。

在AngularJS中,当事件触发时(例如用户输入),AngularJS会遍历所有作用域中的$$watchers数组,并比较当前模型与先前模型的值。如果值发生了变化,AngularJS会调用相应的回调函数来更新视图。

AngularJS中的脏检查是通过$apply()方法来实现的。代码修改绑定值后,必须调用$apply()方法来通知AngularJS进行脏检查。$apply()方法会遍历所有作用域,并检查是否有任何模型发生了变化。如果有变化,AngularJS会更新相应的视图。

这种方法的好处是任何JavaScript对象都可以用作模板中的数据绑定源,并且更新可以正常工作。缺点是每次更新都必须执行大量脏检查,在大型应用中,数据绑定对象会非常多,性能也会很差。

AngularJS是我学习JS接触的第一个框架,当时还写了一些学习笔记,在我的51cto旧博客里,现在重新看了下,真的是相当难用的框架,但在当时却不这么觉得。

React的setState

React引入了setState()。这使得React知道何时应该对vDOM进行脏检查。这样做的好处是,与AngularJS不同,AngularJS对每个异步任务运行脏检查,React仅在开发人员告诉它时才执行。因此,尽管React vDOM脏检查在计算上比AngularJS更昂贵,但它运行的频率会更低。

另外,React引入了从父级到子级的严格数据流。这是迈向框架级别状态管理的第一步,而AngularJS没有。

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

AngularJS和React是粗粒度的响应式,这意味着数据的变化会触发大量的状态变化,如果应用中有频繁变化的属性,比方动画,会有严重的性能问题。因此需要细粒度的响应式,只将有变化的数据反映到UI中。

Knockout的观察者模式和计算属性

Knockout的数据响应式是通过使用观察者模式来实现的。在Knockout中,每个绑定属性都有一个对应的观察者对象,该对象会监听绑定属性的变化,并在变化发生时更新视图。

当ViewModel对象中的属性被修改时,Knockout会通知相应的观察者对象。观察者对象会比较新值和旧值,如果值发生了变化,它会将新值反映到绑定的元素上。这样,视图就能够自动更新。

1
2
3
4
5
6
7
8
9
var ViewModel = function() {
var self = this;
self.name = ko.observable('');
self.setName = function() {
self.name('pomelo');
};
};

ko.applyBindings(new ViewModel());

在上面的代码中,使用ko.observable()方法来定义name属性。当使用self.name()方法设置name属性的值时,Knockout会通知name属性对应的观察者对象,并将新值反映到绑定的元素上。

1
<button data-bind="click: setName">Set Name</button>

click是一个绑定属性,它指定当按钮被点击时调用setName方法。

Knockout还创新性地引入了计算属性的概念,写法如下

1
2
3
4
5
6
7
var ViewModel = function(first, last) {
this.firstName = ko.observable(first);
this.lastName = ko.observable(last);
this.fullName = ko.pureComputed(function() {
return this.firstName() + this.lastName();
}, this);
};

Svelte的原生JS脏检查

Svelte没有采用vDOM,而是将Svelte编译为原生JS代码,将一切视图更新转化为直接的DOM操作。
当有变量赋值时,比如:

1
2
3
4
5
6
7
let firsName = '';
let lastName = '';

function handleClick () {
firsName = 'nameFirst'
lastName = 'nameLast';
}

代码将被Svelte编译为用$$invalidate函数包裹:

1
2
3
4
5
function handleClick () {
// 第一个参数是绑定数据下标
$$invalidate(0, firsName = 'nameFirst');
$$invalidate(1, lastName = 'nameLast');
}
  • 1、当数据发生变化时,就会调用$$invalidate函数
  • 2、$$invalidate函数内会对当前绑定数据进行脏检查,脏检查判定为dirty的组件放入脏组件数组中,并预约下一个microTask的周期更新
  • 3、在microTask中遍历脏组件数组,调用每个组件的update方法,也就是更新对应组件的DOM
  • 4、重置脏检查状态

相比AngularJS,Svelte能做到细粒度的脏检查,同时又不需要React的vDOM来增加复杂度,用纯粹的DOM操作,在Runtime无需依赖框架,是一种原生的响应式实现。

Vue的Proxy/defineProperty

Vue在编译过程中收集依赖,基于Proxy(3.x) ,defineProperty(2.x)的getter/setter实现在数据变更时通知watcher。

细粒度响应式的存储状态方式 - Signal

SignalState之间的主要区别在于Signal返回一个getter和一个setter,而State返回一个值和一个setter

1
2
useState() => value + setter
useSignal() => getter + setter

通过返回getter,可以将状态引用的传递与状态值的读取分开。

1
2
3
4
5
6
7
8
9
function App() {
const [getCount, setCount] = createSignal(1);
return (
<div>
<button onClick={() => setCount(getCount() + 1)}>+1</button>
<Wrapper value={getCount()}/>
</div>
);
}

为什么传递getter比直接传递value有优势呢?因为为了具有响应式,就需要进行依赖收集。
如果传递value,无法提供有关实际使用该值的具体位置信息,当状态变化时,数据传递链路下的组件将会大规模更新,也就是粗粒度的响应式。
如果传递的是getter,因为有getCountgetCount()的区别,就知道自顶而下哪些组件在数据变化时需要更新,也就做到了细粒度的响应式。

使用Signal的框架通常能获得不错的运行时性能,不需要显式指明依赖和额外的性能优化API,所以对开发者更友好。

React团队承认Signal的优点,但改变现有的设计理念(快照理念:UI反映状态在某一刻的快照)很难,所以不会改造为Signal模式。相信未来的响应式框架,必定是Signal模式的。