BigPipe 的原理和实现
date
Aug 24, 2020
slug
big-pipe
status
Published
tags
summary
BigPipe 是 Facebook 在一篇 博文 里提出的一种可以提高网页加载速度的服务架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。
type
Post
是什么
BigPipe 是 Facebook 在一篇 博文 里提出的一种可以提高网页加载速度的服务架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。并且,这个结果,是在不改变现有 Web 技术架构的情况下达成的。
大体上,是通过将网页分割为被称为 pagelet 的小块,然后逐一通过由服务器和浏览器产生的操作组成的流水线,当一个 pagelet 经历完流水线后可以立即交付给用户,即在前端展示出来,而不会被其他 pagelet 的加载阻塞掉。
动机
那么 Facebook 这么做的目的是什么呢?为什么要引入 BigPipe 架构呢?
让我们先来想想,现在的网页和以前的网页区别在哪呢?答案是,随着用户的体验越来越重要,现在的网页变得越来越 动态化 了,但是服务器交付网页的速度却越来越慢了。换句话说就是 网页日益增长的动态化需求同落后的网页交付模式 之间的矛盾。
传统的网页交付模型是这样的:
- 浏览器发送 HTTP 请求到服务器
- 服务器解析请求从其他接口或数据库拉取数据,然后生产出网页附在响应上返回给浏览器
- 响应经过网络传输到浏览器
- 浏览器解析响应,构建 DOM 树,并下载 CSS 和 JS 文件
- 下载完成 CSS 后立刻开始解析,解析后结合 DOM 树生成渲染树。
- 下载完成 JS 后立刻开始解析执行,并阻塞 DOM 渲染。
传统网页交付模型最大问题在于,非常多的步骤是被相互阻塞的,你不可能浏览器拉取数据的时候生成 HTML,或者在生成 HTML 的时候发送响应给浏览器。当然,我们有一些优化手段,但这些优化手段的问题在于,无论怎么优化,我们总是局限于一端。
我们可以在前端提前下载 JS,延迟执行来保证 JS 不会阻塞 DOM 渲染,或者在后端缓存接口数据,更快的生成 HTML,但是我们没法做到在服务器在产生 HTML 时,浏览器就开始解析 HTML。反过来也一样,当浏览器收到响应后,服务器就什么帮助也做不了了。
通过 BigPipe,我们就可以做到传统优化手段不能触及的地方,通过去并行的进行服务器和浏览器的某些行为,不仅可以减少前后端传输的延迟,更能使我们的首屏时间大大加快。
BigPipe 架构最适用于富应用,即应用的数据来源于多个接口。在传统的架构下,服务器必须等待所有数据请求都返回后才能生成 HTML,也就是说任一个请求都会阻塞掉整个交付流程。
原理
为了使得浏览器和服务器可以并行的执行某些操作,BigPipe 首先需要我们将网页分割成被称为 pagelet 的小块。然后我们将网页交付流程分解为如下阶段:
- 解析请求:服务器校验和解析 HTTP 请求
- 数据拉取:服务器从接口或数据库获得数据
- HTML 标记生成:生成对应的 HTML 片段
- 网络传输:服务器返回响应到浏览器
- CSS 下载:浏览器下载需要的 CSS 文件
- 构建 DOM 树和应用样式:浏览器构建 DOM 树,并将相应的 CSS 样式规则应用到节点上
- JavaScript 下载:下载网页引用的 JavaScript 文件
- JavaScript 执行:浏览器执行下载后的 JavaScript
1-3 阶段都是在服务器上执行的,5-8 则是在浏览器上执行的。每一个 pagelet 必须依次经过上述所有阶段,但是 BigPipe 使得多个 pagelte 可以在 同一时刻 分别进行不同的步骤。举个例子,当某个 pagelet 正在 3 阶段,与此同时,可能有个 pagelet 在 2 阶段,甚至有个 pagelet 已经在 6 阶段了。
这里用 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
在我们返回这份片段中,我们在 <head> 里做一些 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(`
<script>
BigPipe.handlePagelet({
selector: 'profile',
content: htmlFragement,
js: [...data.js],
css: [...data.css],
})
</script>
`)
}),
// ... 其他 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) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>legacy</title>
<link rel="stylesheet" href="/legecy/index.css">
</head>
<body>
<div class="red">
我是红:cgi时间是 ${data.red} ms
</div>
<div class="yellow">
我是黄:cgi时间是 ${data.yellow} ms
</div>
<div class="blue">
我是蓝:cgi时间是 ${data.blue} ms
</div>
</body>
</html>
`;
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}`);
});
css 文件
/* /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 架构
在 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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>big-pipe</title>
<style>
* {
margin: 0;
}
</style>
<script>
const BigPipe = window.BigPipe = {};
BigPipe.downloadCSS = href => new Promise((resolve, reject) => {
var node = document.createElement('link');
node.onload = resolve;
node.onerror = reject;
node.type = 'text/css';
node.rel = 'stylesheet';
node.href = href;
var head = document.getElementsByTagName('head')[0];
head.appendChild(node);
console.log(head);
});
BigPipe.handlePagelet = async pagelet => {
const container = document.querySelector(pagelet.selector);
if (container) {
await Promise.all(pagelet.css.map(href => BigPipe.downloadCSS(href)));
container.innerHTML = pagelet.content;
}
};
</script>
</head>
<body>
<div class="red"></div>
<div class="yellow"></div>
<div class="blue"></div>
`;
const generateMakeup = (selector, content, css = [], js = []) => {
const pagelet = {
selector,
content,
css,
js,
};
return `
<script>
BigPipe.handlePagelet(${JSON.stringify(pagelet)});
</script>
`;
}
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(`
</body>
</html>
`);
});
const server = app.listen(3031, '127.0.0.1', () => {
console.log(`server start in http://127.0.0.1:${3031}`);
});
拆分后的 CSS 文件
/* 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 架构是要比传统方式要快的。
这里是 样例代码