/ JavaScript

异步函数的依赖维护

写背景太麻烦了!总之随着AJAX理念的深入人心、随着一些纠结的问题在一个setTimeout之后奇迹般地被解决、随着未来更多的异步(WebWorkerWebSocketmessage事件)功能的引入,编码人员变得必须熟悉“异步执行+回调”的编程模型。

但是随着项目、应用的规模扩展,复杂度上升,异步回调模型的理解和维护成本也使地逐渐攀升,毕竟人看东西总是从上往下的,没人喜欢看完一个函数的时候突然发现要去300行以后、甚至另一个文件里找一个回调函数。同时,为了更灵活地切分系统、更合理地设计接口,类似于“从多源取得数据并合并”之类的需求也被摆到了台面之上,这意味着多个异步调用之间开始存在依赖关系。在设计不那么理想的系统中,此类依赖关系也可能变得非常复杂,导致代码的逻辑不易看懂、回调函数四处传递等问题,维护性极剧下降。

为了处理以上提到的问题,就需要一个“依赖维护”的模块,由其统一管理所有的异步调用,这么做的好处至少有:

  • 单一入口,集中配置,只需要看配置就容易理清依赖关系。
  • 便于脚本分析,甚至能根据配置代码分析出依赖关系的图,形成可视化的文档。
  • 统一维护,便于优化,这在下文会专门抽出章节说明。

概念

首先,什么样的的形态称为“异步+回调”?

简单来说,“异步+回调”的模式自然会分为2个部分,即“异步”和“回调”,它们各自职责不同:

  • 异步块:异步块负责获取、计算、组织、封装必要的上下文,并将上下文提供给回调块。
  • 回调块:回调块依赖于异步块给予的上下文,其执行环境有且仅有这些上下文,在此基础上完成最终的操作。

其次,有哪些模式的“异步”方式?

事实上异步的方式多种多样:

  • AJAX请求,最泛用的异步模型。
  • setTimeout/setInterval,宿主内置的异步模型。
  • WebWorker等新技术。
  • 单纯将一个函数拆成“异步块”和“回调块”,切分逻辑、解除依赖。

最后,有哪些依赖形式?

依赖关系有2种情况:

  • 请求依赖,即请求A必须在请求B、请求C…请求n均完成并结束回调之后再发起
  • 回调依赖,即回调A必须在请求A、请求B…请求n均结束后发起一次

事实上还有一种就是回调和回调之间的依赖,但是这会引起设计及实现上的不便,并且可以从“概念”的层面上绕过这个问题(后文会说明),因此不将其作为一种固有的依赖形式看待。

设计

事实上,上文提到的2种依赖方式,无论哪一种,单独提出来说的话,都是非常普遍的现象。
针对请求依赖,就是将n个异步的请求变成同步,经典实现如下:

function sync(requests) {
    var request = requests.shift();
    if (request) {
        $.get(request.url, function() {
            request.callback();
            sync(requests);
        });
    }
}

针对回调依赖,就是信号量的控制,典型实现如下:

function semaphore(requests, callback) {
    var count = requests.length;
    for (var i = 0; i < count; i++) {
        $.get(requests[i].url, function() {
            count--;
            if (count <= 0) {
                callback();
            }
        });
    }
}

而真正要做的,就是有效地将两者组织起来。其最终的调用方式大致如下:

var manager = new AsyncManager(); // 标识符, 配置, 依赖
manager.addAsync('ra', { url: ... });
manager.addAsync('rb', function() { ... });
manager.addAsync('rc', { url: ... }, ['ra', 'rb']); // 标识符, 函数, 依赖
manager.addCallback('ca', function() { ... }, 'ra');
manager.addCallback('cb', function() { ... }, ['rb', 'rc']);
manager.addCallback('cc', function() { ... }, ['ra', 'rb']);
manager.process();

上面的代码就可能产生如下效果:

  1. 发起rarb
  2. rarb都结束后调用cc
  3. ra结束后调用ca
  4. rb结束后发起rc
  5. r``c结束后调用cb`

原理

从分析来看,在依赖关系中,有两种对象,即“异步块”和“回调块”,两者之间存在“异步块-异步块”和“回调块-异步块”这2种依赖关系。因此为了更直观地表达结构,可以尝试使用图把两者之间的关系表达出来:

"依赖关系图"

从图中,不难发现一些规律:

  • 这是一个有向无环无权的图(当然你也可以造出个环,让程序进死循环)。
  • 存在一些特殊的异步块,这些异步块只有出边,没有入边,因此这些异步块可以与程序的入口相连。
  • 所有的回调块都作为图的叶子节点存在,只有入边,没有出边

再将其中单一的节点抽离出来,特别地进行观察:

"关系图部分"

上图是一个处于图的“中央”地带的节点,算是较为复杂的一块区域。可以看出,一个节点会有n个入边和m个出边与之相连,为此,我们定义节点的结构为:

// 以下伪代码
class Node { Node[] in; Node[] out; }

使用2个数组维护出边和入边的相关节点,形成一张图。虽然从图的结构上来说,out数组完全可以去除,但带来的副作用是要确定特定节点的所有依赖,就必须遍历整张图,因此保留out数组是一种空间换时间的做法,同时也简化了程序。

实现

在知晓原理以后,实现过程就不是很难,其实现主要是分为几个步骤:

数据结构定义

首先要定义好数据结构,其中包括“异步块”、“回调块”、“管理器”等。

前文已经有过说明,“异步块”的方式有很多种,可能是AJAX形成,也可能是setTimeout形成等,因此可以将“异步块”抽离为一种接口的形式,再进行不同的实现。

var Async = function() {};
Async.prototype.addCallback = function(fn) { /* 添加一个回调函数 */ };
Async.prototype.start = function() { //开始执行 };
Async.prototype.callback = function() { /* 依次调用所有回调函数 */ };

随后针对接口,进行不同的实现,形成Async.AjaxAsync.Timeout等。

其次是图节点的数据结构,其仅作为单纯的数据封装:

var Node = function(key, value) {
	this.key = key;
	this.value = value;
	this.determines = []; // 出边
	this.dependencies = []; // 入边
};

同时Node提供函数,判断是否所有依赖项已经完成,当所有依赖项都完成时,该节点代表的“异步块”或“回调块”可以开始执行:

Node.prototype.hasPendingDependency = function() { /* ... */ };

最后是管理器,管理器需要提供对“异步块”和“回调块”的维护功能,同时存在一个入口可开始整个图的执行:

var AsyncManager = function() {
	this.items = {}; // 维护所有Async对象,用于查找方便
    this.graph = []; // 图结构
};
AsyncManager.prototype.addAsync = function(key, type, options, dep) { /* 添加异步块 */ };
AsyncManager.prototype.addCallback = function(key, fn, dep) { /* 添加回调块 */ };
AsyncManager.prototype.process = function() { /* 开始处理 */ };

确定图入口

对于图这种数据结构,AsyncManager只需要保持对入口节点的引用,即可对图进行深度优化的遍历,因此在addAsyncaddCallback两个函数中就需要确定节点是否可以作为图的入口,并保持住入口节点的引用。

在上文的图中,不难发现“回调块”是不可能成为入口节点的,而入口节点的特点是没有入边,即没有任何依赖项,因此只需要在addAsync中,判断dep参数是否为空即可:

AsyncManager.prototype.addAsync = function(key, type, options, dep) {
	var node = ...; // 根据异步块创建相应的Node
	if (!dep || !dep.length) {
		this.graph.push(node);
	}
};

监听异步块运行并处理依赖

为了控制各节点间的依赖,必须监听异步块何时完成,即给异步块添加回调,并在回调中找出可以执行的其他节点并运行之。

当一个异步块完成时,将其Node标记为“已完成”状态,同时从该节点开始,进行深度优化的遍历,查找所有还未运行、但已经可以运行节点,并开始运行相关的“异步块”或“回调块”:

AsyncManager.prototype.addAsync = function(key, type, options, dep) {
	var async = ...;
    var node = ...;
    async.addCallback(function() { checkDependency(node); });
};

checkDependency函数中,根据nodedetermines属性进行查找,通过递归进行尝试优化的遍历:

function checkDependency(finished) {
    var nodes = finished.determines,
        length = nodes.length,
        i = 0,
        node,
        dependencies;

    // 标记已经完成
    finished.done = true;

    // 遍历所有依赖于finished的节点
    for (; i < length; i++) {
        node = nodes[i];
        // 如果该节点:
        //   1、还没有执行
        //   2、所有dependency都已经完成
        // 则该节点可以开始执行
        if (!node.done && !node.hasPendingDependency()) {
            // 区别对待异步块和回调块,异步块要运行start,回调块则直接执行函数
            if (node.value instanceof Async) {
                // 异步块在运行完成后再cehckDependency
                node.value.start();
            }
            else {
                // 回调块运行完成后再次checkDependency,进行图的深度优先遍历
                node.value();
                this.checkDependency(node);
            }
        }
    }
}

不足

每做一样东西,都需要更多的时间去反思。虽然已经有了基本的实现,但是尚没有完善,代码中存在几个比较重要的问题:

  • 在执行“回调块”时,如何确定回调函数的scope,由于回调块将函数单独作为对象传递,调用时其scope无法确定。这一点需要重审接口的设计,是否给予指定scope的相关参数,接口的简化与功能的完善始终是一对矛盾。
  • 同样是接口的设计,addAsync函数也需要进行更好的设计。现阶段仅通过type来创建一个Async的对象,并将options参数完全扔给该对象,这种方式要求所有Async的实现使用options模式来接收参数,必然提高了调用的复杂性。
  • 实际情况中,有相当多的“回调块”只依赖于1个“异步块”,对于这一类的情况,将“回调块”作为节点加入到图中并没有任何好处,只会影响到图遍历的深度,导致性能的下降。反之,采取直接调用Async对象的addCallback添加到Async对象上有助于提升效率。

遗留问题

关于如何优化

由于将所有的异步调用进行了统一的管理,因此内部也有了更多的优化空间,在此仅提出一种方案:

当有2个以上的AJAX请求可以并发进行时,通过对document.domain进行提权,并用不同的三级域名如ajax1.abc.comajax2.abc.com来发起更多的请求,保证最大程序上的并行。

关于如何处理“回调块-回调块”之间的依赖

前文也有提到,事实上还存在着“回调块-回调块”这种形式的依赖,但由于引入这种依赖会导致数据结构变得非常复杂,因此选择从“概念”上绕开这个问题。

具体的方法是,根据“概念”一节中所述,在实际使用中,将一个完全同步的方法拆成2块,其中一块表示“异步块”,另一块表示“回调块”,同样能形成一种“异步块-回调块”的依赖关系。这种方式在实际中非常少用,一般用作解除函数与函数之间的局部代码片段的依赖。

从这个概念中得到启发,如果将“回调块”的回调函数拆成2部分,那么“回调块-回调块”的依赖形式完全可以变为“异步块-回调块”的依赖形式。

相关资料

相关的实现代码可以在此查看,但由于是实现初稿的问题,仅能代表一种思路。