本文首發於CSDN網站,下面的版本又通過進一步的修訂。
原文:webpack與browser-sync熱更新原理深度講解
本文包含以下內容:javascript
開發環境頁面熱更新早已經是主流,咱們不光要吃着火鍋唱着歌,享受熱更新高效率的快感,更要深刻下去探求其原理。html
要知道,觸類則旁通,常見的需求如賽事網頁推送比賽結果、網頁實時展現投票或點贊數據、在線評論或彈幕、在線聊天室等,都須要藉助熱更新功能,才能達到實時的端對端的極致體驗。java
恰好,最近解決webpack-hot-middleware
熱更新延遲問題的過程當中,我深刻接觸了EventSource技術。遂本文由此開篇,進一步講解webpack-hot-middleware
,browser-sync
背後的技術。node
webpack-hot-middleware
中間件是webpack的一個plugin,一般結合webpack-dev-middleware
一塊兒使用。藉助它能夠實現瀏覽器的無刷新更新(熱更新),即webpack裏的HMR(Hot Module Replacement)。如何配置請參考 webpack-hot-middleware,如何理解其相關插件請參考 手把手深刻理解 webpack dev middleware 原理與相關 plugins。webpack
webpack加入webpack-hot-middleware
後,內存中的頁面將包含HMR相關js,加載頁面後,Network欄能夠看到以下請求:nginx
__webpack_hmr是一個type
爲EventSource的請求, 從Time
欄能夠看出:默認狀況下,服務器每十秒推送一條信息到瀏覽器。git
若是此時關閉開發服務器,瀏覽器因爲重連機制,將持續拋出相似GET http://www.test.com/__webpack_hmr 502 (Bad Gateway)
這樣的錯誤。從新啓動開發服務器後,重連將會成功,此時便會刷新頁面。github
以上這些即是咱們使用時感覺到的最初的印象。固然,停留在使用層面不是咱們的目標,接下來咱們將跳出該中間件,講解其所使用到的EventSource
技術。web
EventSource 不是一個新鮮的技術,它早就隨着H5規範提出了,正式一點應該叫Server-sent events
,即SSE
。ajax
鑑於傳統的經過ajax輪訓獲取服務器信息的技術方案已通過時,咱們迫切須要一個高效的節省資源的方式去獲取服務器信息,一旦服務器資源有更新,可以及時地通知到客戶端,從而實時地反饋到用戶界面上。EventSource就是這樣的技術,它本質上仍是HTTP,經過response流實時推送服務器信息到客戶端。
新建一個EventSource對象很是簡單。
const es = new EventSource('/message');// /message是服務端支持EventSource的接口複製代碼
新建立的EventSource對象擁有以下屬性:
屬性 | 描述 |
---|---|
url(只讀) | es對象請求的服務器url |
readyState(只讀) | es對象的狀態,初始爲0,包含CONNECTING (0),OPEN (1),CLOSED (2)三種狀態 |
withCredentials | 是否容許帶憑證等,默認爲false,即不支持發送cookie |
服務端實現/message
接口,須要返回類型爲 text/event-stream
的響應頭。
var http = require('http');
http.createServer(function(req,res){
if(req.url === '/message'){
res.writeHead(200,{
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
setInterval(function(){
res.write('data: ' + +new Date() + '\n\n');
}, 1000);
}
}).listen(8888);複製代碼
咱們注意到,爲了不緩存,Cache-Control 特別設置成了 no-cache,爲了可以發送多個response, Connection被設置成了keep-alive.。發送數據時,請務必保證服務器推送的數據以 data:
開始,以\n\n
結束,不然推送將會失敗(緣由就不說了,這是約定的)。
以上,服務器每隔1s主動向客戶端發送當前時間戳,爲了接受這個信息,客戶端須要監聽服務器。以下:
es.onmessage = function(e){
console.log(e.data); // 打印服務器推送的信息
}複製代碼
以下是消息推送的過程:
你覺得es只能監聽message事件嗎?並非,message只是缺省的事件類型。實際上,它能夠監放任何指定類型的事件。
es.addEventListener("####", function(e) {// 事件類型能夠隨你定義
console.log('####:', e.data);
},false);複製代碼
服務器發送不一樣類型的事件時,須要指定event字段。
res.write('event: ####\n');
res.write('data: 這是一個自定義的####類型事件\n');
res.write('data: 多個data字段將被解析成一個字段\n\n');複製代碼
以下所示:
能夠看到,服務端指定event事件名爲"####"後,客戶端觸發了對應的事件回調,同時服務端設置的多個data字段,客戶端使用換行符鏈接成了一個字符串。
不只如此,事件流中還能夠混合多種事件,請看咱們是怎麼收到消息的,以下:
除此以外,es對象還擁有另外3個方法: onopen()
、onerror()
、close()
,請參考以下實現。
es.onopen = function(e){// 連接打開時的回調
console.log('當前狀態readyState:', es.readyState);// open時readyState===1
}
es.onerror = function(e){// 出錯時的回調(網絡問題,或者服務下線等都有可能致使出錯)
console.log(es.readyState);// 出錯時readyState===0
es.close();// 出錯時,chrome瀏覽器會每隔3秒向服務器重發原請求,直到成功. 所以出錯時,可主動斷開原鏈接.
}複製代碼
使用EventSource技術實時更新網頁信息十分高效。實際使用中,咱們幾乎不用擔憂兼容性問題,主流瀏覽器都了支持EventSource,固然,除了掉隊的IE系。對於不支持的瀏覽器,其PolyFill方案請參考HTML5 Cross Browser Polyfills。
另外,若是須要支持跨域調用,請設置響應頭Access-Control-Allow-Origin': '*'
。
如需支持發送cookie,請設置響應頭Access-Control-Allow-Origin': req.headers.origin
和 Access-Control-Allow-Credentials:true
,而且建立es對象時,須要明確指定是否發送憑證。以下:
var es = new EventSource('/message', {
withCredentials: true
}); // 建立時指定配置纔是有效的
es.withCredentials = true; // 與ajax不一樣,這樣設置是無效的複製代碼
如下是主流瀏覽器對EventSource的CORS的支持:
Firefox | Opera | Chrome | Safari | iOS | Android |
---|---|---|---|---|---|
10+ | 12+ | 26+ | 7.0+ | 7.0+ | 4.4+ |
既然說到了EventSource,便有必要談談遇到的坑,接下來,就說說我遇到的webpack熱更新延遲問題。
如咱們所知,webpack藉助webpack-hot-middleware插件,實現了網頁熱更新機制,正常狀況下,瀏覽器打開 http://localhost:8080 這樣的網頁便可開始調試。然而實際開發中,因爲遠程服務器須要種cookie登陸態到特定的域名上等緣由,所以本地每每會用nginx作一層反向代理。即把 www.test.com 的請求轉發到 http://localhost:8080 上(配置過程這裏不詳述,具體請參考Ajax知識體系大梳理-ajax調試技巧)。轉發事後,發現熱更新便延遲了。
緣由是nginx默認開啓的buffer機制緩存了服務器推送的片斷信息,緩存達到必定的量纔會返回響應內容。只要關閉proxy_buffering便可。配置以下所示:
server {
listen 80;
server_name www.test.company.com;
location / {
proxy_pass http://localhost:8080;
proxy_buffering off;
}
}複製代碼
至此,EventSource部分便告一段落。學習講究由淺入深,按部就班。後面我將重點講解的browser-sync
熱更新機制,請耐心細讀。
開發中使用browser-sync
插件調試,一個網頁裏的全部交互動做(包括滾動,輸入,點擊等等),能夠實時地同步到其餘全部打開該網頁的設備,可以節省大量的手工操做時間,從而帶來流暢的開發調試體驗。目前browser-sync
能夠結合Gulp
或Grunt
一塊兒使用,其API請參考:Browsersync API。
經過上面的瞭解,咱們知道EventSouce
的使用是比較便捷的,那爲何browser-sync
不使用EventSource技術進行代碼推送呢?這是由於browser-sync
插件共作了兩件事:
以上,browser-sync
使用WebSocket技術達到實時推送代碼改動和用戶操做兩個目的。至於它是如何計算推送內容,根據不一樣推送內容採起何種響應策略,不在本次討論範圍以內。下面咱們將講解其核心的WebSocket技術。
WebSocket是基於TCP的全雙工通信的協議,它與EventSource有着本質上的不一樣.(前者基於TCP,後者依然基於HTTP) 該協議於2011年被IETF定爲標準RFC6455,後被RFC7936補充. WebSocket api也被W3C定爲標準。
WebSocket使用和HTTP相同的TCP端口,默認爲80, 統一資源標誌符爲ws,運行在TLS之上時,默認使用443,統一資源標誌符爲wss。它經過101 switch protocol
進行一次TCP握手,即從HTTP協議切換成WebSocket通訊協議。
相對於HTTP協議,WebSocket擁有以下優勢:
permessage-deflate
擴展。優秀技術的落地,調研兼容性是必不可少的環節。所幸的是,現代瀏覽器對WebSocket的支持比較友好,以下是PC端兼容性:
IE/Edge | Firefox | Chrome | Safari | Opera |
---|---|---|---|---|
10+ | 11+ | 16+ | 7+ | 12.1+ |
以下是mobile端兼容性:
iOS Safari | Android | Android Chrome | Android UC | QQ Browser | Opera Mini |
---|---|---|---|---|---|
7.1+ | 4.4+ | 57+ | 11.4+ | 1.2+ | - |
根據RFC6455文檔,WebSocket協議基於Frame而非Stream(EventSource是基於Stream的)。所以其傳輸的數據都是Frame(幀)。想要了解數據的往返,弄懂協議處理過程,Frame的解讀是必不可少。以下即是Frame的結構:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued,if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key,if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+複製代碼
第一個字節包含FIN、RSV、Opcode。
FIN:size爲1bit,標示是否最後一幀。%x0
表示還有後續幀,%x1
表示這是最後一幀。
RSV一、二、3,每一個size都是1bit,默認值都是0,若是沒有定義非零值的含義,卻出現了非零值,則WebSocket連接將失敗。
Opcode,size爲4bits,表示『payload data』的類型。若是收到未知的opcode,鏈接將會斷開。已定義的opcode值以下:
%x0: 表明連續的幀
%x1: 文本幀
%x2: 二進制幀
%x3~7: 預留的非控制幀
%x8: 關閉握手幀
%x9: ping幀,後續心跳鏈接會講到
%xA: pong幀,後續心跳鏈接會講到
%xB~F: 預留的非控制幀複製代碼
第二個字節包含Mask、Payload len。
Mask:size爲1bit,標示『payload data』是否添加掩碼。全部從客戶端發送到服務端的幀都會被置爲1,若是置1,Masking-key
便會賦值。
//若server是一個WebSocket服務端實例
//監聽客戶端消息
server.on('message', function(msg, flags) {
console.log('client say: %s', msg);
console.log('mask value:', flags.masked);// true,進一步佐證了客戶端發送到服務端的Mask幀都會被置爲1
});
//監聽客戶端pong幀響應
server.on('pong', function(msg, flags) {
console.log('pong data: %s', msg);
console.log('mask value:', flags.masked);// true,進一步佐證了客戶端發送到服務端的Mask幀都會被置爲1
});複製代碼
Payload len:size爲7bits,即便是當作無符號整型也只能表示0~127的值,因此它不能表示更大的值,所以規定"Payload data"長度小於或等於125的時候才用來描述數據長度。若是Payload len==126
,則使用隨後的2bytes(16bits)來存儲數據長度。若是Payload len==127
,則使用隨後的8bytes(64bits)來存儲數據長度。
以上,擴展的Payload len可能佔據第三至第四個或第三至第十個字節。緊隨其後的是"Mask-key"。
關於Frame的更多理論介紹不妨讀讀 學習WebSocket協議—從頂層到底層的實現原理(修訂版)。
關於Frame的數據幀解析不妨讀讀 WebSocket(貳) 解析數據幀 及其後續文章。
瞭解了Frame的數據結構後,咱們來實際練習下。瀏覽器上,新建一個ws對象十分簡單。以下:
let ws = new WebSocket('ws://127.0.0.1:10103/');// 本地使用10103端口進行測試複製代碼
新建的WebSocket對象以下所示:
這中間包含了一次Websocket握手的過程,咱們分兩步來理解。
第一步,客戶端請求。
這是一個GET請求,主要字段以下:
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key:61x6lFN92sJHgzXzCHfBJQ==
Sec-WebSocket-Version:13複製代碼
Connection字段指定爲Upgrade,表示客戶端但願鏈接升級。
Upgrade字段設置爲websocket,表示但願升級至Websocket協議。
Sec-WebSocket-Key字段是隨機字符串,服務器根據它來構造一個SHA-1的信息摘要。
Sec-WebSocket-Version表示支持的Websocket版本。RFC6455要求使用的版本是13。
甚至咱們能夠從請求截圖裏看出,Origin是file://
,而Host是127.0.0.1:10103
,明顯不是同一個域下,但依然能夠請求成功,說明Websocket協議是不受同源策略限制的(同源策略限制的是http協議)。
第二步,服務端響應。
Status Code: 101 Switching Protocols 表示Websocket協議經過101狀態碼進行握手。
Sec-WebSocket-Accept字段是由Sec-WebSocket-Key字段加上特定字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",計算SHA-1摘要,而後再base64編碼以後生成的. 該操做可避免普通http請求,被誤認爲Websocket協議。
Sec-WebSocket-Extensions字段表示服務端對Websocket協議的擴展。
以上,WebSocket構造器不止能夠傳入url,還能傳入一個可選的協議名稱字符串或數組。
ws = new WebSocket('ws://127.0.0.1:10103/', ['abc','son_protocols']);複製代碼
等等,咱們慢一點,上面好像漏掉了一步,彷佛沒有提到服務端是怎麼實現的。請繼續往下看:
先作一些準備。ws是一個nodejs版的WebSocketServer實現。使用 npm install ws
便可安裝。
var WebSocketServer = require('ws').Server,
server = new WebSocketServer({port: 10103});
server.on('connection', function(s) {
s.on('message', function(msg) { //監聽客戶端消息
console.log('client say: %s', msg);
});
s.send('server ready!');// 鏈接創建好後,向客戶端發送一條消息
});複製代碼
以上,new WebSocketServer()
建立服務器時如需權限驗證,請指定verifyClient
爲驗權的函數。
server = new WebSocketServer({
port: 10103,
verifyClient: verify
});
function verify(info){
console.log(Object.keys(info));// [ 'origin', 'secure', 'req' ]
console.log(info.orgin);// "file://"
return true;// 返回true時表示驗權經過,不然客戶端將拋出"HTTP Authentication failed"錯誤
}複製代碼
以上,verifyClient
指定的函數只有一個形參,若爲它顯式指定兩個形參,那麼第一個參數同上info,第二個參數將是一個cb
回調函數。該函數用於顯式指定拒絕時的HTTP狀態碼等,它默認擁有3個形參,依次爲:
// 若verify定義以下
function verify(info, cb){
//一旦擁有第二個形參,若是不調用,默認將經過驗權
cb(false, 401, '權限不夠');// 此時表示驗權失敗,HTTP狀態碼爲401,錯誤信息爲"權限不夠"
return true;// 一旦擁有第二個形參,響應就被cb接管了,返回什麼值都不會影響前面的處理結果
}複製代碼
除了port
和 verifyClient
設置外,其它設置項及更多API,請參考文檔 ws-doc。
接下來,咱們來實現消息收發。以下是客戶端發送消息。
ws.onopen = function(e){
// 可發送字符串,ArrayBuffer 或者 Blob數據
ws.send('client ready!);
};複製代碼
客戶端監聽信息。
ws.onmessage = function(e){
console.log('server say:', e.data);
};複製代碼
以下是瀏覽器的運行截圖。
消息的內容都在Frames欄,第一條彩色背景的信息是客戶端發送的,第二條是服務端發送的。兩條消息的長度都是13。
以下是Timing欄,不止是WebSocket,包括EventSource,都有這樣的黃色高亮警告。
該警告說明:請求還沒完成。實際上,直到一方鏈接close掉,請求才會完成。
說到close,ws的close方法比es的略複雜。
語法:close(short code,string reason);
close默承認傳入兩個參數。code是數字,表示關閉鏈接的狀態號,默認是1000,即正常關閉。(code取值範圍從0到4999,其中有些是保留狀態號,正常關閉時只能指定爲1000或者3000~4999之間的值,具體請參考CloseEvent - Web APIs)。reason是UTF-8文本,表示關閉的緣由(文本長度需小於或等於123字節)。
因爲code 和 reason都有限制,所以該方法可能拋出異常,建議catch下.
try{
ws.close(1001, 'CLOSE_GOING_AWAY');
}catch(e){
console.log(e);
}複製代碼
ws對象還擁有onclose和onerror監聽器,分別監聽關閉和錯誤事件。(注:EventSource沒有onclose監聽)
ws的readyState屬性擁有4個值,比es的readyState的多一個CLOSING的狀態。
常量 | 描述 | EventSource(值) | WebSocket(值) |
---|---|---|---|
CONNECTING | 鏈接未初始化 | 0 | 0 |
OPEN | 鏈接已就緒 | 1 | 1 |
CLOSING | 鏈接正在關閉 | - | 2 |
CLOSED | 鏈接已關閉 | 2 | 3 |
另外,除了兩種都有的url屬性外,WebSocket對象還擁有更多的屬性。
屬性 | 描述 |
---|---|
binaryType | 被傳輸二進制內容的類型,有blob,arraybuffer兩種 |
bufferedAmount | 待傳輸的數據的長度 |
extensions | 表示服務器選用的擴展 |
protocol | 指的是構造器第二個參數傳入的子協議名稱 |
之前一直是使用ajax作文件上傳,實際上,Websocket上傳文件也是一把好刀. 其send方法能夠發送String,ArrayBuffer,Blob共三種數據類型,發送二進制文件徹底不在話下。
因爲各個瀏覽器對Websocket單次發送的數據有限制,因此咱們須要將待上傳文件切成片斷去發送。以下是實現。
1) html。
<input type="file" id="file"/>複製代碼
2) js。
const ws = new WebSocket('ws://127.0.0.1:10103/');// 鏈接服務器
const fileSelect = document.getElementById('file');
const size = 1024 * 128;// 分段發送的文件大小(字節)
let curSize, total, file, fileReader;
fileSelect.onchange = function(){
file = this.files[0];// 選中的待上傳文件
curSize = 0;// 當前已發送的文件大小
total = file.size;// 文件大小
ws.send(file.name);// 先發送待上傳文件的名稱
fileReader = new FileReader();// 準備讀取文件
fileReader.onload = loadAndSend;
readFragment();// 讀取文件片斷
};
function loadAndSend(){
if(ws.bufferedAmount > size * 5){// 若發送隊列中的數據太多,先等一等
setTimeout(loadAndSend,4);
return;
}
ws.send(fileReader.result);// 發送本次讀取的片斷內容
curSize += size;// 更新已發送文件大小
curSize < total ? readFragment() : console.log('upload successed!');// 下一步操做
}
function readFragment(){
const blob = file.slice(curSize, curSize + size);// 獲取文件指定片斷
fileReader.readAsArrayBuffer(blob);// 讀取文件爲ArrayBuffer對象
}複製代碼
3) server(node)。
var WebSocketServer = require('ws').Server,
server = new WebSocketServer({port: 10103}),// 啓動服務器
fs = require('fs');
server.on('connection', function(wsServer){
var fileName, i = 0;// 變量定義不可放在全局,因每一個鏈接都不同,這裏纔是私有做用域
server.on('message', function(data, flags){// 監聽客戶端消息
if(flags.binary){// 判斷是否二進制數據
var method = i++ ? 'appendFileSync' : 'writeFileSync';
// 當前目錄下寫入或者追加寫入文件(建議加上try語句捕獲可能的錯誤)
fs[method]('./' + fileName, data,'utf-8');
}else{// 非二進制數據則認爲是文件名稱
fileName = data;
}
});
wsServer.send('server ready!');// 告知客戶端服務器已就緒
});複製代碼
運行效果以下:
上述測試代碼中沒有過多涉及服務器的存儲過程。一般,服務器也會有緩存區上限,若是客戶端單次發送的數據量超過服務端緩存區上限,那麼服務端也須要屢次讀取。
生產環境下上傳一個文件遠比本地測試來得複雜。實際上,從客戶端到服務端,中間存在着大量的網絡鏈路,如路由器,防火牆等等。一份文件的上傳要通過中間的層層路由轉發,過濾。這些中間鏈路可能會認爲一段時間沒有數據發送,就自發切斷兩端的鏈接。這個時候,因爲TCP並不定時檢測鏈接是否中斷,而通訊的雙方又相互沒有數據發送,客戶端和服務端依然會一廂情願的信任以前的鏈接,久而久之,將使得大量的服務端資源被WebSocket鏈接佔用。
正常狀況下,TCP的四次揮手徹底能夠通知兩端去釋放鏈接。可是上述這種廣泛存在的異常場景,將使得鏈接的釋放成爲夢幻。
爲此,早在websocket協議實現時,設計者們便提供了一種 Ping/Pong Frame的心跳機制。一端發送Ping Frame,另外一端以 Pong Frame響應。這種Frame是一種特殊的數據包,它只包含一些元數據,可以在不影響原通訊的狀況下維持住鏈接。
根據規範RFC 6455,Ping Frame包含一個值爲9的opcode,它可能攜帶數據。收到Ping Frame後,Pong Frame必須被做爲響應發出。Pong Frame包含一個值爲10的opcode,它將包含與Ping Frame中相同的數據。
藉助ws包,服務端能夠這麼來發送Ping Frame。
wsServer.ping();複製代碼
同時,須要監聽客戶端響應的pong Frame.
wsServer.on('pong', function(data, flags) {
console.log(data);// ""
console.log(flags);// { masked: true,binary: true }
});複製代碼
以上,因爲Ping Frame 不帶數據,所以做爲響應的Pong Frame的data值爲空串。遺憾的是,目前瀏覽器只能被動發送Pong Frame做爲響應(Sending websocket ping/pong frame from browser),沒法經過JS API主動向服務端發送Ping Frame。所以對於web服務,能夠採起服務端主動ping的方式,來保持住連接。實際應用中,服務端還須要設置心跳的週期,以保證心跳鏈接能夠一直持續。同時,還應該有重發機制,若連續幾回沒有收到心跳鏈接的回覆,則認爲鏈接已經斷開,此時即可以關閉Websocket鏈接了。
WebSocket出世已久,不少優秀的大神基於此開發出了各式各樣的庫。其中Socket.IO是一個很是不錯的開源WebSocke庫,旨在抹平瀏覽器之間的兼容性問題。它基於Node.js,支持如下方式優雅降級:
如何在項目中使用Socket.IO,請參考 第一章 socket.io 簡介及使用。
EventSource,本質依然是HTTP,它僅提供服務端到客戶端的單向文本數據傳輸,不須要心跳鏈接,鏈接斷開會持續觸發重連。
WebSocket協議,基於TCP協議,它提供雙向數據傳輸,支持二進制,須要心跳鏈接,鏈接斷開不會重連。
EventSource更輕量和簡單,WebSocket支持性更好(因其支持IE10+)。一般來講,使用EventSource可以完成的功能,使用WebSocket同樣可以作到,反之卻不行,使用時若遇到鏈接斷開或拋錯,請及時調用各自的close
方法主動釋放資源。
本問就討論這麼多內容,你們有什麼問題或好的想法歡迎在下方參與留言和評論。
本文做者: louis
本文連接: louiszhai.github.io/2017/04/19/…
參考文章