因为一个同步的耗时任务,俺封装了WebWorker

853 阅读8分钟

通常我们在浏览器做大量纯数据处理的时候会直接占用主线程导致页面出现卡顿的现象,那正常情况我们会怎么处理呢?

我中的统一用宏队列来替代细分的延迟队列,网络队列,事件队列等,所以大家别杠哈,没必要

猜测

猜测1

一般可能会想到Promise?那我们先看看Promise是怎么运行的吧

// for (let i = 0; i++ < 1e10;); 大概需要运行10秒
console.log('script start');
new Promise((resolve, reject) => {
  for (let i = 0; ++i < 1e10;);
  resolve('promise done');
}).then((res) => {
  console.log(res);
  for(let i = 0; ++i < 1e10;);
  console.log('then done');
});
console.log('script end');

我们尝试推理一下这段代码的事件循环过程吧

  1. 从上往下执行第2行的时候直接在控制台打印 script start
  2. 执行第3行遇到一个Promise,我们都知道,constructor是同步的,所以我们传递进去的函数也会同步运行,这时候主线程会被我们第4行的空循环阻塞10秒,这10秒我们的页面就是完全卡死的状态什么都执行不了,十秒后,这个Promise的状态会被第5行的resovle调用置为已完成,然后把then的函数 F1 推送到微队列
  3. 然后继续执行下面的同步代码,也就是第11行的控制台输出 script end
  4. 同步代码执行完毕之后,浏览器会先看微队列,发现里面有一个 F1 函数,然后把这个函数取出来在主线程运行,运行到第7行的时候会在控制台输出 promise done 然后在第8行又被空循环阻塞10秒,然后又在控制台输出了 then done,微任务队列也执行完毕了
  5. 然后浏览器回去看宏任务队列,宏任务队列是空的,到这里事件循环就结束了

控制台的输出:

// 第一轮
script start
// 阻塞10s
script end

// 第二轮
promise done
// 阻塞10s
then done

从控制台和页面上我们就可以明显感知到,Promise执行也是需要被调到主线程执行,然后阻塞页面渲染的,所以这个方案也不太好

猜测2

那怎么办呢?或许也有人能想到把一个1e10的循环切片成1000个1e7然后再每次渲染帧执行吧

那我们来看看这个代码吧

const taskTotal = 1e10;
const splitCount = 1000;
const taskItemSize = 1e10 / splitCount; // 1e7

function runTask(taskIdx = 0){
  requestAnimationFrame(() => {
    for (let i = 0; ++i < taskItemSize;);
    console.log('taskItem done idx:', taskIdx);
    runTask(taskIdx + 1);
  });
}

这样的话我们就可以把一个大任务拆分成多个小任务,然后在每个渲染关键帧去执行

我们大概算一下,1e7次空循环我帮大家算了也就是7-9毫秒,我们就算他8毫秒吧,我们大家都知道浏览器一秒60帧,每帧16.6ms,也就是1e7的空循环就要占半帧的时间,然后整个1e10的任务需要1000帧也就是16.6秒的样子才能把整个任务执行完毕

这还只是空循环,我顺便帮大家试了一下,如果在循环体里面加了一句赋值的话一次1e7的循环就会占用10-11毫秒的时间,emmm如果算上页面的其他交互和渲染时间的话,那是不是就会出现掉帧的问题,还是挺影响体验的

解决方案

那这时候我们怎么办呢?我们能不能像后端一样,开多个线程去处理这些数据呢?这样不就不会影响到主线程了吗

那现在问题就变成了前端能不能开启多线程呢?

肯定的告诉大家,前端是可以开启多线程的哦!

百度一搜应该也可以搜到吧,前端可以使用 Web Worker 然后把任务放到新的线程去运行

给大家写一个小示例吧

// 目录结构
----
index.html
index.js
worker.js
<!-- index.html -->
<!DOCTYPE html>
<html leng="en">
  <head>
    <style>
      .test {
        width: 100px;
        height: 100px;
        transform: translateX(0);
        background: pink;
        animation: move 1s linear infinite alternate;
      }
      @keyframes move {
        to {
          transform: translateX(100%);
        }
      }
    </style>
  </head>
  <body>
    <div class="test"></div>
    <script src="./index.js"></script>
  </body>
</html>
// worker.js
function task() {
  for (let i = 0; ++i < 1e10;);
}

self.onmessage = (e) => {
  console.log(e.data)
  task();
  self.postMessage('done');
}
// index.js
const worker = new Worker('./worker.js');

worker.postMessage('start')
worker.onmessage = (e) => {
  console.log(e.data);
}

这个demo大家可以试试看,效果差不多就是打开页面之后等待10s然后打印done,但是这10s时间里面,页面并不会被阻塞,动画还能正常的运行

逐步实现

测试过解决方案之后,发现Web Worker是可行的方案,那我们就可以从这里入手去逐步实现了

大家应该能发现实我们用worker的时候其实是比较麻烦的,要单独写一个worker.js文件然后再通过路径创建一个worker,在通过事件监听去使用怪麻烦的

