← 中文资源

简体中文版本

条件请求

条件请求通过 ETag、Last-Modified 等验证器,让客户端安全地重新验证缓存或修改资源,减少带宽浪费和更新丢失。

阅读英文原文

验证器与前置条件

HTTP 条件请求依赖附加在表示上的验证器。ETag 是表示变化时随之变化的不透明字符串,Last-Modified 是时间戳。客户端把验证器放回前置条件头,服务端在传输字节或执行写入前判断是否仍然匹配。

工作机制

读取时,If-None-Match: "v42" 表示仅当资源不是该版本时才返回。匹配则返回无正文的 304 Not Modified,客户端继续使用本地副本。If-Modified-Since 用日期完成同样的事,但只在没有 ETag 时使用。

写入时,If-Match: "v42" 要求当前版本匹配。如果另一个写入者已经产生 v43,服务端返回 412 Precondition Failed,客户端重新获取、合并并重试。这就是乐观并发:不加锁,只对表示做比较并交换。

何时使用与取舍

对读取频繁、变化较少的订阅源、配置和详情页使用条件 GET;对编辑表单、库存替换、任何读改写流程使用条件写入,避免静默覆盖。纯追加或幂等操作通常不需要前置条件。

ETag 需要存储或计算,弱验证器 W/ 不能保护写入,因为它允许语义等价但字节不同的表示。Last-Modified 便宜但精度只有一秒,跨副本也不可靠。304 节省带宽但仍有一次往返,极短 TTL 下反而不如直接返回。

示例与常见误解

客户端缓存资料并记录 ETag: "v42",随后发送 If-None-Match: "v42" 收到 304,省下正文。另一个客户端用 If-Match: "v42" 编辑同一资料,在第一次保存后收到 412,避免了更新丢失。

常见误解是 304 代表失败。它是一次成功且无正文的验证,客户端应把响应中更新的头合并进缓存。相关概念:ETag、HTTP 缓存、乐观并发。

代码示例

import { createHash } from "node:crypto";

function getHeader(headers, name) {
  const key = Object.keys(headers).find(
    (candidate) => candidate.toLowerCase() === name,
  );
  return key === undefined ? undefined : headers[key];
}

function requireNonNegativeInteger(value, label) {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${label} must be a non-negative integer`);
  }
}

export function createEtag(body, options = {}) {
  if (typeof body !== "string" && !Buffer.isBuffer(body)) {
    throw new TypeError("body must be a string or Buffer");
  }
  const { weak = false } = options;
  const digest = createHash("sha256")
    .update(body)
    .digest("base64url")
    .slice(0, 27);
  return `${weak ? "W/" : ""}"${digest}"`;
}

export function buildCacheControl(options = {}) {
  const {
    visibility = "public",
    maxAge = 0,
    sharedMaxAge,
    staleWhileRevalidate,
    immutable = false,
  } = options;
  if (visibility !== "public" && visibility !== "private") {
    throw new RangeError("visibility must be public or private");
  }
  requireNonNegativeInteger(maxAge, "maxAge");

  const directives = [visibility, `max-age=${maxAge}`];
  if (sharedMaxAge !== undefined) {
    requireNonNegativeInteger(sharedMaxAge, "sharedMaxAge");
    directives.push(`s-maxage=${sharedMaxAge}`);
  }
  if (staleWhileRevalidate !== undefined) {
    requireNonNegativeInteger(staleWhileRevalidate, "staleWhileRevalidate");
    directives.push(`stale-while-revalidate=${staleWhileRevalidate}`);
  }
  if (immutable) {
    if (maxAge === 0) {
      throw new RangeError("immutable requires maxAge greater than zero");
    }
    directives.push("immutable");
  }
  return directives.join(", ");
}

export function cacheHeaders(options = {}) {
  const {
    body,
    maxAge = 0,
    visibility = "public",
    lastModified,
    weak = false,
    staleWhileRevalidate,
  } = options;
  if (body === undefined) {
    throw new TypeError("body is required");
  }

  const headers = {
    "Cache-Control": buildCacheControl({
      visibility,
      maxAge,
      staleWhileRevalidate,
    }),
    ETag: createEtag(body, { weak }),
  };
  if (lastModified !== undefined) {
    const date = new Date(lastModified);
    if (Number.isNaN(date.getTime())) {
      throw new RangeError("lastModified must be a valid date");
    }
    headers["Last-Modified"] = date.toUTCString();
  }
  return headers;
}

function opaqueTag(tag) {
  return tag.trim().replace(/^W\//i, "");
}

export function evaluateConditionalRequest(
  method,
  requestHeaders = {},
  responseHeaders = {},
) {
  const normalizedMethod =
    typeof method === "string" ? method.toUpperCase() : "";
  if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const etag = getHeader(responseHeaders, "etag");
  const lastModified = getHeader(responseHeaders, "last-modified");
  const cacheControl = getHeader(responseHeaders, "cache-control");
  const ifNoneMatch = getHeader(requestHeaders, "if-none-match");
  let notModified = false;

  if (ifNoneMatch !== undefined && etag !== undefined) {
    const candidates = ifNoneMatch.split(",").map((tag) => tag.trim());
    notModified =
      candidates.includes("*") ||
      candidates.some((tag) => opaqueTag(tag) === opaqueTag(etag));
  } else {
    const ifModifiedSince = getHeader(requestHeaders, "if-modified-since");
    if (ifModifiedSince !== undefined && lastModified !== undefined) {
      const since = Date.parse(ifModifiedSince);
      const modified = Date.parse(lastModified);
      if (!Number.isNaN(since) && !Number.isNaN(modified)) {
        notModified =
          Math.floor(modified / 1000) <= Math.floor(since / 1000);
      }
    }
  }

  if (!notModified) {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const headers = {};
  if (etag !== undefined) headers.ETag = etag;
  if (cacheControl !== undefined) headers["Cache-Control"] = cacheControl;
  if (lastModified !== undefined) headers["Last-Modified"] = lastModified;
  return { status: 304, headers };
}

const body = JSON.stringify({ items: [1, 2, 3] });
const headers = cacheHeaders({
  body,
  maxAge: 60,
  staleWhileRevalidate: 30,
  lastModified: "2026-10-21T10:00:00Z",
});
console.log(headers);
console.log(evaluateConditionalRequest("GET", {}, headers).status);
console.log(
  evaluateConditionalRequest("GET", { "if-none-match": headers.ETag }, headers)
    .status,
);
console.log(
  evaluateConditionalRequest(
    "GET",
    { "if-modified-since": headers["Last-Modified"] },
    headers,
  ).status,
);
console.log(
  evaluateConditionalRequest("POST", { "if-none-match": headers.ETag }, headers)
    .status,
);
export class LatestRequestGate {
  private active: AbortController | null = null;
  private sequence = 0;

  private begin(): () => boolean {
    this.sequence += 1;
    const ticket = this.sequence;
    return () => ticket === this.sequence;
  }

  async run<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T | undefined> {
    if (typeof task !== "function") throw new TypeError("task must be a function");
    const controller = new AbortController();
    const isCurrent = this.begin();
    if (this.active) this.active.abort();
    this.active = controller;
    try {
      const result = await task(controller.signal);
      return isCurrent() ? result : undefined;
    } catch (error) {
      if (controller.signal.aborted) return undefined;
      throw error;
    } finally {
      if (isCurrent()) this.active = null;
    }
  }

  abort(): void {
    if (this.active) {
      this.active.abort();
      this.active = null;
    }
  }
}