FrankenPHP 中的 103 Early Hints 物理实现:通过提前推送资源显著提升 LCP 指标

FrankenPHP 中的 103 Early Hints 物理实现:通过提前推送资源显著提升 LCP 指标

各位听众,各位正在忍受网页加载缓慢折磨的极客们,大家晚上好。

今天我们不聊那些虚头巴脑的架构图,也不聊那些只会让你在深夜背锅的“技术债”。今天我们要聊点硬核的,关于速度,关于物理学,关于如何在 HTTP 协议的泥潭里,通过一个小小的状态码,让你的网页像火箭一样发射。

我是你们的向导,一名在这个充满 Bug 的世界里挣扎了二十年的“资深编程专家”。今天我们要深入探讨的主题是:FrankenPHP 中的 103 Early Hints 物理实现

我知道,听到“FrankenPHP”这个名字,你可能会想:“这是什么?一个把弗兰肯斯坦的零件拼凑起来的怪兽?还是某种会咬人的 PHP 扩展?” 其实,它比那帅气得多。FrankenPHP 是 Caddy 的继任者,是一个高性能、单二进制、内置 PHP-FPM 的 Web 服务器。它就像是一个瑞士军刀,还附赠了理发师。

而我们要聊的“怪物”,就是 HTTP 103 Early Hints

第一部分:LCP 的诅咒与等待的痛苦

在深入代码之前,我们先来谈谈为什么我们需要这个。这关系到你的尊严,以及你在老板面前吹嘘技术的能力。

什么是 LCP? Largest Contentful Paint,最大内容绘制。这是 Google Core Web Vitals 中的一个核心指标。简单来说,它是用户看到视口内最大图片或文本块渲染出来的时间。

想象一下,你在一家五星级餐厅(你的网页)。

  1. 第一阶段:服务员(浏览器)冲进厨房(服务器),大声点餐(发起 HTTP 请求)。
  2. 第二阶段:你坐在桌前(渲染 HTML)。你盯着菜单(DOM 结构),心里想:“那个鱼子酱图片(LCP 资源)在哪?怎么还没上来?”
  3. 第三阶段:厨房开始做饭。他们先去磨刀(建立 TCP 连接),再去烧水(DNS 解析)。因为厨房很忙,他们得等水开了才能煎鱼。最后,他们把鱼端上来。
  4. 结果:你饿着肚子等了 3 秒钟,只为了看一眼鱼子酱。你的 LCP 指标爆表了。你愤怒地离开了餐厅。

传统的 HTTP 协议(主要是 HTTP/1.1)是典型的“串行处理”风格。服务器收到请求,说“好的,我正在处理”,然后开始缓冲一大堆 HTML 文本。只有当 HTML 全部缓冲完毕,它才会发送给浏览器。此时,浏览器才开始解析 HTML,遇到 <link> 标签,然后才去下载 CSS,下载图片。

这是一条漫长的流水线。

103 Early Hints 就是那个聪明的服务员。当厨房(服务器)开始磨刀(建立连接)或者水烧开的时候,服务员会对你说:“先生,鱼子酱正在处理中,但我先给你上几杯开胃酒(103 状态码),您可以先尝尝,别饿着。” 而此时,真正的晚餐(HTML)还在锅里煮着。

这就是物理层面的“提前告知”。你利用了等待的时间差。

第二部分:FrankenPHP —— 不仅仅是 PHP 的替身

为什么要用 FrankenPHP?而不是 Nginx + PHP-FPM?或者那个只会说“你好”的 Apache?

FrankenPHP 的哲学非常迷人。它完全兼容 Caddy 的配置文件。这意味着,你不需要去背诵那一堆枯燥的 Nginx 配置指令,也不需要去写复杂的 Shell 脚本来管理 PHP-FPM 的 Socket。你只需要写一个 Caddyfile,就像写配置文件一样简单。

更重要的是,FrankenPHP 对流式响应有着原生级别的支持。传统的 PHP-FPM 往往会对输出进行缓冲,导致你在 PHP 里 echo 了一些东西,结果服务器要攒够一块大的 Buffer 才发出去。这在 HTTP/1.1 下尚可忍受,但在需要秒级响应的 HTTP/2 和 HTTP/3 时代,这就是巨大的性能浪费。

FrankenPHP 是一个“流式”怪物。它能一边煮饭(生成 HTML),一边把菜端给客人(发送 103 提示和 HTML 片段)。

第三部分:物理实现 —— 手把手教你把资源推出去

让我们开始动手。假设我们要构建一个现代化的博客页面,页面底部有一张巨大的英雄图片(Hero Image),这张图片的大小是 2MB,是影响 LCP 的罪魁祸首。

第一步:配置 FrankenPHP

首先,我们需要一个 Caddyfile。它长这样:

