跳到主要内容

从 Axios 迁移

@data-client/rest 用带类型的 endpoint 定义取代 axios 调用。组件仍然直接调用它们,因此应用加载和存储数据的方式不会改变;改变的只是调用的形式。codemod 会转换这些调用;下面的步骤涵盖其余部分。

使用你的编程智能体迁移​

把下面的内容粘贴到你的智能体聊天中:

复制给编程代理的提示词

REST setup skill 遵循本指南:它会运行 codemod,然后逐一完成 codemod 无法完成的步骤。像审查队友的代码一样审查它的 diff。

为什么要迁移?​

URL 只需声明一次:路径参数会根据 path 模板获得类型并被编码,其余参数则成为查询字符串。调用方式不变。
Before
const getComments = ({
userId,
postId,
sort,
}: {
userId: string;
postId: string;
sort?: 'newest' | 'top';
}) =>
axios.get( // ①
`/users/${encodeURIComponent(userId)}/posts/${encodeURIComponent(postId)}/comments`,
{ params: { sort } },
);

getComments({ userId: user.id, postId: post.id, sort: 'newest' });
After
const getComments = new RestEndpoint({
path: '/users/:userId/posts/:postId/comments', // ②
searchParams: {} as { sort?: 'newest' | 'top' },
});

getComments({ userId: user.id, postId: post.id, sort: 'newest' });
  1. ①Each parameter is typed, encoded and placed by hand, and nothing checks the template against the type.
  2. ②userId and postId are typed and encoded from the template, so a missing or misspelled one is a compile error.
  • 端到端类型安全:路径参数、body 和响应的类型都来自各自的定义。
  • 更少的代码:resource() 只需 path 和 schema 就能生成完整的 CRUD API。
  • 请求行为集中在一处:基础 endpoint 的方法取代了 interceptor 和每次调用的配置。
  • 为 store 做好准备:同样的 endpoint 和 schema 可以配合 useSuspense() 和规范化缓存使用,随时可以采用。

逐步迁移​

这些就是你的智能体遵循的步骤。在每一步之前先提交,这样你就可以审查它的 diff。

1. 安装​

npm install --save @data-client/rest
在终端中运行

2. 运行 codemod​

一个 jscodeshift codemod 负责机械性的部分。先提交,然后在你的源码目录上运行它,不要在仓库根目录运行:

npx jscodeshift -t https://dataclient.io/codemods/axios-to-rest.js --extensions=ts,tsx,js,jsx src/
在终端中运行
每个 axios 调用都会变成对 endpoint 的调用。路径参数、params 和 body 成为它的参数,它会解析为 body,因此 .data 不再需要。
Before
import axios from 'axios';

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

export async function getPost(id: string) {
const res = await api.get(`/posts/${id}`);
return res.data;
}

export async function createPost(body: { title: string }) {
const { data } = await api.post('/posts', body);
return data;
}
After
import { RestEndpoint, RestGenerics } from '@data-client/rest';

class ApiEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
urlPrefix = 'https://api.example.com';
}

export async function getPost(id: string) {
const res = await new ApiEndpoint({ path: '/posts/:id' })({ id });
return res;
}

export async function createPost(body: { title: string }) {
const data = await new ApiEndpoint({ path: '/posts', method: 'POST' })(
body,
);
return data;
}

它无法转换的内容,例如 interceptor、timeout、isAxiosError(),或把 axios 响应返回给调用方的函数,会得到一条 // TODO(data-client): 注释,链接到下文中对应的章节。搜索 TODO(data-client),并在完成后续步骤时逐一解决。

如果项目把 axios 封装在自己的类或函数中,从不直接调用 axios.get() 或 .post(),或者只调用 axios(config),codemod 就没什么可做的。跳过它,从第 3 步开始。

3. 把 interceptor 移到基础 endpoint 中​

Axios 的 interceptor 和实例配置会变成一个 RestEndpoint 子类上的方法,所有 endpoint 都继承这个子类。 codemod 会根据 axios.create() 创建这个类;把 interceptor 移到其中。

RestEndpoint 会自行解析 JSON,因此只用于解包 response.data 的 interceptor 已无事可做。
Before
import axios from 'axios';

const api = axios.create({
baseURL: 'https://api.example.com',
headers: { 'X-API-Key': 'my-key' },
});

api.interceptors.request.use(config => {
config.headers.Authorization = `Bearer ${getToken()}`;
return config;
});

api.interceptors.response.use(response => response.data);

export default api;
After
import { RestEndpoint, RestGenerics } from '@data-client/rest';

export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = 'https://api.example.com';

getHeaders(headers: HeadersInit) { // ①
return {
...headers,
'X-API-Key': 'my-key',
Authorization: `Bearer ${getToken()}`,
};
}
}
  1. ①Runs before every request, like a request interceptor. It can be async, to refresh a token first.

修改响应体的响应 interceptor(例如转换 snake_case 键名)应放在 process() 中;参见 snake 转 camel。

当响应 interceptor 返回 response.data 时,调用方的 .data 读取的是 body 中的字段,而不是 axios 的响应,因此要保留它。当 interceptor 位于同一文件中时,codemod 会标记这些调用;如果全局的 axios.interceptors.response 位于另一个文件中,请检查它从那里的调用中移除的 .data。

服务器错误消息​

Axios 代码库经常把 error.response.data.error 或 .message 展示给用户。在基类的 fetchResponse() 中从 Response 的 body 读取一次,这样调用处就可以从 error.message 获取它:

ApiEndpoint.ts
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';

export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async fetchResponse(input: RequestInfo, init: RequestInit) {
try {
return await super.fetchResponse(input, init);
} catch (error) {
if (error instanceof NetworkError) {
const body = await error.response
.clone()
.json()
.catch(() => null);
// keep the NetworkError so `status` and `errorPolicy()` still work
error.message = body?.error ?? body?.message ?? error.message;
}
throw error;
}
}
}

已有的 endpoint 抽象​

封装了 axios 的自定义 endpoint 类(比如带有 path、method 和 toDynamicUrl() 辅助方法)可以继承 RestEndpoint,而不必被替换。它会保留向后兼容的方法,并获得 url()、getRequestInit()、 fetchResponse() 和 parseResponse():

import { RestEndpoint, RestGenerics } from '@data-client/rest';

export class LegacyEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = API_ROOT;
declare queryKey?: string;

/** @deprecated use url() */
toDynamicUrl = this.url;
}

const getUser = new LegacyEndpoint({
path: '/users/:id',
queryKey: 'user',
schema: User,
});

请把 queryKey 之类的额外成员作为选项传入,而不是通过自定义构造函数传入,这样 extend()(resource()、hookifyResource() 和 useCancelling() 都会用到)才能继续正常工作。

基于 context 的认证​

当 token 来自 React context(Okta、Auth0)而不是存储时,getHeaders() 无法读取它们。使用 hookifyResource() 通过 hook添加请求头。这一模式及其他模式请参阅认证指南。

4. 定义 entity 和 resource​

codemod 生成的 endpoint 不带 schema。为 API 返回的每种类型定义一个 Entity:它的字段、字段类型以及主键。用一个 resource() 替换每个模块中针对同一路径的 CRUD 函数:

一个 resource() 就能替换一整个模块的 CRUD 函数,schema 则为每个响应提供类型。
Before
import api from './api';

export interface Post {
id: string;
title: string;
body: string;
}

export const getPosts = () => api.get<Post[]>('/posts');
export const getPost = (id: string) => api.get<Post>(`/posts/${id}`);
export const createPost = (data: Omit<Post, 'id'>) =>
api.post<Post>('/posts', data);
export const updatePost = (id: string, data: Partial<Post>) =>
api.patch<Post>(`/posts/${id}`, data);
export const deletePost = (id: string) => api.delete(`/posts/${id}`);
After
import { Entity, resource } from '@data-client/rest';
import ApiEndpoint from './ApiEndpoint';

export class Post extends Entity {
id = '';
title = '';
body = '';
static key = 'Post';
}

export const PostResource = resource({ // ①
path: '/posts/:id',
schema: Post,
Endpoint: ApiEndpoint,
});
  1. ①Gives getList, get, getList.push, update, partialUpdate and delete, all typed.

针对 mock API 试试结果:

import { resource } from '@data-client/rest';
import ApiEndpoint from './ApiEndpoint';
import Post from './Post';

export const PostResource = resource({
  path: '/posts/:id',
  schema: Post,
  Endpoint: ApiEndpoint,
});
Usage
import { PostResource } from './PostResource';
PostResource.get({ id: '1' });
GEThttps://api.example.com/posts/1
x-api-key
my-key
200 OK
{
  "id": "1",
  "title": "Hello World",
  "body": "First post"
}

像 /projects/:projectId/tasks/:taskId 这样的嵌套路径应有自己的 resource。独立的 new ApiEndpoint() 留给非 CRUD 操作,例如搜索、自定义操作和认证。

注意

不要定义了 Entity 类,却在 endpoint 上不设置 schema:没有 schema,响应会失去类型,并且在你采用 store 时它也无法规范化这些响应。

非标准的主键​

许多 API(例如 MongoDB)使用 _id 而不是 id。覆盖 pk():

import { Entity } from '@data-client/rest';

export class User extends Entity {
_id = '';
name = '';
email = '';
static key = 'User';

pk() {
return this._id;
}
}

