標籤:style http io os ar 使用 java for strong
BigPipe 是 Facebook 開發的最佳化網頁載入速度的技術。網上幾乎沒有用 node.js 實現的文章,實際上,不止於 node.js,BigPipe 用其他語言的實現在網上都很少見。以至於這技術出現很久以後,我還以為就是整個網頁的架構先發送完畢後,用另一個或幾個 ajax 請求再請求頁面內的模組。直到不久前,我才瞭解到原來 BigPipe 的核心概念就是只用一個 HTTP 要求,只是頁面元素不按順序發送而已。
瞭解了這個核心概念就好辦了,得益於 node.js 的非同步特性,很容易就可以用 node.js 實現 BigPipe。本文會一步一步詳盡地用例子來說明 BigPipe 技術的起因和一個基於 node.js 的簡單實現。
我會用 express 來示範,簡單起見,我們選用 jade 作為模版引擎,並且我們不使用引擎的子模版(partial)特性,而是以子模版渲染完成以後的 HTML 作為父模版的資料。
先建一個 nodejs-bigpipe 的檔案夾,寫一個 package.json 檔案如下:
{ "name": "bigpipe-experiment" , "version": "0.1.0" , "private": true , "dependencies": { "express": "3.x.x" , "consolidate": "latest" , "jade": "latest" }}
運行 npm install 安裝這三個庫,consolidate 是用來方便調用 jade 的。
先做個最簡單的嘗試,兩個檔案:
app.js:
var express = require(‘express‘) , cons = require(‘consolidate‘) , jade = require(‘jade‘) , path = require(‘path‘)var app = express()app.engine(‘jade‘, cons.jade)app.set(‘views‘, path.join(__dirname, ‘views‘))app.set(‘view engine‘, ‘jade‘)app.use(function (req, res) { res.render(‘layout‘, { s1: "Hello, I‘m the first section." , s2: "Hello, I‘m the second section." })})app.listen(3000)
views/layout.jade
doctype htmlhead title Hello, World! style section { margin: 20px auto; border: 1px dotted gray; width: 80%; height: 150px; }section#s1!=s1section#s2!=s2
效果如下:
接下來我們把兩個 section 模版放到兩個不同的模版檔案裡:
views/s1.jade:
h1 Partial 1.content!=content
views/s2.jade:
h1 Partial 2.content!=content
在 layout.jade 的 style 裡增加一些樣式
section h1 { font-size: 1.5; padding: 10px 20px; margin: 0; border-bottom: 1px dotted gray;}section div { margin: 10px;}
將 app.js 的 app.use() 部分更改為:
var temp = { s1: jade.compile(fs.readFileSync(path.join(__dirname, ‘views‘, ‘s1.jade‘))) , s2: jade.compile(fs.readFileSync(path.join(__dirname, ‘views‘, ‘s2.jade‘)))}app.use(function (req, res) { res.render(‘layout‘, { s1: temp.s1({ content: "Hello, I‘m the first section." }) , s2: temp.s2({ content: "Hello, I‘m the second section." }) })})
之前我們說“以子模版渲染完成以後的 HTML 作為父模版的資料”,指的就是這樣,temp.s1
和temp.s2
兩個方法會產生 s1.jade 和 s2.jade 兩個檔案的 HTML 程式碼,然後把這兩段代碼作為 layout.jade 裡面 s1、s2 兩個變數的值。
現在頁面看起來是這樣子:
一般來說,兩個 section 的資料是分別擷取的——不管是通過查詢資料庫還是 RESTful 請求,我們用兩個函數來類比這樣的非同步作業。
var getData = { d1: function (fn) { setTimeout(fn, 3000, null, { content: "Hello, I‘m the first section." }) } , d2: function (fn) { setTimeout(fn, 5000, null, { content: "Hello, I‘m the second section." }) }}
這樣一來,app.use() 裡的邏輯就會比較複雜了,最簡單的處理方式是:
app.use(function (req, res) { getData.d1(function (err, s1data) { getData.d2(function (err, s2data) { res.render(‘layout‘, { s1: temp.s1(s1data) , s2: temp.s2(s2data) }) }) })})
這樣也可以得到我們想要的結果,但是這樣的話,要足足 8 秒才會返回。
其實實現邏輯可以看出 getData.d2 是在 getData.d1 的結果返回後才開始調用,而它們兩者並沒有這樣的依賴關係。我們可以用如 async 之類的處理 JavaScript 非同步呼叫的庫來解決這樣的問題,不過我們這裡就簡單手寫吧:
app.use(function (req, res) { var n = 2 , result = {} getData.d1(function (err, s1data) { result.s1data = s1data --n || writeResult() }) getData.d2(function (err, s2data) { result.s2data = s2data --n || writeResult() }) function writeResult() { res.render(‘layout‘, { s1: temp.s1(result.s1data) , s2: temp.s2(result.s2data) }) }})
這樣就只需 5 秒。
在接下來的最佳化之前,我們加入 jquery 庫並把 css 樣式放到外部檔案,順便,把之後我們會用到的瀏覽器端使用 jade 模板所需要的 runtime.js 檔案也加入進來,在包含 app.js 的目錄下運行:
mkdir staticcd staticcurl http://code.jquery.com/jquery-1.8.3.min.js -o jquery.jsln -s ../node_modules/jade/runtime.min.js jade.js
並且把 layout.jade 中的 style 標籤裡的代碼拿出來放到 static/style.css 裡,然後把 head 標籤改為:
head title Hello, World! link(href="/static/style.css", rel="stylesheet") script(src="/static/jquery.js") script(src="/static/jade.js")
在 app.js 裡,我們把它們兩者的下載速度都類比為兩秒,在app.use(function (req, res) {
之前加入:
var static = express.static(path.join(__dirname, ‘static‘))app.use(‘/static‘, function (req, res, next) { setTimeout(static, 2000, req, res, next)})
受外部靜態檔案的影響,我們的頁面現在的載入時間為 7 秒左右。
如果我們一收到 HTTP 要求就把 head 部分返回,然後兩個 section 等到非同步作業結束後再返回,這是利用了 HTTP 的分塊傳輸編碼機制。在 node.js 裡面只要使用 res.write() 方法就會自動加上 Transfer-Encoding: chunked
這個 header 了。這樣就能在瀏覽器載入靜態檔案的同時,node 伺服器這邊等待非同步呼叫的結果了,我們先刪除 layout.jade 中的這 section 這兩行:
section#s1!=s1section#s2!=s2
因此我們在 res.render() 裡也不用給 { s1: …, s2: … } 這個對象,並且因為 res.render() 預設會調用 res.end(),我們需要手動設定 render 完成後的回呼函數,在裡面用 res.write() 方法。layout.jade 的內容也不必在 writeResult() 這個回呼函數裡面,我們可以在收到這個請求時就返回,注意我們手動添加了 content-type 這個 header:
app.use(function (req, res) { res.render(‘layout‘, function (err, str) { if (err) return res.req.next(err) res.setHeader(‘content-type‘, ‘text/html; charset=utf-8‘) res.write(str) }) var n = 2 getData.d1(function (err, s1data) { res.write(‘<section id="s1">‘ + temp.s1(s1data) + ‘</section>‘) --n || res.end() }) getData.d2(function (err, s2data) { res.write(‘<section id="s2">‘ + temp.s2(s2data) + ‘</section>‘) --n || res.end() })})
現在最終載入速度又回到大概 5 秒左右了。實際運行中瀏覽器先收到 head 部分代碼,就去載入三個靜態檔案,這需要兩秒時間,然後到第三秒,出現 Partial 1 部分,第 5 秒出現 Partial 2 部分,網頁載入結束。就不給了,效果和前面 5 秒的一樣。
但是要注意能實現這個效果是因為 getData.d1 比 getData.d2 快,也就是說,先返回網頁中的哪個區塊取決於背後的介面非同步呼叫結果誰先返回,如果我們把 getData.d1 改成 8 秒返回,那就會先返回 Partial 2 部分,s1 和 s2 的順序對調,最終網頁的結果就和我們的預期不符了。
這個問題最終將我們引導到 BigPipe 上來,BigPipe 就是能讓網頁各部分的顯示順序與資料的傳輸順序解耦的技術。
其基本思路就是,首先傳輸整個網頁大體的架構,需要稍後傳輸的部分用空 div(或其他標籤)表示:
res.render(‘layout‘, function (err, str) { if (err) return res.req.next(err) res.setHeader(‘content-type‘, ‘text/html; charset=utf-8‘) res.write(str) res.write(‘<section id="s1"></section><section id="s2"></section>‘)})
然後將返回的資料用 JavaScript 寫入
getData.d1(function (err, s1data) { res.write(‘<script>$("#s1").html("‘ + temp.s1(s1data).replace(/"/g, ‘\\"‘) + ‘")</script>‘) --n || res.end()})
s2 的處理與此類似。這時你會看到,請求網頁的第二秒,出現兩個空白虛線框,第五秒,出現 Partial 2 部分,第八秒,出現 Partial 1 部分,網頁請求完成。
至此,我們就完成了一個最簡單的 BigPipe 技術實現的網頁。
需要注意的是,要寫入的網頁片段有 script 標籤的情況,如將 s1.jade 改為
h1 Partial 1.content!=contentscript alert("alert from s1.jade")
然後重新整理網頁,會發現這句 alert 沒有執行,而且網頁會有錯誤。查看原始碼,知道是因為 <script>
裡面的字串出現 </script>
而導致的錯誤,只要將其替換為 <\/script>
即可
res.write(‘<script>$("#s1").html("‘ + temp.s1(s1data).replace(/"/g, ‘\\"‘).replace(/<\/script>/g, ‘<\\/script>‘) + ‘")</script>‘)
以上我們便說明了 BigPipe 的原理和用 node.js 實現 BigPipe 的基本方法。而在實際中應該怎樣運用呢?下面提供一個簡單的方法,僅供拋磚引玉,代碼如下:
var resProto = require(‘express/lib/response‘)resProto.pipe = function (selector, html, replace) { this.write(‘<script>‘ + ‘$("‘ + selector + ‘").‘ + (replace === true ? ‘replaceWith‘ : ‘html‘) + ‘("‘ + html.replace(/"/g, ‘\\"‘).replace(/<\/script>/g, ‘<\\/script>‘) + ‘")</script>‘)}function PipeName (res, name) { res.pipeCount = res.pipeCount || 0 res.pipeMap = res.pipeMap || {} if (res.pipeMap[name]) return res.pipeCount++ res.pipeMap[name] = this.id = [‘pipe‘, Math.random().toString().substring(2), (new Date()).valueOf()].join(‘_‘) this.res = res this.name = name}resProto.pipeName = function (name) { return new PipeName(this, name)}resProto.pipeLayout = function (view, options) { var res = this Object.keys(options).forEach(function (key) { if (options[key] instanceof PipeName) options[key] = ‘<span id="‘ + options[key].id + ‘"></span>‘ }) res.render(view, options, function (err, str) { if (err) return res.req.next(err) res.setHeader(‘content-type‘, ‘text/html; charset=utf-8‘) res.write(str) if (!res.pipeCount) res.end() })}resProto.pipePartial = function (name, view, options) { var res = this res.render(view, options, function (err, str) { if (err) return res.req.next(err) res.pipe(‘#‘+res.pipeMap[name], str, true) --res.pipeCount || res.end() })}app.get(‘/‘, function (req, res) { res.pipeLayout(‘layout‘, { s1: res.pipeName(‘s1name‘) , s2: res.pipeName(‘s2name‘) }) getData.d1(function (err, s1data) { res.pipePartial(‘s1name‘, ‘s1‘, s1data) }) getData.d2(function (err, s2data) { res.pipePartial(‘s2name‘, ‘s2‘, s2data) })})
還要在 layout.jade 把兩個 section 添加回來:
section#s1!=s1section#s2!=s2
這裡的思路是,需要 pipe 的內容先用一個 span 標籤佔位,非同步擷取資料並渲染完成相應的 HTML 程式碼後再輸出給瀏覽器,用 jQuery 的 replaceWith 方法把佔位的 span 元素替換掉。
本文的代碼在 https://github.com/undozen/bigpipe-on-node ,我把每一步做成一個 commit 了,希望你 clone 到本地實際運行並 hack 一下看看。因為後面幾步涉及到載入順序了,確實要自己開啟瀏覽器才能體驗到而無法從上看到(其實應該可以用 gif 動畫實現,但是我懶得做了)。
關於 BigPipe 的實踐還有很大的最佳化空間,比如說,要 pipe 的內容最好設定一個觸發的時間值,如果非同步呼叫的資料很快返回,就不需要用 BigPipe,直接產生網頁送出即可,可以等到資料請求超過一定時間才用 BigPipe。使用 BigPipe 相比 ajax 即節省了瀏覽器到 node.js 伺服器的請求數,又節省了 node.js 伺服器到資料來源的請求數。
原帖:https://github.com/undoZen/bigpipe-on-node
用 NodeJS 實現 BigPipe