{
    php_fpm {
        # 这里可以配置 PHP-FPM 的 socket 地址
        # 或者直接使用内置的 PHP 处理器
    }
}

example.com {
    # 启用 HTTP/3 (QUIC) 支持
    # 注意:这需要操作系统支持 UDP,Linux/Windows 都支持,macOS 某些版本可能需要额外配置
    protocol {
        quic
    }

    # 指定 PHP 文件根目录
    root * /var/www/html

    # 这里的关键是 php_file_match,它告诉 Caddy 哪些文件由 PHP 处理
    php_file_match *.{php}

    # 路由逻辑
    route {
        # 静态文件服务(如果有的话)
        file_server {
            root public
            precompressed gzip zstd
        }

        # PHP 处理
        php_fastcgi localhost:9000
    }
}

看,简单吧?这就是 FrankenPHP 的“物理”基础。它启动了一个 HTTP/3 监听器,准备好处理 PHP 请求。

第二步:PHP 代码 —— 生成 103 头部

现在,我们要写 PHP 代码了。不要把 HTML 和逻辑混在一起,我们要做到真正的“流式输出”。

<?php
// output_buffering = 0 是必须的,或者你需要显式关闭缓冲
if (ini_get('output_buffering')) {
    ini_set('output_buffering', 0);
}
// 开启隐式刷新,这样 echo 就会立即发送
ob_implicit_flush(1);

// 1. 这是一个巨大的图片 URL,它是我们的 LCP 资源
$imageUrl = 'https://cdn.example.com/hero-landscape-4k.jpg';

// 2. 构建 Link 头部
// rel=preload: 告诉浏览器,“这个资源现在就需要,别等”
// as=image: 告诉浏览器,“这是个图片,请开启并行下载和渲染优化”
// preload:critical: 这是一个自定义指令,告诉浏览器这是关键路径资源
$linkHeader = '<' . $imageUrl . '>; rel=preload; as=image; preload:critical';

// 3. 发送 103 Early Hints
// 这是魔法发生的地方!
// 注意:这个 header() 必须在发送任何 HTML 内容之前被调用
header('HTTP/1.1 103 Early Hints');
header('Link: ' . $linkHeader);

// 4. 继续执行业务逻辑
// 比如查询数据库,生成文章列表
$posts = get_posts(10);

// 5. 开始流式输出 HTML
echo '<!DOCTYPE html>';
echo '<html lang="zh-CN">';
echo '<head>';
echo '<meta charset="UTF-8">';
echo '<title>高性能演示</title>';
// 假设 CSS 还在文件里,我们用 preload 等待它,或者在这里直接推送 CSS
echo '<link rel="preload" href="/styles.css" as="style">';
echo '</head>';
echo '<body>';

foreach ($posts as $post) {
    // 输出文章内容
    echo '<article>';
    echo '<h2>' . htmlspecialchars($post['title']) . '</h2>';
    echo '<p>' . htmlspecialchars($post['excerpt']) . '</p>';
    // 这里有一个小的缩略图,很快
    echo '<img src="' . htmlspecialchars($post['thumbnail']) . '" alt="thumb">';
    echo '</article>';
}

// 6. 最后是那个 2MB 的英雄图片
// 由于我们在头部已经通过 103 发送了 preload 指令,
// 当浏览器读到下面的 <img> 标签时,图片已经在下载了!
echo '<div class="hero">';
echo '<h1>欢迎来到 FrankenPHP 的世界</h1>';
// 这个 img 标签的 src 必须和之前 Link 头部里的一致
echo '<img src="' . $imageUrl . '" alt="Hero Image">';
echo '</div>';

echo '</body>';
echo '</html>';

代码背后的物理逻辑

让我们拆解一下上面的代码,看看发生了什么。

  1. 同步等待 vs 异步预取
    在代码第 3 行,header('HTTP/1.1 103 Early Hints') 发送出去的那一刻,服务器并没有在等待 PHP 脚本执行完毕。FrankenPHP 的 PHP-FPM 接口(或者内置的 SAPI)检测到了这个状态码变更,它会立即向客户端发送这个头部信息。此时,PHP 脚本依然在后台运行,继续执行第 4 到 6 行。

  2. Link 头部的魔力
    rel=preload 是关键。它告诉浏览器:“嘿,别等 HTML 解析完再下载这个,现在就开始下载。” as=image 告诉浏览器使用图片的专用下载通道,并在浏览器空闲时优先处理。

  3. 并行下载
    当 PHP 脚本在第 6 行输出 <img> 标签时,浏览器已经收到了 103 状态码和 Link 头部。浏览器建立了一个独立的下载请求(基于 HTTP/2 多路复用或 HTTP/3),开始下载 2MB 的图片。同时,它还在解析刚才输出的 HTML。

  4. LCP 的提升
    在传统的请求-响应模型中,浏览器解析完 HTML(假设耗时 200ms),发现图片,建立连接(假设耗时 100ms,取决于网络),然后下载图片(假设耗时 2000ms)。总耗时 = 200 + 100 + 2000 = 2300ms。
    在 103 Early Hints 模型中,浏览器解析 HTML(200ms),发现图片。但是,它在 200ms 的时候就已经收到了 103,连接已经建立,下载已经开始(假设 1500ms 后开始下载数据,此时图片还没下完,但在内存缓冲区里)。当 <img> 标签出现时,数据可能已经下载了一半。
    LCP 降低的关键在于减少了“等待连接”和“解析 DOM 导致的延迟”这两个阶段。

