前端常见的变化侦测你了解多少?

196 阅读6分钟

在开始变化侦测之前,我们先了解下命令式编程以及声明式编程这两种重要的编程范式。

1. 命令式和声明式

命令式

在日常的编程中,命令式编程占据着极为重要的地位,大部分编程语言以及内库采用的都是命令式风格。以 JQuery 为例,程序员就如同一位指挥者,精准地告知代码先执行哪一步操作,紧接着再进行后续步骤,这种方式的优势显而易见,它极具直观性,让人能够清晰地追踪代码的执行流程。就像下面这段代码:

<span class="cell b1"></span>
$(document).ready(function() {
  var state = {
    a: 10,
  }
  $('.cell.b1').html(state.a)
})

通过 $(document).ready 确保文档加载完成后,我们手动获取 state 中的值,并将其设置到指定的 HTML 元素中,整个过程一目了然。

声明式

与命令式相对的另一种方式是声明式编程,它秉持着一种更为抽象的理念,主要思想是告诉计算机应该做什么,但不指定具体要怎么做。当把 state 作为输入源,view 视作渲染输出的结果,二者使用 view = render(state) 构建起一种简洁而强大的关系。以 React 为例,这种编程范式就像是搭建了一座桥梁,开发者只需关注数据与视图的映射,无需操心具体的渲染步骤,render 方法如同一位幕后的魔法师,默默完成复杂的转换工作。

为了更直观,采用 react Component 的方式。Component 的方式直接带有 render 函数。

class Clock extends React.Component {
  constructor(props) {
    super(props)
    this.state = { date: new Date() }
  }
  render() {
    return (
      <div>
        <h1>Hello, world!</h1>
        <h2>It is {this.state.date.toLocaleTimeString()}.</h2>
      </div>
    )
  }
}

react 的 hooks 方式,还是 Vue 和 AngularJS 的写法中没有 render 函数,但方式是类似的,都是一种映射关系。

react hooks 的例子:

import React, { useState, useEffect } from 'react';

function Clock() {
  const [date, setDate] = useState(new Date());

  return (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {date.toLocaleTimeString()}.</h2>
    </div>
  );
}

export default Clock;

Vue 的例子:

<div id="app">
  {{ message }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!',
  },
}) 

AngularJS 例子:

<div ng-app="myApp" ng-controller="myCtrl">
  {{message}}
</div>
var app = angular.module('myApp', [])
app.controller('myCtrl', function($scope) {
  $scope.message = 'Hello AngularJS!'
})

2. 变化侦测

view = render(state) 这一表达式所蕴含的意义远不止于将 state 简单渲染到视图之上,它还承载着一项关键使命,当视图所绑定的 state 发生变动时,系统必须能够敏锐地感知到,并自动且高效地更新视图,以确保用户界面始终与数据保持同步。系统究竟是如何精准地察觉到绑定值的变化呢?这就涉及到前端领域中两种主流的处理变化侦测模式:Pull 模式和 Push 模式。

Pull 模式

在 Pull 模式中,消费者主动向生产者请求数据。每次消费者需要新的数据时,它会发起一个请求给生产者。这种模式的特点在于,消费者控制着数据获取的时机,可以选择何时消费数据。

在前端框架中,React 的 setState 和 AngularJS 的脏检查都属于 Pull 模式。当数据变化后,框架并不直接知道哪些具体的数据发生了变化,因此它们采取不同的策略来确保UI与数据保持同步。

React 的 setState

在 React 中,当数据状态发生变化时,系统本身并不会自动感知这些变更,需要开发者通过 setState 来显式通知系统更新。需要注意的是,setState 是异步操作,它不会立即更新组件的状态,而是将状态更新调度到未来的某个时刻,并触发一次重新渲染。React 使用批量更新机制,将多个状态更新合并为一次,以减少不必要的重新渲染。

import React, { useState, useEffect } from 'react';

function Clock() {
  const [date, setDate] = useState(new Date());

  useEffect(() => {
    // 定义一个更新时间的函数
    function tick() {
      setDate(new Date());
    }

    // 设置定时器,每秒调用一次tick函数
    const timerID = setInterval(tick, 1000);

    // 返回一个清理函数,在组件卸载或effect重新运行时清除定时器
    return () => clearInterval(timerID);
  }, []); // 空数组作为第二个参数确保effect只在首次渲染后执行

  return (
    <div>
      <h1>Hello, world!</h1>
      <h2>It is {date.toLocaleTimeString()}.</h2>
    </div>
  );
}

