一道有趣的sleep面试题

267 阅读5分钟

昨天去面试,遇到了一个比较有意思的面试题,题目内容大概如下:

要求使用面相对象的思想,实现一个可以满足下列要求的方法:

image.png

image.png

image.png

当看到题目的时候第一感觉:

  1. 封装一个包含sleep,eat, play,firstSleep方法的类;
  2. 因为链式调用的关系,所以每个方法都会返回这个实例;
  3. 我们通过打印结果可知有延迟打印,可以用setTimeout实现; 但是当看到第3个的打印结果时,就有点困难了,firstSleep比链式调用的顺序提前了,提到了第二行进行打印;暂时有点不知道是什么情况。但是感觉可以利用事件循环的机制去尝试一下;

那我们先去实现一下前面两种情况的打印: 首先,我们先实现链式调用,这个非常简单; 代码如下:

class Person {
    constructor(name) {
        this.name = name;
    }

    introduction() {
        console.log(`你好,我是${this.name}`);
        return this;
    }

    eat(food) {
        console.log(`我吃了${food}`);
        return this;
    }

    play(action) {
        console.log(`我玩了${action}`);
        return this;
    }

    sleep(time) {
        setTimeout(() => {
            console.log(`睡了${time}秒...`);
        }, time * 1000)
        return this;
    }
}

function test(name) {
    const p = new Person(name);
    return p.introduction();
}

test('小明').sleep(3).eat('早饭').play('篮球');

// 打印结果:
// 你好,我是小明
// 我吃了早饭
// 我玩了篮球
// 睡了3秒...

由打印结果得知,睡了3秒由于使用了setTimeout,被延迟执行了,导致打印顺序并没有按照我们预想的打印结果进行打印,所以我们还要对代码进行一些处理;由调用方式我们可以得出sleep后面调用的方法应该一样被延迟调用,这样就能保证打印的顺序;(暂时只考虑第一个场景); 我们可以声明一个队列,然后将sleep后面调用的函数进行缓存,然后当sleep动作被延迟执行以后才调用队列里面的函数。

class Person {
    constructor(name) {
        this.name = name;
    }

    delayEvents = [];
    hasDelay = null; // 是否有延迟事件

    introduction() {
        console.log(`你好,我是${this.name}`);
        return this;
    }