第四部分:高级技巧 —— 不仅仅是图片

图片不是唯一可以推送的东西。让我们看看还能推什么,从而进一步优化 LCP。

1. 预连接 (Preconnect)

有时候,我们不需要下载资源,只需要建立连接。比如,我们的图片托管在一个完全不同的域名(例如 cdn.example.com),而我们的网页是 www.example.com

在 HTTP/1.1 中,跨域连接建立是一个巨大的开销。

我们可以利用 103 状态码发送 preconnect 提示:

header('HTTP/1.1 103 Early Hints');
header('Link: <https://cdn.example.com>; rel=preconnect; as=image');

这告诉浏览器:“先把这个域名搞定,别等以后要下图片的时候再手忙脚乱地握手。”

2. DNS 预解析 (dns-prefetch)

如果图片托管在另一个数据中心,或者 DNS 解析速度慢,我们可以发送 dns-prefetch

header('HTTP/1.1 103 Early Hints');
header('Link: <https://cdn.example.com>; rel=dns-prefetch');

3. 字体与字体加载

很多网站的问题在于字体加载。如果网页使用了自定义字体,而字体文件很大(比如 500KB),那么页面渲染会阻塞。即使字体还没加载,浏览器也会先显示默认字体,然后突然切换到自定义字体,导致文字“抖动”。

通过 103 提示预加载字体:

header('HTTP/1.1 103 Early Hints');
header('Link: <https://cdn.example.com/fonts/my-font.woff2>; rel=preload; as=font; crossorigin');

crossorigin 属性非常重要,因为它告诉浏览器:“这个字体是受 CORS 保护的,不要把它当作图片加载,要当作字体加载,否则会破坏布局。”

第五部分:FrankenPHP 的流式优势与回退机制

FrankenPHP 的真正威力在于它处理流式数据的能力。你可能在想:“如果我的 PHP 代码很慢呢?比如我需要执行一个复杂的计算,耗时 5 秒。”

如果是在普通的 PHP-FPM 模式下,这 5 秒里,浏览器什么也收不到,连 103 提示都发不出去。LCP 指标会极其难看。

但在 FrankenPHP 中,情况有所不同。因为 FrankenPHP 使用 Caddy 作为前端服务器,而 PHP-FPM 作为后端。

场景模拟:

  1. 0-1秒:FrankenPHP 收到请求。开始执行 PHP。PHP 脚本运行。此时 PHP 脚本内部使用 flush() 或者让 Caddy 的 FastCGI 模块自动刷新。
  2. 0.5秒:PHP 脚本生成了一段 HTML,执行了 flush()。Caddy 捕获到这段输出,发送给客户端。同时,PHP 脚本检测到这是头部输出区域,于是它发送了 HTTP/1.1 103 Early HintsLink 头部。
  3. 1秒:客户端收到 103 提示。浏览器开始准备下载图片。
  4. 5秒:PHP 脚本终于跑完了,开始输出剩余的 HTML。客户端收到剩余的 HTML。

在这个场景下,即使用户的 PHP 脚本很慢,只要你的逻辑允许你在输出头部信息之后、剩余 HTML 输出之前,插入 103 状态码,你依然能享受到“提前告知”的红利。

浏览器的兼容性回退

并不是所有浏览器都支持 103 状态码。IE?不存在的。老版本的 Edge?可能不支持。

但在现代浏览器(Chrome, Firefox, Safari, Edge)中,103 是标配。如果不支持,它会怎么处理?它会直接忽略 103 状态码,把它当成 200 处理。这对 LCP 的提升没有帮助,但也完全不会破坏功能。

FrankenPHP 的 header() 函数是标准的 PHP 函数,它会忠实地把你的指令发送给浏览器。如果浏览器听不懂(不支持 103),它就当没听见。

第六部分:实战中的误区与修正

作为一名“资深编程专家”,我看过太多人在实现这个功能时掉进坑里。这里有几个典型的错误,我们用幽默的方式来点评一下。

错误一:在 HTML 里面放 <link rel="preload">

这是最常见的错误。有人觉得:“哦,我可以直接在 HTML 里放 <link rel="preload"> 标签啊,浏览器不也是能读到吗?”

是的,浏览器能读到。但是,HTML 解析是顺序进行的

<html>
<head>
    <!-- 这里写 preload -->
</head>
<body>
    <!-- 这里有 img src="big.jpg" -->
</body>
</html>

浏览器必须先解析完 <head>,遇到 <img>,然后才会去执行 <head> 里的指令。在这个期间,网络延迟就浪费了。

103 Early Hints 的核心在于:在 HTML 输出之前,甚至在没有输出任何 HTML 之前,就告诉浏览器。

错误二:忽略了 as 属性

如果你推送一个资源,却不告诉浏览器它是什么,浏览器只能把它当成“其他类型”处理,无法进行并行下载优化。

// 坏做法
header('Link: <image.jpg>; rel=preload'); 
// 浏览器:这是啥?是个文件吗?是脚本吗?是图片吗?我拿不准,我就不加速下载了。

// 好做法
header('Link: <image.jpg>; rel=preload; as=image');
// 浏览器:哦!图片!那我得给这个下载请求分配最高优先级,并且预留渲染缓冲区!

错误三:过度推送

103 不是万能药。如果你告诉浏览器去下载 100 个资源,而它只有 6 个并发连接(HTTP/2 的限制,或者浏览器的限制),那么过多的请求只会互相阻塞。

你应该只推送 LCP 资源Favicon首屏 CSS首屏字体。其他的资源,就让浏览器自己去 prefetch 或者等到解析 HTML 的时候再下载吧。保持克制,就像点菜一样,别点的太多,消化不良。

第七部分:HTTP/3 中的 103

既然我们用了 FrankenPHP,就不得不提一下 HTTP/3。

HTTP/3 基于 QUIC 协议,使用了 UDP。这意味着连接建立速度非常快(通常在 RTT 的一个往返内)。但是,在 HTTP/3 中,连接建立仍然是建立握手的过程。

如果你在 HTTP/3 中使用 103 Early Hints,浏览器在建立连接(三次握手或者 QUIC 握手)的过程中就会收到提示。

有趣的现象
在 HTTP/2 中,多路复用允许所有请求共享一个连接。如果你在 103 中推送一个资源,它会占用这个连接的带宽。但如果连接本身还没建立(在慢速网络或 WiFi 干扰下),103 就没有意义。

在 HTTP/3 中,虽然连接建立快,但如果网络丢包严重,UDP 的流量控制可能会导致延迟。不过,103 提示依然有效,它能让浏览器在连接建立的缝隙中开始准备下载。

FrankenPHP 对 HTTP/3 的支持是无缝的。你不需要更改 PHP 代码,只需要在 Caddyfile 里加上 protocol { quic }。FrankenPHP 会自动处理底层的协议转换。

第八部分:监控与验证

实现了代码,效果怎么样?怎么验证 LCP 真的提升了?

  1. Chrome DevTools (Network 面板)
    打开开发者工具,勾选 “Disable cache”。
    查看 Network 面板。你应该能看到一个绿色的箭头(200 OK),后面跟着一个紫色的 Link 预留给的请求(如果你使用了预连接,可能会有 dns-prefetch 的状态)。
    重点看那个 preload 的资源,它的 Time to First Byte (TTFB) 应该非常小,因为服务器已经在发送 103 提示了。

  2. WebPageTest
    这是神器。跑一下你的网站,查看 Core Web Vitals 的图表。在 LCP 的那个尖峰处,你应该能看到图片的下载曲线是提前开始的。

  3. Lighthouse
    直接在 Chrome 运行 Lighthouse 报告。如果你的配置正确,你应该能看到 “Preload key requests” 这一项被勾选上了。

结语:拥抱“弗兰肯”式的高性能

我们今天从 LCP 的痛点出发,聊到了 HTTP 协议的历史,深入到了 FrankenPHP 的内部实现,最后亲手写了代码,让网页的加载速度提升了百分之十几。

这不仅仅是关于代码的优化,这是一种思维的转变。传统的 Web 开发是“我给你一整块蛋糕,你慢慢吃”。而现代的 Web 开发(在 FrankenPHP 的加持下)是“我先把蛋糕的包装打开,再上菜,同时告诉你哪里有好吃的馅料”。

FrankenPHP 让这变得简单。它没有复杂的配置,没有晦涩的构建过程,它就像一个熟练的厨师,知道如何利用每一秒钟。

不要让用户的鼠标在那无休止地转圈,不要让用户看着“转圈圈”的加载动画怀疑人生。使用 103 Early Hints,使用 FrankenPHP,让你的服务器像摇滚明星一样充满活力。

现在,拿起你的键盘,去优化你的 LCP 吧。记住,在这个世界上最遥远的距离,不是生与死,而是我在写代码,而你在等页面加载完。

谢谢大家。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注