JavaScript Web Worker

2018-07-24 11:54 更新

目錄

概述

JavaScript語(yǔ)言采用的是單線程模型,也就是說(shuō),所有任務(wù)排成一個(gè)隊(duì)列,一次只能做一件事。隨著電腦計(jì)算能力的增強(qiáng),尤其是多核CPU的出現(xiàn),這一點(diǎn)帶來(lái)很大的不便,無(wú)法充分發(fā)揮JavaScript的潛力。

Web Worker的目的,就是為JavaScript創(chuàng)造多線程環(huán)境,允許主線程將一些任務(wù)分配給子線程。在主線程運(yùn)行的同時(shí),子線程在后臺(tái)運(yùn)行,兩者互不干擾。等到子線程完成計(jì)算任務(wù),再把結(jié)果返回給主線程。因此,每一個(gè)子線程就好像一個(gè)“工人”(worker),默默地完成自己的工作。這樣做的好處是,一些高計(jì)算量或高延遲的工作,被worker線程負(fù)擔(dān)了,所以主進(jìn)程(通常是UI進(jìn)程)就會(huì)很流暢,不會(huì)被阻塞或拖慢。

Worker線程分成好幾種。

  • 普通的Worker:只能與創(chuàng)造它們的主進(jìn)程通信。
  • Shared Worker:能被所有同源的進(jìn)程獲?。ū热鐏?lái)自不同的瀏覽器窗口、iframe窗口和其他Shared worker),它們必須通過(guò)一個(gè)端口通信。
  • ServiceWorker:實(shí)際上是一個(gè)在網(wǎng)絡(luò)應(yīng)用與瀏覽器或網(wǎng)絡(luò)層之間的代理層。它可以攔截網(wǎng)絡(luò)請(qǐng)求,使得離線訪問(wèn)成為可能。

Web Worker有以下幾個(gè)特點(diǎn):

  • 同域限制。子線程加載的腳本文件,必須與主線程的腳本文件在同一個(gè)域。

  • DOM限制。子線程所在的全局對(duì)象,與主進(jìn)程不一樣,它無(wú)法讀取網(wǎng)頁(yè)的DOM對(duì)象,即document、window、parent這些對(duì)象,子線程都無(wú)法得到。(但是,navigator對(duì)象和location對(duì)象可以獲得。)

  • 腳本限制。子線程無(wú)法讀取網(wǎng)頁(yè)的全局變量和函數(shù),也不能執(zhí)行alert和confirm方法,不過(guò)可以執(zhí)行setInterval和setTimeout,以及使用XMLHttpRequest對(duì)象發(fā)出AJAX請(qǐng)求。

  • 文件限制。子線程無(wú)法讀取本地文件,即子線程無(wú)法打開(kāi)本機(jī)的文件系統(tǒng)(file://),它所加載的腳本,必須來(lái)自網(wǎng)絡(luò)。

使用之前,檢查瀏覽器是否支持這個(gè)API。

if (window.Worker) {
  // 支持
} else {
  // 不支持
}

新建和啟動(dòng)子線程

主線程采用new命令,調(diào)用Worker構(gòu)造函數(shù),可以新建一個(gè)子線程。

var worker = new Worker('work.js');

Worker構(gòu)造函數(shù)的參數(shù)是一個(gè)腳本文件,這個(gè)文件就是子線程所要完成的任務(wù),上面代碼中是work.js。由于子線程不能讀取本地文件系統(tǒng),所以這個(gè)腳本文件必須來(lái)自網(wǎng)絡(luò)端。如果下載沒(méi)有成功,比如出現(xiàn)404錯(cuò)誤,這個(gè)子線程就會(huì)默默地失敗。

子線程新建之后,并沒(méi)有啟動(dòng),必需等待主線程調(diào)用postMessage方法,即發(fā)出信號(hào)之后才會(huì)啟動(dòng)。postMessage方法的參數(shù),就是主線程傳給子線程的信號(hào)。它可以是一個(gè)字符串,也可以是一個(gè)對(duì)象。

worker.postMessage("Hello World");
worker.postMessage({method: 'echo', args: ['Work']});

只要符合父線程的同源政策,Worker線程自己也能新建Worker線程。Worker線程可以使用XMLHttpRequest進(jìn)行網(wǎng)絡(luò)I/O,但是XMLHttpRequest對(duì)象的responseXMLchannel屬性總是返回null。

子線程的事件監(jiān)聽(tīng)

在子線程內(nèi),必須有一個(gè)回調(diào)函數(shù),監(jiān)聽(tīng)message事件。

/* File: work.js */

self.addEventListener('message', function(e) {
  self.postMessage('You said: ' + e.data);
}, false);

self代表子線程自身,self.addEventListener表示對(duì)子線程的message事件指定回調(diào)函數(shù)(直接指定onmessage屬性的值也可)?;卣{(diào)函數(shù)的參數(shù)是一個(gè)事件對(duì)象,它的data屬性包含主線程發(fā)來(lái)的信號(hào)。self.postMessage則表示,子線程向主線程發(fā)送一個(gè)信號(hào)。

根據(jù)主線程發(fā)來(lái)的不同的信號(hào)值,子線程可以調(diào)用不同的方法。

/* File: work.js */

self.onmessage = function(event) {
  var method = event.data.method;
  var args = event.data.args;

  var reply = doSomething(args);
  self.postMessage({method: method, reply: reply});
};

主線程的事件監(jiān)聽(tīng)

主線程也必須指定message事件的回調(diào)函數(shù),監(jiān)聽(tīng)子線程發(fā)來(lái)的信號(hào)。

/* File: main.js */

worker.addEventListener('message', function(e) {
	console.log(e.data);
}, false);

錯(cuò)誤處理

主線程可以監(jiān)聽(tīng)子線程是否發(fā)生錯(cuò)誤。如果發(fā)生錯(cuò)誤,會(huì)觸發(fā)主線程的error事件。

worker.onerror(function(event) {
  console.log(event);
});

// or

worker.addEventListener('error', function(event) {
  console.log(event);
});

關(guān)閉子線程

使用完畢之后,為了節(jié)省系統(tǒng)資源,我們必須在主線程調(diào)用terminate方法,手動(dòng)關(guān)閉子線程。

worker.terminate();

也可以子線程內(nèi)部關(guān)閉自身。

self.close();

主線程與子線程的數(shù)據(jù)通信

前面說(shuō)過(guò),主線程與子線程之間的通信內(nèi)容,可以是文本,也可以是對(duì)象。需要注意的是,這種通信是拷貝關(guān)系,即是傳值而不是傳址,子線程對(duì)通信內(nèi)容的修改,不會(huì)影響到主線程。事實(shí)上,瀏覽器內(nèi)部的運(yùn)行機(jī)制是,先將通信內(nèi)容串行化,然后把串行化后的字符串發(fā)給子線程,后者再將它還原。

主線程與子線程之間也可以交換二進(jìn)制數(shù)據(jù),比如File、Blob、ArrayBuffer等對(duì)象,也可以在線程之間發(fā)送。

但是,用拷貝方式發(fā)送二進(jìn)制數(shù)據(jù),會(huì)造成性能問(wèn)題。比如,主線程向子線程發(fā)送一個(gè)500MB文件,默認(rèn)情況下瀏覽器會(huì)生成一個(gè)原文件的拷貝。為了解決這個(gè)問(wèn)題,JavaScript允許主線程把二進(jìn)制數(shù)據(jù)直接轉(zhuǎn)移給子線程,但是一旦轉(zhuǎn)移,主線程就無(wú)法再使用這些二進(jìn)制數(shù)據(jù)了,這是為了防止出現(xiàn)多個(gè)線程同時(shí)修改數(shù)據(jù)的麻煩局面。這種轉(zhuǎn)移數(shù)據(jù)的方法,叫做Transferable Objects。

如果要使用該方法,postMessage方法的最后一個(gè)參數(shù)必須是一個(gè)數(shù)組,用來(lái)指定前面發(fā)送的哪些值可以被轉(zhuǎn)移給子線程。

worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);

同頁(yè)面的Web Worker

通常情況下,子線程載入的是一個(gè)單獨(dú)的JavaScript文件,但是也可以載入與主線程在同一個(gè)網(wǎng)頁(yè)的代碼。假設(shè)網(wǎng)頁(yè)代碼如下:

<!DOCTYPE html>
    <body>
        <script id="worker" type="app/worker">

            addEventListener('message', function() {
                postMessage('Im reading Tech.pro');
            }, false);
        </script>
    </body>
</html>

我們可以讀取頁(yè)面中的script,用worker來(lái)處理。

var blob = new Blob([document.querySelector('#worker').textContent]);

這里需要把代碼當(dāng)作二進(jìn)制對(duì)象讀取,所以使用Blob接口。然后,這個(gè)二進(jìn)制對(duì)象轉(zhuǎn)為URL,再通過(guò)這個(gè)URL創(chuàng)建worker。

var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);

部署事件監(jiān)聽(tīng)代碼。

worker.addEventListener('message', function(e) {
   console.log(e.data);
}, false);

最后,啟動(dòng)worker。

worker.postMessage('');

整個(gè)頁(yè)面的代碼如下:

<!DOCTYPE html>
<body>
  <script id="worker" type="app/worker">
    addEventListener('message', function() {
      postMessage('Work done!');
    }, false);
   </script>

  <script>
    (function() {
      var blob = new Blob([document.querySelector('#worker').textContent]);
      var url = window.URL.createObjectURL(blob);
      var worker = new Worker(url);

      worker.addEventListener('message', function(e) {
        console.log(e.data);
      }, false);

      worker.postMessage('');
    })();
  </script>
</body>
</html>

可以看到,主線程和子線程的代碼都在同一個(gè)網(wǎng)頁(yè)上面。

上面所講的Web Worker都是專屬于某個(gè)網(wǎng)頁(yè)的,當(dāng)該網(wǎng)頁(yè)關(guān)閉,worker就自動(dòng)結(jié)束。除此之外,還有一種共享式的Web Worker,允許多個(gè)瀏覽器窗口共享同一個(gè)worker,只有當(dāng)所有網(wǎng)口關(guān)閉,它才會(huì)結(jié)束。這種共享式的Worker用SharedWorker對(duì)象來(lái)建立,因?yàn)檫m用場(chǎng)合不多,這里就省略了。

實(shí)例:Worker 進(jìn)程完成論詢

有時(shí),瀏覽器需要論詢服務(wù)器狀態(tài),以便第一時(shí)間得知狀態(tài)改變。這個(gè)工作可以放在 Worker 進(jìn)程里面。

var pollingWorker = createWorker(function (e) {
  var cache;

  function compare(new, old) { ... };

  var myRequest = new Request('/my-api-endpoint');

  setInterval(function () {
    fetch('/my-api-endpoint').then(function (res) {
      var data = res.json();

      if(!compare(res.json(), cache)) {
        cache = data;

        self.postMessage(data);
      }
    })
  }, 1000)
});

pollingWorker.onmessage = function () {
  // render data
}

pollingWorker.postMessage('init');

Service Worker

Service worker是一個(gè)在瀏覽器后臺(tái)運(yùn)行的腳本,與網(wǎng)頁(yè)不相干,專注于那些不需要網(wǎng)頁(yè)或用戶互動(dòng)就能完成的功能。它主要用于操作離線緩存。

Service Worker有以下特點(diǎn)。

  • 屬于JavaScript Worker,不能直接接觸DOM,通過(guò)postMessage接口與頁(yè)面通信。
  • 不需要任何頁(yè)面,就能執(zhí)行。
  • 不用的時(shí)候會(huì)終止執(zhí)行,需要的時(shí)候又重新執(zhí)行,即它是事件驅(qū)動(dòng)的。
  • 有一個(gè)精心定義的升級(jí)策略。
  • 只在HTTPs協(xié)議下可用,這是因?yàn)樗軘r截網(wǎng)絡(luò)請(qǐng)求,所以必須保證請(qǐng)求是安全的。
  • 可以攔截發(fā)出的網(wǎng)絡(luò)請(qǐng)求,從而控制頁(yè)面的網(wǎng)路通信。
  • 內(nèi)部大量使用Promise。

Service worker的常見(jiàn)用途。

  • 通過(guò)攔截網(wǎng)絡(luò)請(qǐng)求,使得網(wǎng)站運(yùn)行得更快,或者在離線情況下,依然可以執(zhí)行。
  • 作為其他后臺(tái)功能的基礎(chǔ),比如消息推送和背景同步。

使用Service Worker有以下步驟。

首先,需要向?yàn)g覽器登記Service Worker。

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/sw.js')
    .then(function(registration) {
    // 登記成功
    console.log('ServiceWorker登記成功,范圍為', registration.scope);
    }).catch(function(err) {
    // 登記失敗
      console.log('ServiceWorker登記失?。?, err);
    });
}

上面代碼向?yàn)g覽器登記sw.js腳本,實(shí)質(zhì)就是瀏覽器加載sw.js。這段代碼可以多次調(diào)用,瀏覽器會(huì)自行判斷sw.js是否登記過(guò),如果已經(jīng)登記過(guò),就不再重復(fù)執(zhí)行了。注意,Service worker腳本必須與頁(yè)面在同一個(gè)域,且必須在HTTPs協(xié)議下正常運(yùn)行。

sw.js位于域名的根目錄下,這表明這個(gè)Service worker的范圍(scope)是整個(gè)域,即會(huì)接收整個(gè)域下面的fetch事件。如果腳本的路徑是/example/sw.js,那么Service worker只對(duì)/example/開(kāi)頭的URL有效(比如/example/page1/、/example/page2/)。如果腳本不在根目錄下,但是希望對(duì)整個(gè)域都有效,可以指定scope屬性。

navigator.serviceWorker.register('/path/to/serviceworker.js', {
  scope: '/'
});

一旦登記完成,這段腳本就會(huì)用戶的瀏覽器之中長(zhǎng)期存在,不會(huì)隨著用戶離開(kāi)你的網(wǎng)站而消失。

.register方法返回一個(gè)Promise對(duì)象。

登記成功后,瀏覽器執(zhí)行下面步驟。

  1. 下載資源(Download)
  2. 安裝(Install)
  3. 激活(Activate)

安裝和激活,主要通過(guò)事件來(lái)判斷。

self.addEventListener('install', function(event) {
  event.waitUntil(
    fetchStuffAndInitDatabases()
  );
});

self.addEventListener('activate', function(event) {
  // You're good to go!
});

Service worker一旦激活,就開(kāi)始控制頁(yè)面。網(wǎng)頁(yè)加載的時(shí)候,可以選擇一個(gè)Service worker作為自己的控制器。不過(guò),頁(yè)面第一次加載的時(shí)候,它不受Service worker控制,因?yàn)檫@時(shí)還沒(méi)有一個(gè)Service worker在運(yùn)行。只有重新加載頁(yè)面后,Service worker才會(huì)生效,控制加載它的頁(yè)面。

你可以查看navigator.serviceWorker.controller,了解當(dāng)前哪個(gè)ServiceWorker掌握控制權(quán)。如果后臺(tái)沒(méi)有任何Service worker,navigator.serviceWorker.controller返回null。

Service worker激活以后,就能監(jiān)聽(tīng)fetch事件。

self.addEventListener('fetch', function(event) {
  console.log(event.request);
});

fetch事件會(huì)在兩種情況下觸發(fā)。

  • 用戶訪問(wèn)Service worker范圍內(nèi)的網(wǎng)頁(yè)。
  • 這些網(wǎng)頁(yè)發(fā)出的任何網(wǎng)絡(luò)請(qǐng)求(頁(yè)面本身、CSS、JS、圖像、XHR等等),即使這些請(qǐng)求是發(fā)向另一個(gè)域。但是,iframe<object>標(biāo)簽發(fā)出的請(qǐng)求不會(huì)被攔截。

fetch事件的event對(duì)象的request屬性,返回一個(gè)對(duì)象,包含了所攔截的網(wǎng)絡(luò)請(qǐng)求的所有信息,比如URL、請(qǐng)求方法和HTTP頭信息。

Service worker的強(qiáng)大之處,在于它會(huì)攔截請(qǐng)求,并會(huì)返回一個(gè)全新的回應(yīng)。

self.addEventListener('fetch', function(event) {
  event.respondWith(new Response("Hello world!"));
});

respondWith方法的參數(shù)是一個(gè)Response對(duì)象實(shí)例,或者一個(gè)Promise對(duì)象(resolved以后返回一個(gè)Response實(shí)例)。上面代碼手動(dòng)創(chuàng)造一個(gè)Response實(shí)例。

下面是完整的代碼

先看網(wǎng)頁(yè)代碼index.html。

<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      white-space: pre-line;
      font-family: monospace;
      font-size: 14px;
    }
  </style>
</head>
<body><script>
    function log() {
      document.body.appendChild(document.createTextNode(Array.prototype.join.call(arguments, ", ") + '\n'));
      console.log.apply(console, arguments);
    }
    window.onerror = function(err) {
      log("Error", err);
    };
    navigator.serviceWorker.register('sw.js', {
      scope: './'
    }).then(function(sw) {
      log("Registered!", sw);
      log("You should get a different response when you refresh");
    }).catch(function(err) {
      log("Error", err);
    });
  </script></body>
</html>

然后是Service worker腳本sw.js。

// The SW will be shutdown when not in use to save memory,
// be aware that any global state is likely to disappear
console.log("SW startup");

self.addEventListener('install', function(event) {
  console.log("SW installed");
});

self.addEventListener('activate', function(event) {
  console.log("SW activated");
});

self.addEventListener('fetch', function(event) {
  console.log("Caught a fetch!");
  event.respondWith(new Response("Hello world!"));
});

每一次瀏覽器向服務(wù)器要求一個(gè)文件的時(shí)候,就會(huì)觸發(fā)fetch事件。Service worker可以在發(fā)出這個(gè)請(qǐng)求之前,前攔截它。

