@tobosoft/request v0.9.3
Request
goal
P0
- 比 axios/umi-request 小的 core 核心
- 支持异步拦截器形式的拓展能力
- 支持二次封装
P1
- 支持非 fetch 的请求接口
not goal
- 平替 axios/umi-request
- 比 ky 等号称迷你的库更小
- 洋葱生命周期、setup 组合式配置
runtime requirement
core
综合:
- chrome 63
- edge 18
- safari 11.1
- ff 60
- opera 50
- ios 11.3
fetch
迷思
以 axios 为例的 req/res 两段式拦截器插入周期
这个形式并非银弹,在面对以下场景时需要付出意想不到的 hack 努力:
- core 拦截层:许多内部操作(fetch 自定义/retry 等)想要统合为拦截器概念的话,为了保证 pre/post 调用次序,只能再抽离一个层专门给内部用
- 次序声明问题:因为有且只有两段,在 instance.extend 场景插入拦截器的难度较大,需要声明成【在 xx 拦截器前/后】的形式
const req1 = axios.interceptor.request.use(interceptor1, interceptor2);
const req2 = axios.extend(req1);
// =============================================
// interceptor1_5 可能在业务上与 1/2 根本关联,有没有1/2它都正常工作
// 只是1和2先存在,它被迫只能表达为【1和2的中间】
// 比如 interceptor1_5 其实在以下场景下都是有效的
// axios.interceptor.request.use(interceptor1, interceptor1_5)
// axios.interceptor.request.use(interceptor1_5)
// axios.interceptor.request.use(interceptor1_5, interceptor2)
// axios.interceptor.request.use(interceptor1, interceptor1_5, interceptor2)
// =============================================
req2.interceptor.request.use(/* interceptor1_5 */); // 怎么插到1和2中间?
以 content-type + 手动编码 的形式指定参数对业务十分不友好
如果不加封装,业务被迫需要在 services 层声明很多 hard-code 性质的内容,而且无法提供校验、容易写错;写成 preset 能提供非常好的开发体验,在减少调试成本方面也有不俗表现
// json
axios.post("", { data: { key: "value" } });
// form-data
axios.post("", {
data: { key: "value", file: file },
headers: { "content-type": "application/x-www-form-urlencoded" },
});
// ===
const formdata = new FormData();
formdata.append("key", "value");
axios.post("", { data: formdata });
// www-form-urlencoded
axios.post("", { data: { key: "value" }, headers: { "content-type": "multipart/form-data" } });
// ===
const params = new URLSearchParams();
params.append("key", "value");
axios.post("", { data: params });
// query + www-form-urlencoded
// ???
拦截器传递内容
request 拦截器传递 params/response 拦截器传递 response 对象,这个设定并不好。 一是性能不友好,response 对象设定上并非多次消费友好,于是在层层传递 response 时框架选择多次 clone,纯损耗; 二是许多场景下都存在拦截器处理后的内容并非是一个 response,如果需要适配回去 response 还得想一套序列化方案然后还得考虑前后拦截器的对该序列化方案适配情况。
ky.interceptor.response.use(
async (response) => {
const contentType = response.headers.get("content-type");
if (contentType !== config.accept) throw new Error(/* ... */);
// ^ 拿不到config
},
async (response) => {
// decode once
const body = await response.body.json();
if (!body.success) throw new Error("...");
},
async (response) => {
// decode again!
const body = await response.body.json();
if (body.code === "biz_err_xxx") throw new Error("request not auth");
else if (body.code === "biz_err_xxx") throw new Error("try later");
// 因为约定了只能传递response,这里我们只能重新包装一次
else
return new Response(JSON.stringify(body.data), {
/* ... */
});
},
);
config 应尽可能支持按照上下文给值
很多意向不到的场景其实都有上下文动态的需求,如baseURL
等
axios.default.baseURL = "localhost/xxx";
// 本地代理
axios.post("/services-a/api-1", {});
axios.post("/services-a/api-2", {});
// ...
axios.post("/services-a/api-n", {});
axios.post("/services-b/api-1", {});
axios.post("/services-b/api-2", {});
// ...
// 如果只能字面量写死,比如 services-a 需要连同事做本地联调,services-b 等其它依旧不变,那就需要对n个接口都改 baseURL
执行分层
- 能创建出实例
- 实例调用应该创造一个调用上下文
生命周期暴露形式选择
生命周期的暴露有两种选择,一种是设计好的特定、少量周期,一种是 action 加 pre-action+post-action 的概念。 在本次实现中,原本走的路径是后者的全量暴露路径,但在实现内置拦截器时发现这个方式有一个很膈应的坏处:post-action-1 与 pre-action-2 其实是同一个“节点”的两个周期,很多时候会感觉自己放 post 和 pre 都合理,但没很好的形式通知上下游自己的某个操作是位于什么周期。 经过考虑,这里认为,库应该给予使用者以指引,明确的引导给予用户的价值、体验要高于更多但意义不明的扩展点。故切换为前者,不定量但意义明确的周期暴露形式。
共享 context
让外部直接使用上一次 context 发送请求有逻辑难度,这变相要求整个插件链条都支持幂等操作。对插件书写有压力。
整体设计思路
- 分 core/preset 两层
- core 负责执行拦截器、统合 error 对象
- preset 负责对 core 层封装成常见业务适配的样子,目前计划提供基础 ky-like、ruoyi-like 预设
- 以生命周期形式的暴露拦截器执行点
config
- config 合并操作,默认同名替换,headers 做单独合并params
- 根据 config 生成 paramsfetch
- 发出请求response
- 响应返回(根据实现不同,此时可能是响应未完整返回)responseResolved
- 响应完整返回,可进行各种解码等操作final
- 请求结束,可在此做耗时记录/结果日志/上报等操作
生命周期
flowchart TD
Begin(["*begin*"])
--> Start(start)
--> AfterStart[\afterStart\]
--> BeforeConfig[/beforeConfig/]
--> Config(config)
--> AfterConfig[\afterConfig\]
--> BeforeParams[/beforeParams/]
--> Params(params)
--> AfterParams[\afterParams\]
--> BeforeSend[/beforeSend/]
--> Send(send)
--> AfterSend[\afterSend\]
--> BeforeResponse[/beforeResponse/]
--> Response(response)
--> AfterResponse[\afterResponse\]
--> BeforeSuccess[/beforeSuccess/]
--> Success(success)
--> AfterSuccess[\afterSuccess\]
AfterSuccess & Begin --> IsThrown{is thrown ?}
BeforeError[/beforeError/]
IsThrown -->|"yes"| BeforeError
--> Error(Error)
--> AfterError[\afterError\]
BeforeFinal[/beforeFinal/]
IsThrown -->|"no"| BeforeFinal
AfterError --> BeforeFinal
--> Final(final)
--> E(["**end**"])
6 months ago
8 months ago
8 months ago
9 months ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago