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.xx"
, "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 html
head
title Hello, World!
style
section {
margin: 20px auto;
border: 1px dotted gray;
width: 80%;
height: 150px;
}
section#s1!=s1
section#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 static
cd static
curl http://code.jquery.com/jquery-1.8.3.min.js -o jquery.js
ln -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!=s1
section#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!=content
script
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!=s1
section#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 服務器到數據源的請求數。不過具體的優化和實踐方法,等到雪球網用上BigPipe 以後再分享吧。