self.addEventListener('fetch', function (event) {
  var request = event.request;
  ...
});

實(shí)際應(yīng)用中,我們使用fetch方法去抓取資源,該方法返回一個(gè)Promise對(duì)象。

self.addEventListener('fetch', function(event) {
  if (/\.jpg$/.test(event.request.url)) {
    event.respondWith(
      fetch('//www.google.co.uk/logos/example.gif', {
        mode: 'no-cors'
      })
    );
  }
});

上面代碼中,如果網(wǎng)頁(yè)請(qǐng)求JPG文件,就會(huì)被Service worker攔截,轉(zhuǎn)而返回一個(gè)Google的Logo圖像。fetch方法默認(rèn)會(huì)加上CORS信息頭,,上面設(shè)置了取消這個(gè)頭。

下面的代碼是一個(gè)將所有JPG、PNG圖片請(qǐng)求,改成WebP格式返回的例子。

"use strict";

// Listen to fetch events
self.addEventListener('fetch', function(event) {

  // Check if the image is a jpeg
  if (/\.jpg$|.png$/.test(event.request.url)) {
    // Inspect the accept header for WebP support
    var supportsWebp = false;
    if (event.request.headers.has('accept')){
      supportsWebp = event.request.headers.get('accept').includes('webp');
    }

    // If we support WebP
    if (supportsWebp) {
      // Clone the request
      var req = event.request.clone();
      // Build the return URL
      var returnUrl = req.url.substr(0, req.url.lastIndexOf(".")) + ".webp";
      event.respondWith(fetch(returnUrl, {
        mode: 'no-cors'
      }));
    }
  }
});

如果請(qǐng)求失敗,可以通過(guò)Promise的catch方法處理。

self.addEventListener('fetch', function(event) {
  event.respondWith(
    fetch(event.request).catch(function() {
      return new Response("Request failed!");
    })
  );
});

登記成功后,可以在Chrome瀏覽器訪問(wèn)chrome://inspect/#service-workers,查看整個(gè)瀏覽器目前正在運(yùn)行的Service worker。訪問(wèn)chrome://serviceworker-internals,可以查看瀏覽器目前安裝的所有Service worker。

一個(gè)已經(jīng)登記過(guò)的Service worker腳本,如果發(fā)生改動(dòng),瀏覽器就會(huì)重新安裝,這被稱為“升級(jí)”。

Service worker有一個(gè)Cache API,用來(lái)緩存外部資源。

self.addEventListener('install', function(event) {
  // pre cache a load of stuff:
  event.waitUntil(
    caches.open('myapp-static-v1').then(function(cache) {
      return cache.addAll([
        '/',
        '/styles/all.css',
        '/styles/imgs/bg.png',
        '/scripts/all.js'
      ]);
    })
  )
});

self.addEventListener('fetch', function(event) {
  event.respondWith(
    caches.match(event.request).then(function(response) {
      return response || fetch(event.request);
    })
  );
});

上面代碼中,caches.open方法用來(lái)建立緩存,然后使用addAll方法添加資源。caches.match方法則用來(lái)建立緩存以后,匹配當(dāng)前請(qǐng)求是否在緩存之中,如果命中就取出緩存,否則就正常發(fā)出這個(gè)請(qǐng)求。一旦一個(gè)資源進(jìn)入緩存,它原來(lái)指定是否過(guò)期的HTTP信息頭,就會(huì)被忽略。緩存之中的資源,只在你移除它們的時(shí)候,才會(huì)被移除。

單個(gè)資源可以使用cache.put(request, response)方法添加。

下面是一個(gè)在安裝階段緩存資源的例子。

var staticCacheName = 'static';
var version = 'v1::';

self.addEventListener('install', function (event) {
  event.waitUntil(updateStaticCache());
});

function updateStaticCache() {
  return caches.open(version + staticCacheName)
    .then(function (cache) {
      return cache.addAll([
        '/path/to/javascript.js',
        '/path/to/stylesheet.css',
        '/path/to/someimage.png',
        '/path/to/someotherimage.png',
        '/',
        '/offline'
      ]);
    });
};

上面代碼將JavaScript腳本、CSS樣式表、圖像文件、網(wǎng)站首頁(yè)、離線頁(yè)面,存入瀏覽器緩存。這些資源都要等全部進(jìn)入緩存之后,才會(huì)安裝。

安裝以后,就需要激活。

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys()
      .then(function (keys) {
        return Promise.all(keys
          .filter(function (key) {
            return key.indexOf(version) !== 0;
          })
          .map(function (key) {
            return caches.delete(key);
          })
        );
      })
  );
});

參考鏈接

以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)