65.9K
CodeProject 正在变化。 阅读更多。
Home

构建一个简单的 Promise 构造函数

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (4投票s)

2020年2月6日

GPL3

27分钟阅读

viewsIcon

16610

downloadIcon

57

JavaScript Promise构造函数的因果关系。异步回调与Promise。

引言

“一种低糖替代方案是使用名为“Continuation Passing Style”的纯回调模式。这种无Promise的风格可能有助于你避免我所谓的代码糖尿病,即重要的状态隐藏在语法糖后面,危险的边缘情况潜伏其中。” - Benoit Essiambre

现在是时候了解JavaScript Promise了。它们是什么,它们代表什么。它们承诺什么,它们兑现什么。

我读过许多关于Promise的文章,并偶然发现其各种定义,其中大多数提到了代理一词。我确实知道代理服务器是什么,但代理对象或代理值是什么?那,我只能想象。

让我们踏上短暂的旅程,自己构建一个Promise构造函数。它不是学术上认可和行业标准的A+/Promise,但它在很大程度上帮助我们理解它们。首先,这段旅程从回调开始……

异步

JavaScript是单线程非阻塞异步的。它有一个调用栈、一个回调队列和一个事件循环。现在老实说,每个加粗的概念都需要单独一篇文章来解释。

需要阅读诸如理解异步JavaScript之类的文章,或者如果你喜欢视频,可以观看事件循环到底是什么鬼?

你可以将计算机上执行的应用程序视为操作系统中运行的进程。大多数应用程序在操作系统上作为单个进程运行,其中大多数只有一个执行线程。对于JavaScript,这个单一的执行线程就是你的脚本。历史上,JS程序在浏览器应用程序中执行。

为简单起见,我假设浏览器应用程序在操作系统上作为一个进程运行。浏览器应用程序如何管理其线程是浏览器特定的,并且有许多浏览器,但主要是Firefox和Chromium。

下一个简化是假设每个HTML文档有一个执行线程。该文档中引用或嵌入的所有脚本都组合在一个主体中,并且这个统一的脚本将在一个单一的执行线程中运行。这是一个模型,而不是事实。

人们常常认为异步函数是耗时的。有时你自己的函数可能处理数据很长时间,但这本身并不能使其异步。我们可以编写一个函数,它乘以超大型3D矩阵,运行一分钟,但它仍然会同步执行。

天哪,“需要很长时间”是什么意思?2毫秒对一个函数来说是很长的时间吗?是103毫秒吗?我见过对数据库的请求需要长达60秒……

仅仅基于时间来争论哪个函数应该是异步的,哪个不应该是,这很难,因为时间是一个量。即使是关于时间的最简单的问题也无法用“是”或“否”来回答。它需要“多少”?

另一方面,问题是:单线程?非阻塞?如果评估为true,则会引发异步标志。每当你的执行脚本调用一个与其他进程的线程或你或其他人计算机上的其他进程通信的函数时,JavaScript倾向于异步触发该函数。

为了本文的目的,我将把通过异步回调函数传递数据的函数称为异步函数。你将该回调作为参数传递给被调用的异步函数。请注意异步函数和异步回调之间的区别。

JavaScript在调用栈上跟踪函数、它们的执行顺序和它们的数据。你通过从脚本中调用函数来将它们放入调用栈。被调用函数总是被放在调用函数之上。

有两种方法可以进入调用栈。同步函数直接通过已经存在于调用栈上的某个函数开始在调用栈上

异步回调通过回调队列进入调用栈,当且仅当调用栈为空时。

每个async函数都有其同步部分,它会进入调用栈,向其他线程发出请求,并在相对较短的时间内离开调用栈。当数据最终准备好作为异步回调的参数时,该回调将进入回调队列的最后位置。

事件循环检测到调用栈为空时,其工作是将事件队列中的第一个回调移动到调用栈上。请注意时间间隔。异步回调不会与请求数据的异步函数在同一时间帧内进入调用栈。调用栈在这之间是空的。

JavaScript中另一种表示异步回调的方式是:从回调队列侧进入调用栈的函数

示例 1

var fs = require("fs");

fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
    if (err) { console.trace(err); return }

    var file2 = data1.split(' ')[1] + ".txt";
    fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
        if (err) { console.trace(err); return }

        var file3 = data2.split(' ')[2] + ".txt";
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            if (err) { console.trace(err); return }

            console.log(data2);
            console.log(data3);
        });
    });
});

