理解 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 或上报模块耗时。
同时,需要考虑模块失败的情况:
- 非核心 Pagelet 失败时,隐藏占位或展示简单错误信息。
- 核心内容失败时,提供重试或整页刷新入口。
- BigPipe 运行时未加载成功时,服务端可以退化为普通整页输出。
- 输出内容进入脚本前必须可靠地进行 JSON 编码,避免 HTML 内容破坏脚本结构。
BigPipe 让页面更快开始展示,同时也让页面拥有更多中间状态。中间状态越多,错误处理越不能省略。
PHP 输出中的几个注意点
在 PHP 场景中使用流式输出,需要特别注意:
flush()只是 PHP 尝试把已有输出发送出去,中间的 Nginx、代理或压缩缓冲仍可能延迟内容到达浏览器。- Pagelet HTML 通过 JSON 放入脚本调用时,需要使用可靠序列化,不能手工拼接字符串。
- 全局 CSS 应优先随骨架加载,模块 CSS 则跟随 Pagelet 管理,避免明显闪动。
- JavaScript 初始化不要假定 Pagelet 按固定顺序到达。
- 核心内容与非核心内容需要划分优先级,否则拆块本身不一定带来可见收益。
小结
PHP 与 BigPipe 的配合,关键在于将页面响应从“一次性生成文档”变成“先返回骨架,再不断发送模块结果”:
- PHP 负责尽早输出文档骨架和逐个到达的 Pagelet 数据。
- 浏览器端运行时负责资源加载、HTML 落位与初始化时机。
- 模块之间通过共享状态、事件或声明式依赖进行通信。
- 页面需要处理模块未到达、失败与最终完成等中间状态。
流式渲染并不是把 flush() 加到 PHP 模板里就完成了。只有当模块边界、资源依赖和通信方式都能够独立成立时,用户才真正能够更早看到内容,同时获得稳定的页面交互。