本文章使用的代码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和新浪等就用了这种方式渲染页面,惋惜的是没有开源出来。有错误欢迎你们指正啊。轻喷、轻喷就好。