    // 一般事件
    eat(food) {
        const callback = () => console.log(`我吃了${food}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 一般事件
    play(action) {
        const callback = () => console.log(`我玩了${action}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 延迟事件
    sleep(time) {
        this.hasDelay = true;

        setTimeout(() => {
            console.log(`睡了${time}秒...`)
            this.delayEvents.forEach(cb => cb());
        }, time * 1000);

        return this;
    }

    toAddCallbackToQueue(cb) {
        if (this.hasDelay) {
            this.delayEvents.push(cb);
        } else {
            cb();
        }
    }
}

function test(name) {
    const p = new Person(name);
    return p.introduction();
}

test('小明').sleep(3).eat('早饭').play('篮球');

// 打印结果:
// 你好,我是小明
// 睡了3秒...
// 我吃了早饭
// 我玩了篮球

由打印结果验证,我们的代码对于第一种场景是满足的;

那么接下来我们看第二种场景,调用了两次sleep函数。那么也就是说存在多次调用延迟函数的可能。那么用链式调用的结果可知,每个延迟函数需要缓存的回调函数是自身之后与下一个延迟函数或者结尾之间的所有调用的方法的函数。即第一个sleep函数需要延迟的函数是和第二个sleep之间的函数(包含第二个sleep函数);那么我们的缓存delayEvents应该变为一个二维数组。我们将hasDelay标识,更改为index,用于记录当前缓存的下标;代码实现如下:

class Person {
    constructor(name) {
        this.name = name;
    }

    delayEvents = [];
    index = -1; // 延迟事件队列下标

    introduction() {
        console.log(`你好,我是${this.name}`);
        return this;
    }

    // 一般事件
    eat(food) {
        const callback = () => console.log(`我吃了${food}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 一般事件
    play(action) {
        const callback = () => console.log(`我玩了${action}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 延迟事件
    sleep(time) {
        this.index += 1;
        this.delayEvents[this.index] = [];

        const callback = () => {
            setTimeout(() => {
                console.log(`睡了${time}秒...`)
                this.delayEvents.shift().forEach(cb => cb());
                if (!this.delayEvents.length) this.index = -1;
            }, time * 1000);
        }

        if (this.index) {
            this.delayEvents[this.index - 1].push(callback);
        } else {
            callback();
        }

        return this;
    }

    toAddCallbackToQueue(cb) {
        if (this.index !== -1) {
            this.delayEvents[this.index].push(cb);
        } else {
            cb();
        }
    }
}

function test(name) {
    const p = new Person(name);
    return p.introduction();
}

test('小明').eat('早饭').sleep(3).play('篮球').sleep(5).play('足球');

// 打印结果:
// 你好,我是小明
// 我吃了早饭
// 睡了3秒...
// 我玩了篮球
// 睡了5秒...
// 我玩了足球

由此我们满足了第二个场景的需求(多个延迟函数调用的场景);

接下来,我们去实现第三个场景:firstSleep的前置调用;在现有的执行队列之前去插入后面的链式调用的回调函数并且让我优先执行,对应的方法我不太了解(如果有大佬知道,欢迎评论区留下宝贵的答案);那么我们换一种思路,就是将前面的顺序代码进行缓存,当存在前置方法的时候,先执行前置方法,在执行顺序代码。那么什么时间去判断是否存在前置代码呢?什么时间将缓存代码遍历执行呢?需要一个判断时机,在我的认知范围内没有什么主动的方法去做判断(如果大佬知道的话欢迎评论区留下宝贵的答案);那么我们还是利用事件循环机制吧,在初始方法中利用setTimeout去判断逻辑代码是否执行完毕(即同步链式调用,缓存操作只是缓存了方法,并没有执行);当链式调用完毕(即缓存处理完毕时)我们再去进行判断,然后确定执行的顺序,对所有缓存函数进行执行;

代码如下:

class Person {
    constructor(name) {
        this.name = name;
    }

    events = [];
    delayEvents = [];
    index = -1; // 延迟事件队列下标
    currentPreEvents = null;
    currentTimer = null;

    introduction() {
        console.log(`你好,我是${this.name}`);

        // 缓存函数,方便前置函数进行调用
        this.currentPreEvents = () => {
            // 缓存setTimeout的句柄,方便前置函数取消之前的setTimeout操作
            // 重新改写执行顺序;
            this.currentTimer = setTimeout(() => {
                this.events.forEach(cb => cb());
            })
        }

        this.currentPreEvents();
        return this;
    }

    // 一般事件
    eat(food) {
        const callback = () => console.log(`我吃了${food}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 一般事件
    play(action) {
        const callback = () => console.log(`我玩了${action}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 延迟事件
    sleep(time) {
        this.index += 1;
        this.delayEvents[this.index] = [];

        const callback = () => {
            setTimeout(() => {
                console.log(`睡了${time}秒...`)
                this.delayEvents.shift().forEach(cb => cb());
                if (!this.delayEvents.length) this.index = -1;
            }, time * 1000);
        }

        if (this.index) {
            this.delayEvents[this.index - 1].push(callback);
        } else {
            this.events.push(callback);
        }

        return this;
    }

    // 前置事件
    firstSleep(time) {

        if (this.currentTimer) {
            clearTimeout(this.currentTimer); // 取消原本的初始setTimeout事件
            this.currentTimer = null;
        }

        setTimeout(() => {
            console.log(`需要马上睡${time}秒`);
            this.currentPreEvents(); // 执行之前的setTimeout操作;
        }, time * 1000);

        return this;
    }

    toAddCallbackToQueue(cb) {
        if (this.index !== -1) {
            this.delayEvents[this.index].push(cb);
        } else {
            this.events.push(cb);
        }
    }
}

function test(name) {
    const p = new Person(name);
    return p.introduction();
}

test('小明').eat('早饭').play('篮球').eat('午饭').sleep(5).play('麻将').eat('晚饭').firstSleep(3).play('扑克牌');

// 打印结果:
// 你好,我是小明
// 需要马上睡3秒
// 我吃了早饭
// 我玩了篮球
// 我吃了午饭
// 睡了5秒...
// 我玩了麻将
// 我吃了晚饭
// 我玩了扑克牌

至此,我们完成了这个Person类的封装。使其能够满足3种场景。那么如果我们想对这个类补充一种前置方法呢,为了方便扩展,我们对前置方法进行提取改进; 代码如下(添加了work前置方法):

class Person {
    constructor(name) {
        this.name = name;
    }

    events = [];
    delayEvents = [];
    index = -1; // 延迟事件队列下标
    currentPreEvents = null;
    currentTimer = null;

    introduction() {
        console.log(`你好,我是${this.name}`);
        this.currentPreEvents = () => {
            this.currentTimer = setTimeout(() => {
                this.events.forEach(cb => cb());
            })
        }

        this.currentPreEvents();
        return this;
    }

    // 一般事件
    eat(food) {
        const callback = () => console.log(`我吃了${food}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 一般事件
    play(action) {
        const callback = () => console.log(`我玩了${action}`);
        this.toAddCallbackToQueue(callback);
        return this;
    }

    // 延迟事件
    sleep(time) {
        this.index += 1;
        this.delayEvents[this.index] = [];

        const callback = () => {
            setTimeout(() => {
                console.log(`睡了${time}秒...`)
                this.delayEvents.shift().forEach(cb => cb());
                if (!this.delayEvents.length) this.index = -1;
            }, time * 1000);
        }

        if (this.index) {
            this.delayEvents[this.index - 1].push(callback);
        } else {
            this.events.push(callback);
        }

        return this;
    }

    // 前置事件
    firstSleep(time) {
        const callback = () => console.log(`需要马上睡${time}秒`);

        this.toWarpPreEvents(callback, time);

        return this;
    }

    // 前置事件
    work(time) {
        const callback = () => console.log(`需要马上工作${time}秒`);

        this.toWarpPreEvents(callback, time);

        return this;
    }

    toAddCallbackToQueue(cb) {
        if (this.index !== -1) {
            this.delayEvents[this.index].push(cb);
        } else {
            this.events.push(cb);
        }
    }

    toWarpPreEvents(cb, time) {
        if (this.currentTimer) {
            clearTimeout(this.currentTimer);
            this.currentTimer = null;
        }

        const toDo = this.currentPreEvents; // 这一块需要缓存,因为this.currentPreEvents是一个变量(每调用一次前置函数,就会变一次);

        this.currentPreEvents = () => {
            this.currentTimer = setTimeout(() => {
                cb();
                toDo();
            }, time * 1000);
        }

        this.currentPreEvents();
    }
}

function test(name) {
    const p = new Person(name);
    return p.introduction();
}

test('小明').eat('早饭').play('篮球').eat('午饭').sleep(5).play('麻将').eat('晚饭').firstSleep(3).play('扑克牌').work(4);
// 打印结果:
// 你好,我是小明
// 需要马上工作4秒
// 需要马上睡3秒
// 我吃了早饭
// 我玩了篮球
// 我吃了午饭
// 睡了5秒...
// 我玩了麻将
// 我吃了晚饭
// 我玩了扑克牌

欢迎大佬指正优化,拜谢!