Ky 浅谈

Ky 是一个零依赖、底层基于现代 API Fetch 的轻量级 HTTP 请求工具,用于替代 Axios


Ky 浅谈

Ky 是一个零依赖、底层基于现代 API Fetch 的轻量级 HTTP 请求工具。

在使用 Ky 前,我曾长期使用 Axios。但最近开始接触 AI 后,发现 Axios 对现代浏览器特性的支持不太理想,因其底层基于 XMLHttpRequest,是过去时代的产物。

相对应地,Ky 底层采用的是现代异步请求 API Fetch,此 API 设计出来就是用于替换 XMLHttpRequest 的。

说明

若想流畅阅读本文所有案例,读者需具备以下知识储备:

本文在案例中发起实际请求时,会使用服务器进行验证。服务器运行于 Deno,项目结构如下:

.
├── apps
│   ├── client-error.ts
│   ├── redirect.ts
│   ├── server-error.ts
│   ├── success.ts
│   └── timeout.ts
├── deno.json
├── deno.lock
└── main.ts

deno.json 文件包含了 Deno 运行时的依赖和项目的快捷任务:

{
  "tasks": {
    "dev": "deno run -A --watch main.ts"
  },
  "imports": {
    "@std/assert": "jsr:@std/assert@1",
    "h3": "https://esm.sh/h3@2.0.0-beta.3"
  }
}

JSON 文件中描述了启动项目的脚本命令 dev,以及对 h3 库的依赖。h3 是一款轻量级的 Web 服务器,本文案例经过实际服务器测试, 背后即基于 deno 运行的 h3。

main.ts

import { H3, serve } from "h3";
import successResponse from "./apps/success.ts";
import redirectResponse from "./apps/redirect.ts";
import clientError from "./apps/client-error.ts";
import serverError from "./apps/server-error.ts";
import timeoutResponse from "./apps/timeout.ts";

const app = new H3();

app.mount("/success", successResponse);
app.mount("/redirect", redirectResponse);
app.mount("/client-error", clientError);
app.mount("/server-error", serverError);
app.mount("/timeout", timeoutResponse);
app.mount("*", successResponse);

serve(app, { port: 3000 });

其中 main 文件声明了很多子服务前缀,详情见 github/superAlibi/deno_h3

设计理念

Ky 在设计时,参数与 Fetch API 是兼容的,但又对 Fetch 的第二个参数进行了拓展。

Ky 在 原型 上拓展了 HTTP 方法 同名的方法(面向对象编程概念中的对象函数, 亦称方法),例如:ky.post(url, options)

可以说,Ky 是对 Fetch API 实用性的拓展。即使 Fetch API 已经很不错,但 Ky 用起来会更加顺手!

接下来将对 Ky 在兼容原生 Fetch API 的基础上,对“拓展的属性”进行演示。

API

  • ky(input, options?)
  • ky.get(input, options?)
  • ky.post(input, options?)
  • ky.put(input, options?)
  • ky.patch(input, options?)
  • ky.head(input, options?)
  • ky.delete(input, options?)
  • ky.extend(options)
  • ky.create(options)

常用参数

下文提到的所有的常用参数,实际上是指 Ky 方法的第二个参数,也就是 options

而第一个参数表示字符串请求地址、Request 对象或 URL 对象。 本文不再介绍

body

通常,使用 body 时表示发送 application/x-www-form-urlencodedmultipart/form-dataapplication/json 数据。

这里的 body 实际上就是 RequestInit 结构中的 body 参数,类型为 BodyInit,与 Fetch 第二个参数中的 body 是一致的。

例如发送 application/x-www-form-urlencoded

[!WARNING] 在传入 body 时,请确保 HTTP 方法支持。

import ky from "ky";

const kyInstance = ky.create({
  prefixUrl: '/api',
});

kyInstance('success/200', {
  method: 'post', // [!code warning]
  body: new URLSearchParams({
    name: 'lucardo',
    age: '20'
  })
})