痛点 & 期望

  1. worker的代码需要单独的文件存储,如果有多个处理方法每个处理方法就需要创建一个文件太麻烦了,我想直接写一个函数,然后这个函数就能在worker里面运行就好了
  2. 重复编写事件监听的方法太复杂了,要是能直接像函数调用一样使用并且获取结果就好了

让我们一点一点来实现吧~

痛点解决方案分析

痛点1

第一个痛点就是怎么省略那个worker文件呢

这时候我们就应该去mdn上翻翻看Web Worker的文档,看看它的API之类的

2.png

这时候mdn会直接告诉你对不起,Worker只支持url,那我们只能想办法把js函数转换成一个url了

让我们一点一点来吧,首先是js怎么创建url,通过百度,我们知道,URL.createObjectURL()这个方法可以创建一个url

1714279350160-244f5a15-65e9-4fb1-be81-3c98d94b971c.png

现在我们知道怎么创建url了,但是怎么把js函数变成Blob、File或者MediaSource呢?

分析一下,js本质是什么呢,js的本质就是一段文本吧,通过一顿百度,我们可以在mdn上找到这样一个示例

3.png

这样好像就可以把一个html字符串转为html类型的blob了诶,那js一定 也可以通过这种方式创建blob对象吧

现在还剩一个问题,我们的期望是把一个函数放到worker中运行,也就是说要把一个函数转为字符串然后创建url,再变成worker

函数转为字符串???toString可以吗?我们试一下!

4.png

嗯可以,这下链路通了!!!让我们来实现一下这个代码吧~

function task (a, b) {
  return a + b;
}

const taskString = task.toString();

const blob = new Blob([taskString], { type: "text/javascript" });

const workerUrl = URL.createObjectURL(blob);

console.log(workerUrl);

5.png

这时候我们就得到了一个url,我们访问一下看看

6.png

这个就是我们刚刚想要把函数转为url的效果了!!!

这下第一个痛点就差不多被我们解决了

痛点2

我们先写一下我们期望怎么去调用

function task (a, b) {
  return a + b;
}

const taskString = task.toString();

const blob = new Blob([taskString], { type: "text/javascript" });

const workerUrl = URL.createObjectURL(blob);

const worker = new Worker(workerUrl);

const result = runWorker(1, 2);

console.log(result); // 3

大家是不是很迷惑,runWorker是哪里来的啊?其实我们要实现的就是这个runWorker,他接受的参数和我们传递的task方法一样,然后返回结果也是,但是怎么让他在worker中运行呢?

从前面那个demo中我们发现想要和worker通信的话就需要通过postMessage发送消息告诉worker,然后通过onmessage去接受worker返回的消息,也就是说我们要封装一下,除了函数要转成字符串之外,我们还要把worker的模板也写进去,然后runWorker的时候给它发送消息并监听他的返回,那这里涉及到event了,他就没发是同步的,只能是一部的了,所以使用的方式也需要改一下:

runWorker(1, 2).then((result) => {
  console.log(result); // 3
});

那让我们来实现一下把~

function task (a, b) {
  return a + b;
}

const taskString = `
const func = ${task.toString()};
self.onmessage = (e) => {
  const args = e.data;
  const result = func.apply(null, args);
  self.postMessage(result);
}
`;

const blob = new Blob([taskString], { type: "text/javascript" });

const workerUrl = URL.createObjectURL(blob);

const worker = new Worker(workerUrl);

const runWorker = (...args) => {
  return new Promise((resolve) => {
    worker.postMessage(args);
    worker.onmessage = (e) => {
      resolve(e.data); 
    }
  });
}

runWorker(1, 2).then((result) => {
  console.log(result); // 3
});

这下第二个痛点也解决了

浅浅封装一下

function createWorkerFunc(func) {
  const taskString = `
  const func = ${func.toString()};
  self.onmessage = (e) => {
    const args = e.data;
    const result = func.apply(null, args);
    self.postMessage(result);
  }
  `;

  const blob = new Blob([taskString], { type: "text/javascript" });

  const workerUrl = URL.createObjectURL(blob);

  const worker = new Worker(workerUrl);

  const runWorker = (...args) => {
    return new Promise((resolve) => {
      worker.postMessage(args);
      worker.onmessage = (e) => {
        resolve(e.data); 
      }
    });
  }

  return runWorker;
}

const task = createWorkerFunc((a, b) => a + b);

task(1, 3).then(console.log); // 3

封装完方便好多,只要是想要放到worker中运行的函数直接调用 createWorkerFunc 然后把函数传递进去,调用返回的新方法,就可以了!!!

总结

这下我们就可以把计算量大的任务放到另一个线程去执行了,就比如什么文件分片上传之类的,还是挺方便的~

这篇文章只是实现了最基本的功能,还有错误处理,多次调用的时序问题之类的问题,需要大家自己去实现一下咯,而且这个示例只会打开一个线程每次运行task都会在原来的线程上运行,如果大家想每个task在不同的线程执行的话,还是需要自行实现的喔。对了还有三方依赖,就比如我想在worker中用一下lodash的方法,大家可以个性化实现一下😊