點燈坊

失くすものさえない今が強くなるチャンスよ

Web Worker 初體驗

Sam Xiao's Avatar 2021-04-15

由於 Browser 只有一個 UI Thread 特性,若同時在此 Thread 執行很耗 CPU 運算,將使得 UI 有 Freeze 感覺,若能改用 Web Worker 執行耗 CPU 部分,將大幅提升使用者體驗。

Version

Vue 2.6.11
Comlink 4.3.0

Web Worker

worker000

輸入為 Sam,按下 Uppercase button 後輸出全大寫的 SAM

假設 toUpper() 是一個很耗 CPU 的 synchronous function,這使得按下 button 後,整個畫面會 freeze。

由於 Browser 只有一個 UI thread,若將很耗 CPU 的邏輯也執行在 UI thread,勢必造成整個畫面 freeze,必較好的方式是將很耗 CPU 部分改寫在 Web Worker thread,如此將不會影響 UI thread 執行

Add Worker Loader

$ yarn add worker-loader --dev

由於 Vue CLI 是基於 Webpack,因此安裝 worker-loader 讓 Web Worker 能在 Webpack 下使用。

App.vue

<template>
  <div>
    <input v-model="message">
    <button @click="onClick">Uppercase</button>
    {{ result }}
  </div>
</template>

<script>
import WebWorker from 'worker-loader!./webWorker'

let webWorker = new WebWorker

let onClick = function() {
  webWorker.postMessage(this.message)
  webWorker.addEventListener('message', x => this.result = x.data)
}

export default {
  data: () => ({
    message: '',
    result: ''
  }),
  methods: {
    onClick
  }
}
</script>

10 行

import WebWorker from 'worker-loader!./webWorker'

引入 webWorker.js,稍後會將 Web Worker thread 的 code 寫在此。

12 行

let webWorker = new WebWorker

建立 Web Worker。

14 行

let onClick = function() {
  webWorker.postMessage(this.message)
  webWorker.addEventListener('message', x => this.result = x.data)
}
  • 使用 postMessage() 將 data 傳入 Web Worker
  • 使用 addEventListener('message') 聆聽 message event,Web Worker 將藉此 message event 從 Web Worker thread 傳出資料

由於 Web Worker 為 asynchronous,因此藉由 event 傳遞 data 可以理解,但 event 畢竟還是沒 Promise 好用,若能使用 Promise 就太好了

webWorker.js

self.addEventListener('message',
  x => self.postMessage(x.data.toUpperCase())
)

在 Web Worker thread 一律使用 self 存取 Web Worker。

由於 Vue 使用 postMessage() 對 Web Worker 傳送 data,因此 Web Worker 也要聆聽 message event 接收 data。

在 callback 內就可執行假設 很耗時toUpperCase(),最後再以 postMessage() 將結果傳回 Vue。

可發現 Web Worker 基本上就是使用 postMessage()addEventListener('message') 彼此傳遞 data,雖然能達成在另一個 thread 執行耗 CPU 運算,但其 event-based API 實在很難用

$ yarn add comlink

安裝 GoogleChromeLabs 的 comlink,讓我們以 promise-based API 使用 Web Worker。

App.vue

<template>
  <div>
    <input v-model="message">
    <button @click="onClick">Uppercase</button>
    {{ result }}
  </div>
</template>

<script>
import WebWorker from 'worker-loader!./webWorker'
import { wrap } from 'comlink'

let { toUpper } = wrap(new WebWorker)

let onClick = function() {
  toUpper(this.message).then(
    x => this.result = x
  )
}

export default {
  data: () => ({
    message: '',
    result: ''
  }),
  methods: {
    onClick
  }
}
</script>

11 行

import { wrap } from 'comlink'

從 comlink 引入 wrap()

13 行

let { toUpper } = wrap(new WebWorker)

使用 wrap() 將 Web Worker Object 包成 comlink 自己的 object,並且 destructure 出自行提供的 toUpper()

15 行

let onClick = function() {
  toUpper(this.message).then(
    x => this.result = x
  )
}

由於 comlink 會使 function 都從 event-based 改成 promise-based,因此 toUpper() 回傳 Promise,可在 then() 寫入 side effect 在 HTML template 顯示。

webWorker.js

import { expose } from 'comlink'

let toUpper = msg => msg.toUpperCase()

expose({ toUpper })

第 1 行

import { expose } from 'comlink'

從 comlink 引入 expose()

第 3 行

let toUpper = msg => msg.toUpperCase()

定義自己很耗 CPU 的 function。

第 5 行

expose({ toUpperCase })

使用 expose() 將自定義 funciton 包成 Object 傳入,expose() 將回傳 Web Worker。

Function Pipeline

App.vue

<template>
  <div>
    <input v-model="message">
    <button @click="onClick">Uppercase</button>
    {{ result }}
  </div>
</template>

<script>
import { pipe, andThen as then, always } from 'ramda'
import WebWorker from 'worker-loader!./webWorker'
import { wrap } from 'comlink'

let { toUpper } = wrap(new WebWorker)

let onClick = function() {
  pipe(
    always(this.message),
    toUpper,
    then(x => this.result = x)
  )()
}

export default {
  data: () => ({
    message: '',
    result: ''
  }),
  methods: {
    onClick
  }
}
</script>

16 行

let onClick = function() {
  pipe(
    always(this.message),
    toUpper,
    then(x => this.result = x)
  )()
}

由於 toUpper() 回傳為 Promise,因此可輕易整合在 Function Pipeline 中。

webWorker.js

import { expose } from 'comlink'
import { toUpper } from 'ramda'

expose({ toUpper })

toUpper() 也可改用 Ramda 版本。

Conclusion

  • 本範例僅用簡單的 toUpper() 示意,實務上可以改成自定義很耗 CPU 的 function
  • Comlink 明顯比 Web Worker 原生 API 好用,且由於是回傳 Promise 可輕易 Function Pipeline

Reference

Игорь Харченко, Vue Performance Optimization Using a Web Worker
MDN, Web Worker API
Webpack, worker-loader
GoogleChromeLabs, comlink