export default Clock;

AngularJS 的脏检查

AngularJS采用了一种称为“脏检查”的机制来进行变化侦测。每当模型数据可能发生变化时, 如用户交互 ng-click、网络请求 $http 完成时,AngularJS 会触发脏检查。在这个周期内,它会遍历所有已注册的监听器(watcher),比较当前值和之前保存的旧值,以此来确定是否发生了变化。如果检测到变化,AngularJS 就会更新对应的视图部分。这个过程会重复执行,直到没有新的变化被检测到为止,以确保所有的依赖关系都被正确处理。因为 AngularJS 不知道哪些数据真正发生了变化,所以它必须检查所有监听器,这在大规模应用中可能会导致性能问题。

<div ng-app="myApp" ng-controller="myCtrl">
  {{message}}
  <button ng-click="changeMessage()">Change Message via ng-click</button>
</div>
var app = angular.module('myApp', [])
app.controller('myCtrl', function($scope, $timeout) {
  $scope.message = 'Hello AngularJS!'

  // 添加一个方法给$scope,用于改变message的值
  $scope.changeMessage = function() {
    $scope.message = 'The message has been changed!';
  };

  setTimeout(function() {
    // dom 不会改变
    $scope.message = 'The message has been changed by setTimeout';
  }, 1000);

  $timeout(function() {
    // dom 会改变
    $scope.message = 'The message has been changed by $timeout';
  }, 2000);
})

上面的例子中,ng-click 和 $timeout 会触发脏检查来改变视图。setTimeout 不会触发脏检查,但如果需要更新的dom的话,也可以用 $scope.$apply() 方法去触发更新。

简单总结一下,这两种方式都是典型的 Pull 模式,因为它们都需要在特定的时间点去主动查询或确认数据是否发生变化,而不是由数据源本身通知框架有更新发生。尽管如此,随着技术的发展,这些框架也在不断改进其变化侦测机制,以提高性能和响应速度。例如,React 16 引入的 Fiber 架构显著增强了其处理复杂更新的能力,而 Vue.js 则以其高效的响应式系统提供了另一种解决方案。

Push 模式

在Push模式中,生产者(即数据源或状态)主动将数据发送给消费者(即视图),而不需要消费者的显式请求。这意味着每当数据发生变化时,生产者会立即通知所有相关的消费者,告知它们更新自己。

Vue.js 是一个采用Push模式进行变化侦测的前端框架,它提供了两种不同层次的响应性实现方式:

Vue 1.x 和早期版本

细粒度的响应式系统:在 Vue 1.x 及其早期版本中,当数据改变后,系统明确知道哪些具体的数据发生了变化,并且通过依赖收集机制(dependency collection)追踪到所有依赖于这些数据的组件或 DOM 元素。然后,它会将消息推送给这些监听器,触发对应的更新。这种方式提供了一种更细粒度的控制,确保只有真正受影响的部分才会被更新,从而提高了效率。然而,这样做也带来了额外的开销,因为每个绑定的值都需要有一个 watcher 来监视其变化,这可能会增加内存使用量。

Vue 2.x

中等粒度的响应式系统:到了 Vue 2.x,框架优化了其响应式机制,在组件级别实现了 Push 模式,每一个组件本身是一个响应式的观察者(watcher),当数据变动之后,可以直接知道哪些组件受到了影响,并触发相应的更新。而在组件内部,则使用虚拟 DOM(Virtual DOM)来进行差异比对(diffing algorithm),只更新实际发生变化的部分。

这种方式既保留了 Push 模式的优势————能够精准地感知变化并及时更新视图,又避免了为每个属性都创建 watcher 所带来的性能问题。

Vue 3.x

改进的响应式系统:Vue 3.x 进一步增强了其响应式系统的性能和灵活性,依旧沿用组件级别的 Push 模式。下面是一个例子

// ParentComponent.vue
<template>
  <div>----这里是父组件----</div>
  <div>{{ title }}</div>
  <div>{{ message }}</div>
  <div>{{ getRandomString() }}</div>

  <ChildComponent :message="message" />

  <button @click="changeTitle">Change title</button>
  <button @click="changeMessage">Change Message</button>
