[聊一聊系列]聊一聊网页的分段传输与渲染那些事儿

13

欢迎大家收看聊一聊系列,这一套系列文章,可以帮助前端工程师们了解前端的方方面面(不仅仅是代码):
https://segmentfault.com/blog...

这一节,请跟随笔者聊一聊,网页的分段传输与渲染,用一些非常规手段优化我们的网站响应速度。

1 CHUNKED编码

1.1 传统的渲染方法

1.1.1 传统的渲染方法怎么做?

按照常理,我们渲染一张网页,必定是网页全部拼装完毕,然后生成HTML字符串,传送至客户端。这也意味着,如果一张网页处理的有快有慢的话,必须串行等到所有的逻辑都处理完毕。后端才能进行返回。(这也是我们目前网页的一般逻辑)。如下面的例子,三个很慢的读数据操作,均执行完毕后,才传送渲染页面。渲染效果如图1.1.1,15s之后才传送并渲染出页面:
normal.php

<?php
function getOneData() {
    usleep(5000000);
    return '我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据'; 
}

$var1 = getOneData();

function getTwoData() {
    usleep(5000000);
    return '是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据';
}
$var2 = getTwoData();

function getThreeData() {
    usleep(5000000);
    return '我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据'; 
}
$var3 = getThreeData();

// 渲染模板并输出
include('./normal.html.php');

normal.html.php

<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8" />
    </head>
    <body>
        <div>1. <?php echo $var1;?></div>
        <div>2. <?php echo $var2;?></div>
        <div>3. <?php echo $var3;?></div>
    </body>
</html>

clipboard.png
图1.1.1

上述例子,在本文后github中的normal文件夹中。

1.1.2 传统的渲染方法有哪些弊端?

如上所示,我们能看到,直出的网页中,存在着后端数据串行,互相等待的尴尬局面。这也为我们后续的优化埋下了伏笔。

1.2 分段传输

1.2.1 何为分段传输?

http1.1中引入了一个http首部,Transfer-Encoding:chunked。这个首部标识了实体采用chunked编码传输,chunked编码可以将实体分块儿进行传输,并且chunked编码的每一块内容都会自标识长度。这给了web开发者一个启示,如果需要多个数据,而多个数据均返回较慢的话。可以处理完一块就返回一块,让浏览器尽早的接收到html,可以先行渲染。

1.2.2 如何分段传输?

既然知道了我们可以将网页一块儿一块儿的传送,那么我们就可以将上面的网页进行改造,拿好一块儿需要的数据,便渲染一块儿,无需等待,而模板方面,自然也要拆分为三段,供服务端拿一块儿的模板,就渲染一块儿出去,效果如图1.2.2.1。
normal.php

<?php
function getOneData() {
    usleep(5000000);
    return '我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出>的第一个数据我是取出的第一个数据我是取出的第一个数据我是取出的第一个数据';
}
// 取出第一块儿的数据
$var1 = getOneData();
// 渲染第一块儿
include('./normal1.html.php');
//刷新到缓冲区,渲染第一份儿模板,传送到客户端
ob_flush();
flush();

function getTwoData() {
    usleep(5000000);
    return '我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出>的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出>的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出>的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出>的第二个数据我是取出的第二个数据我是取出的第二个数据我是取出的第二个数据';
}
// 取出第二块儿的数据
$var2 = getTwoData();
// 渲染第二块儿
include('./normal2.html.php');
//刷新到缓冲区,渲染第二份儿模板,传送到客户端
ob_flush();
flush();

function getThreeData() {
    usleep(5000000);
    return '我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出>的第三个数据我是取出的第三个数据我是取出的第三个数据我是取出的第三个数据';
}
// 获取第三块儿的数据
$var3 = getThreeData();
// 渲染第三块儿
include('./normal3.html.php');
// 将第三份儿的模板,传送到客户端
ob_flush();
flush();

normal1.html.php

<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8" />
    </head>
    <body>
        <div>1. <?php echo $var1;?></div>

normal2.html.php

<div>2. <?php echo $var2;?></div>

normal3.html.php

        <div>3. <?php echo $var3;?></div>
    </body>
</html>

clipboard.png
图1.2.2.1
上述例子,在本文后github中的chunked文件夹中。

对比图图1.1.1与图1.2.2.1我们可以发现,虽然最后总的处理时长不变,但是采用了分段输出的网页,可以尽早的将一段HTML渲染到客户端,这样用户可以使用先到达的部分。另一方面,尽早的页面反馈,也可以减少用户等待的焦躁情绪。综上,使用此种优化方法,可以提速网页的渲染速度。

