Skip to content

执行机制 - 如何在保证页面运行流畅的情况下处理海量数据 #38

@logan70

Description

@logan70

如何在保证页面运行流畅的情况下处理海量数据

如何保证流畅

从用户的输入,再到显示器在视觉上给用户的输出,这一过程如果超过100ms,那么用户会察觉到网页的卡顿。

由于JS是单线程的,并且JS线程和UI渲染线程是互斥的,所以保证页面流畅的关键在于避免长耗时任务阻塞主线程

W3C性能工作组在 LongTask规范 中也将超过50ms的任务定义为长任务。50ms这个阈值标准来源于 《RAIL Model》

避免长任务的一种方案是使用Web Worker,将长任务放在Worker线程中执行,缺点是无法访问DOM,另一种方案就是下面要讲的时间切片

时间切片及基础实现

时间切片是一种概念,也可以理解为一种技术方案,核心思想是:如果任务不能在规定时间内执行完,那么为了不阻塞主线程,这个任务应该让出主线程的控制权。

我们可以利用Generator 函数可以暂停执行和恢复执行的特性来实现时间切片。

// 任务列表
const tasks = [
  () => 'task1',
  () => 'task2',
  () => 'task3',
]

// Generator函数
function *gen() {
  for (const i in tasks) {
    yield tasks[i]()
  }
}

// 生成迭代器
const g = gen()

// 依次执行任务
g.next() // {value: "task1", done: false}
g.next() // {value: "task2", done: false}
g.next() // {value: "task3", done: false}
g.next() // {value: undefined, done: true}

当然我们也可以只用一只循环来执行任务,但是如果我们要将任务分批执行,还需要手动记录任务执行到了哪一个,一遍下次继续上次的进度执行。使用Generator函数,生成的迭代器内部会记录状态,省去了我们自己记录的麻烦。

使用Generator函数的另一个好处是如果碰到另一个 Generator 函数(假设函数名为foo),可以使用yield* foo()将其融入我们的迭代队列。

function *foo() {
  yield 'foo'
  yield 'bar'
}

function *baz() {
  yield* foo()
  yield 'baz'
}

const g = baz()

g.next() // {value: "foo", done: false}
g.next() // {value: "bar", done: false}
g.next() // {value: "baz", done: false}
g.next() // {value: undefined, done: true}

最终实现

下面是一个基于GeneratorrequestAnimationFrame的通用时间切片函数。

function timeSlice(tasks, during = 50) {
  const g = gen(tasks) // 生成迭代器
  const next = () => {
    const startTime = performance.now()
    let res

    // 未执行完成且执行时间小于单次执行最大时间时,执行下一个任务
    // 否则放入requestAnimationFrame,下次渲染前执行
    // 
    do {
      res = g.next()
    } while (!res.done && performance.now() - startTime < during)

    if (res.done) return
    window.requestAnimationFrame(next)
  }
  window.requestAnimationFrame(next)
}

function *gen(tasks) {
  for (const task of tasks) {
    if (Object.prototype.toString.call(task) === '[object GeneratorFunction]') {
      // `yield`的作用是:当task为Generator函数时,将其执行生成的迭代器嵌套展开
      yield* task()
    } else {
      yield task()
    }
  }
}

优化对比

同步执行

我们先来看一看一段同步代码的执行效果及表现图:

function task() {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    const p = document.createElement('p')
    p.innerText = 'time slicing'
    document.body.appendChild(p)
  }
}
task()

可以清楚的看到,所有的js都执行完毕后才进行渲染,会给用户造成卡顿感。

时间切片

然后看下将任务进行时间切片后的效果:

function *task() {
  const start = performance.now()
  while (performance.now() - start < 1000) {
    const p = document.createElement('p')
    p.innerText = 'time slicing'
    document.body.appendChild(p)
    yield
  }
}

timeSlice([ task ])

可以清楚看到将任务时间切片后,分为多段进行执行渲染,这样可以提升页面响应速度。

海量数据处理也可以采用时间分片的处理方式,可以将执行间隔可以设定得更小(以60帧为准,可设定为16ms),这样就可以基本保证不阻塞主线程、不影响页面流畅性。

其实时间切片并不是将页面总执行/渲染时间减少(相反会增加),而是通过更快地使用户看到变化、更快地响应用户输入来达优化效果。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions