BigPipe 的原理和实现

是什么

BigPipe 是 Facebook 在 2010 年中一篇 博文 专门介绍的可以提高页面加载速度的网页架构。通过在基础层面重新设计服务器如何交付动态网页,大幅度提高了网页的加载速度。并且,这个结果,是在不改变现有 Web 技术架构的情况下达成的。

大体上,是通过将网页分割为被称为 pagelet 的小块,然后逐一通过由服务器和浏览器产生的操作组成的流水线,当一个 pagelet 经历完流水线后可以立即交付给用户,即在前端展示出来,而不会被其他 pagelet 的加载阻塞掉。

动机

那么 Facebook 这么做的目的是什么呢?为什么要引入 BigPipe 架构呢?

让我们先来想想,现在的网页和以前的网页区别在哪呢?答案是,随着用户需求,现在的网页变得越来越 动态化 了,但是服务器交付网页的速度却越来越慢了。换句话说就是 网页日益增长的动态化需求同落后的网页交付模式 之间的矛盾。

传统的网页交付模型是这样的:

  1. 浏览器发送 HTTP 请求到服务器
  2. 服务器解析请求从其他接口或数据库拉取数据,然后生产出网页附在响应上返回给浏览器
  3. 响应经过网络传输到浏览器
  4. 浏览器解析响应,构建 DOM 树,并下载 CSS 和 JS 文件
  5. 下载完成 CSS 后立刻开始解析,解析后结合 DOM 树生成渲染树。
  6. 下载完成 JS 后立刻开始解析执行,并阻塞 DOM 渲染。

传统网页交付模型最大问题在于,非常多的步骤是被相互阻塞的,你不可能浏览器拉取数据的时候生成 HTML,或者在生成 HTML 的时候发送响应给浏览器。当然,我们有一些优化手段,但这些优化手段的问题在于,无论怎么优化,我们总是局限于一端。

我们可以在前端提前下载 JS,延迟执行来保证 JS 不会阻塞 DOM 渲染,或者在后端缓存接口数据,更快的生成 HTML,但是我们没法做到在服务器在产生 HTML 时,浏览器就开始解析 HTML。反过来也一样,当浏览器收到响应后,服务器就什么帮助也做不了了。

通过 BigPipe,我们就可以做到传统优化手段不能触及的地方,通过去并行的进行服务器和浏览器的某些行为,不仅可以减少前后端传输的延迟,更能使我们的首屏时间大大加快。

BigPipe 架构最适用于富应用,即应用的数据来源于多个接口。在传统的架构下,服务器必须等待所有数据请求都返回后才能生成 HTML,也就是任一个请求都会阻塞掉整个交付流程。

原理

为了使得浏览器和服务器可以并行的执行某些操作,BigPipe 首先需要我们将网页分割成被称为 pagelet 的小块。然后我们将网页交付流程分解为如下阶段:

  1. 解析请求:服务器校验和解析 HTTP 请求
  2. 数据拉取:服务器从接口或数据库获得数据
  3. HTML 标记生成:生成对应的 HTML 片段
  4. 网络传输:服务器返回响应到浏览器
  5. CSS 下载:浏览器下载需要的 CSS 文件
  6. 构建 DOM 树和应用样式:浏览器构建 DOM 树,并将相应的 CSS 样式规则应用到节点上
  7. JavaScript 下载:下载网页引用的 JavaScript 文件
  8. JavaScript 执行:浏览器执行下载后的 JavaScript

1-3 阶段都是在服务器上执行的,5-8 则是在浏览器上执行的。每一个 pagelet 必须依次经过上述所有阶段,但是 BigPipe 使得多个 pagelte 可以在 同一时刻 分别进行不同的步骤。举个例子,当某个 pagelet 正在 3 阶段,与此同时,可能有个 pagelet 在 2 阶段,甚至有个 pagelet 已经在 8 阶段了。

BigPipe 的原理和实现_第1张图片

这里用 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}`); });

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;
}

最终我们看到的画面

BigPipe 的原理和实现_第2张图片

接下来,我们来用 chrome 性能分析工具看下首屏时间

BigPipe 的原理和实现_第3张图片

BigPipe 的原理和实现_第4张图片

可以看到首屏时间基本上和最慢的接口返回时间是一致的,首屏会直接渲染出整个页面,但是需要用户等待很长时间

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 = `



    
    
    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}`); });

拆分后的 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 的原理和实现_第5张图片

这是传统架构下的加载流程,可以看到网页是经过一个较长的延迟,然后瞬间展示出来的

BigPipe 的原理和实现_第6张图片

我们再来看下首屏时间

BigPipe 的原理和实现_第7张图片

BigPipe 的原理和实现_第8张图片

可以看到,我们的首屏时间和最快返回的接口的时间有有关系!虽然这次,首屏渲染仅仅只渲染了红色区块,但对于用户的感知来说,用户在打开网页后的 1s 内就看到了内容,他就会有更大的意愿去等待接下来返回的内容。

BigPipe 的原理和实现_第9张图片

BigPipe 的原理和实现_第10张图片

可以看到 DOMContentLoaded 事件发生的时间,也就是我们完全渲染出画面的时间,这里的时间和传统渲染模型下的首屏时间差不多。也就说,尽管我们最终显示出画面的时间可能是差不多一样的,但是 BigPipe 架构下,我们首屏的时间会非常快!

最后

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

这里是 样例代码

你可能感兴趣的:(前端)