Skip to content
Go back

在 PHP 页面中实现 BigPipe 流式渲染与模块通信

理解 BigPipe 的 Pagelet 思路以后,接下来最实际的问题是:如果页面由 PHP 生成,服务端怎样一块一块输出模块,前端又怎样把这些模块组织成一个能够正常交互的页面?

这篇整理一个简化实现。它不依赖特殊浏览器能力,主要使用 PHP 的分段输出能力,以及浏览器逐步解析 HTML 和执行内联脚本的行为。

页面首先需要一个骨架

BigPipe 页面不能等所有模块内容完成以后再返回完整 HTML。第一步应该尽快输出基础文档、全局资源和每个 Pagelet 的占位节点:

<?phpfunction renderLayoutStart() {    echo '<!doctype html>';    echo '<html>';    echo '<head>';    echo '<meta charset="utf-8">';    echo '<link rel="stylesheet" href="/static/base.css">';    echo '<script src="/static/bigpipe.js"></script>';    echo '</head>';    echo '<body>';    echo '<div class="page">';    echo '  <div id="pagelet-header" class="pagelet-loading"></div>';    echo '  <div id="pagelet-thread-list" class="pagelet-loading"></div>';    echo '  <div id="pagelet-sidebar" class="pagelet-loading"></div>';    echo '  <div id="pagelet-ad" class="pagelet-loading"></div>';    echo '</div>';    flushOutput();}function flushOutput() {    if (function_exists('ob_flush')) {        @ob_flush();    }    flush();}

这段内容越早发给浏览器,浏览器越早能够开始解析 DOM、加载基础资源并展示页面结构。

实际环境中还需要留意 Web Server、代理层和压缩配置是否会缓冲输出。如果中间层一直等到响应足够大才转发,即使 PHP 调用了 flush(),浏览器也未必能够及时收到片段。

Pagelet 使用统一结构输出

每个页面模块可以统一组织为几类信息:

<?phpclass Pagelet {    public $id;    public $html;    public $css = array();    public $js = array();    public $data = array();    public function __construct($id, $html, $css = array(), $js = array(), $data = array()) {        $this->id = $id;        $this->html = $html;        $this->css = $css;        $this->js = $js;        $this->data = $data;    }}

模块生成完成以后,将它序列化成前端可以接收的脚本调用:

<?phpfunction sendPagelet(Pagelet $pagelet) {    $payload = array(        'id' => $pagelet->id,        'html' => $pagelet->html,        'css' => $pagelet->css,        'js' => $pagelet->js,        'data' => $pagelet->data    );    echo '<script>';    echo 'BigPipe.onPageletArrive(' . json_encode($payload) . ');';    echo '</script>';    flushOutput();}

例如页面中的主题列表模块准备好后即可输出:

<?php$threadHtml = renderThreadList($threadList);sendPagelet(new Pagelet(    'pagelet-thread-list',    $threadHtml,    array('/static/thread-list.css'),    array('/static/thread-list.js'),    array('total' => count($threadList))));

此时侧栏或广告模块即便还没有完成,也不会阻止主题列表先出现在页面中。

输出顺序不一定等于视觉顺序

页面骨架中,模块位置是固定的;但服务端输出 Pagelet 的顺序可以根据生成速度和展示优先级安排。

例如:

<?phprenderLayoutStart();sendPagelet(buildHeaderPagelet());sendPagelet(buildThreadListPagelet());sendPagelet(buildSidebarPagelet());sendPagelet(buildAdPagelet());echo '</body></html>';

这是顺序生成的简化代码。如果某些数据能够并行获取,服务端框架还可以让准备更早的高优先级模块更先输出。

不管后端如何执行,前端都不应该依赖“某一个模块一定先到达”,除非依赖关系被明确声明。否则一旦接口速度波动,页面初始化逻辑就可能失效。

前端运行时负责模块落位

浏览器端需要一个运行时管理 Pagelet 的到达、资源加载和初始化:

(function (global) {  var loadedCss = {};  var loadedJs = {};  var pagelets = {};  function loadCss(urls, done) {    var waiting = urls.length;    if (!waiting) {      done();      return;    }    urls.forEach(function (url) {      if (loadedCss[url]) {        waiting -= 1;        if (!waiting) done();        return;      }      var link = document.createElement('link');      link.rel = 'stylesheet';      link.href = url;      link.onload = function () {        loadedCss[url] = true;        waiting -= 1;        if (!waiting) done();      };      document.head.appendChild(link);    });  }  function loadJs(urls, done) {    var index = 0;    function next() {      var url = urls[index++];      if (!url) {        done();        return;      }      if (loadedJs[url]) {        next();        return;      }      var script = document.createElement('script');      script.src = url;      script.onload = function () {        loadedJs[url] = true;        next();      };      document.body.appendChild(script);    }    next();  }  global.BigPipe = {    onPageletArrive: function (pagelet) {      pagelets[pagelet.id] = pagelet;      loadCss(pagelet.css || [], function () {        var container = document.getElementById(pagelet.id);        container.innerHTML = pagelet.html;        container.className = container.className.replace('pagelet-loading', '');        loadJs(pagelet.js || [], function () {          EventBus.emit('pagelet:ready', pagelet.id, pagelet.data);        });      });    }  };})(window);

