本文章使用的代碼css
爲何先後端分離的時代還須要服務端渲染?html
就是爲了快啊!還能作SEO啊!下面咱們來簡單分析下這兩種方式的渲染過程前端
一、瀏覽器發起頁面請求node
二、解析htmlgit
三、發起請求獲取頁面對應的js、cssgithub
四、解析css、jsajax
五、發起ajax請求獲取數據後將數據渲染到DOM中後端
一、瀏覽器發起請求數組
二、服務端發起請求獲取對應的頁面數據後將數據拼接到讀取的html中promise
三、返回拼接後的html給瀏覽器
四、瀏覽器解析html
五、獲取資源、解析資源
經過上面的對比,能夠看出爲何服務端渲染更快?由於前端經過ajax渲染,須要等到獲取js後,再發起http請求獲取到數據後才完成渲染,而服務端免去了屢次http請求的過程(http請求耗時),直接讓服務端返回渲染好的html頁面。
那相似首屏這種對速度有要求的就可使用服務端渲染了。
這裏提出一個問題,若是一個頁面,在服務端渲染中,數據源比較多的狀況下,咱們須要等待全部的請求都返回數據才進行html拼接並返回,這樣咱們頁面最終渲染的速度就限制在最遲返回數據的請求上了。
那針對上述數據源較多的狀況,還有優化的方案嗎?答案就是Bigpipe。
Bigpipe是一種採用流的方式對頁面進行渲染的機制,在瀏覽器請求頁面時,打開管道後持續對頁面的塊進行輸出。
以下圖,塊A、B、C拼裝好塊以後直接經過開始創建的管道輸出到頁面中,這樣頁面的最終輸出就不須要依賴最後一個塊的拼裝時間了。
下面來抽象一個簡單的bigpipe中間件。
以中間件的形式加載bigpipe服務,並指定模板與靜態資源的跟目錄
// app.js
app.use(createBigpipeMiddleware(
templatePath = resolve(__dirname, './template'), // 模板文件夾
publicPath = resolve(__dirname, './view') // 靜態資源目錄
));
使用bigpipe,咱們通常須要讀取一個html-layout,接下來就是定義每個塊的模板路徑和數據源,執行一個render方法後,開始返回html並持續輸出咱們定義的塊。
// app.js
app.use((ctx) => {
let bigpipe = ctx.body = ctx.createBigpipe();
// 定義輸出的html layout
bigpipe.defineLayout('/bigpipe.html');
// 定義片斷,這裏咱們使用promise的方式模擬http請求
bigpipe.definePagelets([
{
id: 'A',
tpl: '/article.handlebars',
getData: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(a)
}, 3000)
})
}
},
{
id: 'B',
tpl: '/article.handlebars',
getData: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(b)
}, 2000)
})
}
},
{
id: 'C',
tpl: '/article.handlebars',
getData: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(c)
}, 0)
})
}
}
]);
bigpipe.render();
})
複製代碼
bigpipe.definePagelets傳入的對象數組中,每個對象中的id爲每個塊對應須要插入的DOM節點的id屬性值,tpl爲該模板在模板根目錄下的路徑,getData只是一個模擬http請求的函數,能夠設定在x秒後返回輸出數據並進行拼接返回。html-layout以下。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>test bigpipe</title>
<body>
<div id="A"></div>
<div id="B"></div>
<div id="C"></div>
<script>
// 渲染模板字符串到對應節點
var renderFlush = function (selector, html) {
var dom = document.querySelector(selector);
dom.innerHTML = html
};
</script>
複製代碼
下面的createBigpipeMiddleware中間件的實現
const { resolve } = require('path')
const Bigpipe = require('./lib/bigpipe')
module.exports = createBigPipeReadable
function createBigPipeReadable (
templatePath = resolve(__dirname, '../../template'), // 模板根目錄(默認)
publicPath = resolve(__dirname, '../../../../public') // html根目錄(默認)
) {
// 返回一個帶有ctx與next參數的async函數
return async function initBigPipe(ctx, next) {
if (ctx.createBigpipe) return next()
// 在上下文中掛載createBigpipe方法供咱們在業務中使用
ctx.createBigpipe = function () {
ctx.type = 'html';
return new Bigpipe({
appContext: ctx,
templatePath: templatePath,
publicPath: publicPath
})
}
return next()
}
}
複製代碼
上面是koa中間件的寫法,不太理解的能夠google查一查。這個中間件會在ctx中掛載方法createBigpipe用於初始化bigpipe服務,那在後續的業務文件中就能夠直接經過調用ctx.createBigpipe來調用bigpipe服務了
下面就是具體bigpipe對象的類實現了。
首先,咱們先讓Bigpipe對象繼承Readable(由於Koa不支持直接調用底層res進行響應處理)
const Readable = require('stream').Readable;
class Bigpipe extends Readable {
constructor(props) {
super(props);
this.appContext = props.appContext; // koa上下文
this.templatePath = props.templatePath;
this.publicPath = props.publicPath;
this.layout = ''; // html-layout
this.pagelets = []; // 用於存放塊
this.pageletsNum = 0;
}
_read() {}
...
}
複製代碼
接下來實現一個defineLayout函數,把layout轉成字符串(也就是上文貼出來的html)
const { join } = require('path');
class Bigpipe extends Readable {
...
defineLayout(realPath) {
let layoutPath = join(this.publicPath, realPath)
this.layout = fs.readFileSync(layoutPath).toString();
}
...
}
複製代碼
下面的definePagelets用於傳入塊的配置,可傳入一個對象屢次調用或者直接傳入一個數組
class Bigpipe extends Readable {
...
definePagelets(pagelets) {
if (Array.isArray(pagelets)) {
this.pagelets = this.pagelets.concat(pagelets);
} else {
if (typeof pagelets === 'object') {
this.pagelets.push(pagelets)
}
}
this.pageletsNum = this.pagelets.length;
}
...
}
複製代碼
接下來是就是render函數了,調用後直接開始輸出layout還有對塊進行拼接傳輸
class Bigpipe extends Readable {
...
// 配置好後渲染主邏輯
async render() {
// 首先輸出html骨架
this.push(this.layout);
// 全部塊完成後,關閉流
await Promise.all(this.wrap(this.pagelets))
// 結束傳輸
this.done();
}
...
}
複製代碼
上面,由於Bigpipe繼承了Readable,因此能夠用push的方式推入數據,接着await後則是一個Promise.all方法,等到全部的塊輸出完成後,才執行done方法閉合html結束數據傳輸。
下面是最重要的方法,wrap方法,用於將傳入的塊數組包裝成promise(這裏咱們使用handlebars做爲模板引擎,固然還有不少其餘選擇)
const Handlebars = require('handlebars');
class Bigpipe extends Readable {
...
//將proxy,包裝成Promise
wrap(pagelets) {
return pagelets.map((pagelet, idx) => {
// 返回一個promise,模板拼接好輸出到頁面中即resolve
return new Promise((resolve, reject) => {
(async () => {
let data = null,
tpl = function() {},
tplHtml = '';
// 調用個個塊的getData方法,等待數據獲取
data = await pagelet.getData()
// 獲取hbs模板
tpl = this.getHtmlTemplate(pagelet.tpl);
// 將數據拼接好後返回模板字符串,並清除換行符
tplHtml = this.clearEnter(tpl(data));
// 以script輸出到頁面中
this.push(`
<script>
renderFlush("#${pagelet.id}","${tplHtml}")
</script>
`)
this.pageletsNum--;
resolve()
})()
})
})
}
// 獲取骨架並轉成字符串
getHtmlTemplate(realPath) {
let tplPath = join(this.templatePath, realPath);
let tplSource = fs.readFileSync(tplPath).toString();
// 編譯模板
return Handlebars.compile(tplSource);
}
// 清除模板字符串的換行符
clearEnter(html) {
return html.replace(/[\r\n]/g,"")
}
...
}
複製代碼
每個promise中,在data返回後,都會調用this.push方法推入一串腳本,執行的就是以下的在html-layout中的函數,傳入的是id屬性值與拼接好的html塊,執行renderFlush就會將塊輸出到html中。
var renderFlush = function (selector, html) {
var dom = document.querySelector(selector);
dom.innerHTML = html
};
複製代碼
上面咱們傳入了getData方法,相應的你也可使用request等模塊去封裝一個函數去獲取對應數據,這裏只是做爲演示,直接使用一個promise返回數據。
執行node app.js後,訪問localhost:9000,結果以下
一、先輸出html與塊C
二、2秒後,輸出塊B
三、3秒後,輸出完畢,管道關閉(注意,瀏覽器刷新按鈕從叉變成了箭頭)
bigpipe渲染確實更快,具體是否須要仍是得看業務場景,好比像facebook和新浪等就用了這種方式渲染頁面,惋惜的是沒有開源出來。有錯誤歡迎你們指正啊。輕噴、輕噴就好。