Geonode logo
Geonode Team

Geonode Team

更新于:2026年10月7日

发布于:2026年9月2日

使用 node-fetch 设置请求头

自 v18 起,Node.js 便内置了 `fetch`,这意味着大多数搜索 `node-fetch` 的用户已完全无需安装该包。 在两种情况下设置头部都很简单。但不太明显的是,`set` 与 `append` 之间的区别、哪些头部不允许设置,以及为何您配置的头部没有出现在网络传输中。 本指南涵盖了所有这些内容,还介绍了 Node 中与其他所有 HTTP 客户端不同的代理行为。

我们需要关注一个具体的陷阱:我们是 Geonode,主要销售代理服务,而 Node 内置的 fetch 会完全忽略 HTTP_PROXY 和 HTTPS_PROXY 这两个环境变量。 生态系统中的其他所有 HTTP 客户端都会尊重这些变量,因此用户配置好代理后,看到请求成功,便以为代理正在正常工作——而实际上流量却直接通过了。 此过程既无警告也无错误提示。解决方法只需几行代码,详见下文的代理部分。如果您正在对 Node 的 fetch 流量进行代理,但未显式设置分发器,那么您的请求几乎可以肯定并未通过代理。

使用原生 fetch 还是 node-fetch 包?

请从这里开始,因为这将决定你需要安装什么。

Node的文档记载,fetch功能在v17.5.0和v16.15.0版本中被引入,自v18.0.0起不再受--experimental-fetch标志限制,并自v21.0.0起“不再属于实验性功能”。 该模块被描述为“基于 undici(一款专为 Node.js 从零开始编写的 HTTP/1.1 客户端)实现的、与浏览器兼容的 fetch() 函数”。Headers、Request 和 Response 遵循相同的时间线。

因此,在任何当前受支持的 Node 版本中,fetch 均为全局变量,无需额外依赖。

node-fetch 包在以下两种情况下仍然有用:在旧版运行时上维护代码,以及需要其 API 存在差异的少数几种行为之一。请注意,版本 3 仅支持 ESM,这会让仍在使用 require 的项目遇到问题。

以下内容同时适用于这两种情况,因为 API 是刻意保持一致的。

设置标头的三种方法

普通对象 —— 这是最常见的情况,也是大多数时候应采用的方式:

const res = await fetch("https://api.example.com/items", {
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer eyJhbG...",
    "Accept": "application/json",
  },
});

**Headers

对象** —— 用于条件性构建设置时:

const headers = new Headers({ "Accept": "application/json" });
if (token) headers.set("Authorization", `Bearer ${token}`);
if (locale) headers.set("Accept-Language", locale);

const res = await fetch(url, { headers });

键值对数组 —— 当标头确实需要重复时非常有用:

const res = await fetch(url, {
  headers: [
    ["Accept", "application/json"],
    ["X-Trace", "a"],
    ["X-Trace", "b"],
  ],
});

对于简单情况,这三种方法效果等同。当需要条件逻辑,或者希望在发送前检查已构建的内容时,Headers

对象便派上了用场。

``set`

与 ``append `:这一区别往往令人意外。

MDN 文档 将 set()` ` 定义为“为现有标头设置新值”并覆盖现有值,而 append() 则“将新值追加到现有标头中,或在标头不存在时添加该标头”。

const h = new Headers();
h.append("X-Custom", "one");
h.append("X-Custom", "two");
h.get("X-Custom");        // "one, two"

h.set("X-Custom", "three");
h.get("X-Custom");        // "three"

append 前者累加;set 则替换。对于您发送的几乎所有标头,set 都是您想要的——发送两个 Authorization 值并不是一个有意义的请求。append 仅适用于允许多个值的标头,而实际上这类标头寥寥无几。

标头名称不区分大小写。 MDN 指出,在所有方法中,它们都是“通过不区分大小写的字节序列进行匹配”的,因此 h.get("content-type") 和 h.get("Content-Type") 返回的值相同。为提高可读性,选择一种命名约定并无需为此担忧。

此外,还有 has() 用于检测是否存在,delete() 用于移除,以及 getSetCookie() ,该方法返回一个包含所有 Set-Cookie 值的数组——这是必要的,因为该标头是少数几个真正存在多个值共存的情况,而单纯的 get() 会将它们拼接成无法可靠拆分的字符串。

无法设置的标头

您配置的标头未显示的原因。

MDN 描述了 Headers 对象上的“保护机制”,该机制决定了哪些内容可以被修改。 独立的 new Headers() 对象没有限制。附加到 Request 上的请求头允许修改“非禁止的请求头”。而“从 Response.error()、Response.redirect() 或 fetch()”获取的 Response 上的请求头是 不可变的 —— 收到响应后,您无法更改其请求头。

“禁止”请求头由运行时控制,尝试设置它们时会被静默忽略,而非抛出错误。该列表包括 Host、Connection、Content-Length、Transfer-Encoding、Origin、某些上下文中的 Referer,以及以 Sec- 和 Proxy- 为前缀的系列。

这会带来两个实际后果。

静默是默认的失败模式。 不会抛出异常,也不会发出警告;该标头 simply 不会被发送。如果服务器坚持表示未收到你设置的内容,请验证实际传输的数据,而不是重新检查你的代码。

对于其中某些情况,Node 比浏览器更宽松,因为它无需保护源。 在 Node 中成功设置的标头,在浏览器中可能会被丢弃,这对共享代码而言是一个真正的可移植性陷阱。

要检查实际发送的内容,请向一个会将请求原样回显的服务发送请求:

const res = await fetch("https://httpbin.org/headers", {
  headers: { "X-Test": "value", "User-Agent": "MyBot/1.0" },
});
console.log(await res.json());

读取响应头

另一半内容中,还有一种行为值得了解。

const res = await fetch(url);

res.headers.get("content-type");
res.headers.has("etag");

for (const [name, value] of res.headers) {
  console.log(name, value);
}

迭代会返回小写名称,因为头部集经过了规范化处理。

**Set-Cookie

需要特殊处理。** 多个 Cookie 会作为多个标头返回,而简单的 get("set-cookie")

会将它们以逗号分隔——这会造成歧义,因为 Cookie 值本身可能包含逗号(例如 Expires

中的日期)。getSetCookie()

正是为此而存在的,它会返回一个数组:

const cookies = res.headers.getSetCookie();

响应头是不可变的。 您无法修改 fetch

返回的内容。若需修改后的版本,请构建一个新的 Response

。

还有一项比任何标头都更重要的检查:fetch

不会因 HTTP 错误状态而拒绝请求。404 或 500 状态码都会被正常解析,因此在解析正文之前,必须先测试 res.ok

。

const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`);

忽略这一步是 .json()

调用中出现 Unexpected token '<'

的直接原因——你解析了一个错误页面。

默认头部字段及 Node 添加的内容

Node 会自动设置几个头部字段,了解这些内容有助于避免混淆。

Host — 由 URL 衍生而来,不可设置。 Connection — 由连接池管理。 Content-Length — 根据请求主体计算得出。 Accept — 默认值为 */*,除非您自行设置。 Accept-Encoding — Node 声明支持压缩,并会透明地解压响应。 User-Agent — Node 默认发送其自身的用户代理,通常标识为 undici。

最后一点对于与第三方通信的情况尤为重要。默认的运行时用户代理虽然是准确的标识,但对于自动化客户端而言却是不理想的——一个带有联系 URL 的真实名称会比匿名的运行时字符串受到更好的对待:

headers: { "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)" }

关于请求正文的说明:当您传递一个 FormData 对象时,请勿自行设置 Content-Type。 该字段必须由运行时自动生成,因为它包含多部分边界,而覆盖该字段会导致服务器无法解析请求。这是导致无法解释的 400 或 415 错误的最常见原因之一。

为每个请求设置头部

对于脚本以外的任何内容,请集中处理。

const DEFAULTS = {
  "Accept": "application/json",
  "User-Agent": "AcmeBot/1.0 (+https://acme.example.com/bot)",
};

async function api(path, options = {}) {
  const res = await fetch(`https://api.example.com${path}`, {
    ...options,
    headers: { ...DEFAULTS, ...options.headers },
  });
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`HTTP ${res.status} ${path}: ${body.slice(0, 200)}`);
  }
  return res;
}

其中有两个细节尤为重要。先展开默认值意味着调用方可以覆盖其中的任何一项,这正是你所期望的行为。而包含错误正文的前 200 个字符,则将一个晦涩难懂的状态码转化为可供处理的消息。

请注意,对象展开是浅展开的,且仅匹配精确的键字符串,因此调用方选项中的 "content-type" 不会覆盖默认值中的 "Content-Type" —— 系统会同时发送这两个值,由运行时选择其中一个。如果调用方可能使用任意大小写,请改用 Headers 对象,并让其不区分大小写的 set() 方法正确处理合并操作。

调试无法正常工作的报头

一套能在几分钟内解决几乎所有报头问题的步骤,按排除可能性最多的顺序排列。

**第一步——查看实际传输的数据。**在完成这一步之前,本列表中的其他内容都无关紧要。使用报头回显服务是最快捷的方式:

const res = await fetch("https://httpbin.org/headers", { headers: myHeaders });
console.log(JSON.stringify(await res.json(), null, 2));

如果你的标头在此处缺失,说明它从未离开过你的进程——可能是被禁止、拼写错误或被覆盖。如果它在此处存在,而目标端却显示缺失,说明在你与目标端之间有某个环节将其剥离了。

第二步——在发送前构建 Headers 对象并检查它。 这可以区分“我构建错误”和“运行时丢弃了它”:

const h = new Headers(myHeaders);
console.log([...h.entries()]);

构建 Headers 对象时会应用与运行时相同的规范化处理,因此在此处通过验证的名称就是会被发送的名称。

第三步——检查是否存在意外重复。 浅合并陷阱:对象展开会按精确字符串匹配键,因此 {...{"Content-Type": "a"}, ...{"content-type": "b"}} 会生成两个条目。如果调用方可能提供任意大小写形式的键,请构建 Headers 对象并使用 set(),因为其不区分大小写的匹配机制能正确完成合并。

四 — 在 curl 中复现该问题。 如果相同的请求在终端上能正常工作,但在 Node 上却不行,那么问题出在你的代码中,而不是服务器端:

curl -v -H "Authorization: Bearer $TOKEN" https://api.example.com/items 2>&1 | grep '^>'

将两个 > 代码块并排比较,通常能让差异一目了然。

五 — 阅读完整的响应,而不仅仅是状态码。 400 或 401 响应通常包含正文,其中会详细说明具体哪个头部有误,而直接忽略正文的代码等于白白丢弃了解答:

if (!res.ok) console.error(res.status, (await res.text()).slice(0, 300));

同时检查重定向。 fetch 默认会跟随重定向,而当重定向跳转到不同源时,某些标头(特别是 Authorization)会被丢弃。如果请求直接发送到最终 URL 时成功,但发送到原始 URL 时失败,那几乎可以肯定就是这个原因。

代理的陷阱

这是与其他所有 Node HTTP 客户端不同的行为,也是本节存在的理由。

**Node 的 fetch

不会读取 HTTP_PROXY

、HTTPS_PROXY

或 NO_PROXY

。** 设置这些参数不会产生任何影响。请求会直接发送,且请求成功,但没有任何迹象表明代理已被绕过。

解决方法来自 undici 的 ProxyAgent

:

import { ProxyAgent, setGlobalDispatcher } from "undici";

setGlobalDispatcher(new ProxyAgent("http://user:pass@proxy.example.com:9000"));

// now every fetch in this process goes through the proxy
const res = await fetch("https://api.example.com/items");

若仅需处理单次请求而非整个进程,请为每次调用传递一个分发器:

const agent = new ProxyAgent("http://proxy.example.com:9000");
const res = await fetch(url, { dispatcher: agent });

