阅读 818

两种 JavaScript 时间分片技术

React Fiber 的出现,在前端社群普及了时间分片的计算优化技术。我曾试着去学习 Fiber 的源码实现,然而最终得到的结论是这个技术只有在面试的时候应付面试官刁难有用,其对于日常开发,甚至是熟练使用 React 都并无多大帮助。

一是因为 Fiber 为了重新实现执行栈,架构设计过于复杂;二是因为 React Fiber 和 React 的组件体系紧密耦合,无法单独抽出处理通用任务。

然而,在前端开发中,我们偶尔确实需要处理计算密集型任务,而且并不是所有任务都适合放到 Worker 处理。(数据非可序列化,线程间数据传输开销等),这就需要我们将计算分片处理,避免超时占用主线程。

问题

最开始促使我研究计算分片的契机,是约两年前面试一家日本公司。对方提出一个线下作业,要求在前端处理超大数据量的新闻展示和搜索,还不影响用户操作体验。新闻的搜索是基于关键词,所以我需要给新闻进行索引:

const newsList = [
  {
    uid: '8be34939-bc25-4b9e-999d-2daf19fbea7b',
    title:
      'Adipisicing do eu magna ex non est eu labore nisi duis enim elit.',
    tags: ['reprehenderit', 'cupidatat', 'ad', 'ea', 'labore'],
  },
  {
    uid: '488399fc-e474-4c52-a36c-625e2218fabe',
    title: 'Elit aute tempor dolore do sunt.',
    tags: ['sint', 'ea', 'Lorem', 'consectetur', 'officia'],
  },
  {
    uid: '0340b317-bd55-4c43-9982-94bf9d08b977',
    title: 'Aliquip qui est sint veniam consectetur.',
    tags: ['sit', 'ullamco', 'consectetur'],
  },
  // ... 省略一万条
];
const newsMap = new Map();
newsList.forEach((news) => {
  news.tags.forEach((tag) => {
    if (!newsMap.has(tag)) {
      newsMap.set(tag, [news]);
    } else {
      newsMap.get(tag).push(news);
    }
  });
});
复制代码

方案一,CPS

经过一番研究,我决定把这个长任务拆分成多个小任务,并把这些小任务放到不同的执行栈中。利用 CPS (Continuation-passing Style) 技术,我实现了这个设计。

Promise 出现之前,上古前端开发前辈们对于 CPS 应该并不陌生。其核心特征在于,当前函数执行完成后,不是返回给调用方,而是执行由调用方提供的回调方法,并提供计算结果。例如,

fetch('https://xyz.com', (err, res) => {
    // 处理请求结果
})
复制代码

即使是没有被历史包袱折磨的新一代 JavaScript 开发者们,应该也对上面这种用回调来处理计算或者 IO 操作结果的模式很熟悉了。如果回调函数嵌套过多,代码会变得非常难看,即所谓 "回调地狱"。Promise 和 Async/Await 出现后,回调模式被扫进了历史的垃圾堆。然而,这个技术依然在解决某些计算问题时有独特优势。

我们先构建一个主任务 indexNews,并让其接受一个回调,在计算完成后执行这个回调:

function indexNews(item, tag, continuation) {
  if (!newsMap.has(tag)) {
    newsMap.set(tag, [item]);
  } else {
    newsMap.get(tag).push(item);
  }
  continuation();
}
复制代码

然后写个遍历操作的驱动器,去调度主任务:

function iterate(list, processTask, continuation) {
  function handleOne(i, j, _continuation) {
    if (i < list.length) {
      const item = list[i];
      if (j < item.tags.length) {
        processTask(item, item.tags[j], function handleNext() {
          handleOne(i, j + 1, _continuation);
        });
      } else {
        handleOne(i + 1, 0, _continuation);
      }
    } else {
      _continuation();
    }
  }
  
  handleOne(0, 0, continuation);
}
iterate(newsList, indexNews, () => {
  // 所有计算任务都已经完成
  console.log('done');
});
复制代码

indexNews方法由 iterate 方法调度,但同时,它对后者提供的 continuation 方法的调用有控制权。没错,这就是控制反转。

