gmfe / Think

观麦前端团队的官方博客
68 stars 3 forks source link

Web Worker实践分享 #51

Open Realwate opened 5 years ago

Realwate commented 5 years ago

背景

一个搜索页面,发送请求拿到数据后,需要对数据做一些计算和处理,然后展示出来。在数据量大的情况下,这个数据处理过程会很耗时,造成浏览器卡顿 or 卡死 or 崩溃。 原因是因为js代码和UI渲染运行在同一个线程,js执行时间过长影响到了正常的渲染。

方案1

减少js代码的执行时间,给浏览器渲染的机会。将大量计算拆分为微任务,控制每个微任务的执行时长。 setTimeoutrequestAnimationFrame ...

方案2

在另外一个线程执行运算,不干扰主线程。 Web Worker ...

更多

(没想到更多,欢迎补充)

Web Worker介绍

基于事件通信

// 运行在主线程
var dataSortWorker = new Worker("sort-worker.js");
dataSortWorker.postMesssage(dataToSort);
// 不阻塞下面代码的执行
/* ... */

dataSortWorker.addEventListener('message', function(evt) {
    var sortedData = evt.data;
   // 拿到worker返回结果...
});
// sort-worker.js 运行在worker线程
self.onmessage = function(e){
// e.data
  let result = sort() // 执行复杂运算
  self.postMessae(result)
}

需要注意的地方

  1. 不能访问dom
  2. 拥有独立的全局对象(self)和执行环境(与主线程互不干扰),没有window对象
  3. 加载 Woker严格遵循同源策略(CORS无效)
  4. 主线程与worker线程的数据传递是拷贝传输(可以直接传递给worker,但是有条件限制transferable objects)
  5. 等等MDN传送门...

代码1.0

原始的 Worker API基于事件的,过于底层,不方便集成到我们的业务代码中来。需要做一层封装

总的来说,希望实现以下两个目标:

  1. 基于事件类型的分发 分别实现 事件触发(trigger) and 事件处理(handler) 代码即可
  2. 触发事件会返回Promise,这样就能将耗时长的计算(函数)视为一个异步操作, 同时配合 await ,现有代码无需做太大改动就能接入

整个过程如下

sender

将耗时长的函数放到 worker 中,形成如下代码。

// 先在 sort-worker.js 中注册事件handler
let receiver = new Receiver()
receiver.registerAction('sort', (data) => {
    let result = sort(data) // 这是一个耗时长的排序
    return result
})
// 以下代码运行在主线程
import Worker from './sort-worker.js'
// 这里的 Worker 使用 worker-loader 加载,下文会提到
let worker = new Worker()
let sender = new Sender(worker)

sender.postAction('sort', data)
  .then((result) => {
      // 拿到计算结果
      console.log(result)
  })

问题

main线程 与 worker线程 的数据传递使用的是拷贝传递。如果传递的 data 很大,序列化(JSON.stringify)和反序列化(JSON.parse)也会非常耗时,阻塞主线程。

代码2.0

第一原则:尽量减少数据在 mainworker 之间的传递 (避免不必要的序列化和反序列化开销) 第二原则:如果一定要传递,将大对象(Object,Array)做拆分,分批序列化、传递过来 (减少对主线程的占用)

目前计算场景

  1. 主线程发请求,拿到最开始的数据源, 传递给 worker ,在worker中执行计算再返回给Main
  2. 执行UI操作,需要触发对数据的更新,仍然传递数据给 worker 执行计算返回给 Main

优化

  1. 请求在 worker 中做,直接拿到最原始的数据做计算
  2. worker 可以对计算结果做缓存,同时将数据返回给 main 时会分批传递
  3. main 向 worker 传递数据时,如果存在 worker 的缓存中,可以直接用,不用再传递过去

因此在 SenderReceiver 的基础上稍做扩展,形成ManagerRegister

不同在于:

  1. Manager 只能作用在 Main 线程, Register 作用 worker 线程
  2. 对大数据量做了优化(缓存与分批传递。通过option来配置)

与组件结合

不需要感知太多的细节,同时将 worker 生命周期与组件绑定。

// data.worker.js
import { createRegister } from './web_worker/register'

// 1. 创建register
let worker = createRegister('SortWorker')

function sort(){
  /* ... */
}

// 2. 注册handler
worker.registerAction('sort', (data) => {
  return sort(data)
})
// 组件
import Worker from './data.worker.js'
import withWorker from './web_worker/with_worker'