</template>

<script setup>
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';

const title = ref('this is title');
const message = ref('Hello in parent');

const changeTitle = () => {
  title.value = 'this is new title';
};

const changeMessage = () => {
  message.value = 'New message in parent';
};

const getRandomString = () => {
  console.log('父组件更新了!')
  return Math.floor(Math.random() * 100000)
};
</script>

子组件的代码如下:

// ChildComponent.vue
<template>
  <div class="content">
    <div>----这里是子组件----</div>
    <div>{{ getRandomString() }}</div>
  </div>
</template>

<script setup>
import { defineProps } from 'vue';
defineProps({
  message: String,
});

const getRandomString = () => {
  console.log('子组件更新了!')
  return Math.floor(Math.random() * 10000)
};
</script>

<style>
.content {
  background-color: #e4f4e8;
  padding: 10px;
}
</style>

在演练场中尝试一下

上面例子中,可以通过 <div>{{ getRandomString() }}</div> 的变化可以看出,当在父组件中执行 changeMessage 方法时,父子组件会同步更新,即便子组件内部没有使用 message 属性。而执行 changeTitle 方法时,只有父组件发生变更,子组件不受影响。若同时执行 changeMessage 和 changeTitle,父组件只会执行一次。

在 Vue 3 中,每个组件都可以视为一个 effect(类似于 Vue 2.x 中的 watcher)。组件模板中使用的响应式数据都指向同一个 effect。当组件内部的一个或多个响应式状态发生变化时,Vue 会将这些变化整合在一起,并通过调度机制统一更新页面,从而提高性能并减少不必要的重复渲染。

从 AngularJS、React 到 Vue 各个版本,它们在响应式设计上遵循了一个共同的基本逻辑:数据变化触发变更通知,系统对比并计算出变化的部分,最终进行局部 DOM 更新。尽管这些框架的核心流程相似,但它们在每个阶段的实现方式各不相同,这也催生了各自独特的性能优化策略。

除了Vue.js,还有其他一些技术或库采用了 Push 模式的变化侦测机制:

MobX

作为一个状态管理库,MobX 提供了透明的函数式反应编程(TFRP)。它自动跟踪状态的变化,并且可以高效地计算派生的数据。每当某个状态发生变更时,MobX 会直接通知所有依赖该状态的观察者,使得它们能够立即做出反应。由于其设计哲学强调最小化不必要的重新计算,因此非常适合构建大型应用。

import { makeObservable, observable, action } from 'mobx';

class CounterStore {
  count = 0;

  constructor() {
    makeObservable(this, {
      count: observable,
      increment: action,
      decrement: action,
    });
  }

  increment() {
    this.count++;
  }

  decrement() {
    this.count--;
  }
}

const counterStore = new CounterStore();
export default counterStore;
import React from 'react';
import { observer } from 'mobx-react';
import counterStore from './CounterStore';

const CounterComponent = observer(() => (
  <div>
    <h1>Count: {counterStore.count}</h1>
    <button onClick={() => counterStore.increment()}>Increment</button>
    <button onClick={() => counterStore.decrement()}>Decrement</button>
  </div>
));

export default CounterComponent;
import React from 'react';
import { useObserver } from 'mobx-react-lite';
import counterStore from './CounterStore';

const CounterComponent = () => {
  return useObserver(() => (
    <div>
      <h1>Count: {counterStore.count}</h1>
      <button onClick={() => counterStore.increment()}>Increment</button>
      <button onClick={() => counterStore.decrement()}>Decrement</button>
    </div>
  ));
};

export default CounterComponent;

RxJS (Reactive Extensions for JavaScript)

虽然不是专门用于 UI 层的状态管理,但 RxJS 是一个强大的响应式编程库,它利用观察者模式(Observer Pattern)和可观察对象(Observable Objects)来处理异步事件流。开发者可以定义复杂的响应链,当数据源发出新值时,整个链条会按照预定规则自动执行相应的操作。

总的来说,Pull 模式和 Push 模式的最大区别在于数据更新的主动权。在 Pull 模式中,系统需要显式检查数据的变化,而在 Push 模式中,数据源主动通知系统更新。React 和 AngularJS 采用了 Pull 模式,依赖开发者触发更新,而 Vue.js 则采用 Push 模式,自动感知并响应数据变化。