Body 类型​

用 body: {} as BodyType 为独立的 POST、PUT 和 PATCH endpoint 的 body 标注类型。不要使用 undefined as unknown as BodyType:RestEndpoint 会把 body: undefined 视为没有 body 参数。resource() 会自行为其 endpoint 的 body 标注类型。

const createUser = new ApiEndpoint({
path: '/users',
method: 'POST',
body: {} as { name: string; email: string },
schema: User,
});

与 Zod 或 Yup 共存​

如果代码库已经在用 Zod 或 Yup 校验响应,请为每种类型选择一种方案:

  • 在 process() 中使用 Zod(推荐):通过在 process() 中解析来保留运行时校验,并让 Entity 负责规范化:

    const getUser = new ApiEndpoint({
    path: '/users/:id',
    schema: User,
    process(value: any) {
    return userSchema.parse(value);
    },
    });
  • 用 Entity 取代 Zod:把字段结构移到 Entity 类中,并删除 Zod schema。Entity 的字段只提供类型,不做运行时检查,因此对于服务器可能发送格式错误的字段,请添加 static validate()。

  • 只用 Zod,不用 Entity:不设置 schema,手动解析。只对无法从规范化中获益的 endpoint(认证 token、一次性响应)这样做。

5. 更新调用处​

组件和服务保留各自的加载逻辑和状态;只有调用会改变。

endpoint 会解析为解析后的 body,因此 .data 不再需要,参数也合并为一个对象。
Before
import { createPost, getPost, getPosts } from './api/posts';

const { data: posts } = await getPosts();
const { data: post } = await getPost('1');
const { data: created } = await createPost({ title: 'Hello', body: '' });
After
import { PostResource } from './api/posts';

const posts = await PostResource.getList();
const post = await PostResource.get({ id: '1' });
const created = await PostResource.getList.push({
title: 'Hello',
body: '',
});

通过 store(借助 useSuspense() 和规范化缓存)读取这些 endpoint,是你之后可以单独进行的一步;参见数据依赖。

6. 迁移错误处理​

NetworkError 提供 status 和原始的 Response,取代 axios 的 error.response。
Before
import axios from 'axios';
import { getPost } from './api/posts';

try {
const { data } = await getPost('1');
} catch (err) {
if (axios.isAxiosError(err)) {
console.log(err.response?.status);
console.log(err.response?.data);
}
}
After
import { NetworkError } from '@data-client/rest';
import { PostResource } from './api/posts';

try {
const post = await PostResource.get({ id: '1' });
} catch (err) {
if (err instanceof NetworkError) {
console.log(err.status);
console.log(err.response);
}
}

在基础 endpoint 中从 body 读取一次服务器的错误消息;参见服务器错误消息。

7. 移除 axios​

搜索仍在使用 axios 的地方:

模式查找目标
TODO\(data-client\)codemod 遗留的内容
import.*from ['"]axios['"]import 语句
axios\.create实例创建
axios\.(get|post|put|patch|delete)直接调用
\.interceptors\.(request|response)\.useinterceptor
isAxiosError错误处理
cancelToken|CancelToken取消(已弃用)
onUploadProgress|onDownloadProgress进度回调

借助下面的参考迁移每一处匹配。全部迁移完后,卸载 axios,然后运行类型检查和测试:

npm uninstall axios
在终端中运行

Axios 选项参考​

Axios@data-client/rest
baseURLurlPrefix
headers 配置getHeaders()
interceptors.requestgetHeaders() / getRequestInit()
interceptors.responseparseResponse() / process()
transformRequestgetRequestInit()
transformResponseprocess()
isAxiosError / error.response带有 .status 和 .response 的 NetworkError
cancelToken / signalsignal
timeout在 getRequestInit() 中使用 AbortSignal.timeout()
responseType: 'blob' / 'arraybuffer'content: 'blob' / 'arrayBuffer'
params / paramsSerializersearchParams / searchToString()
auth: { username, password }配合 btoa() 使用 getHeaders()
validateStatus自定义 fetchResponse()
xsrfCookieName / xsrfHeaderNamegetHeaders()
onUploadProgress使用 XMLHttpRequest 的自定义 fetchResponse()

取消​

axios 的 signal 和已弃用的 CancelToken 都对应于 AbortController 的 signal:

Before
const source = axios.CancelToken.source();
axios.get('/users/1', { cancelToken: source.token });
source.cancel();
After
const getUser = new ApiEndpoint({ path: '/users/:id' });
const controller = new AbortController();
getUser.extend({ signal: controller.signal })({ id: '1' }); // ①
controller.abort();
  1. ①extend() gives this one request its own signal; a signal set when the endpoint is created would abort all of its requests.

更多模式请参阅中止指南。

超时​

Before
axios.get('/users', { timeout: 5000 });
After
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async getRequestInit( // ①
body?: RequestInit['body'] | Record<string, unknown>,
) {
const init = await super.getRequestInit(body);
const timeout = AbortSignal.timeout(5000);
const signal = init.signal // ②
? AbortSignal.any([init.signal, timeout])
: timeout;
return { ...init, signal };
}
}
  1. ①Runs for each request, so each one gets its own 5 seconds. AbortSignal.timeout() passed to the constructor would start counting once, when the endpoint is created.
  2. ②Keeps extend({ signal }) cancellation working: either signal aborts the request.

