编辑注:在 Review 别人的 JavaScript 代码时曾看到过类似的队列函数,不太理解,原来这个是为了保证函数按顺序调用。读了这篇文章之后,发现还可以用在异步执行等。
假设你有几个函数 fn1、fn2 和 fn3 需要按顺序调用,最简单的方式当然是:
- fn1();
- fn2();
- fn3();
但有时候这些函数是运行时一个个添加进来的,调用的时候并不知道都有些什么函数;这个时候可以预先定义一个数组,添加函数的时候把函数 push 进去,需要的时候从数组中按顺序一个个取出来,依次调用:
- var stack = [];
- // 执行其他操作,定义fn1
- stack.push(fn1);
- // 执行其他操作,定义fn2、fn3
- stack.push(fn2, fn3);
- // 调用的时候
- stack.forEach(function(fn) {
- fn()
- });
这样函数有没名字也不重要,直接把匿名函数传进去也可以。来测试一下:
- var stack = [];
- function fn1() {
- console.log('第一个调用');
- }
- stack.push(fn1);
- function fn2() {
- console.log('第二个调用');
- }
- stack.push(fn2,
- function() {
- console.log('第三个调用')
- });
- stack.forEach(function(fn) {
- fn()
- }); // 按顺序输出'第一个调用'、'第二个调用'、'第三个调用'
这个实现目前为止工作正常,但我们忽略了一个情况,就是异步函数的调用。异步是 JavaScript 中无法避免的一个话题,这里不打算探讨 JavaScript 中有关异步的各种术语和概念,请读者自行查阅(例如某篇著名的评注)。如果你知道下面代码会输出 1、3、2,那请继续往下看:
- console.log(1);
- setTimeout(function() {
- console.log(2);
- },
- 0);
- console.log(3);
假如 stack 队列中有某个函数是类似的异步函数,我们的实现就乱套了:
- var stack = [];
- function fn1() {
- console.log('第一个调用')
- };
- stack.push(fn1);
- function fn2() {
- setTimeout(function fn2Timeout() {
- console.log('第二个调用');
- },
- 0);
- }
- stack.push(fn2,
- function() {
- console.log('第三个调用')
- });
- stack.forEach(function(fn) {
- fn()
- }); // 输出'第一个调用'、'第三个调用'、'第二个调用'
问题很明显,fn2 确实按顺序调用了,但 setTimeout 里的 function fn2Timeout() { console.log('第二个调用')} 却不是立即执行的(即使把 timeout 设为 0);fn2 调用之后马上返回,接着执行 fn3,fn3 执行完了然才真正轮到 fn2Timeout。
怎么解决?我们分析下,这里的关键在于 fn2Timeout,我们必须等到它真正执行完才调用 fn3,理想情况下大概像这样:
- function fn2() {
- setTimeout(function() {
- fn2Timeout();
- fn3();
- },
- 0);
- }
但这样做相当于把原来的 fn2Timeout 整个拿掉换成一个新函数,再把原来的 fn2Timeout 和 fn3 插进去。这种动态改掉原函数的写法有个专门的名词叫 Monkey Patch。按我们程序员的口头禅:"做肯定是能做",但写起来有点拧巴,而且容易把自己绕进去。有没更好的做法?
我们退一步,不强求等 fn2Timeout 完全执行完才去执行 fn3,而是在 fn2Timeout 函数体的最后一行去调用:
- function fn2() {
- setTimeout(function fn2Timeout() {
- console.log('第二个调用');
- fn3(); // 注{1}
- },
- 0);
- }
这样看起来好了点,不过定义 fn2 的时候都还没有 fn3,这 fn3 哪来的?
还有一个问题,fn2 里既然要调用 fn3,那我们就不能通过 stack.forEach 去调用 fn3 了, 否则 fn3 会重复调用两次。
我们不能把 fn3 写死在 fn2 里。相反,我们只需要在 fn2Timeout 末尾里找出 stack 中 fn2 的下一个函数,再调用:
- function fn2() {
- setTimeout(function fn2Timeout() {
- console.log('第二个调用');
- next();
- },
- 0);
- }
这个 next 函数负责找出 stack 中的下一个函数并执行。我们现在来实现 next:
- var index = 0;
- function next() {
- var fn = stack[index];
- index = index + 1; // 其实也可以用shift 把fn 拿出来
- if (typeof fn === 'function') fn();
- }
next 通过 stack[index] 去获取 stack 中的函数,每调用 next 一次 index 会加 1,从而达到取出下一个函数的目的。
next 这样使用:
- var stack = [];
- // 定义index 和next
- function fn1() {
- console.log('第一个调用');
- next(); // stack 中每一个函数都必须调用`next`
- };
- stack.push(fn1);
- function fn2() {
- setTimeout(function fn2Timeout() {
- console.log('第二个调用');
- next(); // 调用`next`
- },
- 0);
- }
- stack.push(fn2,
- function() {
- console.log('第三个调用');
- next(); // 最后一个可以不调用,调用也没用。
- });
- next(); // 调用next,最终按顺序输出'第一个调用'、'第二个调用'、'第三个调用'。
现在 stack.forEach 一行已经删掉了,我们自行调用一次 next,next 会找出 stack 中的第一个函数 fn1 执行,fn1 里调用 next,去找出下一个函数 fn2 并执行,fn2 里再调用 next,依此类推。
每一个函数里都必须调用 next,如果某个函数里不写,执行完该函数后程序就会直接结束,没有任何机制继续。
了解了函数队列的这个实现后,你应该可以解决下面这道面试题了:
- // 实现一个LazyMan,可以按照以下方式调用:
- LazyMan("Hank")
- /* 输出:
- Hi! This is Hank!
- */
- LazyMan("Hank").sleep(10).eat("dinner")输出
- /* 输出:
- Hi! This is Hank!
- // 等待10秒..
- Wake up after 10
- Eat dinner~
- */
- LazyMan("Hank").eat("dinner").eat("supper")
- /* 输出:
- Hi This is Hank!
- Eat dinner~
- Eat supper~
- */
- LazyMan("Hank").sleepFirst(5).eat("supper")
- /* 等待5秒,输出
- Wake up after 5
- Hi This is Hank!
- Eat supper
- */
- // 以此类推。
Node.js 中大名鼎鼎的 connect 框架正是这样实现中间件队列的。有兴趣可以去看看它的源码或者这篇解读《何为 connect 中间件》。
细心的你可能看出来,这个 next 暂时只能放在函数的末尾,如果放在中间,原来的问题还会出现:
- function fn() {
- console.log(1);
- next();
- console.log(2); // next()如果调用了异步函数,console.log(2)就会先执行
- }
redux 和 koa 通过不同的实现,可以让 next 放在函数中间,执行完后面的函数再折回来执行 next 下面的代码,非常巧妙。有空再写写。
来源: http://www.jb51.net/article/116597.htm