这里先确保样式可用,再写入模块 HTML,最后加载需要的 JavaScript。样式资源和脚本资源都需要去重,否则多个 Pagelet 共享资源时会反复发起请求或重复执行初始化。

模块之间不要直接寻找彼此实例

流式渲染中,一个模块不能假设另一个模块已经初始化完成。例如广告模块可能先到,用户信息模块可能后到;侧栏也可能需要等登录信息完成后才更新展示。

如果模块之间直接互相调用:

window.userModule.getUserInfo();

那么到达顺序变化时就可能出现 userModule 还不存在的问题。

一种更稳妥的方式是通过事件总线传递已经发生的事实:

var EventBus = (function () {  var events = {};  return {    on: function (name, handler) {      events[name] = events[name] || [];      events[name].push(handler);    },    emit: function (name) {      var args = Array.prototype.slice.call(arguments, 1);      var handlers = events[name] || [];      handlers.forEach(function (handler) {        handler.apply(null, args);      });    }  };})();

用户模块准备完成后发布事件:

EventBus.emit('user:ready', {  isLogin: true,  userName: 'example'});

依赖用户状态的模块订阅这个事件:

EventBus.on('user:ready', function (user) {  if (user.isLogin) {    showPersonalizedContent();  }});

不过这里还有一个时序问题:如果事件在订阅前已经触发,后到达的模块仍然拿不到状态。因此,对于用户信息这类持续有效的数据,事件总线还需要保留最后一次值,或者结合一个共享状态容器:

var PageState = {  user: null};PageState.user = userData;EventBus.emit('user:ready', PageState.user);

后初始化的模块可以先读取 PageState.user,没有数据时再等待事件。这样模块之间依赖的是状态与事件契约,而不是彼此的实现对象。

声明依赖关系再执行初始化

有些模块不能只靠事件等待,例如一个操作栏明确依赖登录模块与帖子详情模块都准备完成。可以为 Pagelet 增加依赖描述:

<?phpsendPagelet(new Pagelet(    'pagelet-operation',    renderOperation(),    array('/static/operation.css'),    array('/static/operation.js'),    array(        'depends' => array('pagelet-user', 'pagelet-thread')    )));

前端在模块 HTML 可展示后,检查依赖是否全部 ready,再执行初始化:

function canInit(pagelet) {  var depends = pagelet.data.depends || [];  return depends.every(function (id) {    return readyPagelets[id];  });}

内容展示和交互初始化可以是两个阶段:模块先让用户看到,依赖齐全后再启用相应操作。这比因为一个操作依赖而推迟整块内容显示更符合 BigPipe 的目标。

流式渲染要有结束信号和降级策略

服务端完成全部 Pagelet 输出后,可以发送一个结束信号:

<?phpecho '<script>BigPipe.onComplete();</script>';echo '</body></html>';flushOutput();

前端收到结束信号以后,可以处理仍未到达的占位区域、停止全局 loading 或上报模块耗时。

同时,需要考虑模块失败的情况:

BigPipe 让页面更快开始展示,同时也让页面拥有更多中间状态。中间状态越多,错误处理越不能省略。

PHP 输出中的几个注意点

在 PHP 场景中使用流式输出,需要特别注意:

  1. flush() 只是 PHP 尝试把已有输出发送出去,中间的 Nginx、代理或压缩缓冲仍可能延迟内容到达浏览器。
  2. Pagelet HTML 通过 JSON 放入脚本调用时,需要使用可靠序列化,不能手工拼接字符串。
  3. 全局 CSS 应优先随骨架加载,模块 CSS 则跟随 Pagelet 管理,避免明显闪动。
  4. JavaScript 初始化不要假定 Pagelet 按固定顺序到达。
  5. 核心内容与非核心内容需要划分优先级,否则拆块本身不一定带来可见收益。

小结

PHP 与 BigPipe 的配合,关键在于将页面响应从“一次性生成文档”变成“先返回骨架,再不断发送模块结果”:

流式渲染并不是把 flush() 加到 PHP 模板里就完成了。只有当模块边界、资源依赖和通信方式都能够独立成立时,用户才真正能够更早看到内容,同时获得稳定的页面交互。

参考资料


Share this post on: