BigPipe 的原理和实现

date
Aug 24, 2020
slug
big-pipe
status
Published
tags
summary
BigPipe 是 Facebook 在一篇 博文 里提出的一种可以提高网页加载速度的服务架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。
type
Post

是什么

 
BigPipe 是 Facebook 在一篇 博文 里提出的一种可以提高网页加载速度的服务架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。并且,这个结果,是在不改变现有 Web 技术架构的情况下达成的。
 
大体上,是通过将网页分割为被称为 pagelet 的小块,然后逐一通过由服务器和浏览器产生的操作组成的流水线,当一个 pagelet 经历完流水线后可以立即交付给用户,即在前端展示出来,而不会被其他 pagelet 的加载阻塞掉。
 

动机

 
那么 Facebook 这么做的目的是什么呢?为什么要引入 BigPipe 架构呢?
 
让我们先来想想,现在的网页和以前的网页区别在哪呢?答案是,随着用户的体验越来越重要,现在的网页变得越来越 动态化 了,但是服务器交付网页的速度却越来越慢了。换句话说就是 网页日益增长的动态化需求同落后的网页交付模式 之间的矛盾。
 
传统的网页交付模型是这样的:
 
  1. 浏览器发送 HTTP 请求到服务器
  1. 服务器解析请求从其他接口或数据库拉取数据,然后生产出网页附在响应上返回给浏览器
  1. 响应经过网络传输到浏览器
  1. 浏览器解析响应,构建 DOM 树,并下载 CSS 和 JS 文件
  1. 下载完成 CSS 后立刻开始解析,解析后结合 DOM 树生成渲染树。
  1. 下载完成 JS 后立刻开始解析执行,并阻塞 DOM 渲染。
 
传统网页交付模型最大问题在于,非常多的步骤是被相互阻塞的,你不可能浏览器拉取数据的时候生成 HTML,或者在生成 HTML 的时候发送响应给浏览器。当然,我们有一些优化手段,但这些优化手段的问题在于,无论怎么优化,我们总是局限于一端。
 
我们可以在前端提前下载 JS,延迟执行来保证 JS 不会阻塞 DOM 渲染,或者在后端缓存接口数据,更快的生成 HTML,但是我们没法做到在服务器在产生 HTML 时,浏览器就开始解析 HTML。反过来也一样,当浏览器收到响应后,服务器就什么帮助也做不了了。
 
通过 BigPipe,我们就可以做到传统优化手段不能触及的地方,通过去并行的进行服务器和浏览器的某些行为,不仅可以减少前后端传输的延迟,更能使我们的首屏时间大大加快。
 
BigPipe 架构最适用于富应用,即应用的数据来源于多个接口。在传统的架构下,服务器必须等待所有数据请求都返回后才能生成 HTML,也就是说任一个请求都会阻塞掉整个交付流程。
 

原理

 
为了使得浏览器和服务器可以并行的执行某些操作,BigPipe 首先需要我们将网页分割成被称为 pagelet 的小块。然后我们将网页交付流程分解为如下阶段:
 
  1. 解析请求:服务器校验和解析 HTTP 请求
  1. 数据拉取:服务器从接口或数据库获得数据
  1. HTML 标记生成:生成对应的 HTML 片段
  1. 网络传输:服务器返回响应到浏览器
  1. CSS 下载:浏览器下载需要的 CSS 文件
  1. 构建 DOM 树和应用样式:浏览器构建 DOM 树,并将相应的 CSS 样式规则应用到节点上
  1. JavaScript 下载:下载网页引用的 JavaScript 文件
  1. JavaScript 执行:浏览器执行下载后的 JavaScript
 
1-3 阶段都是在服务器上执行的,5-8 则是在浏览器上执行的。每一个 pagelet 必须依次经过上述所有阶段,但是 BigPipe 使得多个 pagelte 可以在 同一时刻 分别进行不同的步骤。举个例子,当某个 pagelet 正在 3 阶段,与此同时,可能有个 pagelet 在 2 阶段,甚至有个 pagelet 已经在 6 阶段了。
 
notion image
 
这里用 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;
}
 
最终我们看到的画面
 
 
notion image
 
接下来,我们来用 chrome 性能分析工具看下首屏时间
 
 
notion image
 
notion image
可以看到首屏时间基本上和最慢的接口返回时间是一致的,首屏会直接渲染出整个页面,但是需要用户等待很长时间
 

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 架构下网页加载的流程。传统架构下的加载流程,可以看到网页是经过一个较长的延迟,然后瞬间展示出来的
 
 
BigPipe
BigPipe
legacy
legacy
 
我们再来看下首屏时间
 
notion image
 
notion image
 
可以看到,我们的首屏时间和最快返回的接口的时间有有关系!虽然这次,首屏渲染仅仅只渲染了红色区块,但对于用户的感知来说,用户在打开网页后的 1s 内就看到了内容,他就会有更大的意愿去等待接下来返回的内容。
 
notion image
 
 
可以看到 DOMContentLoaded 事件发生的时间,也就是我们完全渲染出画面的时间,这里的时间和传统渲染模型下的首屏时间差不多。也就说,尽管我们最终显示出画面的时间可能是差不多一样的,但是 BigPipe 架构下,我们首屏的时间会非常快!

最后

 
为了突出 BigPipe 架构优势,我这里将接口请求的时间设置的很长,让我们可以比较直观的看到 BigPipe 架构的优势所在,但这样也因此掩盖了 BigPipe 其他优于传统的地方。实际上,在现实世界中,考虑到 CSS 和 JS 的加载情况,借助于 BigPipe 架构是要比传统方式要快的。
 
这里是 样例代码
 

© 何云飞 2021 - 2024