1. 请求背后的四个组成部分:从URL到请求体的一次完整拆解
先问一个问题:你平时写axios.get('/api/user')的时候,有没有想过浏览器究竟往服务器发了些什么?我面试过不少前端候选人,能说出“GET/POST”的不少,但能把请求结构说完整的,十个里面两三个。这个看似基础的问题,其实决定了你在排查网络问题、设计接口规范、做前端性能优化时能不能快速定位问题。
HTTP请求整体上由四部分组成:请求行(Request Line)、请求头(Headers)、空行、请求体(Body)。其中GET请求通常没有请求体,但前三部分无论如何都存在。我把它们拆开讲,每一部分对应到浏览器开发者工具里你能看到的东西,方便你对照着理解。
1.1 请求行:方法、路径和HTTP版本三件套
请求行是请求的第一行,格式固定:METHOD 路径 HTTP版本。实际抓包看到的模样是这样的:
GET /api/users?page=1&size=20 HTTP/1.1 POST /api/user/login HTTP/1.1第一个要素是请求方法(Method)。GET、POST、PUT、DELETE、PATCH、OPTIONS、HEAD这些都是前端常用方法。关于方法选型,我的建议很简单:读操作用GET,写操作用POST,全量更新用PUT,部分更新用PATCH。但现实项目里很多团队只用了GET和POST,这没问题,但要记住一个核心原则——GET请求参数会出现在URL和浏览器历史记录里,永远不要用它传敏感信息。
第二个要素是请求路径。这里有同学会混淆URL和URI的概念。用一句话区分:URI是资源标识,URL是资源定位。/api/users/123是URI,https://api.example.com/api/users/123是URL。前端代码里的axios.get('/api/users/123')传入的是URI,浏览器会基于当前页面域名自动拼接成完整URL。
第三个要素是HTTP版本。现在主流的HTTP/1.1和HTTP/2之间有个关键差异——HTTP/1.1是纯文本协议,HTTP/2是二进制分帧协议;HTTP/1.1有队头阻塞问题,HTTP/2通过多路复用解决了。你打开Chrome的Network面板,能看到协议版本列,大多数HTTPS站点现在都已经是HTTP/2了。
1.2 请求头:携带元数据的“快递面单”
请求头是HTTP请求里信息密度最高的部分。它像快递面单——上面写满了发件人、收件人、包裹类型、重量、配送要求这些元信息,包裹本身(请求体)还没出场。
前端日常打交道最多的几个头字段,我列个表说明:
| 请求头字段 | 作用 | 典型示例 |
|---|---|---|
Content-Type | 声明请求体的媒体类型 | application/json、application/x-www-form-urlencoded、multipart/form-data |
Authorization | 身份认证凭证 | Bearer eyJhbGciOi... |
Accept | 告知服务器客户端期望的响应类型 | application/json |
Origin | 标识请求来源页面(跨域时必带) | https://example.com |
Referer | 请求来源地址 | https://example.com/login |
Cookie | 携带会话信息 | sessionId=abc123 |
User-Agent | 客户端标识 | Mozilla/5.0 (Windows NT 10.0; Win64; x64) |
Cache-Control | 缓存控制指令 | no-cache、max-age=3600 |
我想展开说两个最容易被忽略的。
第一个是Content-Type。前端传参时,这里最容易出bug。你用原生XHR发POST,如果直接send一个对象,后端大概率收到的是[object Object]这个字符串。正确做法是根据Content-Type拼接对应的数据格式:JSON格式就JSON.stringify(),表单格式就用URLSearchParams或FormData。axios帮我们做了这层转换,但你在抓包时还是要会判断body和Header是否匹配。
第二个是Origin和Referer。跨域请求时,浏览器会自动带上Origin头,后端通过校验这个字段决定是否允许访问。注意它是不可伪造的(浏览器管理),所以服务端做跨域校验时优先信任Origin而不是Referer,因为Referer在隐私模式下可能被省略,也可能被自定义修改。
1.3 空行和请求体:那个容易被忽略的分隔符
空行在HTTP协议里起的是分隔作用——告诉服务器“请求头到此结束,后面是请求体”。实际开发里你不需要手动构造它,浏览器的网络栈会处理好。但是理解这个分隔对调试有帮助:有时候抓包工具显示请求异常,你会看到body解析不完整,实际上问题出在服务端对body的读取没有正确处理。
请求体(Body)是真正交给服务器的数据。三种常见格式对应三种不同的Content-Type:
application/json:{"name":"张三","age":18},适合嵌套结构复杂的对象,是前后端分离项目用最多的格式。application/x-www-form-urlencoded:name=张三&age=18,表单的默认格式,浏览器原生支持,后端解析最简单。multipart/form-data:用于文件上传,每个字段都有独立的boundary分隔线,支持二进制数据。
实际项目中我见过不少同学在这三个格式上栽跟头。典型场景是后端接口明明要的是x-www-form-urlencoded,前端用axios传了JSON对象,导致后端取不到参数。排查的第一步一定是打开Network面板看Content-Type和Payload,十有八九能找到问题。
1.4 URL中的query:请求参数的天然载体
URL从?开始到#之前的部分是query(查询参数)。query在GET请求里是主要传参方式,在POST请求里也时有出现。拼装query有几个容易踩坑的点。
字符串拼接方式不可靠。最典型的坑是参数值里含有特殊字符——比如用户搜索的关键词是a&b=c,直接拼进URL会破坏参数结构。正确做法是用URLSearchParams或encodeURIComponent处理:
// 不推荐 const url = `/api/search?keyword=${keyword}`; // 推荐 const params = new URLSearchParams({ keyword }); const url = `/api/search?${params.toString()}`;另外要区分query和URL编码的关系。URLSearchParams会把中文、空格、&、=等特殊字符自动转义,后端拿到的时候会自动解码。如果后端拿到的中文是乱码,大概率是你手动拼接时用了错误的编码方式,或者后端没有配置正确的字符集。
还有一个细节:query参数有长度限制吗?HTTP规范并没有限制query的长度,但实际情况中,浏览器、服务器、网关都可能有限制(比如Nginx默认是8KB,很多浏览器对超长URL会拦截或截断)。所以大量数据不要往URL里塞,这也是POST存在的重要原因之一。
2. 从地址栏到Ajax:前端发起HTTP请求的四种典型方式及选型思路
理解了请求的组成结构之后,下一个问题是:前端代码里具体怎么把HTTP请求发出去?这个问题的答案很丰富,不同场景下选择不同。我分四类来说,每一类都说明适用的场景和存在的限制。
2.1 原生XMLHttpRequest:老而弥坚的基础形态
XMLHttpRequest(XHR)是浏览器第一个标准化的HTTP请求API,从IE时代就存在,现在依然在使用。它的核心用法:
const xhr = new XMLHttpRequest(); xhr.open('POST', '/api/user/login', true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.timeout = 10000; xhr.onreadystatechange = function() { if (xhr.readyState === 4) { if (xhr.status >= 200 && xhr.status < 300) { console.log(JSON.parse(xhr.responseText)); } } }; xhr.send(JSON.stringify({ username: 'admin', password: '123456' }));这里有三个细节值得展开。
第一,open()的第三个参数async默认是true,代表异步请求。如果设成false,请求会阻塞主线程,页面冻结直到响应返回。我在老代码里见过这种写法,体验极差,现在强烈不建议使用同步XHR。
第二,onreadystatechange里的readyState枚举值有5个(0未初始化、1已打开、2已获取响应头、3下载中、4完成)。大多数时候你只需要判readyState === 4,因为此时响应已经完全接收。但调试大文件下载时,可以监听到3状态来分析下载进度。
第三,XHR的setRequestHeader必须在open()之后、send()之前调用,否则会抛异常。这是个尴尬的BOM顺序问题,用封装库之后就不会遇到,但还是要知道。
对于实际开发,我不推荐直接用XHR写业务代码,因为它的API设计偏底层,还需要处理各种边界情况。但读一些老旧库源码时,你很可能遇到它。理解它可以帮你快速看懂那些维护了五六年以上的老项目。
2.2 Fetch API:现代浏览器的原生方案
Fetch是ES6时代推出的原生HTTP请求API,原生支持Promise。对比XHR,Fetch的语法简洁很多,而且请求和响应都基于Stream,可扩展性更强。
async function fetchWithTimeout(url, options = {}, timeout = 10000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return await response.json(); } finally { clearTimeout(timer); } }但Fetch有几个实际项目中容易忽略的坑。
第一,Fetch默认credentials是same-origin,跨域请求不会携带Cookie。如果你的前后端是不同域名且靠Cookie维持登录态,必须显式设置credentials: 'include'。我知道很多SSO相关的bug都出在这个配置上。
第二,Fetch不会自动在HTTP状态码非2xx时reject Promise,而是正常resolve,只是response.ok为false。这意味着你必须手动检查状态码再决定是否抛出异常,否则很容易出现“请求失败了但代码看起来正常”的诡异现象。
第三,Fetch的响应体只能用一次。response.json()、response.text()、response.blob()是互斥的,调用了一个就不能再调另一个。如果需要同时看文本和解析JSON,必须先拿text()再自行解析。这个限制在实际调试中经常坑人。
第四,和XHR不同,Fetch没有内置超时机制。上面代码里我用AbortController实现了手动超时,这是官方推荐的方案,在业务代码里必须要有,否则网络异常时请求会一直挂着。
那到底选XHR还是Fetch?我的实践结论是:新项目优先Fetch,老项目继续用XHR封装库(比如axios底层还是XHR)。Fetch的Promise风格更适合现代代码,加上AbortController可以提供更细颗粒度的取消控制。
2.3 axios:工程化项目的事实标准
axios是前端最流行的HTTP请求库,底层是XHR(浏览器端)或Node.js的http模块,但封装了层非常友好的API。它之所以成为工程标配,核心原因是几个特性。
第一,请求/响应拦截器。这在统一处理Token、错误提示、日志上报时极其方便:
import axios from 'axios'; const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, withCredentials: true, }); instance.interceptors.request.use((config) => { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); instance.interceptors.response.use( (response) => response.data, (error) => { if (error.response?.status === 401) { // 跳转登录页 } return Promise.reject(error); } ); export default instance;这是每个中大型前端项目里几乎一模一样的封装套路,区别只是在细节上。我曾经在一个项目里见过没有拦截器封装的代码——每个接口调用都手动取token、手动处理错误,三四十个API文件里相同的try/catch写了无数遍。后来重构时把所有逻辑集中到拦截器,删掉了大约40%的重复代码。
第二,自动转换JSON。axios检测到请求体的Content-Type是application/json时会自动调用JSON.stringify,检测到响应类型是JSON时自动JSON.parse。XHR时代你手动处理这些转换的细节,在axios里完全不用操心。
第三,取消请求能力。基于XHR的abort加上AbortSignal,axios提供了CancelToken(旧版)和signal(新版)两种取消方式。页面跳转时取消未完成的请求,是避免“在组件卸载后setState”警告的有效手段。
不过axios也不是完美的。包体积约30KB(gzip后),对体量敏感的项目来说这是个负担。如果你的项目只需要简单的GET/POST,完全可以考虑用Fetch封装轻量方案,不必引入axios。
2.4 浏览器原生导航请求:表单提交与页面跳转的HTTP视角
除了Ajax,浏览器还有一种发起HTTP请求的方式——用户直接导航或表单提交。这种方式的特点是:页面会整体刷新,请求结果直接替换当前页面。
表单提交其实可以手动构造请求。一个不为人知的小技巧是,form的action和method决定请求目标和方式,而表单字段会按x-www-form-urlencoded格式放入请求体。这对于实现某些无需JavaScript的登录场景是有用的,但多数情况下我们不会走这条路。
另有一种特殊请求是图片、脚本、样式等静态资源的HTTP请求。浏览器加载<img>、<script>、<link>时会自动发起GET请求,这些请求由浏览器管理,开发者无法在代码层面用Fetch模拟。理解这一点有助于你排查“为什么我的资源被多端缓存了”“为什么网站加载速度慢”这类问题。Performance面板里能看到这些请求的耗时分布,而抓包工具里能看到它们的请求头与响应头。
3. 实际项目中HTTP请求组成的落地:拦截器、统一封装与参数处理的成熟实践
这个章节聊一聊真实项目里,请求相关代码的组织方式。既然标题叫“前端的http请求组成”,那在工程里落地时就要考虑:如何管理URL?参数从哪里来?统一错误处理怎么做?我把这些经验拆成几条讲。
3.1 API层集中管理与模块化拆分
很多前端项目把所有的接口调用直接扔在组件里。小项目还行,一旦上了几十个接口,页面组件里到处是axios.get(...)的代码,维护成本直线上升。
我的推荐做法是单独建一个api目录,按业务域拆分模块。比如一个商城系统,可以分成auth.js、product.js、order.js、user.js。每个文件里导出该域下所有接口的调用函数:
// api/product.js import request from '@/utils/request'; export function getProductList(params) { return request({ url: '/api/products', method: 'get', params, }); } export function getProductDetail(id) { return request({ url: `/api/products/${id}`, method: 'get', }); }这样做的好处有两个:一是调用方不需要感知HTTP细节,组件只关心业务函数;二是接口路径集中管理,后端一旦改了URL前缀或路径,只需改一处。我在重构这种代码时总结的经验是——把接口路径集中在API层,改路径、改参数格式都在一个文件里完成,粒度小且影响面可控。
3.2 请求参数的三种来源与组装顺序
一个复杂的页面请求,参数往往来自三个地方:路由参数(比如从/order/123页面获取id)、组件状态(比如搜索框内容)、全局状态(比如用户信息)。组装参数时遵循的顺序是:先设置默认值,再合并组件状态,最后用路由参数覆盖。
const params = { page: 1, size: 20, ...searchForm, ...routeParams, };这段代码的逻辑很简单,但实际项目中为了达成这个效果,我见过很多种拼装方式。有些项目混用Object.assign、展开运算符、lodash.merge,风格不一致,容易出优先级问题。我建议全项目统一用展开运算符收口到API层去组装,不要把拼参逻辑散落在各组件里。
对DELETE和PUT这类方法,参数放query还是body容易出现分歧。我的约定是:删除类操作如果只传ID,放query;需要传复杂条件时放body。前后端约定好之后写进接口文档,避免联调时反复扯皮。
3.3 拦截器统一处理:登录态过期、Token刷新与错误提示
拦截器是工程化项目的必用功能,我来详细展开三个常见场景。
Token过期自动刷新。这是我处理过很多次的问题。流程是:请求携带过期Token → 后端返回401 → 前端拦截器收到401 → 调用刷新Token接口(用refreshToken)→ 获取新Token → 重放原请求。这里涉及对pending请求的缓存,我通常用一个数组保存失败的请求以及它们的resolve/reject,刷新完成后统一重放:
let isRefreshing = false; let pendingQueue = []; instance.interceptors.response.use( (response) => response.data, async (error) => { const { response, config } = error; if (response?.status === 401 && !config._retry) { if (isRefreshing) { return new Promise((resolve, reject) => { pendingQueue.push({ resolve, reject, config }); }); } config._retry = true; isRefreshing = true; try { const { token } = await refreshTokenApi(); localStorage.setItem('accessToken', token); pendingQueue.forEach(({ resolve, reject, config }) => { instance(config).then(resolve).catch(reject); }); pendingQueue = []; return instance(config); } catch (refreshError) { // 刷新失败,跳转登录页 window.location.href = '/login'; } finally { isRefreshing = false; } } return Promise.reject(error); } );统一错误提示。我的经验是:不要在拦截器里对所有错误都弹提示框,需要分业务类型。网络断连(error.message是Network Error或者状态码0)统一提示“网络异常”并做断网重连逻辑;超时(ECONNABORTED)提示“请求超时,请重试”;4xx/5xx按错误码表做映射。接口返回的业务错误(HTTP状态码200但业务code非0)不要放拦截器处理,丢给具体页面决定要不要提示。
3.4 超时与重试策略:从前端角度控制请求的可靠性与体验
超时和重试是请求组成里的增强功能。axios默认超时是0(不超时),在实际项目中一定要设置一个合理的阈值。怎么定?我的经验是参考后端99%响应耗时。如果后端P99是2秒,那超时时间设6~10秒是合理的;如果后端P99是5秒,那前端超时至少15秒,否则会出现大量“用户还没等到响应就被我们掐断”的情况。
const instance = axios.create({ timeout: 15000, });重试策略要区分场景。对于查询类GET请求,网络抖动时重试一次是有价值的;对于创建类POST请求,重试可能导致重复下单,这时要依赖后端幂等键。前端重试的常规实现是给请求函数加一层循环:
async function requestWithRetry(config, retry = 2) { try { return await instance(config); } catch (error) { if (retry > 0 && isRetryableError(error)) { await sleep(500); return requestWithRetry(config, retry - 1); } throw error; } } function isRetryableError(error) { const status = error.response?.status; return !status || status >= 500 || error.code === 'ECONNABORTED'; }这里我加了个isRetryableError判断:状态码500以上或请求超时值得重试,而4xx参数错误重试没有意义。
4. 请求头与请求体的核对排查:从Network面板到Bug定位的完整链路
HTTP请求组成的知识,最实际的用途是在Bug定位时快速判断问题出在哪一层。我总结了一套从“抓包”到“定责”的排查思路。
4.1 Network面板里应该看哪些字段
当联调发现接口不通时,打开Chrome开发者工具的Network面板,按下面顺序查。
第一,Headers标签里确认请求方法、请求URL、Status Code。状态码直接决定责任方——4xx是前端传参有问题,5xx是后端异常,3xx是重定向配置问题。
第二,确认Request Headers里的Content-Type和后端接收格式一致。如果后端用@RequestBody接收对象而前端发的是表单格式,后端会直接报错或取到空对象。
第三,看Payload或Request Body,与接口文档核对参数名、参数类型、嵌套结构。这里最常见的问题是:前端传了userId但后端要的是id;前端把数字123传成了字符串"123";后端要数组但前端传了逗号分隔的字符串。
第四,看Response里的内容,区分是HTTP层错误还是业务层错误。HTTP 200但code: 500的意思是后端处理逻辑报错了;HTTP 400才是真正的请求本身问题。
4.2 拦截器、Proxy和Service Worker三层影响请求的隐形因素
有些时候Network面板显示的请求和代码里写的不同,这通常是三层因素造成的。
第一层是拦截器。项目中全局配置的拦截器可能会修改请求头、URL或请求体。排查时先关掉自定义拦截器逻辑,看原始请求是否正常。
第二层是开发环境代理(Proxy)。Vite/webpack的proxy配置会让请求路径在本地被改写,比如把/api代理到http://backend:8080。调试时要注意Network面板里显示的请求域名是后端实际地址,这就是判定代理生效的依据。配了跨域代理又发现400错误,建议直接看代理日志后端接到的URL是否和预期一致。
第三层是Service Worker,这是最隐蔽的。如果网站注册了Service Worker,所有请求都可能被拦截并由缓存返回,Network面板里会显示from ServiceWorker。这种环境下,请求根本没有到达服务器,而后端日志里也看不到该请求。排查思路是:先确认站点是否注册了Service Worker(Application面板 → Service Workers),必要时可以在开发环境绕过它。
4.3 常见联调问题的分类定位表
根据我几年一线的联调经验,整理一份“问题 → 可能原因 → 排查方向”的表单:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 请求404 | 路径拼错、代理未生效、路由前缀缺失 | 比对URL与后端路由,检查代理配置 |
| 请求405 | 方法不对(用GET提交了POST接口) | 检查方法;如果是跨域OPTIONS,需要后端配置允许该method |
| 请求400 | 参数格式不对、必填字段缺失、Content-Type与Body不匹配 | 核对Payload和后端接收方式 |
| 请求401 | Token缺失、过期、无效 | 检查Authorization头,刷新Token |
| 请求403 | 权限不足、IP白名单、CSRF校验失败 | 检查角色权限;确认是否漏带CSRF token |
| 请求500 | 后端代码异常 | 查后端日志,比对入参是否合法 |
| 请求超时 | 后端耗时过长、网络异常、前端timeout过短 | 用curl绕过前端确认后端响应时间 |
| CORS报错 | 后端未配置跨域、预检请求失败 | 检查Access-Control-Allow-Origin/Headers/Methods配置 |
这套表格我每次面试前端也会聊到,核心思想是:HTTP请求问题,永远先看“实际发的是什么”,再推测“应该是怎么处理”,最后对照“后端收到的是什么”。三方对上了,问题就浮出水面了。
5. 从HTTP规范到前端边界:同源策略、跨域与预检请求的前端视角
请求组成离不开“谁允许我发”的规则。这部分是浏览器安全模型的核心,也是前端面试必问题目。我从请求组成的角度来讲,重点是理解实际请求背后浏览器做了什么额外的动作。
5.1 同源策略限制的到底是什么
同源策略规定:协议、域名、端口三者都相同,才属于同源。前端代码发起请求时,浏览器默认只允许请求同源资源。
这不是HTTP协议本身的限制——HTTP协议并不关心你从哪来,它可以正常传输请求。这是浏览器基于安全考虑加的客户端限制。前端发请求到不同源时,请求实际到达了服务器,服务器也正常处理了,只是浏览器在拿到响应后被拦截了,然后报一个CORS错误。
所以很多年前端都在问“为什么后端明明返回了数据,我在浏览器里看不到”。这个问题的本质就是浏览器拦截了跨域响应。因此排查跨域问题时,应对方案要么由后端加响应头声明“我允许这个来源访问”,要么由前端启动代理绕开浏览器的同源检查,要么在开发阶段用浏览器插件临时关闭同源策略,后者只适合本地调试。
5.2 简单请求与预检请求:OPTIONS请求为什么存在
跨域请求分两种:简单请求和预检请求。简单请求是指满足以下条件之一的GET、HEAD、POST:Content-Type只能是application/x-www-form-urlencoded、multipart/form-data或text/plain,且不带自定义请求头。这类请求浏览器直接发出,不经过预检。
其余请求(比如Content-Type为application/json、携带自定义Header、使用PUT等方法)都会触发预检——浏览器先发一个OPTIONS请求,询问服务器“我接下来的请求你是否允许”,服务器响应允许后,浏览器才发真正的请求。
很多后端同事不理解为什么前端老是发OPTIONS请求。这个问题的根源在于预检机制。后端需要在OPTIONS请求上返回正确跨域响应头,即ACAM等字段。前端排查CORS问题时,如果Network里只有OPTIONS没有后续请求,多半是预检没通过——后端没有对OPTIONS返回正确的响应头,或返回了非2xx状态码。
注意:预检请求本身不带业务数据,它只是“探路”。也就是说在跨域POST场景下你会发现,后端打印的访问日志里多了一堆OPTIONS请求,但实际业务请求只有一条。这是正常现象,不是入侵。
5.3 实际项目中的跨域方案选择
开发环境和生产环境的跨域处理方式不同。开发环境用vite/server的proxy代理是最省事的,前端配置:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'https://api.example.com', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, });这样浏览器里的请求是同源的(都指向localhost:5173),由Vite在服务端转发到目标域名,规避了跨域。注意changeOrigin: true的作用是让后端看到的Host头变成目标域名,否则有些后端会拒绝请求。
生产环境一般靠后端配CORS响应头解决,前端不需要也不能在代码层面绕过。设计后端CORS头时,Access-Control-Allow-Origin是重点,如果前端带了自定义header如Authorization,还要配合Access-Control-Allow-Headers声明允许携带的请求头。
6. 请求大小限制与性能优化:从请求组成角度谈数据瘦身
请求组成里的最大隐私是:很多前端根本不知道自己发出去的数据有多大,也没想过怎么优化。我见过一个页面打开时同时发十几个请求,每个请求还带了一整套用户信息对象——数据量完全是浪费的。这一部分来讲请求组成的“物理极限”和优化策略。
6.1 常见限制:请求头大小与请求体大小
服务器对请求头和请求体一般都有大小限制。以Nginx默认值为例:large_client_header_buffers默认是8KB(4个4KB),client_max_body_size默认是1MB。超过这个值,Nginx会直接返回413 Request Entity Too Large。
前端在请求设计时要注意几个点:
- 不要把大对象丢进query。有一个需要避免的操作是把整个筛选条件对象序列化后放进URL,筛选条件多时URL很容易超过几KB。
- 不要重复携带冗余信息。比如每一个请求都带上完整的用户信息对象,而不是只带user_id。
- 请求体超过服务器限制时,需要考虑分片上传方案,或者协商后端调整
client_max_body_size。
6.2 从HTTP请求头的角度做性能优化:减少体积、合理使用缓存
前端请求性能优化的核心思路是“少发、发小”。从这个角度出发,有几个和请求头相关的优化策略。
合并请求。几个独立的接口如果可以合并成一个聚合接口,可以减少请求头重复携带的固定开销(每个请求都有Cookie、Authorization等头,虽然单个不大,但请求多了累积就很可观)。当然这不是万能的,需要权衡并行度和聚合度的关系。
合理使用缓存头。浏览器会自动根据响应里的Cache-Control决定是否缓存。可缓存的GET请求发送一次后,第二次直接命中本地缓存,不再发出网络请求。这在Network面板显示为from disk cache或from memory cache。前端可以做的是:为动态API设置合适的no-cache策略,为静态资源设置长缓存并配合hash文件名,避免重复下载。
压缩请求体。目前普遍支持gzip/brotli压缩的是响应体,请求体压缩并不常见,需要服务器支持。项目中如果确实有非常大的POST请求,可以考虑用浏览器端的CompressionStream压缩后再传输,但这属于进阶优化,一般项目用不到。
6.3 请求放弃和取消:前端如何优雅地终止一次请求
请求发出后,有时用户已经离开了页面,或者搜索框里输入了新的关键词,此时之前的请求还在跑,它既浪费带宽,又可能在返回后污染当前页面状态。正确的做法是取消它。
axios前端取消请求有经典实现:
const CancelToken = axios.CancelToken; let cancel; axios.get('/api/search', { cancelToken: new CancelToken(c => { cancel = c; }) }); // 需要取消时: cancel('用户取消请求');新版axios建议用AbortController:
const controller = new AbortController(); axios.get('/api/search', { signal: controller.signal, }); controller.abort();在React/Vue组件里,我习惯在unmount或beforeUnmount时取消所有未完成的请求。注意:取消请求不等同于关闭网络连接,它只是让前端不再等待该请求的响应,同时释放回调。请求如果已经到达服务器,后端依然会处理——所以取消请求前端性能优化的一部分,不要把它当作后端的“真正终止”。
这里再分享一个关于请求并发控制的小技巧:如果页面需要同时请求多个接口,但网络带宽有限,可以用Promise.all并行但限制并发数。前端没有自带的并发限制API,我通常封装一个简易的异步Queue,整理成一个带缓存的应用式发布订阅调度器,限制最大并发并复用离线数据。这在数据看板这类页面(同时拉取十几个图表数据)非常有效。
7. 关于请求组成的三个不常见但必须知道的细节
写到这里,正文主体已经足够详实。我最后补充三件我在实际工作中踩过坑、在常规文档里很少被写透的细节。
7.1 Content-Type为application/x-www-form-urlencoded时,Body的正确编码方式
axios里这样传:
const params = new URLSearchParams(); params.append('username', '张三'); params.append('age', '18'); axios.post('/api/user', params); // axios检测到URLSearchParams自动设置Content-Type为x-www-form-urlencoded注意不要传普通对象,否则axios会把它序列化成JSON格式,而Content-Type却还是表单形式,后端按表单解析会取不到值。我见过有人手动设置Content-Type: application/x-www-form-urlencoded但body传的是一个JSON字符串,结果后端ASP.NET、Spring都解析失败。现在的做法是:构造好URLSearchParams再交给axios,让框架自动做正确的处理。
7.2 请求头Authorization和Cookie同时存在时的优先级
如果服务器同时支持Authorization头和Cookie,很多后端框架会先读Authorization再读Cookie,但不同框架可能有差异。前端实际做单点登录时,常见做法是优先使用一个,避免两套逻辑都维护。
我的建议是选Authorization头方案,因为它在跨域请求中靠credentials控制,显式传递更可靠;Cookie在跨域时还需要设置SameSite=None; Secure,容易踩兼容性坑。另外,Cookie方案在移动端原生webview里偶尔出现自动携带失败的问题,而Authorization头完全由前端控制,不会出现这种“带不上去”的情况。
7.3 请求异常时,如何区分“请求从未发出”和“响应异常”
Fetch异常可能是网络层、HTTP层和业务层的错误,这在控制台的报错信息里有明确的区分。网络层错误(DNS解析失败、连接拒绝、断网)表现为fetch本身reject,错误说TypeError: Failed to fetch;HTTP状态码错误需要手动检查response.ok和response.status;业务错误则是HTTP 200但code非0。定位时建议用这个顺序排除:网络层 → HTTP层 → 业务层。不是所有报错都值得前端处理,网络层交给全局兜底,业务层交给具体页面。
在我实际处理的几起线上事故中,很多看起来是“后端没返回数据”的问题,最后定位到是前端网络层请求根本没发出去——比如浏览器拦截了跨域、Service Worker吞了请求、或者请求代理解析错了URL。所以排查异常的第一步永远是:打开Network面板确认请求是否真实发出,发出后状态码是多少,Body内容是否符合预期。这个习惯养成了,你会发现自己排查Bug的速度比同事快一半以上。
HTTP请求组成这件事,听起来很基础,工作中全是用得上的。从组装的四个部分,到发起的各种方式,再到工程化的封装和排查链路,整个知识体系就像一张地图——有了地图,你才可能在各种网络问题面前不走弯路。