JavaScript語言采用的是單線程模型,也就是說,所有任務排成一個隊列,一次只能做一件事。隨著電腦計算能力的增強,尤其是多核CPU的出現(xiàn),這一點帶來很大的不便,無法充分發(fā)揮JavaScript的潛力。
Web Worker的目的,就是為JavaScript創(chuàng)造多線程環(huán)境,允許主線程將一些任務分配給子線程。在主線程運行的同時,子線程在后臺運行,兩者互不干擾。等到子線程完成計算任務,再把結果返回給主線程。因此,每一個子線程就好像一個“工人”(worker),默默地完成自己的工作。這樣做的好處是,一些高計算量或高延遲的工作,被worker線程負擔了,所以主進程(通常是UI進程)就會很流暢,不會被阻塞或拖慢。
Worker線程分成好幾種。
Web Worker有以下幾個特點:
同域限制。子線程加載的腳本文件,必須與主線程的腳本文件在同一個域。
DOM限制。子線程所在的全局對象,與主進程不一樣,它無法讀取網頁的DOM對象,即document
、window
、parent
這些對象,子線程都無法得到。(但是,navigator
對象和location
對象可以獲得。)
腳本限制。子線程無法讀取網頁的全局變量和函數(shù),也不能執(zhí)行alert和confirm方法,不過可以執(zhí)行setInterval和setTimeout,以及使用XMLHttpRequest對象發(fā)出AJAX請求。
文件限制。子線程無法讀取本地文件,即子線程無法打開本機的文件系統(tǒng)(file://),它所加載的腳本,必須來自網絡。
使用之前,檢查瀏覽器是否支持這個API。
if (window.Worker) {
// 支持
} else {
// 不支持
}
主線程采用new
命令,調用Worker
構造函數(shù),可以新建一個子線程。
var worker = new Worker('work.js');
Worker構造函數(shù)的參數(shù)是一個腳本文件,這個文件就是子線程所要完成的任務,上面代碼中是work.js
。由于子線程不能讀取本地文件系統(tǒng),所以這個腳本文件必須來自網絡端。如果下載沒有成功,比如出現(xiàn)404錯誤,這個子線程就會默默地失敗。
子線程新建之后,并沒有啟動,必需等待主線程調用postMessage
方法,即發(fā)出信號之后才會啟動。postMessage
方法的參數(shù),就是主線程傳給子線程的信號。它可以是一個字符串,也可以是一個對象。
worker.postMessage("Hello World");
worker.postMessage({method: 'echo', args: ['Work']});
只要符合父線程的同源政策,Worker線程自己也能新建Worker線程。Worker線程可以使用XMLHttpRequest進行網絡I/O,但是XMLHttpRequest
對象的responseXML
和channel
屬性總是返回null
。
在子線程內,必須有一個回調函數(shù),監(jiān)聽message事件。
/* File: work.js */
self.addEventListener('message', function(e) {
self.postMessage('You said: ' + e.data);
}, false);
self代表子線程自身,self.addEventListener表示對子線程的message事件指定回調函數(shù)(直接指定onmessage屬性的值也可)?;卣{函數(shù)的參數(shù)是一個事件對象,它的data屬性包含主線程發(fā)來的信號。self.postMessage則表示,子線程向主線程發(fā)送一個信號。
根據(jù)主線程發(fā)來的不同的信號值,子線程可以調用不同的方法。
/* 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});
};
主線程也必須指定message事件的回調函數(shù),監(jiān)聽子線程發(fā)來的信號。
/* File: main.js */
worker.addEventListener('message', function(e) {
console.log(e.data);
}, false);
主線程可以監(jiān)聽子線程是否發(fā)生錯誤。如果發(fā)生錯誤,會觸發(fā)主線程的error事件。
worker.onerror(function(event) {
console.log(event);
});
// or
worker.addEventListener('error', function(event) {
console.log(event);
});
使用完畢之后,為了節(jié)省系統(tǒng)資源,我們必須在主線程調用terminate方法,手動關閉子線程。
worker.terminate();
也可以子線程內部關閉自身。
self.close();
前面說過,主線程與子線程之間的通信內容,可以是文本,也可以是對象。需要注意的是,這種通信是拷貝關系,即是傳值而不是傳址,子線程對通信內容的修改,不會影響到主線程。事實上,瀏覽器內部的運行機制是,先將通信內容串行化,然后把串行化后的字符串發(fā)給子線程,后者再將它還原。
主線程與子線程之間也可以交換二進制數(shù)據(jù),比如File、Blob、ArrayBuffer等對象,也可以在線程之間發(fā)送。
但是,用拷貝方式發(fā)送二進制數(shù)據(jù),會造成性能問題。比如,主線程向子線程發(fā)送一個500MB文件,默認情況下瀏覽器會生成一個原文件的拷貝。為了解決這個問題,JavaScript允許主線程把二進制數(shù)據(jù)直接轉移給子線程,但是一旦轉移,主線程就無法再使用這些二進制數(shù)據(jù)了,這是為了防止出現(xiàn)多個線程同時修改數(shù)據(jù)的麻煩局面。這種轉移數(shù)據(jù)的方法,叫做Transferable Objects。
如果要使用該方法,postMessage方法的最后一個參數(shù)必須是一個數(shù)組,用來指定前面發(fā)送的哪些值可以被轉移給子線程。
worker.postMessage(arrayBuffer, [arrayBuffer]);
window.postMessage(arrayBuffer, targetOrigin, [arrayBuffer]);
通常情況下,子線程載入的是一個單獨的JavaScript文件,但是也可以載入與主線程在同一個網頁的代碼。假設網頁代碼如下:
<!DOCTYPE html>
<body>
<script id="worker" type="app/worker">
addEventListener('message', function() {
postMessage('Im reading Tech.pro');
}, false);
</script>
</body>
</html>
我們可以讀取頁面中的script,用worker來處理。
var blob = new Blob([document.querySelector('#worker').textContent]);
這里需要把代碼當作二進制對象讀取,所以使用Blob接口。然后,這個二進制對象轉為URL,再通過這個URL創(chuàng)建worker。
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
部署事件監(jiān)聽代碼。
worker.addEventListener('message', function(e) {
console.log(e.data);
}, false);
最后,啟動worker。
worker.postMessage('');
整個頁面的代碼如下:
<!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>
可以看到,主線程和子線程的代碼都在同一個網頁上面。
上面所講的Web Worker都是專屬于某個網頁的,當該網頁關閉,worker就自動結束。除此之外,還有一種共享式的Web Worker,允許多個瀏覽器窗口共享同一個worker,只有當所有網口關閉,它才會結束。這種共享式的Worker用SharedWorker對象來建立,因為適用場合不多,這里就省略了。
有時,瀏覽器需要論詢服務器狀態(tài),以便第一時間得知狀態(tài)改變。這個工作可以放在 Worker 進程里面。
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有以下特點。
postMessage
接口與頁面通信。Service worker的常見用途。
使用Service Worker有以下步驟。
首先,需要向瀏覽器登記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);
});
}
上面代碼向瀏覽器登記sw.js
腳本,實質就是瀏覽器加載sw.js
。這段代碼可以多次調用,瀏覽器會自行判斷sw.js
是否登記過,如果已經登記過,就不再重復執(zhí)行了。注意,Service worker腳本必須與頁面在同一個域,且必須在HTTPs協(xié)議下正常運行。
sw.js
位于域名的根目錄下,這表明這個Service worker的范圍(scope)是整個域,即會接收整個域下面的fetch
事件。如果腳本的路徑是/example/sw.js
,那么Service worker只對/example/
開頭的URL有效(比如/example/page1/
、/example/page2/
)。如果腳本不在根目錄下,但是希望對整個域都有效,可以指定scope
屬性。
navigator.serviceWorker.register('/path/to/serviceworker.js', {
scope: '/'
});
一旦登記完成,這段腳本就會用戶的瀏覽器之中長期存在,不會隨著用戶離開你的網站而消失。
.register
方法返回一個Promise對象。
登記成功后,瀏覽器執(zhí)行下面步驟。
安裝和激活,主要通過事件來判斷。
self.addEventListener('install', function(event) {
event.waitUntil(
fetchStuffAndInitDatabases()
);
});
self.addEventListener('activate', function(event) {
// You're good to go!
});
Service worker一旦激活,就開始控制頁面。網頁加載的時候,可以選擇一個Service worker作為自己的控制器。不過,頁面第一次加載的時候,它不受Service worker控制,因為這時還沒有一個Service worker在運行。只有重新加載頁面后,Service worker才會生效,控制加載它的頁面。
你可以查看navigator.serviceWorker.controller
,了解當前哪個ServiceWorker掌握控制權。如果后臺沒有任何Service worker,navigator.serviceWorker.controller
返回null
。
Service worker激活以后,就能監(jiān)聽fetch
事件。
self.addEventListener('fetch', function(event) {
console.log(event.request);
});
fetch
事件會在兩種情況下觸發(fā)。
iframe
和<object>
標簽發(fā)出的請求不會被攔截。fetch
事件的event
對象的request
屬性,返回一個對象,包含了所攔截的網絡請求的所有信息,比如URL、請求方法和HTTP頭信息。
Service worker的強大之處,在于它會攔截請求,并會返回一個全新的回應。
self.addEventListener('fetch', function(event) {
event.respondWith(new Response("Hello world!"));
});
respondWith
方法的參數(shù)是一個Response對象實例,或者一個Promise對象(resolved以后返回一個Response實例)。上面代碼手動創(chuàng)造一個Response實例。
下面是完整的代碼。
先看網頁代碼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!"));
});
每一次瀏覽器向服務器要求一個文件的時候,就會觸發(fā)fetch
事件。Service worker可以在發(fā)出這個請求之前,前攔截它。
self.addEventListener('fetch', function (event) {
var request = event.request;
...
});
實際應用中,我們使用fetch
方法去抓取資源,該方法返回一個Promise對象。
self.addEventListener('fetch', function(event) {
if (/\.jpg$/.test(event.request.url)) {
event.respondWith(
fetch('//www.google.co.uk/logos/example.gif', {
mode: 'no-cors'
})
);
}
});
上面代碼中,如果網頁請求JPG文件,就會被Service worker攔截,轉而返回一個Google的Logo圖像。fetch
方法默認會加上CORS信息頭,,上面設置了取消這個頭。
下面的代碼是一個將所有JPG、PNG圖片請求,改成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'
}));
}
}
});
如果請求失敗,可以通過Promise的catch
方法處理。
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(function() {
return new Response("Request failed!");
})
);
});
登記成功后,可以在Chrome瀏覽器訪問chrome://inspect/#service-workers
,查看整個瀏覽器目前正在運行的Service worker。訪問chrome://serviceworker-internals
,可以查看瀏覽器目前安裝的所有Service worker。
一個已經登記過的Service worker腳本,如果發(fā)生改動,瀏覽器就會重新安裝,這被稱為“升級”。
Service worker有一個Cache API,用來緩存外部資源。
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
方法用來建立緩存,然后使用addAll
方法添加資源。caches.match
方法則用來建立緩存以后,匹配當前請求是否在緩存之中,如果命中就取出緩存,否則就正常發(fā)出這個請求。一旦一個資源進入緩存,它原來指定是否過期的HTTP信息頭,就會被忽略。緩存之中的資源,只在你移除它們的時候,才會被移除。
單個資源可以使用cache.put(request, response)
方法添加。
下面是一個在安裝階段緩存資源的例子。
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樣式表、圖像文件、網站首頁、離線頁面,存入瀏覽器緩存。這些資源都要等全部進入緩存之后,才會安裝。
安裝以后,就需要激活。
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);
})
);
})
);
});
更多建議: