关于前端:一文带你了解-Web-Worker-前端的多线程

43次阅读

共计 9599 个字符,预计需要花费 24 分钟才能阅读完成。

前言

家喻户晓,JavaScript 采纳的是单线程模型,即所有工作都在一个线程上实现,一次只能做一件事件。但单线程意味着所有的工作都须要排队,前一个工作完结了,才会执行后一个工作。如果一个工作消耗了太长的工夫,后一个工作就始终无奈执行。体现在浏览器里就是浏览器卡住了,无奈操作。

试一下,把上面的代码粘贴到浏览器 console 外面,会发现浏览器卡住无奈操作。

// 计算斐波那契数列
const fibonacci = (n) => {
    count += 1;
    if (n === 0) return 0;
    if (n === 1) return 1;
    if (n > 1) return fibonacci(n - 1) + fibonacci(n - 2)
}
const time0 = new Date().getTime();
console.log('time0', time0);

fibonacci(40);

const time1 = new Date().getTime();
console.log('time1', time1);
const duration = time1 - time0;
console.log('duration', duration);

// const f = (n) => n > 1 ? f(n - 1) + f(n -2) : n

js 为什么是单线程的?

JavaScript 能够操纵 DOM,如果在批改元素属性同时渲染界面,渲染线程前后取得的元素数据可能不统一。为了避免渲染呈现不可预期的后果,浏览器设置 GUI 渲染线程与 JS 引擎为互斥关系,当 JS 引擎执行时 GUI 线程会被挂起,GUI 更新则会被保留在一个队列中等到 JS 引擎线程闲暇时立刻被执行。

那么问题来了

如果 JS 引擎的计算量过大,GUI 的更新会进入队列,页面无反馈,卡顿感就产生了。

所以,咱们要尽量避免应用 JS 执行大量计算。但在日常的需要中咱们不可避免的会有 js 解决大量计算的场景,这时候 Web Worker 就派上了用场。

概述

什么是 Web Worker

Web Worker 是 HTML5 规范的一部分,他定义了一整套的 api 容许开发者在 js 线程之外独立出一个独自的线程,解决额定的 js 代码。

因为是独立的线程,Web Worker 能够和主线程 js 同时运行,互不影响。咱们能够把简单且耗时的计算交给 Web Worker 进行,待 Worker 计算实现之后,再交由主线程 js 去生产。这样主线程仅须要关怀业务逻辑和页面渲染,不须要把工夫消耗在计算上,晦涩度能够大大晋升。

Web Worker 能够干什么,有什么限度

Web Worker 能够认为是一个独立的 js 环境,你能够在外面运行任何你喜爱的代码,除了操作 dom 或者运行 window 对象中的一些办法和属性。

实际上 Web Worker 没有 window 的概念(也没有 document 对象,所以无奈操作 Dom),其运行上下文环境是 WorkerGlobalScope 对象的实例,通过 self 关键字裸露进去。

WorkerGlobalScope 对象上的可用属性是 window 对象的子集,其中有些属性和 window 统一,而有些属性则并不完全相同。

Web Worker 专用工作者线程

Worker 线程应用有些留神点

1. 同源限度 Worker
线程执行的脚本文件(即 上述代码的 worker.js)必须和主线程的文件同源,从其余源加载 Worker 脚本文件会报错。

2. 文件限度 Worker
线程无奈读取本地文件,文件须要通过主线程读取到文件之后再传输给 Worker。

3.DOM 操作限度
下面提到了,Worker 和主线程在不同的上下文环境运行,无奈读取主线程所在的 DOM 对象以及 document 和 window 对象,但 Worker 的全局对象 WorkerGlobalScope 提供了对 navigator、location、setTimeOut 等浏览器 API 的拜访能力,只管其中的有些 API 的属性和 window 上并不相同。

4. 通信限度
Worker 和主线程无奈间接通信,须要通过 postMessage 或者 BroadcastChannel 进行通信。

创立 Worker

能够通过将文件门路提供给 Worker 构造函数的形式来创立 专用工作者。options 是可选的配置,能够配置 Worker 的一些属性。

// 主线程
const worker = new Worker(jsUrl, options);

options 参数

参数名称 形容 类型
name worker 线程的名称,能够在工作者线程中通过 self.name 获取到字符串标识 string
type 示意加载脚本的形式,能够是 ‘classic’ 或者 ’module’。’classic’ 将脚本作为一般脚本来执行,’module’ 将脚本作为模块来执行。 ‘classic’|’module’
credentials 当 type 为 ’module’ 时,指定如何获取与传输凭证数据(cookie)相干的 Web Worker 脚本,与 fetch 的 credentials 属性统一。在 type 为 ’classic’ 时默认为 ’omit’。 ‘omit’|’same-origin’|’include’

