← 中文资源

简体中文版本

103 Early Hints

103 Early Hints 让服务器在最终响应就绪前先发送 preload 与 preconnect 等提示头,浏览器在应用计算答案的同时预热连接。

阅读英文原文

概念定义

Early Hints 是一种信息性 HTTP 状态码,用于在最终响应之前先行携带提示。服务器先发送带 Link 头的 103(预加载样式、脚本或 preconnect 目标),慢速工作完成后再发送 200 等真实状态。浏览器立即利用提示而不是空等。

工作机制

服务器在同一条连接上先写 103 中间响应再写最终响应。允许的头是安全子集,核心是 Link。提示必须是幂等 advisory:提前行动不会因最终响应不同而产生错误行为。

使用场景

带慢速数据获取的 SSR 页面收益最大,因为关键 CSS 与 preconnect 在数据返回前就可知。CDN 可为缓存页面缓存并重放提示。度量点在首字节时间与首次渲染时间的差距,收益就出现在这个窗口里。

常见误区

Early Hints 不是服务器推送的替代品,它只能提示声明式加载,不能注入任意数据。若最终响应改变了提示的 URL,会造成浪费抓取甚至重复下载。中间盒对 1xx 响应处理不当会破坏整个交换,上线前必须验证整条代理链。提示与常规优先级调度竞争,集合务必小而关键。

代码示例

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class Link:
    target: str
    rel: str
    title: str | None = None


def split_parts(header: str) -> list[str]:
    parts: list[str] = []
    current: list[str] = []
    in_quotes = False
    in_angle = False
    for char in header:
        if char == '"':
            in_quotes = not in_quotes
        elif char == "<" and not in_quotes:
            in_angle = True
        elif char == ">" and not in_quotes:
            in_angle = False
        if char == "," and not in_quotes and not in_angle:
            parts.append("".join(current).strip())
            current = []
        else:
            current.append(char)
    if "".join(current).strip():
        parts.append("".join(current).strip())
    return parts


def parse_link_header(header: str) -> dict[str, Link]:
    links: dict[str, Link] = {}
    for part in split_parts(header):
        if not part.startswith("<") or ">" not in part:
            continue
        target, _, rest = part[1:].partition(">")
        attributes: dict[str, str] = {}
        for attribute in rest.split(";"):
            attribute = attribute.strip()
            if not attribute or "=" not in attribute:
                continue
            name, _, raw_value = attribute.partition("=")
            attributes[name.strip().lower()] = raw_value.strip().strip('"')
        rel = attributes.get("rel")
        if rel:
            links[rel] = Link(target=target, rel=rel, title=attributes.get("title"))
    return links


def main() -> None:
    header = (
        '<https://api.example.com/items?page=2&limit=20>; rel="next"; '
        'title="Next, page 2", '
        '<https://api.example.com/items?page=9&limit=20>; rel="last"'
    )
    links = parse_link_header(header)
    print(links["next"].target)
    print(links["next"].title)
    assert links["last"].target.endswith("page=9&limit=20")
    assert parse_link_header("") == {}


if __name__ == "__main__":
    main()
export function buildPaginationLinkHeader(
  baseUrl: string,
  options: { page: number; perPage: number; total: number },
): string {
  if (typeof baseUrl !== "string" || baseUrl.length === 0) {
    throw new TypeError("baseUrl must be a non-empty string");
  }
  const { page, perPage, total } = options;
  if (!Number.isInteger(page) || page < 1) {
    throw new RangeError("page must be a positive integer");
  }
  if (!Number.isInteger(perPage) || perPage < 1) {
    throw new RangeError("perPage must be a positive integer");
  }
  if (!Number.isInteger(total) || total < 0) {
    throw new RangeError("total must be a non-negative integer");
  }
  const lastPage = Math.max(1, Math.ceil(total / perPage));
  if (page > lastPage) throw new RangeError("page must not exceed the last page");
  const base = new URL(baseUrl);
  const link = (target: number, rel: string): string => {
    const url = new URL(base);
    url.searchParams.set("page", String(target));
    url.searchParams.set("limit", String(perPage));
    return "<" + url.toString() + '>; rel="' + rel + '"';
  };
  const parts = [link(page, "self"), link(1, "first")];
  if (page > 1) parts.push(link(page - 1, "prev"));
  if (page < lastPage) parts.push(link(page + 1, "next"));
  parts.push(link(lastPage, "last"));
  return parts.join(", ");
}