手把手教你写前端框架(四):任务调度

503 阅读7分钟

本系列文章是分享我自己一步一步编写整个框架的过程,有兴趣的xdm可以参考源代码阅读。git仓库:github.com/sullay/Art-…

通过之前的努力我们已经实现,框架的渲染已经数据更新。这篇文章中我们将通过任务调度来优化框架的渲染性能。

任务队列

设计我们的任务队列之前我们首先需要清楚,我们的任务队列要完成哪些工作。

任务调度的实现非常多下面以art-js框架中的任务调度为例,任务队列需要支持以下功能:

  • 允许通过key值更新任务,防止单个组件的重复更新
  • 允许传入一组回调函数在任务完成时调用
  • 可以通过优先级指定任务的执行顺序
  • 任务更新时如果优先级提高需要调整任务的执行顺序

一般来说优先级队列我们会最先想到使用这种数据结构来存储,但是如果想要通过key值来快速定位并修改任务的话就需要维护一组频繁变更的下标,实现上会比较繁琐,性能也不会特别好。

此处我使用一个map+双向跳表的数据结构来实现我们的任务队列。(不了解跳表的xdm可以先去了解一下跳表的基本原理再继续看)

简单分析一下为什么要这样实现,任务队列主要功能就是插入任务与取出任务。

取出任务时,我们的操作是从跳表中取出首个任务,然后根据key值从map中删除,这个操作我们需要的时间复杂度只有O(1)。

出入任务时分多种情况

  • map中不存在相同key值的任务,此时我们需要将新任务存入map,并且根据优先级插入到跳表中,时间复杂度为O(logN)

  • map中存在相同key值任务并且新任务优先级更低,此时我们只需要通过map查找出任务,然后更新任务、合并回调函数,时间复杂度为O(1)

  • map中存在相同key值任务并且新任务优先级更高,此时我们除了要通过map查找出任务,然后更新任务、合并回调函数外,还需要向前移动任务在跳表中的位置,时间复杂度为O(logN)。

直接上代码实现

const MAX_LEVEL = 16;
const HEAD = Symbol('HEAD');
const TAIL = Symbol('TAIL');


export const PRIORITY_TYPES = {
  IMMEDIATE_PRIORITY_TIMEOUT: -1,
  HIGH_BLOCKING_PRIORITY_TIMEOUT: 250,
  NORMAL_PRIORITY_TIMEOUT: 1000,
  LOW_PRIORITY_TIMEOUT: 5000,
  IDLE_PRIORITY_TIMEOUT: 1073741823,
}

// 单个任务
export class Task {
  constructor(key, val, callbackList, priority) {
    this.key = key;
    // 任务的方法
    this.val = val;
    // 任务的所有回调函数
    this.callbackList = callbackList;
    // 任务最后超时时间
    this.timeout = performance.now() + priority;
  }
}
// 任务节点包装一层标记前后任务
class Node {
  constructor({ key, val = () => { }, callbackList = [], priority = PRIORITY_TYPES.NORMAL_PRIORITY_TIMEOUT } = {}) {
    this.task = new Task(key, val, callbackList, priority);
    // 跳表各层级后一个节点
    this.next = new Array(MAX_LEVEL).fill(null);
    // 跳表各层级前一个节点
    this.pre = new Array(MAX_LEVEL).fill(null);
  }
}

// 任务列表
export class TaskList {
  constructor() {
    // 使用map可以通过key值直接定位到目标任务
    this.map = new Map();
    // 跳表最大层级
    this.maxLevel = 0;
    // 跳表头
    this.head = new Node({ key: HEAD });
    // 跳表尾
    this.tail = new Node({ key: TAIL });
    // 空跳表链接收尾
    for (let i = 0; i < MAX_LEVEL; i++) {
      this.head.next[i] = this.tail;
      this.tail.pre[i] = this.head;
    }
  }
  // 获取当前节点层级
  static getLevel() {
    let level = 1;
    for (let i = 1; i < MAX_LEVEL; i++) {
      if (Math.random() > 0.5) level++;
    }
    return level;
  }
  // 通过任务key获取任务
  get(key) {
    return this.map.get(key);
  }
  // 判断是否存在任务
  has(key) {
    return this.map.has(key);
  }
  // 获取最紧急任务的超时时间
  getFirstTimeOut() {
    if (this.head.next[0] === this.tail) return null;
    return this.head.next[0].task.timeout;
  }

  // 取出首个任务
  shift() {
    // 任务为空
    if (this.head.next[0] === this.tail) return null;
    // 首个任务
    let currentNode = this.head.next[0];
    // 从跳表中删除首个任务
    for (let i = this.maxLevel - 1; i >= 0; i--) {
      if (currentNode.next[i] && currentNode.pre[i]) {
        currentNode.pre[i].next[i] = currentNode.next[i];
        currentNode.next[i].pre[i] = currentNode.pre[i];
      }
      // 删除后判断当前层级是否为空,空则最大高度-1
      if (this.head.next[i] === this.tail) this.maxLevel--;
    }
    // 删除map中的任务
    this.map.delete(currentNode.task.key);
    return currentNode.task;
  }

  // 添加任务,如果已经存在相同key的任务,更新任务方法,回调函数合并到callbackList,并根据超时时间移动位置。
  put({ key = Symbol('default'), val = () => { }, callbackList = [], priority = PRIORITY_TYPES.NORMAL_PRIORITY_TIMEOUT }) {
    if (this.has(key)) {
      // 已经存在key值相同的任务
      // 获取相同key值任务
      let node = this.get(key);
      // 计算新任务的超时时间
      let timeout = performance.now() + priority;
      // 旧任务重新赋值
      node.task.val = val;
      // 合并新旧任务回调函数
      node.task.callbackList = node.task.callbackList.concat(callbackList);
      // 如果新任务更紧急,则修改过期时间,并移动位置
      if (timeout < node.task.timeout) {
        // 赋值超时时间
        node.timeout = timeout;
        let level = this.maxLevel;
        let nextNode = node.next[level - 1];
        // 计算当前任务的层级与最高层级的下一个任务
        while (!nextNode) {
          level--;
          nextNode = node.next[level - 1];
        }
        // 从跳表中删除该任务
        for (let i = level - 1; i >= 0; i--) {
          node.pre[i].next[i] = node.next[i];
          node.next[i].pre[i] = node.pre[i];
        }
        // 各层级插入新的位置
        for (let i = level - 1; i >= 0; i--) {
          while (nextNode.pre[i] !== this.head && nextNode.pre[i].timeout > node.task.timeout) {
            nextNode = nextNode.pre[i];
          }
          node.next[i] = nextNode;
          node.pre[i] = nextNode.pre[i];
          nextNode.pre[i] = node;
          node.pre[i].next[i] = node;
        }
      }
    } else {
      // 不存在key值相同的任务
      // 生成当前任务跳表层级
      let level = TaskList.getLevel();
      // 创建任务
      let node = new Node({ key, val, callbackList, priority });
      // 将新任务插入map
      this.map.set(key, node);
      // 将任务根据超时时间插入跳表,超时时间相同插入到最后
      let preNode = this.head;
      for (let i = level - 1; i >= 0; i--) {
        while (preNode.next[i] !== this.tail && preNode.next[i].task.timeout <= node.task.timeout) {
          preNode = preNode.next[i];
        }
        node.pre[i] = preNode;
        node.next[i] = preNode.next[i];
        preNode.next[i] = node;
        node.next[i].pre[i] = node;
      }
      // 重新赋值跳表最大层级
      if (level > this.maxLevel) this.maxLevel = level;
    }
  }
}

使用任务队列

此时我们已经完成了一个性能出色的任务队列,接下来我们需要将数据更新操作插入到任务队列中。 修改一下Component