此示例所需的文件在files.tar.gz存档中。最终版本的Promise构造函数也在其中。

这个在Node.js中执行的简单示例演示了日常的异步代码。我们发出一个异步请求。当数据准备好时,我们的回调被调用,并将数据传递给它。然后我们从数据中提取第二个单词,并用它来生成我们的文件名。我们发出另一个文件读取请求,依此类推……直到第三次,我们最终读取最后一个文件并打印它,但我也打印了第二个文件。;)

代码清晰,忠实地再现了计算机中的情况。异步回调的反对者称之为回调地狱。他们喜欢将这种在处理回调地狱时出现的特定源代码缩进称为厄运金字塔

首先,如果我们将回调函数取消嵌套,就可以摆脱缩进。

示例 2

var fs = require("fs");

function read1(err, data1) {
    if (err) { console.trace(err); return }

    var file2 = data1.split(' ')[1] + ".txt";
    fs.readFile(file2, {encoding: "ascii"}, read2);
}

function read2(err, data2) {
    if (err) { console.trace(err); return }

    var file3 = data2.split(' ')[2] + ".txt";
    fs.readFile(file3, {encoding: "ascii"}, read3);
}

function read3(err, data3) { console.log(data3); }

fs.readFile("file.txt", {encoding: "ascii"}, read1);

看起来更美观吗?现在它与使用 Promise 的等效代码具有相同的缩进。然而,示例 1示例 2 并不相同。正确的源代码缩进表示其二维结构。它提醒我们,在示例 1 中调用最后一个回调时,我可以打印 file3,但我也可以打印 file2。在第二个示例中,在你打印 file3 的函数中,你不能打印 file2

时间顺序对于理解异步回调非常重要。我想用这样的假设来解释这两个例子:每行同步代码需要1毫秒执行,所有异步回调需要100毫秒才能到达回调队列。我们假设事件循环将事件队列中的第一个回调移动到执行栈中所需的时间为零毫秒。

这些示例从fs.readFile函数开始,该函数想要从名为“file.txt”的文件中读取数据。

示例 1中,此函数需要1毫秒才能完成并返回。从我们的角度来看,100毫秒内什么也没发生,尽管Node.js运行时已使用工作线程与操作系统完成我们的工作并读取该文件。

在这100毫秒浪费之后,突然,我们在fs.readFile函数中定义的最后一个参数的回调在第101毫秒被放置到回调队列中。它上面没有任何东西,所以我们的回调是第一个进入也是第一个离开的。事件循环“看到”了这一点,并将我们的异步回调从队列中移到栈上。

请注意,从我们调用fs.readFile到异步回调进入回调队列的时间点,已经过去了101毫秒。还要注意,自从fs.readFile函数返回后,调用栈为空的100毫秒。这个时间间隔以及异步回调无法进入执行栈,因为它正在被那些调用异步操作的函数使用,这就是你通常看不到回调显式返回值的原因。

继续执行我们的示例。异步回调在第101毫秒开始执行,并运行其第一行代码,该行代码在第102毫秒完成。那将是if语句,检查错误。

接下来,它将进行一些数据处理,并在第103毫秒结束时,将要读取的下一个文件的名称提取到变量file2中。在第104毫秒再次调用fs.readFile,然后工作转移到另一个线程……

fs.readFile在第104毫秒返回时,又过去了100毫秒,突然在第204毫秒,我们的回调进入回调队列并立即进入调用栈……

这一切重复进行,直到file2file3分别在第308和309毫秒打印出来。

Promises

当Promise最终作为JavaScript标准引入时,引起了很大的轰动。那是在官方ES6标准发布的时候,也伴随着轰动效应。人们开始对ES6对语言的添加感到自豪和大胆。几乎达到了他们Java同行的程度。我不知道这是否是因为class关键字或其他因素……

在此需要注意的是,本文中的所有示例都将遵循ES5 JavaScript标准,除了使用ES6自带的Promise对象。

让我们用Promise重写示例1的等价代码。

示例 3

var fs = require("fs");

new Promise(function(resolve, reject) {
    fs.readFile("./file.txt", {encoding: "ascii"}, function(err, data1) {
        if (!err) {
            resolve(data1);
        } else {
            reject(err);
        }
    });
}).then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new Promise(function(resolve, reject) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            if (!err) {
                resolve(data2);
            } else {
                reject(err);
            }
        });    
    });
}, function(err) {
    console.trace(err);
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new Promise(function(resolve, reject) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            if (!err) {
                resolve(data3);
            } else {
                reject(err);
            }
        });    
    });
}, function(err) {
    console.trace(err);
}).then(function(data3) {
    console.log(data3);
}, function(err) {
    console.trace(err);
});

我不知道我是否做错了什么,但这对我来说看起来更丑。当然,美是主观的。示例1的缩进“消失了”,但同时打印file3file2的能力也消失了。除非,你用一个数组来resolve第三个Promise,像这样:resolve([data2, data3])

示例 1 中,我很容易就能在最后打印 data1data3。要在示例 3 中做到这一点,我必须始终将数据推入一个数组中,并且必须在每个新的 Promise 中传递和解析该数组。从该数组中提取信息也需要一两行代码……

Promise构造函数返回一个带有then方法的对象。该方法有两个参数。第一个是处理数据成功读取的函数。第二个也是一个函数,但它处理传递给Promise构造函数的异步函数可能出现的错误。then方法也返回一个新的Promise。在上面的示例中,我们使用从then返回的Promise来链式调用then操作。

你看,当谈论 Promise 时,查阅Dan Streetmentioner 博士的著作来处理所有未来可能的代理解析的语法是很有用的。实际上,then 的第一个参数是一个函数,它将在fs.readFile 调用成功时执行,第二个参数是一个函数,它可能会在fs.readFile 调用出错时发生错误。这,即使其他都是真的,但事实并非如此,这根本不可能,怀疑者说。

开个玩笑,我希望这些示例尽可能简单,以便我们能专注于 Promise 的运作方式。因此,我将排除处理异步 API 函数可能出现的错误(willan on-errored)的场景。以下是所有三个示例,不包含错误处理代码。

示例 4

var fs = require("fs");

fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {

    var file2 = data1.split(' ')[1] + ".txt";
    fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {

        var file3 = data2.split(' ')[2] + ".txt";
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {

            console.log(data2);
            console.log(data3);
        });
    });
});

示例 5

var fs = require("fs");

function next1(err, data1) {
    var file2 = data1.split(' ')[1] + ".txt";
    fs.readFile(file2, {encoding: "ascii"}, next2);
}

function next2(err, data2) {
    var file3 = data2.split(' ')[2] + ".txt";
    fs.readFile(file3, {encoding: "ascii"}, next3);
}

function next3(err, data3) { console.log(data3); }

fs.readFile("file.txt", {encoding: "ascii"}, next1);

示例 6

var fs = require("fs");