@withWorker('Sort')
class Sort extends React.Component {
  constructor (props) {
    super(props)
      // 1. 初始化worker
      let worker = new Worker()
      this.initWorker(worker)
  }

  handlerClick = async () => {
    // 2. 调用 worker 代码
    let result = await this.postWorkerAction('sort',{})
  }

  render () {
    return <div>
        <button onclick={this.handleClick}>
          计算
        </button>
      </div>
  }
}

异常处理

  1. worker.onerror

  2. try...catch

worker 中的handler,在执行时会包裹在try...catch块中,如果执行出错,会将异常抛回给Main线程。理论上来说,所有的错误都能通过这种类似同步的方式被捕获。 但是还有可能,worker 在加载的时候就出错了,这个时候只能通过worker.onerror 事件来拿到错误信息。

其他

同源策略

Worker() 构造函数创建一个 Worker 对象,该对象执行指定的URL脚本。这个脚本必须遵守 同源策略 如果 此URL违反同源策略,会抛出一个 SECURITY_ERR 类型的DOMException

方案

  1. 不部署到 cdn ,放在服务器上 (publicPath)
  2. URL.createObjectURL (inline)
 function inline(content) {
   var blob = new Blob([content]);
   return new Worker(URL.createObjectURL(blob));
 }

Blob URL 格式为

blob:[origin]/[uuid]

blob:http://localhost:7777/ceb1fcc2-cadd-4d7e-b754-e79879589e8d

worker中碰到url时要注意,不能直接写/xxx/yy,需要对手动的做一层处理,添加 origin

与webpack的结合

webpackloader本质上只做一件事情, 即

(sourceStr) => ([codeStr,sourcemap])

worker-loader

使用 worker-loader 来加载 worker 代码,其原理如下。

loader

为了保证 worker 中的代码被 babel 转译,需要让 babel-loaderworker-loader 之前执行

loader的执行顺序

// config
module.exports = {
  //...
  module: {
    rules: [
      {
        //...
        use: [
          'a-loader',
          'b-loader',
          'c-loader'
        ]
      }
    ]
  }
};
// 执行顺序
|- a-loader `pitch`
  |- b-loader `pitch`
    |- c-loader `pitch`
      |- requested module is picked up as a dependency
    |- c-loader normal execution
  |- b-loader normal execution
|- a-loader normal execution

loader 自身的执行顺序是从后往前的。

所以将 worker-loader 放到最前面

config.module.rules.unshift({
  test: /\.worker\.js$/,
  use: [
    { loader: 'worker-loader', options: { inline: false } }
  ]
})

polyfill的处理

worker 中的环境是独立于 window 的,因此在 window 中引入的 polyfill,还需要在 worker 中再引入一次。

importScripts用于在 worker 中加载其他脚本,并且它是 同步加载 的,在所有代码执行前引入即可。

importScripts('https://xx/xx/polyfill.min.js')

问题还没有彻底解决,有以下代码

polyfill-src

async函数的转译依赖于generatorpolyfill,babel@6 转译后生成如下代码。

babel6-polyfill

babel@7 没有这个问题,如下所示

babel7-polyfill

如果项目使用的是 babel@6 ,则不能在 worker 中直接使用 async / generator

window 对象

将部分代码搬到 worker 中要注意 worker 环境的限制,比如没有 window,要将代码做一些改动才能正常运行。

// import { keyMirror } from 'gm-util' 
// gm-util is 判断使用了window

import { keyMirror } from './web_worker/util'

方案

  1. 单独提供无 window 的版本
  2. 没想到其他更好的办法(调整库的依赖关系?改动可能会太大?)...

浏览器内存限制

不同浏览器(chrome/firefox...),浏览器的不同版本(version)、不同平台(64bi/32bit,windows/macOS) 都有可能存在差异。

做了个测试,结果仅供参考。

memory-code

memory-32

memory-64

当数组长度增加到240W时,chrome有如下提示(也可能因为版本差异没有该提示)

memory-crash

最后一点点感想

感觉大数据量场景下的复杂计算,前端能做的事情有限(浏览器限制,只有一个主线程,内存限制,不能读写文件,多线程不能共享内存。。)

碰到瓶颈,只能尽量的去优化数据结构与算法,如果 数据量真的到了一定级别 还是在后端做比较好。

毕竟后端能做的事情太多。。。

---------------------------- 🐶 END 🐶 -----------------------------

silent-tan commented 5 years ago

干货十足

ghost commented 5 years ago

+++