// 或者
kyInstance.post('success/200', { // [!code warning]
  body: new URLSearchParams({
    name: 'lucardo',
    age: '20'
  })
})

发送包含文件的表单或 multipart/form-data 格式的 body:

const formData = new FormData();
formData.append('name', 'lucardo');
formData.append('age', '20');

kyInstance.post('success/200', { body: formData });

使用 body 发送 JSON 数据时,需要手动设置 header:

kyInstance.post('success/200', {
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'lucardo5',
    age: '20'
  })
});

json

现代开发中,通常与后台对接接口时均采用 JSON 交换数据信息。Ky 也专门为发送 JSON 准备了属性:

kyInstance.post('success/200', {
  json: {
    name: 'lucardo4',
    age: '20'
  }
});

使用 json 属性可以免去调用 JSON 对象手动序列化,同时还可以免去设置 header。因此,在现代开发中,此属性应该会更常用一些。

searchParams

此属性用于自动拼接到 URI 地址上,也就是通常 URL 地址上的查询参数。

kyInstance('success/200', {
  headers: {
    'Accept': 'application/json'
  },
  searchParams: {
    name: 'lucardo6',
    age: '20'
  }
});

传入 searchParams 后,发送的实际 URL 为 /api/success/200?name=lucardo6&age=20

searchParams 参数的实际类型应该是 URLSearchParams 对象的构造函数参数。

prefixUrl

此方法用于在第一个参数前面追加前缀。此参数在大部分情况下, 在调用 ky.create()ky.extend() 创建KyInstance实例时会用到。

下面有一些例子:

import ky from 'ky';

// 例如当前的域名为 https://example.com

const response = await ky('unicorn', { prefixUrl: '/api' });
// 将请求 => 'https://example.com/api/unicorn'

const response2 = await ky('unicorn', { prefixUrl: 'https://cats.com' });
// 将请求 => 'https://cats.com/unicorn'

在使用此参数的情况下,第一个参数就不能以 / 开头。也就是 ky.[httpMethod](input, options)ky(input, options)input 参数不能以 / 开头。

至于原因,官方文档 表示:

  • After prefixUrl and input are joined, the result is resolved against the base URL of the page (if any).
  • Leading slashes in input are disallowed when using this option to enforce consistency and avoid confusion about how the input URL is handled, given that input will not follow the normal URL resolution rules when prefixUrl is being used, which changes the meaning of a leading slash.

通俗解释:

当使用 prefixUrl 时,Ky 会按照以下规则处理 URL:

  1. URL 拼接规则prefixUrl + input 拼接后的结果会相对于页面的 base URL 进行解析。
  2. 禁止前导斜杠input 参数不能以 / 开头。

原因分析:

  • 正常情况下,以 / 开头的 URL 表示从域名根路径开始。
  • 但使用 prefixUrl 后,URL 解析规则发生了变化。
  • 如果允许前导斜杠,会导致混淆:用户可能以为是从根(host 后面第一个 /)路径开始,实际却是从 prefixUrl 开始。
  • 禁止前导斜杠可以强制保持一致性,避免歧义。

举例说明:

// ❌ 错误:不能以 / 开头
kyInstance('/users');  // 这样会报错

// ✅ 正确:直接写路径
kyInstance('users');   // 最终 URL: /api/users

// 如果允许前导斜杠,用户可能会困惑:
// kyInstance('/users') 到底是指 /users 还是 /api/users?

retry {#options-retry}

相比 Axios,retry 功能在服务端响应错误时可以自动进行重试操作。retry 参数用来配置重试逻辑和次数。

官方给出的默认配置是符合客观默认有效值的,此处不再做过多介绍,如有需要可以参考官方文档

其值可以有“两种”形式:数字和配置对象。

默认值为:

{
  limit: 2,
  methods: ['get', 'put', 'head', 'delete', 'options', 'trace', 'post'],
  statusCodes: [408, 413, 429, 500, 502, 503, 504],
  afterStatusCodes: [413, 429, 503],
  maxRetryAfter: void 0,
  backoffLimit: void 0,
  delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000
}

timeout

与常用工具 Axios 相同,在请求超过一定时间后直接中断连接,并抛出 TimeoutError 异常。官方默认值为 10 秒,非常合理!此参数的基本单位是毫秒,也就是说官方默认值为 10 * 1000

Hooks

Hooks 用于在请求的不同阶段:请求前(beforeRequest)、响应后(afterResponse)、重新请求前(beforeRetry)、抛出错误前(beforeError),对行为进行修改。钩子函数可以是异步的,并按照配置顺序依次执行。

beforeRequest

此 Hook 使您能够在发送请求之前对其进行修改。在此之后,Ky 将不会对请求进行进一步更改。钩子函数接收请求和选项作为参数,例如,您可以在此处修改 request.headers

钩子可以返回一个请求来替换传出的请求,或者返回一个响应来完全避免发出 HTTP 请求。这可以用于模拟请求、检查内部缓存等。当从该钩子返回请求或响应时,一个重要的考虑事项是将跳过任何剩余的 beforeRequest 钩子,因此您可能希望只从最后一个钩子返回它们。

import ky from 'ky';

const api = ky.extend({
  hooks: {
    beforeRequest: [
      request => {
        request.headers.set('X-Requested-With', 'ky');
      }
    ]
  }
});

const response = await api.get('https://example.com/api/users');

beforeRetry

此 Hook 需要配合 options.retry 使用。

不做具体介绍,具体查看官方文档,太复杂了,本人未曾使用。

afterResponse

该钩子使您能够读取并有选择地修改响应。钩子函数参数共三个,依次为标准的 RequestoptionsResponse 对象的 clone 作为参数。如果钩子函数的返回值是 Response 的实例,Ky 将使用它作为响应对象。

kyInstance('server-error/503', {
  hooks: {
    afterResponse: [
      (_request, _options, response) => {
        // You could do something with the response, for example, logging.
        console.log(response);

        // 或者返回个 Response,覆盖原来的响应
        return new Response('返回一个不同的响应', { status: 200 });
      },

      // 或者在遇到 403 错误时,重新发起请求以获得 token
      async (request, options, response) => {
        if (response.status === 403) {
          // Get a fresh token
          const token = await ky('https://example.com/token').text();

          // Retry with the token
          request.headers.set('Authorization', `token ${token}`);

          return ky(request);
        }
      }
    ]
  }
});

[!WARNING] 请注意,如果在 afterResponse 中抛出错误,将导致剩下的 Hook 直接跳过,并将异常直接抛出到用户层,而不是 beforeError Hook 中。因此,如果想让自定义的错误还可以流转到 beforeError Hook,请重新实例化一个 Response 对象并返回,Responsestatus 参数确保为 HTTP 错误状态码即可。

kyInstance('server-error/503', {
  hooks: {
    afterResponse: [
      (_request, _options, response) => {
        // You could do something with the response, for example, logging.
        if (some_condiction) {
          throw new HTTPError(response, _request, _options);
        }

        // 或者返回个 Response,覆盖原来的响应
        return new Response('返回一个不同的响应', { status: 200 });
      },

      // 因为上一个 Hook 抛出了错误,因此该 Hook 不会执行
      async (request, options, response) => {
        // 做点什么事情
      }
    ],
    beforeRetry: [
      // 此 Hook 会执行
      (state) => {
        console.log('custom beforeRetry', state);
        return ky.stop;
      }
    ],
    beforeError: [
      // 因为在 afterResponse 中抛出了错误,因此该 Hook 不会执行
      (error) => {
        const { response, request, options } = error;
        console.error('custom beforeError', error);
        return error;
      }
    ]
  }
});

beforeError

该 Hook 用于对抛出 HTTPError 前进行修改,也就是说只有在 HTTP 响应状态码(重定向后)为非正常状态码时才会走该 Hook。

import ky from 'ky';

await ky('https://example.com', {
  hooks: {
    beforeError: [
      error => {
        const { response } = error;
        if (response && response.body) {
          error.name = 'GitHubError';
          error.message = `${response.body.message} (${response.status})`;
        }

        return error;
      }
    ]
  }
});

onUploadProgress

此功能也算对fetch 不支持 上传进度的弥补. 本质上该请求使用的也是 stream api 实现的. 通过上传chun, 与请求体的总size进行比较, 得出的上传进度. 但是 fetch 为什么不提供像 XMLHttpRequest 那样的 onUploadProgress 呢? 究其根本, 是因为 fetch 这样的基于 http 协议, 本质上不可能知道在网络的链路层究竟成功发送了多少数据帧. 因此 上传进度是不可靠的. 故在设计时, 就没有该功能.

函数有进度统计对象 和 已发送数据的 TypedArray 实例两个参数:

  • 进度是具有以下属性的对象:
    • percent 是介于0和1之间的数字,表示进度百分比。
    • transferredBytes是到目前为止传输的字节数。
    • totalBytes是要传输的字节总数。这是一个估计值,如果无法确定总大小,则可能为0。
  • chunk是包含发送的数据的Uint8Array的实例。注意:第一次发送时它是空的。
import ky from 'ky';

const response = await ky('https://example.com', {
	onDownloadProgress: (progress, chunk) => {
		// Example output:
		// `0% - 0 of 1271 bytes`
		// `100% - 1271 of 1271 bytes`
		console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
	}
});

onDownloadProgress

本质上下载进度也是将 Response 对象的 Stream api 作为底层技术实现的.

函数有进度统计和已发送数据 TypedArray 实例两个参数:

  • 进度统计对象是具有以下属性的对象:
    • percent 是介于0和1之间的数字,表示进度百分比。
    • transferredBytes是到目前为止传输的字节数。
    • totalBytes是要传输的字节总数。这是一个估计值,如果无法确定总大小,则可能为0。
  • chunk是包含发送的数据的Uint8Array的实例。注意:第一次发送时它是空的。
import ky from 'ky';

const response = await ky.post('https://example.com/upload', {
	body: largeFile,
	onUploadProgress: (progress, chunk) => {
		// Example output:
		// `0% - 0 of 1271 bytes`
		// `100% - 1271 of 1271 bytes`
		console.log(`${progress.percent * 100}% - ${progress.transferredBytes} of ${progress.totalBytes} bytes`);
	}
});

拓展实例(extend)

创建一个新的 Ky 实例,其中一些默认值被您自己的覆盖。

ky.create() 不同,ky.extend() 继承其父级的默认值。

您可以将标头作为 Headers 实例或普通对象传递。

您可以通过传递带有 undefined 值的标头来使用 .extend() 删除标头。将 undefined 作为字符串传递只会删除来自 Headers 实例的标头。

同样,您可以通过使用显式 undefined 扩展钩子来删除现有的钩子条目。

import ky from 'ky';

const url = 'https://sindresorhus.com';

const original = ky.create({
  headers: {
    rainbow: 'rainbow',
    unicorn: 'unicorn'
  },
  hooks: {
    beforeRequest: [() => console.log('before 1')],
    afterResponse: [() => console.log('after 1')],
  },
});

const extended = original.extend({
  headers: {
    rainbow: undefined
  },
  hooks: {
    beforeRequest: undefined,
    afterResponse: [() => console.log('after 2')],
  }
});

const response = await extended(url);
//=> after 1
//=> after 2

console.log('rainbow' in response);
//=> false

console.log('unicorn' in response);
//=> true

您还可以通过为 .extend() 提供一个函数来引用父默认值:

import ky from 'ky';

const api = ky.create({ prefixUrl: 'https://example.com/api' });

const usersApi = api.extend((options) => ({ prefixUrl: `${options.prefixUrl}/users` }));

const response = await usersApi.get('123');
//=> 'https://example.com/api/users/123'

const response = await api.get('version');
//=> 'https://example.com/api/version'

创建全新实例 create

使用全新的默认值创建新的 Ky 实例。

const kyInstance = ky.create({
  prefixUrl: '/api',
  retry: 1,
  hooks: {
    beforeRequest: [
      (request, options) => {
        console.log('base beforeRequest');
      }
    ],
  }
});

const newInstance = kyInstance.create({
  prefixUrl: '/api',
  retry: 1,
  hooks: {
    beforeRequest: [
      (request, options) => {
        console.log('new beforeRequest');
      }
    ],
  }
});

await newInstance('success/201');
// => new beforeRequest

await kyInstance('success/201');
// => base beforeRequest

Ky 内置的 HTTPError 对象

该对象只会在服务器正确响应的情况下才会出现此错误,所以当网络不可达时,Ky 可能不会抛出此异常。

Response 使用简介

由于 Ky 是对 fetch 的扩展,因此 Ky 对请求结果的处理行为与 Fetch API 保持一致。这与 Axios、ofetch 等其他工具不同。Fetch API 将请求结果的处置权完全交给用户。

如何理解这一点?

以 Axios 为例,其配置选项中有一个名为 responseType 的参数,可选值包括:'arraybuffer''document''json''text''stream''blob'。该参数用于指定 Axios 应如何解析响应结果,并将其作为函数调用的返回值。例如,若将 responseType 设置为 'blob'

import axios from 'axios';

axios.post('/api/success/200', { responseType: 'blob' });
// 响应结果将被解析为 Blob 对象后返回

以上调用将返回一个 Blob 对象。

到目前为止,这种做法似乎没有问题,也符合大多数常见需求。

然而,假设服务器因某种原因返回了错误,实际响应内容是 JSON 格式,但此次调用仍按 Blob 格式解析返回,就无法满足本次调用的需求了。

熟悉 Axios 的人可能会说,可以在 响应拦截器(response hook) 中处理这个问题。确实可以,但这意味着你需要在拦截器中编写各种针对业务逻辑的判断,显然会增加其复杂度。

我举这个例子是为了引出 Response API 的设计理念。在调用时,我们不应关心最终返回的数据格式,而应只关注此次调用是否已成功到达 HTTP 服务器并获得了响应。数据处理是业务层应关心的事情,而不应在 HTTP 调用时过度关注。

总的来说,Fetch API 只关注 HTTP 请求本身,而不关心具体的数据内容。因此,即使响应状态为 404,只要服务器返回了响应,本次 HTTP 调用就是成功的。至于响应内容为 404,这不是本次调用行为本身应关注的事情。

只有当网络出现错误(如服务器不可达)或其他不可抗力导致请求无法完成时,本次调用才是失败的。只要 HTTP 服务器返回了响应,本次请求就是完整的,并会返回一个 Response 对象,无论其内容是什么。

基于这一设计原则,Fetch API 将请求与处理分离,符合常见的编程与设计原则。这种分离在不同规模的代码工程中都能提升代码质量和可维护性。

Ky 本质上是对 Fetch API 的扩展,因此 Ky 也将响应结果以继承自 PromiseResponse 对象形式返回。下文将使用 Ky 介绍 Response 对象的常见用法。


Response 常用解析方法

按 JSON 解析

Response 对象调用 .json() 方法,可将响应内容解析为 JSON 格式,并返回 JavaScript 对象:

const resp = ky('success/200');
const jsonObj = await resp.json();

解析结果 jsonObj 示例:

{
  "search": {},
  "body": null,
  "message": "this is json response"
}

按纯文本解析

若想将响应结果按文本解析,可调用 .text() 方法,返回字符串(相当于用反引号包裹的原始文本):

{"search":{},"body":null,"message":"this is text response"}

按 ArrayBuffer 解析

若希望结果以 ArrayBuffer 形式返回:

ky.post('success/200')
  .arrayBuffer()
  .then(buffer => {
    console.log('buffer', buffer);
  });

按 ReadableStream 解析

若响应内容为不间断的数据流,可通过 Response 对象的 body 属性获取 ReadableStream

ky.post('success/200')
  .then(resp => resp.body);

参考链接

Ressponse 对象还有其他的常见属性,可以辅助判断一个响应解析方法,比如ok属性和headers属性。