从 useWorker 源码开始,掌握 Web Worker

1,662 阅读5分钟

前言

在前端开发领域,性能优化是提升用户体验的关键。面对复杂或计算密集型任务时,我们必须确保不会长时间阻塞主线程,从而影响用户界面的流畅性。

Web Worker API 提供了一种在后台线程运行 JavaScript 的能力,有效避免了界面卡顿的问题。

useWorker 库进一步简化了 Web Worker 的使用,通过 React Hooks 封装了 Web Worker API,使得执行异步任务变得更加简洁和直观。

静态 Worker 脚本的使用

首先展示静态 Worker 脚本的使用(以 React 为例)。

通常我们会有一个单独的 .js 文件作为 Worker 的脚本。

worker.js

const workerCode = () => {
  onmessage = (e) => {
    // 打印从主线程发送的消息
    console.log(e.data);
    postMessage('woker收到回复');
  };
};
export default workerCode;

可以肯定的是,在 react 中如果像下面这么直接去 new 的话:

 new Worker('./worker.js');

你将会收到这个报错信息:

worker.js:1 Uncaught SyntaxError: Unexpected token '<' (at worker.js:1:1)

下面展示在组件中创建 Worker 实例并使用的正确流程:

import React, { useEffect } from 'react';
import workerCode from './worker'; // 导入 Worker 脚本

const APP: React.FC = () => {

  useEffect(() => {
    const workerUrl = URL.createObjectURL(
      new Blob([`(${workerCode})()`], { type: 'application/javascript' }),
    );

    const myWorker = new Worker(workerUrl);
    myWorker.postMessage('主线程发送信息');
    myWorker.onmessage = function (event) {
      // 接收 Worker 发送的消息
      console.log(event.data);
    };

    return () => {
      myWorker.terminate();
      URL.revokeObjectURL(workerUrl);
    };
  }, []);

  return <div>React组件内容</div>;
};

export default APP;

注意,这里使用 IIFE 来确保脚本在 Blob 中作为自执行函数运行。

或者也可以通过提取函数体的方式:

let code = workerCode.toString();

//提取函数体
code = code.substring(code.indexOf('{') + 1, code.lastIndexOf('}'));

const workerUrl = URL.createObjectURL(
  new Blob([`(${workerCode})()`], { type: 'application/javascript' }),
);

当然可以使用 worker-loader,来让静态加载的代码更加优雅。

useWorker 源码分析

探索 useWorker 的源码,我们会发现其实现并不复杂。

一些状态定义和引用。

  const [workerStatus, setWorkerStatus] = React.useState<WORKER_STATUS>(WORKER_STATUS.PENDING);
  const worker = React.useRef<Worker & { _url?: string }>();
  const isRunning = React.useRef(false);
  const promise = React.useRef<{
    [PROMISE_REJECT]?:(result: ReturnType<T> | ErrorEvent) => void;
    [PROMISE_RESOLVE]?:(result: ReturnType<T>) => void;
  }>({});
  const timeoutId = React.useRef<number>()

终止 Worker,并清理相关资源。

  const killWorker = React.useCallback(() => {
    if (worker.current?._url) {
      worker.current.terminate()
      URL.revokeObjectURL(worker.current._url)
      promise.current = {}
      worker.current = undefined
      window.clearTimeout(timeoutId.current)
    }
  }, [])

处理 Worker 结束的逻辑。

  const onWorkerEnd = React.useCallback((status: WORKER_STATUS) => {
    const terminate = options.autoTerminate != null
      ? options.autoTerminate
      : DEFAULT_OPTIONS.autoTerminate

    if (terminate) {
      killWorker()
    }
    setWorkerStatus(status)
  }, [options.autoTerminate, killWorker, setWorkerStatus])

创建 Blob URL 并实例化 Worker (函数依赖是深度遍历) ,并有一些配置项。

  const generateWorker = useDeepCallback(() => {
    const {
      remoteDependencies = DEFAULT_OPTIONS.remoteDependencies,
      timeout = DEFAULT_OPTIONS.timeout,
      transferable = DEFAULT_OPTIONS.transferable,
    } = options

    const blobUrl = createWorkerBlobUrl(fn, remoteDependencies!, transferable!)
    const newWorker: Worker & { _url?: string } = new Worker(blobUrl)
    newWorker._url = blobUrl
    //响应消息
    newWorker.onmessage = (e: MessageEvent) => {
      const [status, result] = e.data as [WORKER_STATUS, ReturnType<T>]

      switch (status) {
        case WORKER_STATUS.SUCCESS:
          promise.current[PROMISE_RESOLVE]?.(result)
          onWorkerEnd(WORKER_STATUS.SUCCESS)
          break
        default:
          promise.current[PROMISE_REJECT]?.(result)
          onWorkerEnd(WORKER_STATUS.ERROR)
          break
      }
    }
    //错误处理
    newWorker.onerror = (e: ErrorEvent) => {
      promise.current[PROMISE_REJECT]?.(e)
      onWorkerEnd(WORKER_STATUS.ERROR)
    }
    //超时计时器
    if (timeout) {
      timeoutId.current = window.setTimeout(() => {
        killWorker()
        setWorkerStatus(WORKER_STATUS.TIMEOUT_EXPIRED)
      }, timeout)
    }
    return newWorker
  }, [fn, options, killWorker])

调用 Worker 的 postMessage 方法,修改 worekerstate 状态,并返回 Promise。

  const callWorker = React.useCallback((...workerArgs: Parameters<T>) => {
    const { transferable = DEFAULT_OPTIONS.transferable } = options
    return new Promise<ReturnType<T>>((resolve, reject) => {
      promise.current = {
        [PROMISE_RESOLVE]: resolve,
        [PROMISE_REJECT]: reject,
      }
      const transferList: any[] = transferable === TRANSFERABLE_TYPE.AUTO ? (
        workerArgs.filter((val: any) => (
          ('ArrayBuffer' in window && val instanceof ArrayBuffer)
            || ('MessagePort' in window && val instanceof MessagePort)
            || ('ImageBitmap' in window && val instanceof ImageBitmap)
            || ('OffscreenCanvas' in window && val instanceof OffscreenCanvas)
        ))
      ) : []
      worker.current?.postMessage([[...workerArgs]], transferList)
      setWorkerStatus(WORKER_STATUS.RUNNING)
    })
  }, [setWorkerStatus])

主要逻辑,控制 Worker 的执行,调用 generateWorker 和 callWorker。

  const workerHook = React.useCallback((...fnArgs: Parameters<T>) => {
    const terminate = options.autoTerminate != null
      ? options.autoTerminate
      : DEFAULT_OPTIONS.autoTerminate

    if (isRunning.current) {
      console.error('[useWorker] You can only run one instance of the worker at a time, if you want to run more than one in parallel, create another instance with the hook useWorker(). Read more: https://github.com/alewin/useWorker')
      return Promise.reject()
    }
    if (terminate || !worker.current) {
      worker.current = generateWorker()
    }

    return callWorker(...fnArgs)
  }, [options.autoTerminate, generateWorker, callWorker])
  // 作为 WorkerController 的 kill 方法传递到外部,用来终止 Web Worker
  const killWorkerController = React.useCallback(() => {
    killWorker()
    setWorkerStatus(WORKER_STATUS.KILLED)
  }, [killWorker, setWorkerStatus])
  
  // 创建 WorkerController 对象,包含状态和终止方法
  const workerController = {
    status: workerStatus,
    kill: killWorkerController,
  };

  // 更新 isRunning 的状态
  React.useEffect(() => {
    isRunning.current = workerStatus === WORKER_STATUS.RUNNING
  }, [workerStatus])

  // 在组件卸载时终止 Worker
  React.useEffect(() => () => {
    killWorker()
  }, [killWorker])

  return [
    workerHook, workerController,
  ] as [typeof workerHook, WorkerController];
如何动态生成 Web Worker

其中最关键的是 generateWorker 方法内的 createWorkerBlobUrl,这涉及到如何动态生成 Web Worker。

接着来看下这段代码:

//动态创建 Blob URL
const createWorkerBlobUrl = (
  fn: Function, deps: string[], transferable: TRANSFERABLE_TYPE, 
) => {
  const blobCode = `
    ${remoteDepsParser(deps)};
    onmessage=(${jobRunner})({
      fn: (${fn}),
      transferable: '${transferable}'
    })
  `
  const blob = new Blob([blobCode], { type: 'text/javascript' })
  const url = URL.createObjectURL(blob)
  return url
}

//解析远程依赖,并生成导入脚本的字符串
const remoteDepsParser = (deps: string[]) => {
  if (deps.length === 0) return ''

  const depsString = (deps.map(dep => `'${dep}'`)).toString()
  return `importScripts(${depsString})`
}

//在 Worker 内部执行任务
const jobRunner = (options: JOB_RUNNER_OPTIONS): Function => (e: MessageEvent) => {
  const [userFuncArgs] = e.data as [any[]]
  return Promise.resolve(options.fn(...userFuncArgs))
    .then(result => {
      //检查结果是否可传输
      const isTransferable = (val: any) => (
        ('ArrayBuffer' in self && val instanceof ArrayBuffer)
        || ('MessagePort' in self && val instanceof MessagePort)
        || ('ImageBitmap' in self && val instanceof ImageBitmap)
        || ('OffscreenCanvas' in self && val instanceof OffscreenCanvas)
      )
      const transferList: any[] = options.transferable === 'auto' && isTransferable(result) ? [result] : []
      postMessage(['SUCCESS', result], transferList)
    })
    .catch(error => {
      postMessage(['ERROR', error])
    })
}

