【js 编程】讲一道关于懒汉的面试题

1,936 阅读2分钟

前言

前天晚上在一个技术群里面看到一些小伙伴在讨论这么一道有趣的题目,题目大致意思就是实现一个懒汉,并且提供一系列的行为方法,调用 eat 就打印吃饭信息,调用 sleep 方法则进行延迟传入时间,再进行下一步的操作。

当然,限制条件是不使用 Promise。

实现一个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

以此类推。

题目分析

链式调用

LazyMan(“Hank”).sleepFirst(5).eat(“supper”) 可以看出,每一个方法结尾都可以通过 . 来注册或执行下一步,第一直觉就想到了返回 this

sleep 延迟

如何通过 sleep 方法延迟后续方法的执行呢?可以使用 setTimeout 。那么问题又来了, setTimeout 能够通知宿主浏览器在一定时间再把我们的方法推进事件队列中,但我们无法阻碍下一步方法的调用。能够这么想,说明我们把链式调用想象为马上执行了。就好像是这样:

// 错误的代码
sleep (timeout) {
    setTimeout(() => {
        console.log(`${this.name} end sleep ~`)
    }, timeout * 1000)
    return this
}

这必然是错误的。我们无法等待一个异步执行完了之后,再去执行下一个同步的操作。那么,我们可以尝试把思维转换,如果我们是维护一个队列呢?

维护队列 和 调度顺序

我们把链式调用定义为一个注册行为,事情就好办了。这里使用一下观察者模式,首先 LazyMan 在链式调用的每一个环节中都会会往队列里面注册一个方法。而在队列调度期间,程序从最前面一个方法开始执行,队列中每个方法运行结束的时候都会调用下一个方法的执行,事情就变得井然有序。这时候下一个问题就来了,我们何时开始队列中第一个函数的执行?

调度时机

考虑一波,开始触发队列中第一个方法的时机是什么?我们当然可以使用一个额外的 start 方法来触发队列的调度,但这道题目并不允许。那么,该怎么办呢?事实上由于事件循环的机制,我们依然可以使用 setTimeout 来解决, 在构造函数中开始异步调度即可:

class LazyMan {
    constructor (name) {
        // any code ...
        setTimeout(() => {
            this.next() // 负责调度下一个方法执行
        },0)
    }
}

执行栈、事件循环 与 事件队列

当我们运行一个 javascript 程序的时候,javaScript 引擎会解析这段代码,并且按照顺序把我们的同步代码加入到栈中,从前往后开始执行,而如果遇到同步方法调用,则会创建一个执行环境,然后进入这个执行环境一步步执行这个方法的代码,如果方法执行完毕则退出当前执行环境,并销毁它。

当然,如果在执行过程中,当执行到异步代码的时候,javaScript 并不会等待,而是选择挂起,继续往前执行。当异步事件返回结果的时候,javaScript 或者 宿主(通常是浏览器)会把它推到一个队列中等待执行。这个队列就是事件队列

当执行栈中所有任务都完成以后,主线程处于空闲状态,这个时候就会去查找事件队列是否还有任务,如果有,则取出排在最前面的任务开始执行,如此反复,这就是所谓的事件循环(Event loop)。

当然我们所说的异步任务也有不同,可以分为 微任务(micro task)和宏任务(macro task 这里不再继续描述下去。

结合我们这道题目,在调度的时机上,可以选用异步任务的方式:

setTimeout(() => {
    this.next() // 负责调度下一个方法执行
},0)

这样,在一连串同步链式调用进行注册之后,我们会在下一个循环开始 this.next() 方法进行调度。

代码实现

// 懒汉 类
class LazyManClass {
    constructor (name) {
        this.name = name
        this.queue = [] // 队列
        console.log(`Hi! This is ${name}`)

        // 延迟调度
        setTimeout(() => {
            this.next()
        },0)
    }

    // 调度方法
    next () {
        const fnc = this.queue.shift()
        fnc && fnc()
    }

    /**
     * 注册函数方法
     * @param {*} fn 要注册的函数 
     * @param {*} isFirst 是否注册在队列最前
     */
    register (fn, isFirst) {
        if (isFirst) {
            this.queue.unshift(fn)
        } else {
            this.queue.push(fn)
        }
    }

    // 吃
    eat (food) {
        const _eat = () => {
            console.log(`Eat ${food}~`)
            this.next()
        }
        this.register(_eat)
        return this
    }

    // 睡在最前面
    sleepFirst (s) {
        return this.sleep(s, true)
    }

    // 睡觉
    sleep (s, isFirst=false) {
        const timeout = s * 1000
        const _sleep = () => {
            console.log(`Wake up after ${s}`)
            setTimeout(() => {
                this.next()
            }, timeout)
        }
        this.register(_sleep, isFirst)
        return this
    }
}

// 懒汉 返回一个懒汉实例
function LazyMan (name) {
    return new LazyManClass(name)
}

接下来解析一下这段代码(啰嗦一波废话)。

  • 首先,对于链式调用,我们统一在方法的结尾 return this,这样我们就可以肆无忌惮的玩耍了。

  • 使用队列,通过 register 注册方法进行函数注册,在链式调用过程中按顺序将方法推到队列中。

  • 利用事件循环,在构造函数中通过 setTimeout 将调度队列的时机放在了之后的事件循环中进行。

效果

分两组测试来看:

第一组 ,在浏览器控制台输入:

LazyMan('小明').eat('午餐').sleep(2).eat('晚餐').sleepFirst(3);

效果如下:

第二组,输入

LazyMan('小明').eat('午餐').eat('晚餐').sleep(2).eat('早餐');

效果如下:

小结

这道题事实上并没有太大难度,考验的是程序员的编程能力。

从本文的解决思路来说,用到的知识点有链式调用、事件循环、闭包和观察者模式等等。

在线直通车

【javaScript 编程】如何实现一个 LazyMan?