当LazyMan遇上compose函数...
哈喽大家好,今天天气好啊,出来摸摸鱼。不知道大家是否跟笔者一样,曾几何时都被这个“懒男人”困扰过(也算是经典面试题了吧),反正笔者就被困扰过,不是因为别的,正是因为笔者是菜鸡!
前言
笔者认为,有时候学习新的东西,难的可能不是这个东西本身,而是缺少的一个应用场景让我们对其产生自己的理解。有时候我们看似学会了一个新知识点,但是没有深入思考、应用到实战中,久而久之就忘记了,再过段时间可能又回到了原点。所以,本文通过实战 compose 函数来解决一个经典面试题,让大家学习 compose 的同时 get 到它的应用场景!
废话不多说啦,直接上菜吧
实现一个 LazyMan,可以按照以下方式调用:
LazyMan('Hank')
输出:
'Hi! This is Hank!'
LazyMan('Hank').sleep(10).eat('dinner')
输出:
'Hi! This is Hank!'
//等待10秒..
'Wake up after 10s'
'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 5s'
'Hi This is Hank!'
'Eat supper'
笔者是秉着实战 compose 函数来解这道题的,所以如果你已经对 compose 函数很熟悉了那就直接关掉吧,不要浪费时间。当然啦,这道题的解法很多,不局限于我的解法,不过如果你也想试试用 compose 函数来玩玩这题,那就接着往下看吧...
一、队列
首先在讲 compose 函数的使用场景时,我们先来简单理解下 compose 函数大概是个啥玩意。抛开深涩难懂的概念,我们直接从大白话讲解:当 A、B、C 函数需要按照一定顺序执行,先执行 C,再到 B,再到 A。我们可以按照如下方式来写:
C()
B()
A()
相信上面的代码大家看了都没有疑问,那现在 B 函数的执行需要依赖 C 函数的返回值,我们将代码改写成下面这样:
const result = C()
B(result)
A()
好,讲到这里相信大家都能明确一点就是:C、B、A 三个函数要顺序执行,并且其中 B 需要依赖 C 的执行结果。这时候有个小伙子就解决上面的写法太 low 了?那有没有牛逼一点的写法?别说,还真有,比如我写成这样:
A(B(C()))
怎么样,一行就能写完,并且 C 函数执行后的返回值顺便还能当成参数给到 B 函数。到这里,我们简单的看看浏览器的执行是不是如我们所愿?

看起来效果很不错?那我们总不能每次都自己 A(B(C())) 这样搞吧?毕竟如果哪天产品经理发飙给你搞了个 A1 出来,那岂不是要变成这样:A(A1(B(C())))?哎妈呀,万一不小心干掉一个括号,数括号都让人头疼,何况这里才区区4个函数组合起来呢?那这个时候,有没有人能帮我们解决这一层繁琐的函数调用写法呢?有呀,我们有 compose 函数 + 队列 的组合啊!
好了,讲到这里,终于扯出第一小节的一个重点——队列。我们先不纠结 compose 函数,我们暂且相信它能为我们解决函数组合的问题,所以我们直接把目光放到 队列 身上。队列其实就是一个先进先出的概念,我们在 js 中用数组来实现它。
那我们现在尝试把每个函数都放到队列里面,并想象有个函数能把队列中的函数组合成我们想要的样子,比如:

诶,你说好巧不巧,正好 compose 函数就能帮我们实现这样的需求,但是有点不一样就是队列中元素的顺序跟我们的习惯是相反的。只能说,对于 compose 而言,它的执行顺序是从右到左的,也就是对我们从左往右看的习惯有点相悖,不过没关系,习惯一下就好了。就像你平时开左舵车,突然看到了辆右舵车,其实也就已开始新奇,看久了也就习惯了。
既然这样,我们可以把上面的函数组合需求通过 “队列” 来表示(注意这里的队列的头是右边)。
const queue = [A, B, C]
const run = compose(queue)
run() // 这里相当于执行了 A(B(C()))
有了 compose 函数的帮忙,对应上面提到的需求变更,别说加个 A1,即使再加个 A2、B2 啥的我们都可以比较方便的插入并保证执行顺序(如:[A, A1, B, C]),再有就是阅读代码的人也更轻松了。
那话都说到这个份上了,相信大家都 get 到了,只要有函数的组合运行,我们就能通过队列来将其表示出个先来后到,再搞个 compose 函数包装一下就好了。其实同步的 compose 函数实现非常简单,我们可以写成这样:
function compose (queue) {
// 返回一个新的函数 (...args) => A(B(C(args)))
return queue.reduce((acc, cur) => {
return (...args) => acc(cur(args))
})
}
直接来看看执行效果:

不出所料,按我们的预想实现了,那接下来我们就看看,如何让 compose 函数和本文的 懒汉 扯上关系...
二、解题思路
这里,我们回到题目本身,看看怎么通过 compose 函数来解题?当然,说到这里很多童鞋的第一反应就是先把 懒汉 的一系列动作转换成 队列的概念。确实是这样的,我们可以看到 懒汉 的链式调用来执行一系列的动作,我们完全可以将其通过队列来表示。
比如以下(我们按从右到左的顺序排列):
LazyMan('Hank').sleep(10).eat('dinner')
queue = [eat, sleep, LazyMan]
LazyMan('Hank').eat('dinner').eat('supper')
queue = [eat, eat, LazyMan]
好了,上述我们先忽略异步执行(如sleep),暂且先用同步的眼光看待他们,确实可以通过队列的形式表达出来,然后再通过 compose 函数就能将一系列动作排序并执行。
这里我们细心看题目发现有一个 sleepFirst,他虽然在链式调用中排老二,但是却比老大先执行了,那其实他是一个插队的任务,我们继续通过队列将其表示出来(从右到左):
LazyMan('Hank').sleepFirst(5).eat('supper')
queue = [eat, LazyMan, sleepFirst]
接下来,我们就将这种链式调用转换成队列吧。这里笔者就不详细展开太多了,直接讲大白话,平时我们执行一个函数,如果不写 return 那函数默认会返回一个 undefined ,那好办,我们返回一个对象不就可以接着调用了?比如我们看下面这个例子:
const obj = {
eat () { console.log('eat') }
}
function LazyMan (name) {
console.log(name)
return obj
}

如上述提到的 obj ,那既然是“懒汉”,他又能睡又能吃还能say hello,那我们完全可以用面向对象的编程思维将其通过对象来表示出来。为了方便大家理解,笔者这里通过 es5 的写法来将这个 懒汉 表示出来。
// 最终调用这个执行
function LazyMan (name) {
// 返回一个 LazyMan 的实例
return new LazyManCtor(name)
}
// LazyMan 的构造器(类)
function LazyManCtor (name) {
// 实例的属性
this.name = name
console.log(`Hi! This is ${this.name}!`)
}
/* 原型上的方法 */
LazyManCtor.prototype.eat = function (type) {
console.log(`Eat ${type}`)
return this // 返回当前实例对象
}
LazyManCtor.prototype.sleep = function (during) {
console.log(`Wake up after ${during}s`)
return this // 返回当前实例对象
}
LazyManCtor.prototype.sleepFirst = function (during) {
console.log(`Wake up after ${during}s`)
return this // 返回当前实例对象
}
好,那我们不妨直接在浏览器中运行一下看看效果:

如图可以发现,在还没调用 sleepFirst 之前,一切都是符合预期的,他们按照链式调用的执行顺序输出了(暂时只是同步)。那如果我们将 sleepFirst 的案例调用,结果会是如何?我们接着来看:
啊,它怎么没有插队到 'Hi! This is Hank!' 之前执行?啊...这不是废话吗,我们都没处理过它,它怎么插队呢?所以我们接下来就要处理它的插队行为。
这时候我们想一下,如果我们需要将链式调用中的某一项插队到某个步骤前,那我们一定要抢在那个阶段执行之前去执行我们的插队函数,也就是说我们在函数正式执行之前,需要一个可以插队的时间。这时候 eventloop 就可以上场了,我们把真正的函数执行放到下一轮宏任务或本轮微任务中不就创造了一个插队的时间了嘛?
这时候又要放出大名鼎鼎的 setTimeout 了,可倔强的笔者就不用它。记得以前某位大佬跟我说,微任务存在的意义就是为了在下一轮循环之前,插队执行一些任务。那不就巧了,我还就要插队,我就要搞个微任务 queueMicrotask。
到这里,我们就要拿出上文提到的 队列 了。主要的做法是:
- 在链式调用的期间暂缓执行,将真正的任务都先存到一个队列中
- 如果有插队的任务,就从队列的头部插入,其余在尾部插入
- 在本轮循环的微任务执行阶段,执行队列中的函数
到这里,我们改写一下上述的代码实现:
function LazyMan (name) {
return new LazyManCtor(name)
}
function LazyManCtor (name) {
this.name = name
this.queue = []
const fn = () => console.log(`Hi! This is ${this.name}!`)
this.queue.unshift(fn)
}
LazyManCtor.prototype.eat = function (type) {
const fn = () => {
console.log(`Eat ${type}`)
}
this.queue.unshift(fn)
return this
}
LazyManCtor.prototype.sleep = function (during) {
const fn = () => {
console.log(`Wake up after ${during}s`)
}
this.queue.unshift(fn)
return this
}
LazyManCtor.prototype.sleepFirst = function (during) {
const fn = () => {
console.log(`Wake up after ${during}s`)
}
this.queue.push(fn)
return this
}
其实改动很简单,就是把真正的输出语句 console.log 都包到函数中,执行链式调用的时候把这堆函数按顺序的排列到队列中,大家粗略看看即可。需要注意的是,这里依然是按照从右到左的顺序,所以 push 、 unshift 方法别用反了~
现在,我们再来看一下队列中的顺序是否如我们所愿?先看一个无插队的:

