学习任务调度问题的通用解法:精讲前端高频代码题 LazyMan

211 阅读5分钟

我们将通过一道高频代码题来给出任务调度问题的一个通用解法,同时抛砖引玉,希望有其他理解的大佬在评论区发表意见。

实现类LazyMan

new LazyMan('Hank')
// 你好,我是Hank

new LazyMan('Hank').eat('🍎')
// 你好,我是Hank
// 吃了 🍎

new LazyMan('Hank').eat('🍎').sleep(1).eat('🍌')
// 你好,我是Hank
// 吃了 🍎
// (过了1秒) 睡了 1秒
// 吃了 🍌

new LazyMan('Hank').eat('🍎').sleepFirst(3)
// 你好,我是Hank
// (过了3秒) 睡了 3秒
// 吃了 🍎

new LazyMan('Hank').eat('🍎').sleep(1).eat('🍌').sleepFirst(3)
// 你好,我是Hank
// (过了3秒) 睡了 3秒
// 吃了 🍎
// (过了1秒) 睡了 1秒
// 吃了 🍌

拆解

初见题目,会有一种看着不难却总有哪里不对劲的感觉。Anyway,我们先将其拆解,职责分离,一切就清晰很多。同时采用 TS 编写也是某种程度上的 TDD,能做到编辑器不飘红就成功了一半。

我们先把代码架子搭建一下。

class LazyMan {
  constructor(name: string) {
    console.log(`你好,我是${name}`)
  }

  sleep(second: number) {
    console.log(`睡了 ${second}秒`)
  }

  sleepFirst(second: number) {
    console.log(`睡了 ${second}秒`)
  }

  eat(food: string) {
    console.log(`吃了 ${food}`)
  }
}

拆解如下:

  1. 链式调用。
  2. 阻塞运行
    1. sleep方法正常阻塞。
    2. sleepFirst方法提前阻塞。

链式调用

这个简单,在方法内直接将实例返回出来。

class LazyMan {
  constructor(name: string) {
    console.log(`你好,我是${name}`)
  }

  sleep(second: number) {
    console.log(`睡了 ${second}秒`)
    return this
  }

  sleepFirst(second: number) {
    console.log(`睡了 ${second}秒`)
    return this
  }

  eat(food: string) {
    console.log(`吃了 ${food}`)
    return this
  }
}

new LazyMan('Hank').eat('🍎').sleep(1) // ts不报错

阻塞运行

阻塞运行貌似就是这道题的关键。试想下,该如何实现阻塞呢? 最直观的方式:长任务堵死线程。

// (伪代码)
class LazyMan {
  sleep(second) {
    let startTime = performance.now()
    while (true) {
      if (performance.now() - startTime > 1000 * second) {
        break
      }
    }
    return this
  }
}

这个方式直观、简单。sleep 会卡住 n 秒,结束之后也会自动调用下一个方法,一切都是那么的顺其自然。但他仍旧无法实现需求:

  1. 功能孱弱,无法实现 sleepFirst 这种特殊功能。
  2. 性能浪费严重。

不过能提出问题就相当于成功了一半,我们接下来就来解决它们。

调度才是关键

对于问题一,“你方唱罢我登场”式的设计之所以简单直观,是因为我们不用考虑任务调度。但真的没有调度吗?不是的。

我们的链式调用本身就自带一种调度系统:方法调用的顺序决定了任务执行的顺序。所以如果你没考虑到调度,是因为你忽视了链式调用自带的调度。

看到这你或许就明白了:阻塞运行只是一个表象,隐藏在背后的其实是任务的调度。而链式调用更像是一个陷阱,让你忽视了调度问题。

所以问题一的本质是:默认的调度系统太弱了。既然如此,那我们就自己实现一套更强的调度系统。

所谓调度,可以理解为通过维护一个指定的上下文,在指定的时机做指定的事。既然调度由我们自己来实现,那就可以不用长任务卡死线程来实现阻塞,这样也同时解决了问题二。

如果你已经灵感迸发了,不妨自己接着解题~

实现

首先,我们构造任务。使用interface而不是type,确保可扩展与可维护性。

interface Task {
  action: () => any // 任务行为
}

任务有了,我们接着实现调度,看下面这段代码。

new LazyMan('Hank').sleep(1).eat('🍎').sleep(1).eat('🍌')

我们猜测它的输出为

0s   无动作
1.0s 打印 睡了 1秒
1.0s 打印 吃了 🍎
2.0s 打印 睡了 1秒
2.0s 打印 吃了 🍌

第一列是时间,第二列是行为

我们之前所说的调度本质:通过维护一个指定的上下文,在指定的时机做指定的事。而在这里,“时机”就是第一列的时间,“事”就是第二列的“行为”。

行为我们不需要从上下文而来,因为在这个场景下不会对行为有什么处理,而时间则需要从上下文而来:如果睡了n秒,那么后面所有行为都要延后n秒,所以我们需要维护一个名为totalDelay的上下文,每一个任务都会影响totalDelay,同时totalDelay也会影响每一个任务的时间。由此,我们先扩展Task,并实现调度具体的系统。

// 行为和时间应该是分离的,具体的调度的控制权要交给上层的调度系统
interface Task {
  delay: number // 调用时机
  action: () => any // 任务行为
}

// 一个工厂函数,构造一个任务。之所以不用class,是因为类太重了,用不上。
function taskFactory(delay: number, action: Task['action']): Task {
  return {
    delay,
    action,
  }
}

调度系统方面,我们使用队列这一数据结构,在任务调度的场景下十分常见,比如JS的核心机制:事件循环,就是在任务队列+调用栈之上运转的。

class TaskQueue {
  constructor() {}

  queue: Task[] = []

  totalDelay = 0 // 重点关注
  run() { // 重点关注
    this.queue.forEach((task) => {
      this.totalDelay += task.delay
      setTimeout(() => {
        task.action()
      }, this.totalDelay)
    })
  }
  addTask(task: Task) {
    this.queue.push(task)
  }
  unshiftTask(task: Task) {
    this.queue.unshift(task)
  }
}

这段代码大家都能看懂,重点关注totalDelayrun方法。这个totalDelay就是我们调度系统中的上下文。run则是调度的核心:维护上下文并根据上下文来确定任务的调用时机。至此

与实际场景集成

大家应该发现了,这套调度系统和我们的实际场景LazyMan是完全解耦的,因为实际场景是千变万化的,我们要确保可扩展性。现在我们将二者集成:

class LazyMan {
  name: string
  private taskQueue: TaskQueue

  constructor(name: string) {
    this.name = name
    this.taskQueue = new TaskQueue()
    this.greet()
    setTimeout(() => {
      this.taskQueue.run()
    })
  }

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

  sleep(second: number) {
    this.taskQueue.addTask(
      taskFactory(second * 1000, () => {
        console.log(`睡了 ${second}秒`)
      })
    )
    return this
  }

  sleepFirst(second: number) {
    this.taskQueue.unshiftTask(
      taskFactory(second * 1000, () => {
        console.log(`睡了 ${second}秒`)
      })
    )
    return this
  }

  eat(food: string) {
    this.taskQueue.addTask(
      taskFactory(0, () => {
        console.log(`吃了 ${food}`)
      })
    )
    return this
  }
}

注意要在一个异步任务中调用taskQueue.run(),或者自己实现一套订阅、触发机制,而如果要做这些改动也只要改动LazyMan而不用去动taskQueue。