BigPipe 是 Facebook 在 2010 年中一篇 博文 专门介绍的可以提高页面加载速度的网页架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。并且,这个结果,是在不改变现有 Web 技术架构的情况下达成的。
大体上,是通过将网页分割为被称为 pagelet 的小块,然后逐一通过由服务器和浏览器产生的操作组成的流水线,当一个 pagelet 经历完流水线后可以立即交付给用户,即在前端展示出来,而不会被其他 pagelet 的加载阻塞掉。
那么 Facebook 这么做的目的是什么呢?为什么要引入 BigPipe 架构呢?
让我们先来想想,现在的网页和以前的网页区别在哪呢?答案是,随着用户需求,现在的网页变得越来越 动态化 了,但是服务器交付网页的速度却越来越慢了。换句话说就是 网页日益增长的动态化需求同落后的网页交付模式 之间的矛盾。
传统的网页交付模型是这样的:
传统网页交付模型最大问题在于,非常多的步骤是被相互阻塞的,你不可能浏览器拉取数据的时候生成 HTML,或者在生成 HTML 的时候发送响应给浏览器。当然,我们有一些优化手段,但这些优化手段的问题在于,无论怎么优化,我们总是局限于一端。
我们可以在前端提前下载 JS,延迟执行来保证 JS 不会阻塞 DOM 渲染,或者在后端缓存接口数据,更快的生成 HTML,但是我们没法做到在服务器在产生 HTML 时,浏览器就开始解析 HTML。反过来也一样,当浏览器收到响应后,服务器就什么帮助也做不了了。
通过 BigPipe,我们就可以做到传统优化手段不能触及的地方,通过去并行的进行服务器和浏览器的某些行为,不仅可以减少前后端传输的延迟,更能使我们的首屏时间大大加快。
BigPipe 架构最适用于富应用,即应用的数据来源于多个接口。在传统的架构下,服务器必须等待所有数据请求都返回后才能生成 HTML,也就是任一个请求都会阻塞掉整个交付流程。
为了使得浏览器和服务器可以并行的执行某些操作,BigPipe 首先需要我们将网页分割成被称为 pagelet 的小块。然后我们将网页交付流程分解为如下阶段:
1-3 阶段都是在服务器上执行的,5-8 则是在浏览器上执行的。每一个 pagelet 必须依次经过上述所有阶段,但是 BigPipe 使得多个 pagelte 可以在 同一时刻 分别进行不同的步骤。举个例子,当某个 pagelet 正在 3 阶段,与此同时,可能有个 pagelet 在 2 阶段,甚至有个 pagelet 已经在 8 阶段了。
这里用 Facebook 的例图来举个例子。在 BigPipe 架构下,一个 HTTP 请求的生命周期是这样的:首先浏览器发送请求到服务器。服务器在收到请求后立刻返回一份不完整的 HTML 片段,然后将请求挂起,并不结束
<html>
<head>
<script>
window.BigPipe = {};
// ... do something
BigPipe.handlePagelet = (pagelet) => {
const conainer = document.querySelector(pagelet.selector)
downloadAllCSS(pagelet.css)
.then(() => {
conainer.innerHTML = pagelet.content;
// ... do things
});
}
<script>
head>
<body>
<div id="profile">div>
<div id="friends">div>
<div id="messages">div>
<div id="feeds">div>
...others
在我们返回这份片段中,我们在 里做一些 BigPipe 相关的初始化操作,这样当我们接收到后端发送的 pagelte 时可以将其渲染到屏幕上,pagelte 包含着自身的 HTML 内容,以及所需的 JS 和 CSS 文件列表。
在返回 HTML 片段后,服务器会根据刚才所说的步骤去生产 pagelet,并行的去拉取每个 pagelet 的数据,一旦某个 pagelet 的数据拉取完成,马上生成对应的 HTML 标记返回给浏览器,然后浏览器接管相关操作,比如当某个 pagelet 数据拉取完后,会生成如下数据返回给前端,这里用了 express 作为例子
app.use(async (req, res) => {
await Promise.all([
getProfilePageletData(req)
.then(data => {
const htmlFragement = makeHtml(data);
res.write(`
`)
}),
// ... 其他 pagelet 操作
])
res.end();
});
在浏览器端,通过 handlePagelet 函数接收到相应的 pagelet 后,首先会去下载相应的 CSS 文件,等到 CSS 样式文件下载完成后,通过 selector 属性,我们将 pagelet 的 html 内容设置到正确的 div 标签下。并且多个 pagelte 的 CSS 文件的下载可以是并行的,我们简单的遵循着谁先完成,谁先展示的策略。对于 JS 文件,BigPipe 会等到所有的 pagelet 被展示后,才会开始下载,执行顺序同样遵循着简单的谁先完成,谁先执行的策略。
通过 BigPipe 架构,我们就能做到当服务器在拉取数据的时候,浏览器端已经开始渲染出画面了。我们的界面将被渐进的渲染出来,并且我们的首屏时间会变得非常快,使得用户感受到的延迟非常的低。
虽然 Facebook 声称 BigPipe 提高他们 50% 的网页加载速度,但是我们还是实践一下,因为不能你说很快,我就马上就去用,第一我要试一下,因为我不愿意用完再加一些特效上去,“guang” 的一下很快,很迅速,这样读者们一定会骂我,因为根本没有这么快,就证明上面说的是假的。后来实践后确实感觉很快,我用了一段时间,感觉还不错,后来我在介绍的时候也要求不要加特效,我要保证我用完之后是这个样子,你们用完之后也是这个样子!
这里,我们用 koa2 实现一个传统交付模型,然后改造成 BigPipe 架构来对比下。为了突出重点,减少干扰,这次测试没有加入 JS 文件相关的演示和代码逻辑,也就是我们返回的 pagelet 中不会带有 JS 文件的地址,实际上按照我们的描述 JS 的处理逻辑很简单,我们只需要保存收到的 pagelet 的 JS 文件地址,等到最后动态加载即可,不用考虑加载顺序。我相信在理解以下代码后,你会很容易明白如何处理 JS 文件。
// project
├── package.json
├── src
| ├── big-pipe
| | └── index.js // big-pipe 架构的实现
| └── legacy
| └── index.js // 经典架构的实现
└── static
├── big-pipe // big-pipe 架构下的 css 文件
| ├── blue.css
| ├── red.css
| └── yellow.css
└── legecy // 经典架构下的 css 文件
└── index.css
// /src/legacy/index.js
const Koa = require('koa');
const static = require("koa-static");
const path = require('path')
const app = new Koa();
console.log(__dirname);
app.use(static(path.resolve(__dirname, '../../static')));
const asyncWait = (time = 1000) => new Promise(resove => setTimeout(resove, time));
const getRedData = async () => {
const time = 1000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const getYellowData = async () => {
const time = 3000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const getBlueData = async () => {
const time = 5000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const makeHTML = (data) => `
legacy
我是红:cgi时间是 ${data.red} ms
我是黄:cgi时间是 ${data.yellow} ms
我是蓝:cgi时间是 ${data.blue} ms
`;
app.use(async (ctx, next) => {
const datas = await Promise.all([
getRedData(),
getYellowData(),
getBlueData(),
]);
const data = {
red: datas[0],
yellow: datas[1],
blue: datas[2],
};
const html = makeHTML(data);
ctx.body = html;
});
const server = app.listen(3030, '127.0.0.1', () => {
console.log(`server start in http://127.0.0.1:${3030}`);
});
/* /static/legacy/index.css */
* {
margin: 0;
}
.red, .yellow, .blue {
height: 33.33vh;
font-size: 24px;
line-height: 33.33vh;
text-align: center;
}
.red {
background: red;
}
.yellow {
background: yellow;
}
.blue {
background: royalblue;
}
最终我们看到的画面
接下来,我们来用 chrome 性能分析工具看下首屏时间
可以看到首屏时间基本上和最慢的接口返回时间是一致的,首屏会直接渲染出整个页面,但是需要用户等待很长时间
在 BigPipe 架构下,我们需要将整个页面拆分成 pagelets ,这里我们正好对应着红黄蓝三个色块拆封成 3 个 pagelet ,同样的我们需要对应 css 文件也进行拆分,这里为了方便,我们将全局通用的 css 直接用 inline 的方式写在 head 里了。
const Koa = require('koa');
const static = require("koa-static");
const path = require('path')
const app = new Koa();
console.log(__dirname);
app.use(static(path.resolve(__dirname, '../../static')));
const asyncWait = (time = 1000) => new Promise(resove => setTimeout(resove, time));
const getRedData = async () => {
const time = 1000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const getYellowData = async () => {
const time = 3000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const getBlueData = async () => {
const time = 5000;
const date = Date.now();
await asyncWait(time);
return Date.now() - date;
}
const startHTML = `
big-pipe
`;
const generateMakeup = (selector, content, css = [], js = []) => {
const pagelet = {
selector,
content,
css,
js,
};
return `
`;
}
app.use(async (ctx, next) => {
ctx.res.statusCode = 200;
ctx.res.write(startHTML);
await Promise.all([
getRedData()
.then(time => {
const html = `我是红:cgi时间是 ${time} ms`;
ctx.res.write(generateMakeup(
'.red',
html,
['/big-pipe/red.css'],
));
}),
getYellowData()
.then(time => {
const html = `我是黄:cgi时间是 ${time} ms`;
ctx.res.write(generateMakeup(
'.yellow',
html,
['/big-pipe/yellow.css'],
));
}),
getBlueData()
.then(time => {
const html = `我是蓝:cgi时间是 ${time} ms`;
ctx.res.write(generateMakeup(
'.blue',
html,
['/big-pipe/blue.css'],
));
}),
]);
ctx.res.end(`
`);
});
const server = app.listen(3031, '127.0.0.1', () => {
console.log(`server start in http://127.0.0.1:${3031}`);
});
/* static/big-pipe/red.css */
.red {
background: red;
height: 33.33vh;
font-size: 24px;
line-height: 33.33vh;
text-align: center;
}
/* static/big-pipe/yellow.css */
.yellow {
background: yellow;
height: 33.33vh;
font-size: 24px;
line-height: 33.33vh;
text-align: center;
}
/* static/big-pipe/blue.css */
.blue {
background: royalblue;
height: 33.33vh;
font-size: 24px;
line-height: 33.33vh;
text-align: center;
}
为了能够直观的感受到 BigPipe 架构下网页的神奇之处,这次我们来看下 BigPipe 架构下网页加载的流程
这是传统架构下的加载流程,可以看到网页是经过一个较长的延迟,然后瞬间展示出来的
我们再来看下首屏时间
可以看到,我们的首屏时间和最快返回的接口的时间有有关系!虽然这次,首屏渲染仅仅只渲染了红色区块,但对于用户的感知来说,用户在打开网页后的 1s 内就看到了内容,他就会有更大的意愿去等待接下来返回的内容。
可以看到 DOMContentLoaded
事件发生的时间,也就是我们完全渲染出画面的时间,这里的时间和传统渲染模型下的首屏时间差不多。也就说,尽管我们最终显示出画面的时间可能是差不多一样的,但是 BigPipe 架构下,我们首屏的时间会非常快!
为了突出 BigPipe 架构优势,我这里将接口请求的时间设置的很长,让我们可以比较直观的看到 BigPipe 架构的优势所在,但这样也因此掩盖了 BigPipe 其他优于传统的地方。实际上,在现实世界中,考虑到 CSS 和 JS 的加载情况,借助于 BigPipe 架构是要比传统方式要快的。
这里是 样例代码