批量任務(wù)導(dǎo)致頁(yè)面卡死?怎么辦?任務(wù)拆分?

需求背景需要基于高德地圖展示海量點(diǎn)位(大概幾萬(wàn)個(gè)),點(diǎn)位樣式要自定義(創(chuàng)建DOM),雖然使用了聚合點(diǎn),但初始化時(shí)仍需要將幾萬(wàn)個(gè)點(diǎn)位的DOM結(jié)構(gòu)都創(chuàng)建出來(lái)。

這里補(bǔ)充一句,高德地圖在2.0版本對(duì)這種方式進(jìn)行了優(yōu)化,但同時(shí)少了某些功能,我的需求要使用1.4版本的這種方式渲染。

問(wèn)題及定位分析
功能實(shí)現(xiàn)后,發(fā)現(xiàn)從開(kāi)始加載點(diǎn)位,到點(diǎn)位出現(xiàn)的過(guò)程中,頁(yè)面會(huì)卡死,無(wú)法響應(yīng)用戶交互,可以點(diǎn)擊Demo的常規(guī)模式查看效果(實(shí)際業(yè)務(wù)下有更多邏輯,阻塞時(shí)間會(huì)更久)。

238722bk-1.png

可以看到,當(dāng)我開(kāi)始渲染點(diǎn)位后,點(diǎn)擊輸入框進(jìn)行輸入,是沒(méi)有立即響應(yīng)的,點(diǎn)位加載完后才會(huì)對(duì)之前的交互做響應(yīng)。

問(wèn)題分析
其實(shí)從上面高德地圖的點(diǎn)位渲染邏輯很容易想到主要是批量創(chuàng)建點(diǎn)位的DOM結(jié)構(gòu)占用了主線程

238722bk-2.png

可以看到,批量的genMarker任務(wù)占用了大量時(shí)間,genMarker會(huì)在每次創(chuàng)建點(diǎn)位時(shí)執(zhí)行一次,一次創(chuàng)建4w個(gè)點(diǎn)位,就會(huì)連續(xù)執(zhí)行4w次。

// 生成點(diǎn)位,創(chuàng)建DOM自定義樣式
genMarker(device) {
    const innerHTML = `
      <div class="camera"></div>
    `
    const size = [48, 49]
    const markerOffset = new AMap.Pixel(-size[0] / 2, -size[1] / 2)
    const marker = new AMap.Marker({
      position: device.lnglat,
      extData: device,
      size,
    })
    const container = document.createElement('div')
    container.className = 'map-marker'
    container.innerHTML = innerHTML
    marker.setContent(container)
    marker.setOffset(markerOffset)
    marker.selected = false

    return marker
}
頁(yè)面顯示機(jī)制
動(dòng)的畫(huà)面其實(shí)是由一幀一幀的靜態(tài)圖快速切換組成的,人眼的反應(yīng)速度有限,當(dāng)畫(huà)面切換的夠快,人眼看著就是連續(xù)的動(dòng)畫(huà)了。

對(duì)于人眼來(lái)說(shuō),當(dāng)每秒切換60張圖片時(shí),就會(huì)認(rèn)為是連貫的。所以主流的顯示器是60hz的,1s刷新60次,那么每16.7ms需要刷新一次,瀏覽器會(huì)自動(dòng)適配這個(gè)頻率,這時(shí)對(duì)應(yīng)我們前端頁(yè)面就是每16.7ms需要渲染一次。

238722bk-3.png

頁(yè)面每隔16.7ms才會(huì)渲染一次,那么在兩次渲染的中間時(shí)間,就是瀏覽器的空閑時(shí)間,在這段空閑時(shí)間執(zhí)行的任務(wù),是不會(huì)阻塞到頁(yè)面渲染的流暢性的。反之,對(duì)于上面的案例,數(shù)萬(wàn)個(gè)genMarker在一個(gè)幀區(qū)間內(nèi)連續(xù)的執(zhí)行,下一幀一直不能渲染,頁(yè)面看起來(lái)就被卡住了。

238722bk-4.png

任務(wù)拆分
對(duì)于大量的計(jì)算或許首先考慮的是Web Worker使其不占用主線程,但是由于要操作DOM,不適合當(dāng)前場(chǎng)景。

對(duì)于頁(yè)面的流暢性來(lái)說(shuō),這些點(diǎn)位的創(chuàng)建屬于「低優(yōu)先級(jí)任務(wù)」。既然卡頓的原因是這些genMarker任務(wù)一個(gè)接一個(gè)的「連續(xù)」的在執(zhí)行,一直占用著主線程,那么我們可以將這些批量的任務(wù)進(jìn)行拆分,保證這些任務(wù)只在空閑時(shí)間執(zhí)行。每次執(zhí)行下一個(gè)任務(wù)的時(shí)候,先檢查一下當(dāng)前頁(yè)面是否該渲染下一幀了,這時(shí)需要「把主線程讓出來(lái)」,讓頁(yè)面進(jìn)行渲染(了解react的人應(yīng)該感覺(jué)很熟悉,思路來(lái)自react的Fiber)

238722bk-5.png

requestIdleCallback
「讓出主線程」,關(guān)鍵的一點(diǎn)在于我們?nèi)绾沃朗裁磿r(shí)候是空閑時(shí)間,什么時(shí)候空閑時(shí)間結(jié)束,該進(jìn)行渲染了。requestIdleCallback就是瀏覽器提供給我們用來(lái)判斷這個(gè)時(shí)機(jī)的api,它會(huì)在瀏覽器的空閑時(shí)間來(lái)執(zhí)行傳給它的回調(diào)函數(shù)。另外如果指定了超時(shí)時(shí)間,會(huì)在超時(shí)后的下一幀強(qiáng)制執(zhí)行

const id = window.requestIdleCallback((deadline) => {
  // 當(dāng)前幀剩余時(shí)間大于0,或任務(wù)已超時(shí)
  if(deadline.timeRemaining() > 0 || deadline.didTimeout) {
      // do something
      console.log(1)
  }
}, { timeout: 2000 }) // 指定超時(shí)時(shí)間

// window.cancelIdleCallback(id) 與定時(shí)器類似,支持取消

requestIdleCallback在Event Loop的執(zhí)行時(shí)機(jī)如下圖所示,藍(lán)色區(qū)域代表一幀內(nèi)的渲染任務(wù),當(dāng)這些任務(wù)執(zhí)行完后,剩余的時(shí)間被認(rèn)為是空閑時(shí)間

238722bk-6.png

以一個(gè)簡(jiǎn)單的任務(wù)(singlTask)為例,以常規(guī)模式連續(xù)執(zhí)行2w次,全部執(zhí)行完需要大概2s時(shí)間(依賴機(jī)器性能變化),這期間主線程被一直被占用,頁(yè)面會(huì)被卡住。

function singleTask() {
  const now = performance.now()
  while (performance.now() - now < 0.001) { } // 模擬耗時(shí)操作,每次任務(wù)耗時(shí)約0.001ms
}

const data = new Array(20000).fill(1)

function normarlRun() {
  for (let i = 0; i < data.length; i++) {
    // 2w個(gè)任務(wù)連續(xù)執(zhí)行
    singleTask(data[i])
  }
  result('done')
}
對(duì)其使用requestIdleCallback進(jìn)行拆分,只在空閑時(shí)間執(zhí)行部分任務(wù),若當(dāng)前幀的空閑時(shí)間結(jié)束,則暫停批量任務(wù),讓出主線程:

function ridRun() {
  let i = 0
  let option = { timeout: 200 } // 任務(wù)超時(shí)時(shí)間

  function handler(idleDeadline) {
    while ((idleDeadline.timeRemaining() > 0 || idleDeadline.didTimeout) && i < data.length) {
      // 當(dāng)前幀有剩余時(shí)間,或任務(wù)已等待超時(shí)強(qiáng)制執(zhí)行
      singleTask(data[i++])
    }
    
    // idleDeadline.timeRemaining() === 0 當(dāng)前幀已沒(méi)有空閑時(shí)間,讓出主線程

    if (i < data.length) {
      window.requestIdleCallback(handler, option) // 任務(wù)未執(zhí)行完,繼續(xù)等待下次空閑時(shí)間執(zhí)行
    } else {
      result('done')
    }
  }

  window.requestIdleCallback(handler, option)
}
模擬requestIdleCallback
不幸的是requestIdleCallback兼容性不夠好,Safari完全不支持:

參考react的實(shí)現(xiàn),我們可以使用requestAnimationFrame和MessageChannel來(lái)模擬實(shí)現(xiàn)一個(gè)requestIdleCallback requestAnimationFrame在每一幀開(kāi)始渲染前執(zhí)行(見(jiàn)上面的Event Loopt圖),當(dāng)幀開(kāi)始渲染前,我們標(biāo)記開(kāi)始時(shí)間(start),并使用MessageChannel創(chuàng)建一個(gè)宏任務(wù),根據(jù)上面的Event Loop流程,渲染完畢后,會(huì)執(zhí)行剛才創(chuàng)建出的宏任務(wù),這時(shí)在宏任務(wù)中對(duì)比標(biāo)記的開(kāi)始時(shí)間,是否超出了一幀的渲染時(shí)間(current - start > 16.7),來(lái)判斷當(dāng)前是否是空閑時(shí)間。

setTimeout即使指定時(shí)間為0 瀏覽器實(shí)際也會(huì)延時(shí)幾毫秒后才執(zhí)行(chrome大概為4ms),因此使用MessageChannel而不是setTimeout來(lái)創(chuàng)建宏任務(wù)

模擬requestIdleCallback的具體實(shí)現(xiàn):

const genId = (function () {
  let id = 0
  return function () {
    return ++id
  }
})()

const idMap: {
  [key: number]: number
} = {}

const _requestIdleCallback: (
  cb: (idleDeadline: IdleDeadline) => void,
  options?: { timeout: number }
) => number = function (cb, options) {
  const channel = new MessageChannel()
  const port1 = channel.port1
  const port2 = channel.port2
  let deadlineTime: number // 超時(shí)時(shí)間
  let frameDeadlineTime: number // 當(dāng)前幀的截止時(shí)間
  let callback: (idleDeadline: IdleDeadline) => void

  const id = genId()

  port2.onmessage = () => {
    const frameTimeRemaining = () => frameDeadlineTime - performance.now() // 獲取當(dāng)前幀剩余時(shí)間
    const didTimeout = performance.now() >= deadlineTime // 是否超時(shí)

    if (didTimeout || frameTimeRemaining() > 0) {
      const idleDeadline = {
        timeRemaining: frameTimeRemaining,
        didTimeout
      }
      callback && callback(idleDeadline)
    } else {
      idMap[id] = requestAnimationFrame((timeStamp) => {
        frameDeadlineTime = timeStamp + 16.7
        port1.postMessage(null)
      })
    }
  }

  idMap[id] = window.requestAnimationFrame((timeStamp) => {
    frameDeadlineTime = timeStamp + 16.7 // 當(dāng)前幀截止時(shí)間,按照 60fps 計(jì)算
    deadlineTime = options?.timeout ? timeStamp + options.timeout : Infinity // 超時(shí)時(shí)間
    callback = cb
    port1.postMessage(null)
  })

  return id
}

const _cancelIdleCallback = function (id: number) {
  if (!idMap[id]) return
  window.cancelAnimationFrame(idMap[id])
  delete idMap[id]
}

export const requestIdleCallback = window.requestIdleCallback || _requestIdleCallback
export const cancelIdleCallback = window.cancelIdleCallback || _cancelIdleCallback
使用requestIdleCallback拆分點(diǎn)位生成
將genMarker批量任務(wù)進(jìn)行拆分,只在空閑時(shí)間時(shí)間進(jìn)行拆分:

addMarkersByRid() {
    cancelIdleCallback(this.ridId)
    const { markerList, points, genMarker, genCluster } = this
    let index = 0
    const ridOption = { timeout: 20 }
    const handler = (idleDeadline) => {
      const { timeRemaining } = idleDeadline
      // 只在空閑時(shí)間生成點(diǎn)位
      while (timeRemaining() > 0 && index < points.length) {
        const device = points[index]
        const marker = genMarker(device)
        markerList.push(marker)
        index++
      }
      if (index < points.length) {
        this.ridId = requestIdleCallback(handler, ridOption)
      } else {
        console.log('done') // 全部點(diǎn)位生成完畢
      }
    }
    this.ridId = requestIdleCallback(handler, ridOption)
}

238722bk-7.png

可以看到,點(diǎn)位的渲染并沒(méi)有再影響到頁(yè)面的響應(yīng)了



作者:rasck


歡迎關(guān)注微信公眾號(hào) :前端Q