请注意,dispatcher

是 Node 特有的扩展,而非标准 Fetch API 的一部分,因此使用它的代码无法移植到浏览器。

务必验证其是否生效。 向服务查询其看到的地址,分别在启用和禁用分发器的情况下进行测试:

const res = await fetch("https://api.ipify.org?format=json");
console.log(await res.json());

如果地址未发生变化,则说明代理未被纳入路径——鉴于系统不会提示错误,此项检查是区分有效配置与被静默绕过配置的唯一依据。 这正是我们在 为什么测试代理很重要 中所提到的那种无声故障。

此处 HTTP 客户端之间的行为差异,正是我们在 axios 与 fetch 的对比 中所比较的那类情况。

大家还问

如何使用 node-fetch 设置请求头?

在选项中传入一个 headers 对象:fetch(url, { headers: { "Authorization": "Bearer ..." } })。您也可以传入一个 Headers 实例或一个名称-值对数组。这种语法同样适用于 Node 的内置 fetch 方法。

我还需要 node-fetch 包吗?

通常不需要。Node 自 v17.5.0 起便提供了全局变量 fetch,自 v18 起不再需要显式启用,并自 v21 起成为稳定功能。仅当运行环境较旧或存在特定的行为差异时才需安装该包——请注意,第 3 版仅支持 ESM。

headers.set 和 headers.append 有什么区别?

set 会替换该标头的任何现有值;append 则会添加另一个值,因此两次 append 操作会生成以逗号分隔的列表。绝大多数情况下请使用 set —— 仅当某个标头允许多个有效值时,才需要使用 append。

为什么我的标头没有被发送?

最可能的原因是该标头属于运行时控制的“禁止”标头——包括 Host、Connection、Content-Length 以及 Sec- 系列。这些标头会被静默忽略,而非触发错误。请向标头回显服务发送请求,以查看实际传输的数据内容。

在 fetch 中,标头名称区分大小写吗?

不区分。MDN 规定,在所有 Headers 方法中,标头名称的匹配均采用不区分大小写的字节序列,因此 get("content-type") 和 get("Content-Type") 是等效的。遍历 Headers 对象时,返回的名称均为小写。

如何读取多个 Set-Cookie 头?

使用 res.headers.getSetCookie(),该方法会返回一个数组。普通的 get("set-cookie") 会用逗号将它们连接起来,但这会产生歧义,因为 cookie 值本身可能包含 Expires 日期中的逗号。

为什么 Node fetch 会忽略我的 HTTP_PROXY 设置?

因为它根本不会读取这些环境变量,这与几乎所有其他 Node HTTP 客户端不同。请使用 undici 的 ProxyAgent 并配合 setGlobalDispatcher,或者在每次请求中传递 dispatcher —— 随后请验证出口地址,因为绕过代理时不会产生错误。

发送 FormData 时是否应设置 Content-Type?

不需要。运行时会自动生成该字段(包括多部分分界符),而手动设置会移除该分界符,导致服务器无法解析请求。这是引发原因不明的 400 和 415 响应的常见原因。

总结

无论使用哪种 API,在 Node 中设置标头都只需一行代码,而且内置的 fetch 意味着大多数项目根本不再需要为此安装任何包。

有三种行为导致了几乎所有的困惑:set 会替换现有标头,而 append 会累加标头,如果将这两者弄反,就会产生以逗号分隔的标头值,而服务器会拒绝这些值。 被禁止的标头会被静默丢弃而非抛出错误,因此服务器未接收到的标头需要通过网络传输进行验证,而非在编辑器中重新读取。此外,fetch会在HTTP错误发生时解析,因此必须先检查res.ok,正文内容才具有实际意义。

Node特有的陷阱在于代理配置,由于其失败时毫无提示,因此值得再次强调:内置的fetch会完全忽略HTTP_PROXY。若需代理流量,请显式设置分发器——并确认出口地址,因为一个毫无作用的配置看起来与正常工作的配置完全相同。