对于 Worker 的初始化脚本

如果是一般我的项目,间接把初始化文件放在一个文件夹下,能够间接创立 Worker。

const worker = new Worker('worker.js');

在 Webpack 我的项目中,咱们须要增加各种 loader 反对新技术,创立 Worker 须要应用 worker-loader:

// webpack 4.0
import Worker from 'worker-loader!./worker';

const worker = new Worker();

但 Webpack 5.0 之后,咱们不须要 worker-loader 了,于是咱们能够这么创立:

const worker = new Worker(new URL('./worker.js', import.meta.url));

此处的 new URL(),能够约等于 nodejs 中的 path.resolve(baserul + ‘./worker.js’)。

还有一个简略的解决方案:把 worker 脚本放到 public 文件夹下,这样打包产物就和 worker 脚本在同一个文件夹下,能够失常初始化 Worker。

除了应用脚本文件创建 Worker 之外,咱们还能够应用 行内 js 来创立工作者线程。通过 Blob 对象 URL 咱们能够更快的初始化工作者线程,因为没有网络提早。

// 创立代码字符串
const workerScriptStr = `
    self.onmessage = (e) => {console.log(e.data);
        postMessage('get message from main thread');
    }
`;

// 基于脚本字符串生成 Blob 对象
const workerBlob = new Blob([workerScriptStr]);

// 基于 Blob 实例创建对象 URL
const workerBlobUrl = URL.createObjectURL(workerBlob);

// 基于对象 URL 创立专用工作者线程
const worker = new Worker(workerBlobUrl);
worker.postMessage('main thread send message');
// main thread send message

下面的例子是把步骤合成开,一步步的创立 Worker,能够写一块:

