知识

如何在 Node.js 中用 Axios 和 Got 使用住宅代理

Node.js 里的代理:为什么 Axios 需要 https-proxy-agent 和 proxy:false、Got 的 agent 选项、undici 给 fetch 用的 ProxyAgent,以及按请求的地理轮换。

Chris Collins

Chris Collins

2026年7月29日 · 4 分钟阅读

Node.js 是海量抓取与自动化工作的默认运行时:一个对成千上万并发请求耸耸肩的事件循环、一个庞大的包生态,以及从前端到后端同一门语言。把一个住宅代理接进去只是几行,但那些细节会以一种 Node 特有的方式把人绊倒——因为最流行的 HTTP 客户端 Axios,有一个在 HTTPS 上并不做你所期望之事的代理选项。把 agent 搞对,剩下的就都容易了。

这是与用 Python 使用住宅代理用 Playwright用 Go同一系列的 Node.js 篇:对 Axios、Got 和原生 fetch 都能跑的代码,加上 Node 生态特有的那些陷阱。

下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。

一段话讲清 gateway 模型

代理用户名同时承载你的认证你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。在 Node 里,这一整条字符串进入你交给一个 proxy agent 的代理 URL。

Axios:用一个 agent,而不是内建的 proxy 选项

这是本文最重要的一件事。Axios 有一个 proxy 选项,而对带认证的 HTTPS 目标它并不可靠——它不会开一条正经的 CONNECT 隧道,会默默失败、或泄露你的真实 IP。生态最终落定的解法是:给 Axios 一个用 https-proxy-agent 构建的 httpsAgent,并设 proxy: false,好让 Axios 不去自己处理代理。

import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
const user = `${process.env.SHIFTER_USER}-country-us`;
const pass = process.env.SHIFTER_PASS;
const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const agent = new HttpsProxyAgent(proxyUrl);
const client = axios.create({
httpsAgent: agent,
proxy: false, // 关键:让 agent 来处理,而不是 Axios
timeout: 30000,
});
const res = await client.get('https://api.ipify.org');
console.log(res.data); // 一个美国住宅 IP

有两件事要内化。user 字符串里包含了定位标志(-country-us),因为地理就住在那里。而 proxy: false 不是可选项——没有它,Axios 自己的代理逻辑会和 agent 相撞,你就会得到你本想避免的那个坏行为。这一行,正是最常见的 Node 代理 bug。

Got:agent 放进 agent.https

Got 通过它按协议作键的 agent 选项接收 proxy agent。底层是同一个 https-proxy-agent,没有 proxy: false 那套舞步,因为 Got 没有内建的代理处理要去对抗。

import got from 'got';
import { HttpsProxyAgent } from 'https-proxy-agent';
const proxyUrl = `http://${user}:${pass}@p.shifter.io:443`;
const res = await got('https://api.ipify.org', {
agent: { https: new HttpsProxyAgent(proxyUrl) },
timeout: { request: 30000 },
});
console.log(res.body); // 一个美国住宅 IP

一个打包上的说明:Got 自 v12 起是纯 ESM,所以 import got from 'got' 需要一个 ESM 项目(package.json"type": "module")或一个动态 import()。如果你被困在 CommonJS 上,要么留在 Got v11、要么改用 Axios/undici。这个 ESM 对 CommonJS 的分裂是一个 Node 特有的绊脚石,不是代理问题,但它会咬第一次搭建的人。

原生 fetch:把 undici 的 ProxyAgent 当 dispatcher

Node 18+ 自带一个由 undici 支撑的全局 fetch,而 undici 有它自己的代理支持,根本不用 https-proxy-agent。你把一个 ProxyAgent 当作请求的 dispatcher 传进去:

import { ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(`http://${user}:${pass}@p.shifter.io:443`);
const res = await fetch('https://api.ipify.org', { dispatcher });
console.log(await res.text()); // 一个美国住宅 IP

要把进程里的每个 fetch 都路由经代理,就改成全局设置:

import { setGlobalDispatcher, ProxyAgent } from 'undici';
setGlobalDispatcher(new ProxyAgent(proxyUrl));

如果你在现代 Node 上、又想要零 HTTP 客户端依赖,这是最干净的路。

坑 1:复用 agent,别每个请求造一个

无论你选哪个客户端,proxy agent 都持有连接池。每个请求都构建一个新鲜的 HttpsProxyAgentProxyAgent,会把 keep-alive 扔掉,并每次都经代理付一次完整的 TCP + TLS 握手——正是延迟指南存在的意义所要消除的那部分开销。为一个给定身份把 agent 构建一次、并跨请求复用它。在启动时创建 Axios/Got 客户端(或 undici dispatcher)并把它留住。

坑 2:给你的并发封顶,事件循环不会替你做

Node 的事件循环让一口气打出一千个请求变得轻而易举,而没有什么拦着你。对一个大数组 await Promise.all(urls.map(fetchOne)) 会同时打开每一条连接,这会耗尽你这侧的套接字,并在目标看来像一次攻击。用一个小并发限制器(p-limit 是常见选择)或一个简单队列给在途请求封顶:

import pLimit from 'p-limit';
const limit = pLimit(8); // 最多 8 个请求在途
const results = await Promise.all(
urls.map(url => limit(() => client.get(url)))
);

按目标主机限制并发,而不只是全局限制,好让脆弱的站点不被猛击、宽松的站点也不被饿死。越过一个目标的容忍度之后,更多并行买来的是封锁,而不是吞吐量(如何避免被封)。把限制调到每个主机所能容忍的量。

坑 3:异步错误需要显式处理

一次代理或连接失败会表现为一个被拒绝的 promise,而一个未处理的拒绝可能让进程崩溃,或者更糟——默默丢掉一个批次里的任务。把每个请求包起来,好让一次传输失败用一个新鲜身份重试、而不是把整轮拖垮:

async function fetchWithRetry(client, url, attempts = 3) {
for (let i = 0; i < attempts; i++) {
try {
return await client.get(url);
} catch (err) {
if (i === attempts - 1) throw err;
// 瞬时(ECONNRESET、超时、代理 5xx):退避并重试
await new Promise(r => setTimeout(r, 500 * 2 ** i));
}
}
}

把一个值得重试的破碎连接、和一次刻意的放慢区分开;一个超时是一次破碎尝试,而一个 429 是服务器在请求喘息空间、应当退避、而不是猛击。

轮换地理与会话

因为定位住在用户名里,不同的身份就是不同的代理 URL,也就意味着不同的 agent。高效的模式是按身份缓存一个 agent,好让你按会话保住连接池、而不是重建它:

const agents = new Map();
function agentFor(country, sid) {
const key = `${country}:${sid ?? 'rotate'}`;
if (!agents.has(key)) {
const u = `${process.env.SHIFTER_USER}-country-${country}` +
(sid ? `-sid-${sid}-ttl-600` : '');
const url = `http://${u}:${process.env.SHIFTER_PASS}@p.shifter.io:443`;
agents.set(key, new HttpsProxyAgent(url));
}
return agents.get(key);
}
// 按请求:
await axios.get(targetUrl, { httpsAgent: agentFor('de', 'job-42'), proxy: false });

给每个逻辑工作单元它自己的 sid,并在单元之间轮换、而不是在流程中途轮换(粘性 vs 轮换讲了这个区分),并按负载均衡那篇所述把工作映射到身份。

验证你确实在走代理

在给别的任何东西做基准或调试之前,先确认出口 IP:

const res = await client.get('http://ip-api.com/json');
console.log(res.data); // 期望是目标国家的一个住宅 IP

返回你自己的 IP,说明 agent 没被应用(对 Axios,几乎总是漏了 proxy: false)。挂住不动,说明本地出站被挡了。这两种情况都在超时诊断指南里有讲。

常见问题

为什么 Axios 的 proxy 选项对我的 HTTPS 代理不管用? Axios 内建的 proxy 选项不能可靠地对带认证的 HTTPS 打隧道。用一个由 https-proxy-agent 构建的 httpsAgent,并设 proxy: false、好让 Axios 别再试图去处理它。这个组合是可靠的路,能修好”它返回我的真实 IP”这个症状。

如果我用原生 fetch,还需要 https-proxy-agent 吗? 不需要。Node 18+ 的 fetch 由 undici 支撑,它有自己的 ProxyAgent,你把它当 dispatcher 传进去(或用 setGlobalDispatcher 全局设置)。https-proxy-agent 是给 Axios、Got 和内建的 http/https 模块用的。

为什么 import got from 'got' 在我的项目里抛错? Got 自 v12 起是纯 ESM,所以它需要一个 ESM 项目("type": "module")或一个动态 import()。在 CommonJS 上,留在 Got v11 或改用 Axios/undici。这是一个模块系统的问题,不是代理问题。

在 Node.js 里我怎么按请求轮换 IP? 变换代理用户名,这意味着不同的代理 URL 和不同的 agent。在一个 Map 里按身份缓存一个 agent,好让每个会话保住它自己的连接池,并按请求挑选 agent。在用户名里去掉 sid,就能在每条新连接上轮换。

抓取该用 Axios、Got 还是 fetch? 三者都能用。在现代 Node 上,原生 fetch + undici 有零额外依赖;Got 有符合人体工程学的重试和流;Axios 无处不在又熟悉,但需要 proxy: false 那个修正。按易用性和你已有的依赖来选——上面的代理配置和那些坑对三者都适用。

底线

一旦你知道那条唯一不显然的规则,Node.js 加住宅代理就能很快搭起来:对 Axios,用一个 https-proxy-agent 并设 proxy: false,绝不用内建的 proxy 选项;对 Got,把 agent 放进 agent.https;对原生 fetch,把一个 undici ProxyAgent 当 dispatcher 传进去。然后复用 agent 让连接保持温热,给你的并发封顶(因为事件循环不会替你做),处理异步错误好让一个坏请求去重试而不是崩溃,并用变换代理用户名来切换地理或会话。

把这些做对,Node 处理并发采集的能力不输任何东西。把它指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始