1.2.3 分段传输小TIPs

我们代码虽然如上所述,但是读者尝试的时候可能会发现,并没有什么效果。和我截图并不一样,还是等到15s后一起渲染出来了。这里要提醒大家一下,可能是由于nginx配置的原因。如果使用的是nginx做server的话,要使用如下配置才能看到效果。

http {
    ....
    fastcgi_buffer_size 1k; 
    fastcgi_buffers 16 1k; 
    gzip  off;
    ....
}

其实读者们可以这么理解上面的配置,nginx会在攒够一块儿缓冲区的量后,可以将一块儿数据发出去。上面我们配置了fastcgi_buffers 16 1k; 就是16块儿,大小为1K的缓存。

我们的数据量太小了,连默认的一块儿缓冲区都填不满,没法看到分块儿发送的效果,所以这里我们将缓冲区给调小为1K,这样就能1K为单位分块儿,1K一发,体现出实验效果了。笔者这里建议做实验的时候,最好把gzip给关了,因为,咱们做实验的时候数据量不大,实际使用中建议chunked与gzip均开启(如图1.2.3.1,如果量比较大的话,gzip与chunked均开启使用效果更佳哦~~~)。
clipboard.png
图1.2.3.1

1.2.4 分段传输适用场景

当页面的某些后端处理比较耗时的时候,可以试试采用分段传输,可以渲染一部分,就发送一部分到客户端,虽然总时长不变,但是浏览器在全部传输完之前不会处于干等状态。可以尽早的渲染并给予用户反馈。

2 BIGPIPE

2.1 分段传输的局限

刚刚笔者和读者们一起做了分段传输的实验,思路是基于读者们想展示的网页也是上快下慢的。可是读者们有没有想过,如果整个网页中,最快的是下方,而最慢的是上方呢?这样我们就无法利用分段传输的优势了吗?如图2.1.1,整个页面依旧是被最慢的第一部分数据渲染给hold住了。而后两块儿渲染较快,完全可以先传输过来。

<?php
// 获取第一块儿数据最慢
function getOneData() {
    usleep(2000000);
    $str = ''; 
    for ($i = 0; $i < 500; $i++) {
        $str .= '我是取出的第一个数据';
    }   
    return $str;
}

$var1 = getOneData();
// 渲染第一块儿
include('./normal1.html.php');
ob_flush();
flush();

// 获取第二块儿数据较快
function getTwoData() {
    $str = ''; 
    for ($i = 0; $i < 500; $i++) {
        $str .= '我是取出的第二个数据';
    }   
    return $str;
}
$var2 = getTwoData();
// 渲染第二块儿
include('./normal2.html.php');
ob_flush();
flush();

// 获取地三块儿数据也较快
function getThreeData() {
    $str = ''; 
    for ($i = 0; $i < 500; $i++) {
        $str .= '我是取出的第三个数据';
    }   
    return $str;
}
$var3 = getThreeData();
// 渲染第三块儿
include('./normal3.html.php');
ob_flush();
flush();

clipboard.png
图2.1.1

上述例子,在本文后github中的bigpipprepare文件夹中。

2.2 解决分段传输顺序的问题

看完上述描述,读者们肯定在想,如果能把最慢的部分放置于底部传过来就好了。于是有了一种加载思路,便是使用js回填的方式,先将左边最慢的部分架空,然后在底部写上js回填。这样不就可以先渲染相对较快的右侧两块儿了么。如图2.2.1
后端可以先渲染快的模板,然后再渲染最慢的模板。

<?php
// 渲染第一块儿的架子,还未获取内容
include('./normal1.html.php');
ob_flush();
flush();

// 获取第二块儿数据较快
function getTwoData() {
    $str = ''; 
    for ($i = 0; $i < 50; $i++) {
        $str .= '我是取出的第二个数据';
    }   
    return $str;
}
$var2 = getTwoData();

// 渲染第二块儿
include('./normal2.html.php');
ob_flush();
flush();

// 获取地三块儿数据也较快
function getThreeData() {
    $str = ''; 
    for ($i = 0; $i < 70; $i++) {
        $str .= '我是取出的第三个数据';
    }   
    return $str;
}
$var3 = getThreeData();
// 渲染第三块儿
include('./normal3.html.php');
ob_flush();
flush();

// 获取第一块儿数据最慢
function getOneData() {
    usleep(2000000);
    $str = ''; 
    for ($i = 0; $i < 50; $i++) {
        $str .= '我是取出的第一个数据';
    }   
    return $str;
}
$var1 = getOneData();
// 渲染回填第一块儿
include('./normal4.html.php');
ob_flush();
flush();

normal1.html.php

<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8" />
        <style>
            html, body {
                margin: 0;
            }   
            .part1 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #0f0;
                outline: 1px solid #000;
            }   
            .part2 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #f00;
                outline: 1px solid #000;
            }   
            .part3 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #00f;
                outline: 1px solid #000;
            }   
        </style>
    </head>
    <body>
        <div class="part1">
        </div>

normal2.html.php

<div class="part2">2. <?php echo $var2;?></div>

normal3.html.php

<div class="part3">3. <?php echo $var3;?></div>

normal4.html.php

        <script>
            // 把最慢且顶在前面的部分用js回填回去
            document.querySelector('.part1').innerHTML = "<?php echo $var1?>";
        </script>
    </body>
</html>

clipboard.png
图2.2.1
如上图,可以看到49ms的时候,就已经渲染出来了右侧两块儿,2S的时候,左侧也渲染出来了。

上述例子,在本文后github中的bigpipe文件夹中。

2.3 回填思路的扩展与并行化

我们刚刚做了一个实验,是将耗时最慢的块儿放在底部。然而,事实情况是,如果你也不知道哪块儿慢了呢?或者是,你的几块儿数据区块儿是并行的呢?出于刚刚的经验,我们可以把页面上所有的块儿都架空,然后并行渲染,谁快谁就先渲染回填js。这样就可以达到并行且先到先渲染的目的了。我这里做了个php并行取并回填的实验,如图2.3.1,可以看到,中间红色的虽然被阻塞,但是框架先行渲染出来了所有的内容均是空的。绿色最快,先行回填渲染了出来,蓝色稍慢,也跟着渲染了出来,最后红色完毕,回填渲染结束了。
并行渲染的PHP(normal.php)

<?php
function asyncRequest($host, $url, $port=8082, $conn_timeout=30, $rw_timeout=86400) {
    $errno = ''; 
    $errstr = ''; 
    $fp = fsockopen($host, $port, $errno, $errstr, $conn_timeout);
    if (!$fp) {
       echo "Server error:$errstr($errno)";
       return false;
    }   
    stream_set_timeout($fp, $rw_timeout);
    stream_set_blocking($fp, false);

    $rq = "GET $url HTTP/1.0\r\n";
    $rq .= "Host: $host\r\n";
    $rq .= "Connect: close\r\n\r\n";
    fwrite($fp, $rq);
    return $fp;
}

function asyncFetch(&$fp) {
   if ($fp === false) return false;

   if (feof($fp)) {
      fclose($fp);
      $fp = false;
      return false;
   }   
   return fread($fp, 10000);
}

$fp1 = asyncRequest('localhost', '/bigpipeparal/data1.php');
$fp2 = asyncRequest('localhost', '/bigpipeparal/data2.php');
$fp3 = asyncRequest('localhost', '/bigpipeparal/data3.php');

include('normal_frame.html.php');
ob_flush();
flush();
while (true) {
    sleep(1);
    $r1 = asyncFetch($fp1);
    $r2 = asyncFetch($fp2);
    $r3 = asyncFetch($fp3);
    //谁快谁先渲染并flush刷出
    if ($r1 != false) {
        preg_match('/\|(.+)\|/i', $r1, $res);
        $var1 = $res[1];
        include('normal1.html.php');
    }

    if ($r2 != false) {
        preg_match('/\|(.+)\|/i', $r2, $res);
        $var2 = $res[1];
        include('normal2.html.php');
    }

    if ($r3 != false) {
        preg_match('/\|(.+)\|/i', $r3, $res);
        $var3 = $res[1];
        include('normal3.html.php');
    }

    if ($r1 == false && $r2 == false && $r3 == false) {
        break;
    }
  
    ob_flush();
    flush();
}

主框架的模板,架空,等待回填。normal_frame.html.php

<!DOCTYPE HTML>
<html>
    <head>
        <meta charset="utf-8" />
        <style>
            html, body {
                margin: 0;
            }   
            .part1 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #0f0;
                outline: 1px solid #000;
            }   
            .part2 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #f00;
                outline: 1px solid #000;
            }   
            .part3 {
                vertical-align: top;
                display: inline-block;
                width: 200px;
                background: #00f;
                outline: 1px solid #000;
            }   
        </style>
    </head>
    <body>
        <!--三块儿全部架空,等待回填--> 
        <div class="part1"></div>
        <div class="part2"></div>
        <div class="part3"></div>
    </body>
</html>

具体回填模板,normal1.html.php/normal2.html.php/normal3.html.php

<script>
    document.querySelector('.part1').innerHTML = "第一块儿回填!其值如下:<?php echo $var1?>";
</script>

clipboard.png
图2.3.1

上述例子,在本文后github中的bigpipeparal文件夹中。

2.4 为什么不用ajax?

相信读着在此处会有疑问,为什么慢的数据,不用ajax去请求呢?这样模板框架也能尽早的渲染出来。ajax毕竟是请求。相信很多读着也有这样的经历,后端处理如果遇到了瓶颈,那么有的时候我们会选择同步页面渲染完之后,再发个请求去获取后端数据。但是笔者认为,这样做有一定弊端:
1、ajax毕竟是个请求,请求就要有连接,要有解析等过程。
2、服务端和客户端都会有闲的时候,发送ajax之前服务端闲,发送ajax出去之后,浏览器又闲着了。
所以,我们使用bigpipe的方式还是比多发送一个ajax有优势的。

3 分段传输与bigpipe适用场景

3.1 分段传输的适用场景

笔者总结了一些使用分块儿传输比较合适的场景
1 前端需要尽早传输head中的一些css/js外联文件的情况下(可以先flush给客户端head前面的html内容,让浏览器尽早的去请求)
2 后端处理渲染的数据,上方较快,下方较慢的情况(可以先行渲染上方较快的部分)

3.2 使用bigpipe的场景

对于更为复杂一点的bigpipe方式,如果上面的情况就适用于你的网站了的话,则最好采用简单的分块传输,否则如下情况,需要回填,则采用bigpipe方式渲染页面。毕竟,使用js回填还是有性能损耗的。
1 后端有较慢的数据处理,阻塞住了页面的情况下,且最慢的部分不是在网页的最后。(可以把最慢的部分变为回填)
2 后端有多块儿数据要并行处理的情况下(你也不知道哪块儿先回来了,所以先渲染一个架子。对于并行的请求,先回来的先flush回填)

3.3 国内的应用

据笔者观察,新浪微博正是采用了bigpipe的方式进行渲染,如图3.3.1,我们看到新浪微博的左侧导航栏与中间feed流区块儿都是架空的:
clipboard.png
图3.3.1
在下方,有对左侧导航栏和中间feed流部分的回填,如图3.3.2
clipboard.png
图3.3.2

所以,整个网页的渲染效果如下(如图3.3.3/图3.3.4/图3.3.5)
clipboard.png
图3.3.3
clipboard.png
图3.3.4
clipboard.png
图3.3.5
笔者猜测,可能微博是并行渲染这几块儿的数据,所以采用了bigpipe的方式。

4 课后作业

请读者们回想一下,自己的网站到底适不适合使用分块儿传输,能否使用上面的技术,使自己的网站更快一些呢?如果使用的话,是适合使用普通的chuned提速呢?还是使用bigpipe进行提速呢?

如有说明不周的地方欢迎回复详询

本文中所有的例子,均在我的github上可以找到:
https://github.com/houyu01/ch...

接下来的一篇文章,我将会和读者们一起聊聊HTTPS那些事儿,不要走开,请关注我.....

https://segmentfault.com/a/11...

如果喜欢本文请点击下方的推荐哦,你的推荐会变为我继续更文的动力。

以上内容仅代表笔者个人观点,如有意见笔者愿意学习参考各读者的建议。

你可能感兴趣的

Sokiy_FE · 2016年07月20日

咨询您一个问题啊,有点不理解,你说的

按照常理,我们渲染一张网页,必定是网页全部渲染完毕,然后生成HTML字符串,传送至客户端

这句话怎么理解啊?渲染不应该是后端返回HTML字符串然后浏览器渲染吗?你这个渲染是在传递字符串之前,感觉逻辑上不是很理解,
希望你能解惑一下,谢谢

回复

侯医生 作者 · 2016年07月22日

我这里所说的是后端渲染,是指后端模板引擎将数据与HTML拼装,生成HTML串,这里可能会有理解不一致的情况。可能换成拼装更合适一些。

回复

p2227 · 2016年07月29日

问你一相关问题, http代理会把响应 transfer-encoding chunked 的效果抵消掉吗? https://segmentfault.com/q/10...

回复

slogeor · 2016年08月01日

BIGPIPE 是不是只有PHP 才支持,Java这样的语言支持吗

回复

xgqfrms · 2016年09月03日

网页的分段传输与渲染

回复

载入中...