二进制响应​

Before
axios.get('/files/1', { responseType: 'blob' });
After
const downloadFile = new RestEndpoint({
path: '/files/:id',
content: 'blob',
});

content 也接受 'arrayBuffer' 和 'text'。如何触发浏览器下载,请参阅文件下载。

查询参数序列化​

Before
axios.get('/users', {
params: { ids: [1, 2, 3] },
paramsSerializer: params =>
qs.stringify(params, { arrayFormat: 'repeat' }),
});
After
export default class ApiEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
searchToString(searchParams: Record<string, any>) {
return qs.stringify(searchParams, {
arrayFormat: 'repeat',
skipNulls: true,
});
}
}

axios 会省略值为 null 和 undefined 的参数,但默认的 searchToString() 使用 URLSearchParams,它会把它们作为字符串 "null" 和 "undefined" 发送;上面的 skipNulls 会去掉它们。参见 searchToString()。

Basic 认证​

Before
axios.get('/api', { auth: { username: 'user', password: 'pass' } });
After
export default class BasicAuthEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
getHeaders(headers: HeadersInit) {
return {
...headers,
Authorization: `Basic ${btoa('user:pass')}`,
};
}
}

接受错误状态码​

对于任何不是 ok 的状态码,fetchResponse() 都会抛出 NetworkError。覆盖它即可改变哪些情况算作错误:

Before
axios.get('/api', { validateStatus: status => status < 500 });
After
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';

export default class LenientEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async fetchResponse(input: RequestInfo, init: RequestInit) {
const response = await fetch(input, init);
if (response.status >= 500) throw new NetworkError(response);
return response;
}
}

CSRF 请求头​

Before
axios.create({
xsrfCookieName: 'csrftoken',
xsrfHeaderName: 'X-CSRFToken',
});

对于不是 GET 的请求,在 getHeaders() 中读取 cookie。完整的 endpoint 类请参阅 Django 集成。

上传进度​

fetch 无法报告上传进度,因此需要在 fetchResponse() 中使用 XMLHttpRequest。onProgress 字段和其他成员一样,作为 endpoint 选项传入。

Before
axios.post('/upload', formData, {
onUploadProgress: e => console.log(e.loaded / e.total),
});
UploadEndpoint.ts
import {
NetworkError,
RestEndpoint,
RestGenerics,
} from '@data-client/rest';

export default class UploadEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
declare onProgress?: (progress: number) => void;

fetchResponse(input: RequestInfo, init: RequestInit) {
return new Promise<Response>((resolve, reject) => {
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
const abortError = () =>
new DOMException('The operation was aborted.', 'AbortError');
if (init.signal?.aborted) return reject(abortError());
init.signal?.addEventListener('abort', abort, { once: true });

xhr.open(
init.method ?? 'POST',
typeof input === 'string' ? input : input.url,
);
new Headers(init.headers).forEach((value, key) =>
xhr.setRequestHeader(key, value),
);
xhr.onloadend = () =>
init.signal?.removeEventListener('abort', abort);
xhr.upload.onprogress = e => {
if (e.lengthComputable) this.onProgress?.(e.loaded / e.total);
};
xhr.onload = () => {
const headers = new Headers();
for (const line of xhr
.getAllResponseHeaders()
.trim()
.split(/\r?\n/)) {
const [key, ...rest] = line.split(': ');
if (key) headers.append(key, rest.join(': '));
}
// 204, 205 and 304 responses can't have a body
const body = [204, 205, 304].includes(xhr.status)
? null
: xhr.response;
const response = new Response(body, {
status: xhr.status,
statusText: xhr.statusText,
headers,
});
if (response.ok) resolve(response);
else reject(new NetworkError(response));
};
xhr.onerror = () => reject(new TypeError('Network request failed'));
xhr.onabort = () => reject(abortError());
xhr.send(init.body as XMLHttpRequestBodyInit | null);
});
}
}

const uploadFile = new UploadEndpoint({
path: '/upload',
method: 'POST',
body: {} as FormData,
onProgress: (progress: number) => console.log(progress),
});