时序

对于前端开发者来说,时序是一个非常重要的概念,但也是许多令人头痛的问题的根源。 为什么时序问题在前端开发中会如此突出呢?让我们一起分析一个简单的例子,来看看前端常遇到的时序问题。

从TodoList开始

我们来看一个简单的 TodoList 的例子。这个 TodoList 由两个页面组成,一个是添加或编辑 Todo 的详情页,另一个是展示 Todo 的列表页面。 这个场景代表了一类典型的前端交互,用户在详情页添加或编辑 Todo,然后在列表页查看所有的 Todo。 为了给读者一个直观的印象,我们在这里附上一个简单的示意图:

overview

查询 Todo 列表

当用户想要搜索 Todo 时,他们会在列表页中输入关键字,然后点击“搜索”按钮。这个时候,列表页会发起一个网络请求,获取符合条件的 Todo 列表。 此时,我们期望一个顺序的时序过程:

  • 用户输入关键字
  • 用户点击“搜索”按钮
  • 网络请求发送,并等待响应
  • 列表页刷新

看上去很简单,但是实际上,考虑到具体实现的细节,真实的时序过程可能会超乎预计地复杂:

  • 用户输入关键字
  • 用户点击“搜索”按钮
  • 发送搜索请求
  • 列表页渲染加载中状态
  • 搜索接口响应,更新列表页数据
  • 列表页重新渲染

在这个过程中,涉及到了用户事件、网络请求、页面渲染等多个环节,这些环节中有同步执行的,也有异步执行的;有些是依赖于当前状态的,有些是相对独立的。 如果考虑到用户的操作可能是不可预测的,例如用户快速地连续搜索了两次,且第二次搜索在第一次的结果尚未返回之时发出,程序的实际执行过程可能会变得出乎意料:

  • 如果第二次搜索请求在第一次请求返回之前收到响应,那么第二次请求的结果会覆盖第一次请求的结果;
  • 如果第二次搜索请求在第一次请求返回之后收到响应,那么第二次请求的结果会被忽略。

这就是一个典型的时序问题:程序的执行顺序并不总是与预期一致,而是由外部因素(此处是网络请求的响应速度)决定的。

陷入时序问题

那么,要如何解决这个时序问题呢?一般来说,我们可以通过以下几种方式来解决:

  • 禁用用户操作,直到当前操作完成;
  • 在异步任务执行完成后,检查当前状态是否允许执行下一步操作。

这两种方式都是通过控制时序来解决问题的,但是这样的方式会带来一些问题:

  • 可能会牺牲一些用户体验,用户在等待时无法进行其他操作;
  • 代码复杂度增加,需要在多个地方添加时序控制的逻辑;
  • 时序控制的逻辑可能会分散在多个地方,导致代码难以维护;
  • 程序的正确性依赖于开发者对时序的理解,非常容易出现时序错误。

有没有一种更好的方式来解决这个问题呢?fluently.js 尝试提供一种新的解决方案。

声明式时序

在 fluently.js 中,我们提供了一种声明式的时序控制方式。开发者可以通过声明式的方式来描述时序关系,而不需要关心具体的时序控制逻辑。

时间片

在 fluently.js 中,我们引入了时间片的概念。时间片是一个抽象的概念,用于描述一个不可分割的时间单元。

在一个时间片内,只能执行一个或多个连续的(也就是同步的)动作,这样在Javscript的并发模型下,这个动作是原子的,不会被其他动作打断。 也就是说,只有在执行完一个时间片内的所有动作之后,才会执行下一个时间片内的动作。 这个概念和Javscript中的任务并行执行规则类似,但是并不完全相同。

(配图:时间片和任务的示意图)

如何声明一个时间片呢?在 fluently.js 中,我们并不需要显式地声明时间片,而是通过事件源(EventRegistry)来隐式地定义时间片。

事件源定义时间片的方式是:当一个事件发生时,所有与该事件直接联动的动作都属于同一个时间片。

什么是“直接”联动的动作?

