一. 前言
第一次看到ChatGPT的时候,我的关注点不是它那近乎无所不知的回复功能,而是它呈现内容时的“打字机”效果。顿时,setTimeout、requestAnimationFrame这些字眼不自觉的从脑海中浮现,而且它“打字”的速度不是匀速,时快时慢,有种“抑扬顿挫”的感觉,我心中惊奇,难道这种拟人化的交互效果也在ChatGPT的计算之中吗?我搜索了一番之后,才发现自己想岔了......
其实是因为大语言模型一边计算一边返回词元,导致视觉上看起来像是“打字机”效果。这其中涉及到了SSE、流式请求等知识点,本文尝试使用fetch实现类似的请求效果。
参考文章和SSE的更多介绍可移步:# 模拟ChatGPT流式数据——SSE最佳实践(附可运行案例)
二. 代码实现
2.1 需求分析
- 拦截器:发送请求前添加自定义请求头,如token
- 参数自动序列化:get、post统一传参Object,内部自行转换成url参数
- 响应内容自动处理:返回的是stream流,内部转换成文本
- 请求支持终止
- 超时:这种场景下我感觉不需要设置超时,也可以实现一下
2.2 后端模拟
使用node简单模拟stream响应,新建mockServer.js文件
const http = require("http");
const texts = [
"轻轻的我走了,",
"正如我轻轻的来;",
"我轻轻的招手,",
"作别西天的云彩。",
];
const server = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Transfer-Encoding", "chunked");
let index = 0;
res.write(texts[index]);
const timer = setInterval(() => {
index++;
res.write(texts[index]);
if (index === texts.length - 1) {
clearInterval(timer);
res.end();
}
}, 1000);
});
const port = 666;
server.listen(port, () => {
console.log(`启动: http://localhost:${port}`);
});
启动
node mockServer.js
打开对应地址可以看到页面上每秒返回一句文本。
2.3 前端实现
以下的实现,模拟了axios的request拦截器,传参支持Object类型的params、body
import _ from "lodash";
type TFetchOptions = Omit<RequestInit, "headers" | "body"> & {
headers?: Record<string, any>;
params?: Record<string, any>;
body?: Record<string, any> | BodyInit | null;
timeout?: number;
};
// 默认配置
const defaultOptions: Partial<TFetchOptions> = {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
timeout: 60 * 1000,
};
// 拦截器
const requestInterceptors = (options: TFetchOptions) => {
if (!options.headers) options.headers = {};
options.headers["token"] = "111";
return options;
};
// get请求body转化成query
const parseParams = (params?: Record<string, any>) => {
const searchObj = new URLSearchParams();
Object.entries(params || {}).forEach(([key, value]) => {
searchObj.append(key, value);
});
const query = searchObj.toString();
return query ? `?${query}` : "";
};
// fetch封装
const baseFetch = (
url: string,
fetchOptions?: TFetchOptions,
) => {
// 处理request配置
let options = _.merge(
defaultOptions,
fetchOptions
) as TFetchOptions;
options = requestInterceptors(options);
// 处理request参数
let urlFix = url;
if (_.toUpper(options.method) === "GET") {
fetchOptions?.params && (urlFix = url + parseParams(fetchOptions.params));
} else {
fetchOptions?.body && (options.body = JSON.stringify(fetchOptions.body));
}
return globalThis.fetch(urlFix, options as RequestInit)
};
// 导出post请求
export const postStream = (url: string, body?: Record<string, any>) => {
return baseFetch(url, { method: "post", body });
};
发送请求试试
const body = [{ role: "user", content: "写一句诗" }];
const response = await postStream("http://localhost:666", body);
const reader = response.body?.getReader();
while (reader) {
const chunk = await reader.read();
console.log(chunk);
if (chunk?.done) {
break;
}
}
查看请求结果
类似迭代器的返回格式,返回值是字节流,需要使用TextDecoder转换成文本。
如果转换的过程在封装的函数内部进行,而接口又是持续性的,不能直接return数据,所以采用callback函数的方式return数据。
下面的代码把中断请求的逻辑也一起实现了,借助 AbortController API。
type TResponseCallback = {
onData?: (data: any) => void; // chunk的回调
onEnd?: () => void; // 传输完毕的回调
onError?: (err: any) => void; // 错误的回调
setAbortController?: (controller: AbortController) => void; // 获取中止控制器的回调
onAborted?: () => void; // 中止的回调
};
// fetch封装
const baseFetch = (
url: string,
fetchOptions?: TFetchOptions,
callback?: TResponseCallback
) => {
// 取消请求的控制器
const abortController = new AbortController();
callback?.setAbortController?.(abortController);
// 处理request配置
let options = _.merge(
{ signal: abortController.signal },
defaultOptions,
fetchOptions
) as TFetchOptions;
options = requestInterceptors(options);
// 处理request参数(略...)
// 响应处理
return globalThis
.fetch(urlFix, options as RequestInit)
.then((res) => {
const response = res.clone();
return /^(2|3)\d{2}$/.test(String(res.status))
? handleStream(response, callback)
: handleError(response, callback);
})
.catch((err) => {
if (err.name === "AbortError") {
callback?.onAborted?.();
} else {
callback?.onError?.(err);
}
callback?.onEnd?.();
});
};
// 处理错误
const handleError = (response: Response, callback?: TResponseCallback) => {
if (response.status === 401) {
window.location.href = "/login";
}
};
// 处理流式数据
const handleStream = async (
response: Response,
callback?: TResponseCallback
) => {
if (!response.ok) {
throw new Error("服务出错");
}
const reader = response.body?.getReader();
const textDecoder = new TextDecoder("utf-8");
while (reader) {
const chunk = await reader.read();
if (chunk?.done) {
callback?.onEnd?.();
break;
}
const chunkText = textDecoder.decode(chunk?.value);
callback?.onData?.(chunkText);
}
};
export const postStream = (
url: string,
body?: Record<string, any>,
callback?: TResponseCallback
) => {
return baseFetch(url, { method: "post", body }, callback);
};
上面的代码主要有两个坑:
- fetch对于400和500的响应不会reject,而是正常解析,在处理响应数据时需要注意。
- 处理响应数据时调用了clone方法,这样做可以避免一些意外情况的出现。在流式请求中,使用了response对象,该对象就会被消耗,无法再次使用。clone之后再使用,不会影响到原始响应数据。
最后一点,超时可以使用Promise.race实现。
以下是完整代码
三. 使用示例
以下是在react中的使用示例
import React, { useState } from "react";
import { postStream } from "./fetchStream";
export default function Test() {
const [loading, setLoading] = useState(false);
const [text, setText] = useState("");
const [abortController, setAbortController] = useState<AbortController>();
const onSend = async () => {
if (loading) return;
const body = [{ role: "user", content: "写一句诗" }];
setLoading(true);
postStream("http://localhost:666", body, {
setAbortController,
onData(data) {
console.log("响应数据: ", data);
setText(pre => pre + data);
},
onEnd() {
console.log("结束");
setLoading(false);
},
onError(err) {
console.log("错误: ", err);
},
onAborted() {
console.log("终止");
},
});
};
const onStop = () => {
abortController?.abort();
};
return (
<div style={{ padding: 100 }}>
<div>
<button onClick={onSend}>发送请求</button>
<button onClick={onStop}>终止请求</button>
</div>
<div>{loading && "加载中..."}</div>
<div>{text}</div>
</div>
);
}