动态创建 Blob URL,解析了远程依赖( importScripts 的作用就是,脚本同步导入到 worker 的作用域中),还判断了传递的是否是可转移对象

简化核心代码

下面以 React 为例,简化一下上面的关键代码。

import React, { useEffect } from 'react';

const APP: React.FC = () => {
  function onmessage(e: MessageEvent) {
    console.log(e.data);
    postMessage('woker收到回复');
  }
  useEffect(() => {
    const workerUrl = URL.createObjectURL(
      new Blob([`onmessage=${onmessage}`], { type: 'application/javascript' }),
    );

    const myWorker = new Worker(workerUrl);
    myWorker.postMessage('主线程发送信息');
    myWorker.onmessage = function (event) {
      // 接收 Worker 发送的消息
      console.log(event.data);
    };

    return () => {
      myWorker.terminate();
      URL.revokeObjectURL(workerUrl);
    };
  }, []);

  return <div>React组件内容</div>;
};

export default APP;

Worker 内如何引入脚本

通过 worker-plugin ,可以实现自动编译在 Web Workers 中加载的模块,从而实现 worker 内可以使用 import。

使用 worker-plugin 到放弃

我用的是 umi4 ,在 .umirc.ts 内配置:

  chainWebpack(config) {
    const WorkerPlugin = require('worker-plugin');
    config.plugin('worker-plugin').use(WorkerPlugin);
    return config;
  },

结果 start 后,导致报错 fatal - Error: Cannot find module 'webpack/lib/ParserHelpers'

起初以为是这些插件使用了 webpack 包中的一些深层的方法或类, 但 umi 预打包的 webpack 版本并没有包含所需的深层类。

然后按照此方法,安装 webpack ,然后手动导入找不到的 webpack 变量。

结果依旧报错,发现 webpack5 的时候 ParserHelpers 被删除了

好吧,那安装 webpack4总有了吧。有是有了,但是依旧不行,2个 webpack 版本不同,导致了一些其他问题。

worker-plugin@5.0.1 最新版本是3年前不是没有理由的,我放弃了!(;´༎ຶД༎ຶ`)

下下策的使用脚本

好吧,那使用原生 API importScripts 总没问题了吧。

不过 Worker 的 type 不能设置为 module,否则意味着你正在使用 ES 模块,而 importScripts 是用于加载传统脚本的,不支持模块语法。(意味着也不支持 CommonJS 模块)

否则会导致报错: TypeError: Failed to execute 'importScripts' on 'WorkerGlobalScope': Module scripts don't support importScripts().

最简单的是直接在 importScripts 内写入要访问库对应的 cdn。

但如果是内网情况(或想加载自己写的脚本),需在 node_modules 内找到想要导入的库,并选用编译为 UMD 模块的文件,然后拷贝到 public 文件夹内。

然后在 web worker 内通过这行代码导入:

importScripts(`${self.location.origin}/umd.js`);

是的,不够优雅,所以实乃下下策(>人<;)

动态 worker 内使用脚本的 ts 问题

在上面简化核心代码为例,如果你想要直接使用脚本,除了通过 importScripts 导入脚本后,还会遇到一些 ts 的报错。

我的解决方法如下:

import * as idb from 'idb';

declare global {
  interface Worker {
    idb: typeof idb;
  }
}

const APP: React.FC = () => {

//......代码

 function onmessage(e: MessageEvent) {
   const { idb } = self as any as Worker;
 };

};

Q&A

  • Blob URL 是什么?

Blob URL 是一个指向 Blob 对象(一种包含二进制数据的 JavaScript 对象,可以是图片、视频、音频等)的 URL,Blob URL 允许我们将这些二进制数据作为一个可访问的 URL 来使用。

在 Web Worker 中,Blob URL 用于加载脚本,实现了脚本的动态加载和执行。

postMessage 方法的 transfer 参数允许我们在 Worker 线程传递可转移对象数组。这不仅减少了内存占用,还避免了数据的复制,提高了性能。

但对于普通的对象或 Map 这类集合类型不能直接通过 transfer 参数传递,这些对象在发送时会被序列化,然后在接收端被反序列化,仍然是通过复制的方式来在线程间传递。(但涉及到离屏幕渲染 canvas 时,你一定会需要用到

最后

不管是静态还是动态,其实本质是一样。都是将脚本转换为字符串,并创建一个 Blob 对象。然后使用 URL.createObjectURL 方法将 Blob 对象转换为一个 URL,这个 URL 是 Web Worker 将执行的脚本。

当业务更复杂一点,以上的内容肯定不能够满足你。如果想要了解如何让 Worker 的通信更优雅一些,可以看看后续的文章