对于前端开发者来说,时序是一个非常重要的概念,但也是许多令人头痛的问题的根源。 为什么时序问题在前端开发中会如此突出呢?让我们一起分析一个简单的例子,来看看前端常遇到的时序问题。
我们来看一个简单的 TodoList 的例子。这个 TodoList 由两个页面组成,一个是添加或编辑 Todo 的详情页,另一个是展示 Todo 的列表页面。 这个场景代表了一类典型的前端交互,用户在详情页添加或编辑 Todo,然后在列表页查看所有的 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的触发引起的。那么,这个时间片内的动作执行顺序是:
通过这个时序控制,我们可以得出以下结论:
虽然声明式时序具有一定的约束,让程序的编写者不能随心所欲地指定代码的执行顺序,但是它也带来了一些优势:
声明式时序的本质优势是将不确定的、可变的时序问题转化为明确的、被定义的时序问题,从而降低了时序问题的复杂度。
根据它的特性,我们提取了一些使用原则:
遵循这些原则,能够避免大部分时序问题,提高代码的可维护性和可预测性。
在 fluently.js 中,我们提供了一些高级的时序控制方式,用于处理一些复杂的时序问题。
在实际的应用中,一个操作可能会分为多个步骤,每个步骤都有自己的时序关系。在 fluently.js 中,我们提供了SequenceAction
对象,用于描述一个多步骤操作的时序关系。
SequenceAction的作用是可以将多个Action组合在一起,形成一个整体的操作。这个整体的操作可以被视为一个Action,它的执行顺序是固定的,不会受到外部因素的影响。
在这个例子中,sequenceAction
是一个由三个Action组成的SequenceAction对象,它同时也继承了Action对象的特性,可以被视为一个Action对象。
在执行sequenceAction
时,它的三个Action会按照定义的顺序依次执行。
每次上一个Action执行完毕后,下一个Action才会开始执行。它们之间也是通过内部事件触发来实现的。
对于单个Action而言,它的执行是不可中断的。但是在某些情况下,我们可能需要一个可中断的过程,例如本文开头提到的,查询Todo列表的过程。如何让这个连续的过程变得可中断呢?
答案是通过使用SequenceAction对象和Scope对象。这可能看上去有些复杂,但是实际上非常简单:
这个过程同时遵循了Action和Scope本身的语义和生命周期规则,是在原本基础上的一种扩展,因此你不会感到陌生。
回到本节提出的问题,如何让创建一个Todo的过程变得可中断呢?我们可以这样做:
在这个例子中,我们通过SequenceAction对象来定义了一个查询Todo列表的过程。 当查询Todo事件被触发时,这个过程会被执行。如果查询Todo事件被多次触发,那么上一次的查询会被取消,从而保证了查询的唯一性。
在某些情况下,我们需要保证一个过程的执行顺序是FIFO(先进先出)的。在 fluently.js 中,通过配置项concurrent: 'fifo'
,我们可以实现这个功能。
例如,修改上面的例子,我们可以将查询Todo列表的过程改为FIFO过程:
这样,当查询Todo事件被触发时,如果上一个查询Todo过程还在运行,那么queryTodoProcess
的结束事件将会被排队,等待上一个过程执行完毕后再触发。
相比于使用中断过程,FIFO过程在这个场景中“尽最大努力”地让用户看到最新的数据,而不是让用户等待;同时,它也保证了查询结果的有序性和最终一致性。