const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage = 
({data}) => console.log(data);`])));

worker.postMessage('main thread send message');
// main thread send message

ES Module

在初始化 Worker 时,如果不传第二个配置参数,默认执行脚本的形式为 ‘classic’,此时在脚本里仅能够通过 Worker 的全局对象 WorkerGlobalScope 提供的 importScripts 办法援用在线脚本。

如果应用 import 关键字引入,会报错 Cannot use import statement outside a module 不容许在 module 外应用 import。

// main.js
const worker = new Worker('worker.js');

// worker.js
// import {sum} from 'lodash'; // Error: Cannot use import statement outside a module
importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');
_.sum([1, 2]);
...

但如果在创立时指定了 type 为 ‘module’:

// main.js
const worker = new Worker('worker.js', { type: 'module'});

// worker.js
import {sum} from 'lodash';
sum([1, 2]);
...

则不会报错,从而能够欢快的应用按需导入能力了。

因为 Web Worker 是一个独立的线程,所以实践上,你能够在 Web Worker 里再启用一个 Web Worker 子线程,在有多个 CPU 外围的时候,应用多个子线程能够实现并行计算,这里就不开展了。

与 Web Worker 通信

与工作者线程通信都是通过 postMessage 办法发送音讯,通过 onmessage 事件处理函数来承受音讯。数据传输的形式是通过 结构化克隆算法 克隆数据,传递数据正本。

浏览器反对另一种性能更好的对象传输方式 可转移对象(Transferable objects),通过可转移对象,资源的所有权会从一个上下文间接转移到另一个上下文,而并不会通过克隆。传输后,原始对象将不可用;它将不再指向转移后的资源,并且任何尝试读取或者写入的操作都将抛出异样。

与主线程的数据交互方式如下图所示:

试一下:

// main.js
const worker = new Worker(new URL('worker.js', import.meta.url), {type: 'module'});
worker.onmessage = (e) => {
    // 接管来自 worker 的音讯
    setInfo(e.data);
}
// 发送音讯给 worker
worker.postMessage('message from main thread');

// 可转移对象
// 创立一个 8MB 的文件并填充
const uInt8Array = new Uint8Array(1024 * 1024 * 8).map((v, i) => i);
console.log(uInt8Array.byteLength); // 8388608
// 将底层 buffer 传递给 worker
worker.postMessage(uInt8Array, [uInt8Array.buffer]);
console.log(uInt8Array.byteLength); // 0


// worker.js
import {sum} from 'lodash';
// 如果是 classic 模式,则须要通过 improtscripts 来引入网络脚本
// importScripts('https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js');

// 接管来自主线程的音讯
onmessage = (e) => {console.log(e.data);
    const temp = Array.from(e.data).map((e) => +e);
    // 将计算结果发送给主线程
    postMessage(sum(temp));
};

备注:像 Int32Array 和 Uint8Array 等类型化数组(TypedArray)是可序列化的(Serializable object),然而不能转移。然而,它们的底层缓冲区是一个 ArrayBuffer,它是一个可转移对象。咱们能够在数据参数中发送 uInt8Array.buffer,然而不能在传输数组中发送 uInt8Array。

除了 postMessage 办法发送音讯之外,还有另外一种形式,能够发送音讯。

BroadcastChannel

BroadcastChannel 从字面意思上了解是播送频道,他能够让同源页面的浏览器上下文来订阅它。

它容许 同源的 不同浏览器窗口、tab 页、frame 或者 iframe 下的不同文档之间相互通信。通过触发 message 事件,音讯能够播送到所有监听了该频道的 BroadcastChannel 对象。

此个性在 Web Worker 中可用,因为初始化 Worker 的脚本和主线程是同源的,在 Web Worker 中播送的音讯,主线程能够监听到,反之亦然。

试一下:

// 初始化具名频道
const channel = new BroadcastChannel('bm channel');
// 播送音讯,发送的音讯本人接管不到,其余源能够接管到
channel.postMessage('全场两元,统统两元');
// 接管其余源发送的音讯
channel.onmessage = (e) => {console.log('get message from other broadcast', e.data);
};

尝试一下:任意关上两个雷同的页面,把下面的代码别离粘贴到浏览器的 console 调试外面,在一个页面调用一下 channel 的 postMessage 办法,在另一个页面看一下,发现音讯能够打印进去。

工作者线程的生命周期

1. 初始化
调用 Worker() 构造函数是一个专用工作者线程生命周期的终点。调用之后,它会初始化对工作者线程脚本的申请,并把 Worker 对象返回给父上下文。尽管父上下文中能够立刻应用这个 Worker 对象,但与之关联的工作者线程可能还没有创立,因为存在申请脚本的网格提早和初始化提早。

初始化时,尽管工作者线程脚本尚未执行,但能够先把要发送给工作者线程的音讯退出队列。这些 音讯会期待工作者线程的状态变为流动,再把音讯增加到它的音讯队列。

2. 流动中
创立之后,专用工作者线程就会随同页面的整个生命期而存在,除非自我终止 self.close() 或通过内部终止 worker.terminate()。即便线程脚本已运行实现,线程的环境仍会存在。只有工作者线程仍存在,与之关联的 Worker 对象就不会被当成垃圾收集掉。

3. 终止
在整个生命周期中,一个专用工作者线程只会关联一个网页(Web 工作者线程标准称其为一个文档)。除非明确终止(通过 self.close() 或者 worker.terminate()),否则只有关联文档存在,专用工作者线程就会存在。如果浏览器来到网页(通过导航或敞开标签页或敞开窗口),它会将与其关联的工作者线程标记为终止,它们的执行也会立刻进行。

Shared Worker 共享工作者线程

Shared Worker 与 Web Worker 相似,但能够被多个可信赖的执行上下文拜访。例如,同源的两个标签页能够拜访同一个共享工作者线程。SharedWorker 与 Worker 的音讯接口稍有不同,包含内部和外部。

共享线程适宜开发者心愿通过在多个上下文间共享线程缩小计算性耗费的情景。比方,能够用一个 共享线程治理多个同源页面 WebSocket 音讯的发送与接管。共享线程也能够用在同源上下文心愿通过一个线程通信的情景。

从行为上讲,共享工作者线程能够看作是专用工作者线程的一个扩大。线程创立、线程选项、平安限度和 importScripts() 的行为都是雷同的。与专用工作者线程一样,共享工作者线程也在独立执行上下文中运行,也只能与其余上下文异步通信。

创立 Shared Worker

Shared Worker 线程的创立和应用与 Worker 相似,事件和办法根本一样。不同点在于主线程与 Shared Worker 是通过 MessagePort 建设的链接,数据通讯办法都挂载在 SharedWorker.port 上。

另外,如果你采纳 addEventListener 来接管 message 事件,那么在主线程初始化 SharedWorker() 后,还要调用 SharedWorker.port.start() 办法来手动开启端口。

试一下:

// main.js
const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
// 接管到共享工作者线程音讯时触发
sharedWorker.port.onmessage = (e) => {console.log('get shared worker message:', e.data);
}
// 向共享工作者线程发消息
sharedWorker.port.postMessage('message for shared worker');


// sharedWorker.js
onconnect = (e) => {
    // 页面与 shared worker 创立链接时触发
    console.log('shared worker connect ~~', e);
    let port = e.ports[0];

    // 接管到页面传入的音讯时触发
    port.onmessage = (p) => {console.log('shared worker get message', p.data);
  }
}

共享工作者的生命周期

共享工作者线程的生命周期具备与专用工作者线程雷同的阶段的个性。不同之处在于,专用工作者线程只跟一个页面绑定,而共享工作者线程只有还有一个上下文连贯就会继续存在。

你能够在创立共享工作者线程时,指定不同的线程名,来强制开启多个共享工作者线程。

利用 Shared Worker 手动实现 BroadcastChannel 播送

1. 主线程创立 Shared Worker

// main.js
if (window.SharedWorker) {const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
    sharedWorker.port.postMessage('全场 2 元,统统两元;买不了吃亏,买不了受骗');
    sharedWorker.port.onmessage = (e) => {console.log('-- 接管到其余页面 sharedWorker 的播送音讯 --',e.data);
    }
}

2.sharedWorker.js 解决连入的线程
因为要向其余连入的线程发送音讯,所以要将所有连入的线程全都保护起来。

// sharedWorker.js
/** 创立一个 port 池,把所有的 port 缓存起来,用于播送音讯 */
const portPool = [];

onconnect = (e) => {console.log('shared worker connect ~~', e);
    let port = e.ports[0];

    // 将以后 port 缓存进 portPool
    portPool.push(port);

    // 接管到页面传入的音讯时触发
    port.onmessage = (p) => {
        // 向本人发消息
        port.postMessage(p.data);
    }
}

3. 向其余页面发送音讯
因为是播送音讯,所以在发送音讯时须要将本身排除在外。

// 向其余页面发送音讯
const boradcastMessage = (msg, selfPort) => {portPool.forEach((p) => {if (p !== selfPort) {
            // 向其余页面播送音讯
            p.postMessage(msg);
        }
    });
};

4. 解决生效线程
共享线程与父上下文的启动和敞开不是对称的。每个新 SharedWorker 连贯都会触发一个事件,但没有事件对应断开 SharedWorker 实例的连贯(如页面敞开)。

在后面的例子中,随着与雷同共享线程连贯和断开连接的页面越来越多,portPool 线程池中会受到死端口的净化,没有方法辨认它们。一个解决方案是在销毁页面时,明确发送卸载音讯,让共享线程有机会革除死端口。

// 清空有效的 port
if (e.data === 'NEED CLOSE') {const index = portPool.findIndex((p) => p === port);
    portPool.splice(index, 1);
}

// main.js 页面敞开时
sharedWorker.port.postMessage('NEED CLOSE');

5. 残缺代码

// main.js
if (window.SharedWorker) {const sharedWorker = new SharedWorker('sharedWorker.js', '宝明的 shared worker ~');
    sharedWorker.port.postMessage('全场 2 元,统统两元;买不了吃亏,买不了受骗');
    sharedWorker.port.onmessage = (e) => {console.log('-- 接管到其余页面 sharedWorker 的播送音讯 --',e.data);
    }
}
document.addEventListener('beforeunload', () => {sharedWorker.port.postMessage('NEED CLOSE');
})


// sharedWorker.js
/** 创立一个 port 池,把所有的 port 缓存起来,用于播送音讯 */
const portPool = [];

// 向其余页面发送音讯
const boradcastMessage = (msg, selfPort) => {portPool.forEach((p) => {if (p !== selfPort) {
            // 向其余页面播送音讯
            p.postMessage(msg);
        }
    });
};

onconnect = (e) => {console.log('shared worker connect ~~', e);
    let port = e.ports[0];

    // 将以后 port 缓存进 portPool
    portPool.push(port);

    // 接管到页面传入的音讯时触发
    port.onmessage = (p) => {
        // 向本人发消息
        // port.postMessage(p.data);

        // 向其余页面发送音讯
        boradcastMessage(p.data, port);

        // 清空有效的 port
        if (e.data === 'NEED CLOSE') {const index = portPool.findIndex((p) => p === port);
            portPool.splice(index, 1);
        }
    }
}

调试 Worker

调试 Web Worker

Web Worker 能够在以后页面的 Source 中进行查看。

调试 Shared Worker

Shared Worker 须要在谷歌调试中调试,链接:chrome://inspect/#workers

1. 关上谷歌工作管理器,记录过程 id

2. 关上 mac 的流动监视器,找到过程

点击取样

咱们能够看到,关上了两个雷同的页面,有两个专用工作者线程,而仅有一个共享工作者线程,因为初始化多个同名共享工作者线程,会共享同一个实例。

总结

工作者线程能够运行异步 JavaScript 而不阻塞用户界面。这非常适合简单计算和数据处理,特地是须要花较长时间因此会影响用户应用网页的解决工作。工作者线程有本人独立的环境,只能通过异步音讯与外界通信。

工作者线程能够是专用线程、共享线程。专用线程只能由一个页面应用,而共享线程则能够由同源的任意页面共享。

(本文作者:陈宝明)

正文完
 0