这里的“直接”是指,动作发生与事件发生是“同步”的,不经过异步的中间环节。这些动作至少包括:ReducedValue值的变化、Action函数的调用,但不包括Action函数内部的首次异步操作的完成及其后续的所有动作。

事件联动是如何被定义的?

如果事件A通过事件联动触发了事件B,那么事件A和事件B属于同一个时间片。如果事件B又通过事件联动触发了事件C,那么事件A、事件B和事件C属于同一个时间片。以此类推。

也就是说,通过事件源的定义,我们可以隐式地定义时间片。在同一个时间片内,所有的动作都是同步执行的,不会被其他动作打断。

时间片内的执行顺序

在同一个时间片内,所有的动作都是同步执行的,但是它们的执行顺序是有一定规则的。受限于Javascript的单线程执行模型,所有的动作会在实际上有执行的顺序。

假设现在有一个时间片,它是由事件A的触发引起的。那么,这个时间片内的动作执行顺序是:

  1. 与事件A直接相关的所有ReducedValue,它们的reduce函数会按照定义的顺序依次执行;
  2. 递归地触发与事件A通过“事件联动”所串联的事件下的所有ReducedValue,事件间的执行顺序按照联动的定义顺序;
  3. 与事件A执行相关的所有Action动作,它们的同步部分会按照定义的顺序依次执行;
  4. 递归地触发与事件A通过“事件联动”所串联的事件下的所有Action动作,事件间的执行顺序按照联动的定义顺序。
  5. 在第3-4步中,如果有Action仅包含同步部分,且它的完成或失败同步地触发了事件X,那么事件X会在事件A相同的时间片内排队执行,等待事件A的所有动作执行完毕后将从步骤1开始处理事件X。

通过这个时序控制,我们可以得出以下结论:

  • 在同一个事件触发周期内,ReducedValue的执行永远在Action之前;
  • 同级操作的执行顺序按照与定义代码相同的顺序执行;
  • 联动事件的触发会略晚于直接事件的触发,但仍然在同一个时间片内;
  • Action触发的动作虽然在同一个时间片内,但是它的执行优先级较低。

时序控制的优势

虽然声明式时序具有一定的约束,让程序的编写者不能随心所欲地指定代码的执行顺序,但是它也带来了一些优势:

  • 对一段特定的代码而言,它的执行顺序是固定的,不会受到外部因素的影响;
  • 通过代码阅读,可以清晰地了解代码的执行顺序;
  • 使用技术手段对代码进行静态分析,就可以检查代码的时序问题。

声明式时序的本质优势是将不确定的、可变的时序问题转化为明确的、被定义的时序问题,从而降低了时序问题的复杂度。

根据它的特性,我们提取了一些使用原则:

  • ReducedValue永远在Action之前执行,这意味着Action中总是会看到最新的状态;
  • 多个联动的事件可以被视为一个整体,联动关系应该用于表达业务语义,而不要用于时序控制;
  • 声明顺序会影响执行顺序,但是同上一条,请不要依赖于这种隐式的时序关系;
  • 视图层不会出现在时序控制的逻辑中;
  • Action的执行结果就如同用户触发的事件一样,它们的到来应该被视为一个时序上的“分割点”。

遵循这些原则,能够避免大部分时序问题,提高代码的可维护性和可预测性。

高级时序(WIP)

在 fluently.js 中,我们提供了一些高级的时序控制方式,用于处理一些复杂的时序问题。

多步骤操作

在实际的应用中,一个操作可能会分为多个步骤,每个步骤都有自己的时序关系。在 fluently.js 中,我们提供了SequenceAction对象,用于描述一个多步骤操作的时序关系。

SequenceAction的作用是可以将多个Action组合在一起,形成一个整体的操作。这个整体的操作可以被视为一个Action,它的执行顺序是固定的,不会受到外部因素的影响。

SequenceAction示例
const sequenceAction = new SequenceAction((scope) => {
  return [
    new Action(() => {
      // 第一步
    }),
    new Action(() => {
      // 第二步
    }),
    new Action(() => {
      // 第三步
    }),
  ];
});

在这个例子中,sequenceAction是一个由三个Action组成的SequenceAction对象,它同时也继承了Action对象的特性,可以被视为一个Action对象。 在执行sequenceAction时,它的三个Action会按照定义的顺序依次执行。 每次上一个Action执行完毕后,下一个Action才会开始执行。它们之间也是通过内部事件触发来实现的。

可中断过程

对于单个Action而言,它的执行是不可中断的。但是在某些情况下,我们可能需要一个可中断的过程,例如本文开头提到的,查询Todo列表的过程。如何让这个连续的过程变得可中断呢?

答案是通过使用SequenceAction对象和Scope对象。这可能看上去有些复杂,但是实际上非常简单:

  • 在SequenceAction对象中,我们可以定义一个或多个Action对象,这些Action对象会按照定义的顺序执行;
  • 当每次SequenceAction对象被触发时,它会创建一个新的Scope对象,并将这个Scope对象传递给每个Action对象;
  • 如果sequenceAction对象被中断,那么它会dispose掉这个Scope对象,从而中断整个过程。也就是说,回调函数中提供的Scope对象的生命周期此时表示的是“连续过程”的生命周期。

这个过程同时遵循了Action和Scope本身的语义和生命周期规则,是在原本基础上的一种扩展,因此你不会感到陌生。

回到本节提出的问题,如何让创建一个Todo的过程变得可中断呢?我们可以这样做:

创建一个可中断的查询Todo过程
const queryTodoEvent = new EventRegistry<TodoListFilter>();
const updateTodoListEvent = new EventRegistry<TodoItem[]>();

const queryTodoProcess = new SequenceAction(
  (scope) => {
    return [
      // 由于查询仅包含一个异步操作(fetch),因此我们只需要声明一个Action即可
      new Action(() => {
        // 发送网络请求
        const filter = scope.get<TodoListFilter>('event');
        const response = await fetchTodoList(filter);
        // 解析响应
        return response.success ? response.data : false;
      }),
    ];
  },
  {
    // 通过这个配置项,我们可以让这个过程仅有一个运行中的实例
    concurrent: 'singleton',
  },
);

// 当触发查询Todo事件时,执行查询Todo过程
queryTodoProcess.runOn(queryTodoEvent);
// 当查询完成时,触发更新Todo列表事件
queryTodoProcess.triggersDoneEvent(updateTodoListEvent);

// 查询Todo列表
function queryTodoList(filter: TodoListFilter) {
  // 触发查询Todo事件,如果emit了多次,会自动取消上一次的查询
  queryTodoEvent.emitOnce(filter);
}

在这个例子中,我们通过SequenceAction对象来定义了一个查询Todo列表的过程。 当查询Todo事件被触发时,这个过程会被执行。如果查询Todo事件被多次触发,那么上一次的查询会被取消,从而保证了查询的唯一性。

FIFO过程

在某些情况下,我们需要保证一个过程的执行顺序是FIFO(先进先出)的。在 fluently.js 中,通过配置项concurrent: 'fifo',我们可以实现这个功能。

例如,修改上面的例子,我们可以将查询Todo列表的过程改为FIFO过程:

创建一个FIFO过程
const queryTodoProcess = new SequenceAction(
  (scope) => {
    return [
      new Action(() => {
        // 发送网络请求
        const filter = scope.get<TodoListFilter>('event');
        const response = await fetchTodoList(filter);
        // 解析响应
        return response.success ? response.data : false;
      }),
    ];
  },
  {
    // 通过这个配置项,我们可以让这个过程仅有一个运行中的实例
    concurrent: 'fifo',
  },
);

这样,当查询Todo事件被触发时,如果上一个查询Todo过程还在运行,那么queryTodoProcess的结束事件将会被排队,等待上一个过程执行完毕后再触发。

相比于使用中断过程,FIFO过程在这个场景中“尽最大努力”地让用户看到最新的数据,而不是让用户等待;同时,它也保证了查询结果的有序性和最终一致性。