new Promise(function(resolve) {
    fs.readFile("./file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
}).then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new Promise(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new Promise(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

我们的代码现在100%乐观。我们不需要reject参数。

让我们举一个例子,它使用我们作为参数传递给各自then方法的已定义函数。这次,明确地将从then方法创建的每个Promise分配给它自己的变量。

示例 7

var fs = require("fs");

function executor1(resolve) {
    function callback(err, datac) {
        resolve(datac);
    }
    fs.readFile("file.txt", {encoding: "ascii"}, callback);
}

function next1(data) {
    function executor2(resolve) {
        function callback(err, datac) {
            resolve(datac);
        }
        var file2 = data.split(' ')[1] + ".txt";
        fs.readFile(file2, {encoding: "ascii"}, callback);
    }
    return new Promise(executor2);
}

function next2(data) {
    function executor3(resolve) {
        function callback(err, datac) {
            resolve(datac);
        }
        var file3 = data.split(' ')[2] + ".txt";
        fs.readFile(file3, {encoding: "ascii"}, callback);
    }
    return new Promise(executor3);
}

function next3(data) { console.log(data) };

var promise1 = new Promise(executor1);
var promise2 = promise1.then(next1);
var promise3 = promise2.then(next2);
var promise4 = promise3.then(next3);

比较示例 7示例 5,我在其中相应地更改了函数名称。啊,面向对象编程的暴行……为什么人们更喜欢将next函数塞进then方法,而不是旧式的过程执行?

我之前在解释示例 1 时提到,请注意示例 7时间间隔。如果按照每行同步代码执行 1 毫秒(无论该行多么复杂)的比喻,你会在 4 毫秒内创建所有 Promise:promise1promise2promise3promise4。然后,在第 5 毫秒时,调用栈被清空。在相当长的一段时间内,调用栈上没有更多可执行的代码。

传递给Promise构造函数的函数executor1在其内部执行,这会触发Node.js工作线程与操作系统通信并获取“file.txt”的文件内容。JavaScript运行时将通过executor1内部定义的callback函数返回你的数据。从在第1行创建promise1callback函数进入回调队列,它将经过我们约定好的100毫秒。在执行的第二行,promise2是从promise1.then方法返回的,我们在其中向promise1声明,当promise1解析时,我们希望调用函数next1

现在executor1内部的callback在第101毫秒开始运行,并在第102毫秒用其datac调用resolve,我们说我们希望它执行函数next,而next1中唯一可执行的行是return new Promise(executor2),因此根据我们的模型,该行将在第103毫秒执行。

很明显,从next1返回的Promise绝不会是promise2。如果你之前是这么认为的,那是因为在示例7中,promise2发生在执行的第2毫秒,而从next1返回的Promise发生在执行的第103毫秒。

如果没有我们创建自己的 Promise 构造函数,进一步的解释是不可能的。然而,为了简单起见,让我们考虑只调用一个异步函数的场景。

示例 8

var fs = require("fs");

fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {

    var file2 = data1.split(' ')[1] + ".txt";
    console.log(file2);
});

构造函数

“我不能创造的,我就不理解。” - 理查德·费曼

示例 9

var fs = require("fs");

function P(executor) {
    var _then;
    
    var resolve = function(data) {
        _then(data);
    }
    
    this.then = function(next) {
        _then = next;
        return this;
    }

    executor(resolve);
}

var P1 = new P(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

P1.then(function(data1) {
    var file2 = data1.split(' ')[1] + ".txt";
    console.log(file2);
});

//or should I say

function executor1(resolve) {
    function callback(err, data1) {
        resolve(data1);
    }
    fs.readFile("file.txt", {encoding: "ascii"}, callback);
}

function next1(data1) {
    var file2 = data1.split(' ')[1] + ".txt";
    console.log(file2);
}

P11 = new P(executor1);
P11.then(next1);

最简单的Promise构造函数,没有任何错误检查,也不符合任何标准。但是,它有效!

让我们稍作修改,为一些关于面向对象编程的建设性思考留出空间。那些不喜欢离题的读者可以安全地跳过本文的下一部分,直接进入Promise的构建。

面向大众编程,而非面向类编程

“所有使用‘class’一词来表示类型的语言,都是Simula的后代。Kristen Nygaard和Ole-Johan Dahl是数学家,他们不以类型的角度思考,但他们理解集合和元素的类,所以他们将他们的类型称为类。基本上,在C++中和Simula中一样,一个类是用户定义的类型。” - Bjarne Stroustrup在人工智能播客中

示例 10

var fs = require("fs");

function Q(executor) {
    this._then = function() { console.log("dummy") };
    this.foo = function(data) {
        this._then(data);
    }

    this.then = function(next) {
        this._then = next;
        return this;
    }

    executor(this.foo);
}

var Q1 = new Q(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

Q1.then(function(data1) {
    var file2 = data1.split(' ')[1] + ".txt";
    console.log(file2);
});

糟糕,这东西不起作用!它说this._then不是一个函数。它当然不是,除非你在全局作用域中定义了一个名为_then的函数。

JavaScript相当函数式的特性使得这一点更加明显。你看到那个被赋值给this.foo的匿名函数了吗?当运行时执行构造函数Q时,它解释这个函数或者即时编译它,随便。然后它会将该函数的地址赋给在Q构造函数内部被标识为this的新创建对象的foo属性。也就是所谓的foo方法。

往下几行,它将执行executor函数,也就是我们传递给构造函数的那个函数,正确地将引用this.foo传递给executor,但那个引用只是一个函数的地址。

我定义executor函数的方式是它将一个名为resolve的参数用作函数。我可以用其他任何方式命名该参数,例如bar,但它总是表示与对象Q1foo方法相同的函数。foo始终是正确的调用函数,但这次是在错误的上下文中。

在JavaScript中,关于函数执行的最重要概念之一是上下文。上下文就是this的值。

你可以将this视为一个特殊的变量,你不能直接给它赋值,但可以通过使用Function.prototype的方法,如:bindapplycall来对其进行操作。

每当JavaScript看到你试图执行对象方法(mayan have on-try)的语法时,它会立即将this的值切换为该对象,然后跳转到该函数的执行。从该对象方法返回后,this的旧值会被弹出。

JavaScript中的OOP就这么多。难道这不会让你在数据面前感到赤裸裸吗?很好,因为本来就应该如此。任何告诉你必须在你的数据和你之间建立一个拜占庭式的类层次结构,并且你应该委托官僚主义的人都是一个……

如果你想,“啊哈,我一直说JS是假的,但我基于类的语言是正确的!”,请考虑一下。在你的C++和Java中,当你调用一个对象的方法时,你实际上是将该对象作为参数传递给一个函数。

我将使用ANSI/ISO C++98来说明我的观点。

示例 11

#include <iostream>

class Cat {
public:
    Cat(int inital);
    int itsEnergy();
    void Eat(int e);
private:
    int energy;
};

int Cat::itsEnergy() {
    return this->energy;
}

void Cat::Eat(int e) {
    this->energy += e;
}

Cat::Cat(int inital) {
    this->energy = inital;
}

int main() {

    Cat Tom(5);
    std::cout << Tom.itsEnergy() << std::endl;
    Tom.Eat(11);
    std::cout << Tom.itsEnergy() << std::endl;
    
    return 0;
}

如果你认为你在给Tom对象发送Eat消息……

你正在调用一个函数,其在C语言中的真实性质看起来像这样:void cat_eat(Cat *this, int e) { this->energy += e; }

为了瞥见真相,只需将Cat的第一个“方法”itsEnergy中的this->energy替换为this.energy。你会收到一个错误。

[error: request for member ‘energy’ in ‘this’, which is of pointer type ‘Cat* const’ 
(maybe you meant to use ‘->’ ?)]

它是一个指向不那么常量的Cat的常量指针。Tom的大小等于那个唯一的成员数据energy是什么。在我的机器上,它是一个4字节对象,一个integer。我没有将“对象”加引号,因为它确实是一个对象。一个仅由数据组成的对象。用程序员的术语来说,类是一种数据类型

我们在这里处理的,在最基本的层面上,至少是拥有命名空间的能力。Cat类的命名空间,Dog类的命名空间,以及MammalStrayCat的命名空间。所有对象都是数据。

如果你使用虚函数实现著名的运行时类型多态,你将在对象中嵌入另一个隐藏的数据成员。一个指向相应命名空间中virtual函数表的指针。

如果你创建一个多态的Cat对象,你会在对象中嵌入一个指向Cat命名空间中virtual函数表的指针,这样当你将该Cat赋值给一个Mammal类型的指针时,它就可以轻松地解引用到Cat类中精确命名的virtual函数的映射调用。

为了使示例 10 能够工作,我们只需将函数this.foo绑定到在Q构造函数内部新创建的对象,像这样:executor(this.foo.bind(this))

示例 12

var fs = require("fs");

function Q(executor) {
    this._then = function() { console.log("dummy") };
    this.foo = function(data) {
        this._then(data);
    }

    this.then = function(next) {
        this._then = next;
        return this;
    }

    executor(this.foo.bind(this));
}

var Q1 = new Q(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

Q1.then(function(data1) {
    var file2 = data1.split(' ')[1] + ".txt";
    console.log(file2);
});

这种绑定将创建一个新函数,在该函数内部,JavaScript 将调用我们知道的引用为 Q1.fooresolve 的函数,但在调用该函数之前,它会玩一个把戏,将 this 的值切换为 Q1

它大致是这样运作的。

示例 13

var obj = { value: 4, see: function() { console.log(this.value) }}
var fSee = obj.see
obj.see()                    //4
fSee()                       //undefined
var bSee = fSee.bind(obj)
bSee()                       //4
bSee == obj.see              //false
bSee == fSee                 //false
obj.see == fSee              //true

更多Promise

我们的目的是构建一个Promise构造函数,它将替换给定基本示例中的ES6 Promise构造函数。因此,让我们继续添加所有对fs.readFile函数的异步调用。

示例 14

var fs = require("fs");

function P(executor) {
    var _then;
    
    var resolve = function(data) {
        _then(data);
    }
    
    this.then = function(next) {
        _then = next;
        return this;
    }

    executor(resolve);
}

var P1 = new P(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

P1.then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

它打印的不是file3,而是“file.txt”。在then方法中创建的所有新“promise”都消失在空气中。

因为我们总是返回同一个对象,所以它的_then值被覆盖了3次,在最后一次调用P1then方法时,它最终变成了记录数据的函数。所有这些都发生在异步回调启动并调用resolve函数,传递从第一个文件读取的数据之前很久。然后调用由then方法设置的最后一个函数,只是打印该数据。这个Promise构造函数只有短期记忆。

现在,我们将逐步深入探讨这个问题。这里最适合我们问题的数据类型是数组。

我们无法保存代表数据处理后续步骤的函数,因为我们只有一个保存占位符。现在,有了数组,我们想有多少占位符就有多少。

通过then方法,将函数按照它们所需的执行顺序推入“promise”的数组中,但在调用resolve函数时,通过shift操作将其从数组中删除。以使它们保持与填充时相同的顺序。更像一个队列,而不是一个栈。

示例 15

var fs = require("fs");

function P(executor) {
    var _then = [];
    
    var resolve = function(data) {
        var f = _then.shift();
        f(data);
    }
    
    this.then = function(next) {
        return this;
    }

    executor(resolve);
}

var P1 = new P(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

P1.then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

这执行了一小段,然后报错,说f不是一个函数。所有传递给then方法的函数都及时地填充到数组中。然后,调用栈被清空。此后一段时间,fs.readFile完成其工作(我们提出的100毫秒),第一个回调被推送到回调队列中。

事件循环将回调从队列移动到调用栈。回调以data1参数(“file.txt”的内容)开始执行。我们匿名回调内部的resolve函数被执行,从“promise”的_then数组中取出其第一个函数(第0个元素)。

该函数被赋值给f并执行,传递data1。到目前为止一切顺利。第一个填充的函数使用data1提取下一个要读取的文件名。它创建一个新的“promise”,在其内部进行新的异步调用以读取file2,并将该promise返回到空中……

当第二个匿名“Promise”最终完成其异步任务(大约100毫秒后)并愉快地将其data2传递给其resolve函数时,该resolve函数试图从其关联的数组中提取下一步处理data2的程序,然后出现问题。

我们只将过程填充到我们首先创建的名为P1的“promise”中。

解决这个戈尔迪结的一种方法是告诉所有新创建的、返回到空气中的“Promise”去解析P1自己的resolve函数。

要么那样,要么让P1告诉新创建的“Promise”下一步要执行什么步骤,这些步骤都塞进了它的_then数组中。这样,就必须调用新创建并返回的“Promise”的then方法。这将改变代码的结构。我们使用“Promise”的方式与我们使用ES6构建的Promise的方式不同。

我们采取了懦夫的方式,告诉新创建的Promise去解析那个唯一知道如何一步步解决这个烂摊子的Promise。

一个小问题是,当P构造函数被调用创建P1时,它的resolve函数被嵌入到它自己的闭包中。要将其取出,我们需要在P内部定义一个函数并将其赋值给this,该函数将返回我们创建的任何对象的resolve地址,以便我们可以传递它。

我们不写这样的函数,而是将resolve作为P1对象的一部分,通过在构造函数内部将其赋值给this。我们将resolve的引用从闭包移动到对象中。对象也是该闭包的一部分,但那是另一个故事。

示例 16

var fs = require("fs");

function P(executor) {
    var _then = [];
    
    this.resolve = function(data) {
        var f = _then.shift();
        f(data);
    }
    
    this.then = function(next) {
        _then.push(next);
        return this;
    }

    executor(this.resolve.bind(this));
}

var P1 = new P(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

P1.then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            P1.resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            P1.resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

这确实解决了问题,但你知道这只是一个拙劣的技巧。它有几个问题。

首先,如果我没有将第一个“promise”返回给变量P1,我就无法将其resolve方法传递给那些新创建的“promise”。

将那些“promise”发送到空气中也感觉不好。尽管它们确实完成了任务。当它们内部的async函数被调用时,它会调用正确的resolve方法。唯一知道数组中填充的then方法的那个。

更糟糕的是,你不应该从一个then方法返回同一个唯一的 Promise。这与我们ES6示例中的真实Promise所做的不同。示例6中的每个then方法都返回一个新的Promise,我们通过它的then方法再链接其他的Promise,依此类推。天哪,我们的数组就这样没了……

按书本

Promise无处不在。处处可见。无影无踪……

示例 17

var fs = require("fs");

function P(executor) {
    var _then;
    
    this.resolve = function(data) {
        this.resolvePromise = _then(data);
    }
    
    this.then = function(next) {
        _then = next;
        this.thenPromise = new P();
        return this.thenPromise;
    }

    if (typeof executor == "function") executor(this.resolve.bind(this));
}

var P1 = new P(function (resolve) {
    fs.readFile("file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
});

P1.then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

现在的情况更加疯狂。我们有“Promise”从两个不同的地方飞来。一个知道下一步该做什么,另一个负责数据。我们需要缩小差距,将其压缩。

我们必须获取then方法的参数,并将其设置为另一个未来将用数据解析的“promise”。

then返回的“promise”没有什么可做的。我们没有它的执行器。它唯一的工作是窃取你将要处理(wioll haven be-processen)未来数据的程序。一个IF语句就能完成这项工作。

让我们看看发生了什么,使用我们的1毫秒和100毫秒模型。首先,创建P1,发出异步fs.readFile请求,该请求将在100毫秒内完成。一毫秒后,调用P1.then方法,参数是一个函数。该函数将在数据准备好时处理数据,并提取文件名,发出第二个异步fs.readFile调用,返回一个新的“promise”。

then方法将记录本应处理数据的函数到P1_then占位符中。

在下一毫秒,P1.then将生成一个新的“Promise”,这是一个完全的虚拟 Promise,并返回它。我们称这个“Promise”为thenPromise暂时暂停一下。

只过去了4-5毫秒。我们有2个Promise。我们有一个异步回调在大约95毫秒后运行。该回调将用数据调用P1.resolve。这将解析为调用_then函数,该函数提取数据,创建一个新的Promise,并返回它。

这个返回的 Promise 会出现在哪里?

P1.resolve内部。我们最好保存这个返回的Promise,因为第二个读取的文件的数据将出现在这里。我们称之为resolvePromise

所以,现在突然之间,在P1中,我们掌握了两个Promise。一个带有未来数据,另一个正是应该告诉那个Promise如何处理其数据的Promise。

示例 17 读取第二个文件,然后在this.resolvePromise = _then(data)处因TypeError_then is not a function”而崩溃。我们缺少一条重要的信息。我们必须将_then的引用从thenPromise复制到resolvePromise_then引用中。

为了节省笔墨,让我们更进一步。要将所有内容压缩,我们还必须将thenPromise.thenPromise的引用复制到resolvePromise.thenPromise中。

resolvePromise本身解析时,它必须知道自己的thenPromise才能复制其_then引用。

在脚本运行中,在第一个异步回调被放入回调队列之前很久,你只能提出你希望对数据执行的单独过程。没有可能接触到这些数据。这些过程被塞进一个Promise链表中。所有这些Promise除了捕获未来数据处理过程的“声明”(可以这么说)之外,别无他用。

无论你链式调用多少个 Promise,总有一天会到达终点。调用栈将被清空,然后真正的行动才开始。

新的 Promise 开始从另一侧出现,就像镜子一样,对应着每个发出请求的async函数。这些async函数的回调拥有数据(或错误),但它们不知道如何处理。我们的任务是将这些 Promise,一个带有数据,一个带有相应的代码,像量子纠缠一样纠缠在一起。

我将更改_then占位符。现在,它是一个存在于闭包中的比私有更私有的变量。为了更方便地在构造函数P之外操作其值,并且由于我对封装并不那么狂热,我将用this.thenFunction替换var _then。这样,我们就不必编写访问器方法了。

按下播放键,让我们将所说的内容转化为代码。

本文Promise的最终版本。

示例 18

var fs = require("fs");

function P(executor) {

    this.resolve = function(data) {
        if (typeof this.thenFunction == "function") {
            this.resolvePromise = this.thenFunction(data);
            if (this.resolvePromise && this.resolvePromise.constructor == P) {
                this.resolvePromise.thenFunction = this.thenPromise.thenFunction;
                this.resolvePromise.thenPromise = this.thenPromise.thenPromise;
            }
        }
    }
    
    this.then = function(next) {
        this.thenFunction = next;
        this.thenPromise = new P();
        return this.thenPromise;
    }
    
    if (typeof executor == "function") executor(this.resolve.bind(this));
}

new P(function(resolve) {
    fs.readFile("./file.txt", {encoding: "ascii"}, function(err, data1) {
        resolve(data1);
    });
}).then(function(data1) {
    var file2 = "./" + data1.split(' ')[1] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file2, {encoding: "ascii"}, function(err, data2) {
            resolve(data2);
        });    
    });
}).then(function(data2) {
    var file3 = "./" + data2.split(' ')[2] + ".txt";
    return new P(function(resolve) {
        fs.readFile(file3, {encoding: "ascii"}, function(err, data3) {
            resolve(data3);
        });    
    });
}).then(function(data3) {
    console.log(data3);
});

我在这里学到的一课是,口头语言描述比代码冗长得多。

再三思考

当你在then之前调用resolve时会发生什么?

示例 19

var fs = require("fs");

var rFile = new Promise(function(resolve, reject) {
    fs.readFile('./file1.txt', {encoding: 'ascii'}, function(err, data) {
        resolve(data);
    });
});

setTimeout(rFile.then, 3000, function(message) {console.log("message")});

在node 6.2.2上,3秒后,我们收到错误

timers.js:333
      ontimeout = () => callback.call(timer, arguments[2]);
                                 ^
TypeError: #<Timeout> is not a promise

在node 4.0.0上,错误是

timers.js:89
        first._onTimeout();
              ^
TypeError: [object Object] is not a promise

Node 0.12.0

timers.js:223
      callback.apply(timer, args);
               ^
TypeError: [object Object] is not a promise

有趣的实现细节……

我真的看不到使用 Promise 与异步回调相比有什么好处(尤其是在示例 2的形式中)。

一些高级语言专家说它是一个同步点。它确实将你之前编写的代码与你未来的输入数据同步。这与编程一直以来的方式并没有太大不同。

有人说,使用异步回调,你不知道你当前的代码是否会先完成,或者你请求的异步数据是否会先弹出。嗯,在那些可能在并行线程中执行程序的高级语言中可能确实如此。

在JavaScript的运行时模型中,这根本不可能。你当前的代码将首先完成,然后异步回调才能进入执行栈。这完全是同步的。;)

网络上有很多文档都在吹嘘你如何通过Promise重新获得控制权,如何知道事情何时完成……在JavaScript中,事情的完成方式永远不会改变。它们只是会向你隐藏起来。

我更喜欢透明的语言、库和框架。从高空到地面。

如果使用 Promise 有好处,那么也一定有弊端。事情就是这样。

关于ES6中的Promise,需要注意的一点是:“一旦Promise被履行或拒绝,相应的处理函数(onFulfilledonRejected)将异步调用(在当前线程循环中调度)。” 引自MDN Web Docs。就我目前的说法,fulfilled(履行)意味着resolved(解决),提到的唯一处理函数是onFulfilled,也就是传递给构造函数Pthen方法的参数函数。

人们通过使用运行时提供的setTime函数来简单地异步调用一个函数。setTime的主体在另一个线程中,所以无论你传递给它什么,它都会通过回调队列返回,有效地使其“异步调用(在当前线程循环中调度)”。如果我理解正确,我找不到这样做的原因。也许它与ES2017中语言的补充有关,如asyncawait

关键字async使函数以异步方式执行,这样它将在其中所有标记为await的表达式/函数进入回调队列后才进入回调队列。我在准备一篇关于asyncawait的文章之前做出了这个假设。很高兴我可以在我更好地理解事物时纠正这篇文章(mayan on-uderstand re-correcten)。

一个真正的Promise比我上面16个示例中编码的要复杂得多。另一方面,我希望读完这篇文章后,你不仅能够使用Promise,而且能够理解它们及其怪癖。

我将错误情况和方便的Promise方法(如all)留作家庭作业。promise状态的标志:pendingrejectedfulfilled……也留作家庭作业。我认为promise的状态只是其功能的副产品,并不重要。另一方面,Promise方法(如all)很重要且值得构建。

祝您编码愉快!

历史

  • 2020年2月6日:初始版本
© . All rights reserved.