// 自定义组件类
export class Component {
  constructor({
    props = {}
  }) {
    this.data = {}
    this.props = props;
  }

  // 更新响应式数据
  setData(data, ...callbackList) {
    setDataFuc.call(this, data, callbackList)
  }
  setDataNow(data, ...callbackList) {
    setDataFuc.call(this, data, callbackList, PRIORITY_TYPES.IMMEDIATE_PRIORITY_TIMEOUT)
  }
  forceUpdate() {
    setDataFuc.call(this)
  }
}

function setDataFuc(data = {}, callbackList, priority) {
  pushTask({
    key: this.$vNode,
    val: () => {
      for (const key in data) {
        this.data[key] = data[key];
      }
      this.$vNode.updateComponent();
    },
    callbackList,
    priority
  });
}

这里我们提供了三个方法:

  • setData 正常使用的数据更新方法
  • setDataNow 任务优先级设置为立即执行,此任务必须执行完以后才会释放资源,如果一次渲染中存在过多的此优先级任务可能会导致一帧执行时间过长,造成视觉上的卡顿,并且阻塞用户的操作
  • 强制渲染

任务调度

编写任务调度代码之前需要了解一下,浏览器渲染一帧究竟要完成哪些工作?

image.png

通过上图可看到,一帧内需要完成如下六个步骤的任务:

  • 响应用户交互事件
  • 执行js代码
  • 帧开始,窗口尺寸变更,页面滚动等
  • rAF
  • 布局
  • 渲染

其中有三个时间点可以执行我们的任务队列

  • js执行阶段 在 js执行阶段执行任务队列中的任务时比较常见的做法,可以通过将未执行完的任务放入宏任务,使得下一帧中继续执行未完成的任务,缺点是无法与渲染帧对齐。

  • rAF阶段 通过调用requestAnimationFrame方法,可以在rAF阶段执行任务,可以保证对齐渲染帧比较适合修改dom的操作。

  • requestIdleCallback requestIdleCallback方法的回调函数可以在某一帧结束后的剩余时间内执行,可以最大限度的利用好一帧的时间。 但是此时一帧的渲染已经结束,如果再修改dom的话需要到下一帧才能完成渲染。并且更新dom的用时很难估算,有可能造成超过一帧的用时,造成卡顿。

时间分片

如果我们任务队列中的任务很多,要在一帧中全部执行完虽然可以保证速度最快,但是这一帧的时长必然会很长,造成卡顿,并且这段时间内都无法响应用户的操作。

我们可以通过时间分片在每一帧中只执行一段时间后便释放资源,到了下一帧中继续执行。通过这种方式就可以在任务队列执行期间不阻塞用户事件与页面渲染。

如下是我任务调度的实现

import { TaskList } from '../modal/Scheduler';

const taskList = new TaskList();
let isWorking = false;

function workLoop(timestamp) {
  while (taskList.getFirstTimeOut() && (performance.now() - timestamp < 5 || taskList.getFirstTimeOut() <= performance.now())) {
    let task = taskList.shift();
    task.val();
    for (const callback of task.callbackList) {
      callback();
    }
  }
  if (taskList.getFirstTimeOut()) {
    requestAnimationFrame(workLoop);
  } else {
    isWorking = false;
  }
}

export function pushTask(task) {
  taskList.put(task);
  if (!isWorking) {
    isWorking = true;
    requestAnimationFrame(workLoop)
  };
}

到此为止我们已经实现了一个性能出色的任务调度,此时我们的框架已经基本完成,可以正常使用了,但是框架的性能还有很大的提升空间,下一篇文章中我会找出框架中存在的一些性能问题并进行优化。

xdm觉得这系列文章对你有帮助的点赞支持啊!!!

前端框架ArtJs也欢迎大家体验使用,欢迎各位大神提出问题和意见。

github.com/sullay/Art-…

npm i js-art