在获得对下一个任务怎么执行的控制权后,indexNews 就可以进行时间分片了:

function indexNews(item, tag, continuation) {
  if (!newsMap.has(tag)) {
    newsMap.set(tag, [item]);
  } else {
    newsMap.get(tag).push(item);
  }
  if (indexNews.skip++ % 500 === 0) {
    setTimeout(continuation, 0);
  } else {
    continuation();
  }
}
indexNews.skip = 0;
复制代码

这里通过对 continuation 的调度,indexNews 告诉 interate 每个执行栈只处理 500 个任务,接下来的 500 个任务放到下一个执行栈,如此延续,完成对任务的分片。

setTimeout 在这里的作用是把任务放到下一个宏任务队列里面。

上面这个方案是我当时完成作业使用的方案。这个方案有两个缺陷。

一是任务调度的控制权太弱。任务分片全靠 hack JS 引擎的事件循环,无法做到随意暂停和恢复。这导致分片时机没法做到精确,只能凭主观经验去设定(上面例子是选了 500 作为分割区间)。然而 500 个任务可能太长,依然会超时占用主线程;也有可能太短,没充分利用当前执行栈。

二是 setTimeout 计时不精确,实际时间间隔会有偏差。结果是各个任务累计延后,任务完成总时长大幅增加。

方案二:协程(Coroutine)

若某个计算任务能暂停自身,让渡执行权给其它任务,这个任务就是协程 (Coroutine)。

在 JavaScript 里面,若要实现对程序的随意暂停和恢复,同时又不像 React Fiber 的实现那样费力,只能借助 Generator 了。

首先我们实现一个协程的调度器,并不复杂:

function run(coroutine, threshold = 1, options = { timeout: 160 }) {
  return new Promise(function (resolve, reject) {
    const iterator = coroutine()
    window.requestIdleCallback(step, options)

    function step(deadline) {
      const minTime = Math.max(0.5, threshold)
      try {
        while (deadline.timeRemaining() > minTime) {
          const { value, done } = iterator.next()
          if (done) {
            resolve(value)
            return
          }
        }
      } catch (e) {
        reject(e)
        return
      }
      // 执行到这里,说明任务超时,将剩余的任务再次放到下一个 idle 周期内执行
      window.requestIdleCallback(step, options)
    }
  })
}
复制代码

调度器 run 通过 requestIdleCallback 拿到当前浏览器剩下的时间预算。在这个时间预算里面,调度器循环执行传入的协程 (iterator.next());若任务完成,则终止循环,resolve Promise;若超时未完成,则将剩下的任务通过 requestIdleCallback 再次放到下一个 idle 周期。

我们要处理的 indexNews 任务其实是一个列表 fold 操作,这里我们可以实现一个协程版本的 reduce,来辅助完成任务。

function* reduce(array, fn, initial) {
  let result = initial || array[0]
  for (let i = 0; i < array.length; i++) {
    result = yield* fn(result, array[i], i, array)
  }
  return result
}

function sliceTask(fn, yieldInterval = 10) {
  let yieldCount = 0
  return function* sliced(...params) {
    let result = fn(...params)
    if (yieldCount++ > yieldInterval) {
      yieldCount = 0
      yield
    }
    return result
  }
}

function reduceAsync(array, reducer, initial) {
  return run(compute)
  
  function* compute() {
    return yield* reduce(array, sliceTask(reducer, 20), initial)
  }
}

const indexedData = await reduceAsync(
  newsList,
  (newsMap, item) => {
    for (const tag of item.tags) {
      if (!newsMap.has(tag)) {
        newsMap.set(tag, [item])
      } else {
        newsMap.get(tag).push(item)
      }
    }
  },
  new Map()
)
复制代码

sliceTask 的作用是将普通的任务转换成协程,任务被执行一定次数(默认 10 次)后,yield 暂停,让外层驱动器有机会检查 timeRemaining 并判断是否应该中断。

上述协程调度方法不仅能在浏览器中使用,若能在 Node.js 中模拟 requestIdleCallback,其也可用于 Node 中的协程调度。(参考 React 的 scheduler,不难实现)

文章分类
前端