再看一下插队的 sleepFirst 队列情况:

这下 ok 了,我们成功将链式调用转换成队列描述的形式。现在只要我们把队列函数通过第一节的 compose 函数包装后调用,估计就能按预期执行了。我们接着改造一下我们的构造器代码,再实现一个 flushQueue 的方法即可:
function LazyManCtor (name) {
this.name = name
this.queue = []
const fn = () => console.log(`Hi! This is ${this.name}!`)
this.queue.unshift(fn)
// 异步执行队列中的函数,并通过箭头函数绑定当前 this
queueMicrotask(() => this.flushQueue())
}
// 直接拿上文的 compose 来包装一下即可
LazyManCtor.prototype.flushQueue = function () {
function compose (queue) {
return queue.reduce((acc, cur) => {
return (...args) => acc(cur(args))
})
}
// 通过 compose 组合队列函数
const queueFn = compose(this.queue)
// 执行队列中的函数
queueFn()
}
接下来我们看看执行效果:

完美了,那剩下最后一步就是异步执行了,我们接着往下吧~
三、异步 compose
当然,这题如果只为了得到固定的输出,是可以通过阻塞线程而非异步的形式实现的。比如我们 sleep(n) 的时候,如果直接在里面写一个 while 循环,在还没到达指定时间的时候就一直阻塞下去...如果这样写的话,直接用上述的同步 compose 就能完成解题了,不过可能出这道题的作者想打死你而已...
说到异步,我们首先要改写一下 sleep 、 sleepFirst 函数,将他们变成异步。就以 sleep 函数为例:
LazyManCtor.prototype.sleep = function (during) {
// 包进 Promise 里,并在倒计时结束后执行 resolve
const fn = () => new Promise((resolve) => {
setTimeout(() => {
console.log(`Wake up after ${during}s`)
resolve()
}, during * 1000)
})
this.queue.unshift(fn)
return this
}
那 sleep 改成异步之后,我们在没改变 compose 函数的情况下,输出发生了改变:
当然,这也是符合预期的,毕竟我们把 console.log 放到 setTimeout 中执行。所以我们接下来就要把 异步的compose 给整出来。
异步的compose实现也不会很难,也就几行代码的事情,对你们来说都洒洒水啦。笔者就直接在之前同步的基础上进行改造了。回忆一下我们同步执行的写法:A(B(C())),执行顺序是 C() -> B() -> A(),那现在笔者打算通过 promise 将其改造一下,变成:
C().then(resC => {
return B().then(resB => {
return A()
})
})
那我们看看改造后浏览器运行的结果:
也是先执行了 C,再到 B,最后到 A。跟我们同步执行的结果也一致。也就是说,我们通过上述的 Promise 链式调用,就可以达到一个 异步compose 的效果了,不信我们给 B 加个延迟执行看看:

所以本题的最后一步就是,将 异步 compose 实现出来就 ok 了。我们直接上代码:
LazyManCtor.prototype.flushQueue = function () {
function composeAsync (queue) {
// 注意这里反转了函数,也就是从左到右的执行
const reverseQueue = queue.reverse()
// 取出第一个函数(用作 reduce 的初始值)
const firstFn = reverseQueue.shift()
return (...args) => {
reverseQueue.reduce((acc, cur) => {
// 转换成 promise.then 的方式执行
return acc.then(result => {
// then 返回值是一个新的 Promise(以此类推)
return cur(result)
})
}, Promise.resolve(firstFn(args)))
}
}
const queueFn = composeAsync(this.queue)
queueFn()
}
那我们试试将异步 compose 放到题目中看看执行效果:

完美,按照期望执行了,并且也有 sleep 的效果了!
写在最后
源码地址:github。感兴趣的朋友可以去上面看完整的代码,其实跟本文讲到的没啥区别的~
最后,笔者通过这道题作为引子,更多是想通过其带出 compose 函数,也算是自己想了个场景去实战写写代码,加深自己对 compose 的理解。本文的源码只针对了题目的那几个输入输出,可能并不完善,如有错误请指出,笔者感激不尽。感谢你的阅读,我们下次再见!
转载自:https://juejin.cn/post/7204345146161397816