本文,我们将根据前文来实现一个ai聊天对话项目,感受真实的业务。
项目技术栈
- vite---一个前端工程构建工具。
- antd --- 一个react ui组件库。
- @ant-design/icons ---- 一个react图标库。
- mockjs --- 模拟消息对话数据。
- dayjs --- 一个日期处理库
- react --- 一个javascript框架。
- typescript --- javascript的超集。
- ew-message --- 个人写的一个消息提示框插件。(ps: 为什么要用这个而不用antd自带的message,因为我想用一下看看我写的消息提示框好用不。)
- animate.css----一个动画样式文件。
初始化项目
参考vite官网,我们来初始化一个react-ts工程。如下:
shell代码解读复制代码pnpm create vite ai-dialog --template react-ts
初始化项目完成之后,接着执行如下命令:
shell代码解读复制代码cd ai-dialog pnpm install
接着添加相关依赖:
shell代码解读复制代码pnpm add antd @ant-design/icons mockjs @types/mockjs animate.css ew-message dayjs
项目初始化完成,我们需要将原本的代码逻辑给删掉,即App.tsx以及App.css,index.css等代码删掉,然后我们接着往下继续。
编码时刻
1. 定义消息的类型
在src目录下创建一个types目录,该目录下,新增一个messge.d.ts文件,然后定义消息类型接口,代码如下:
ts 代码解读复制代码export interface Message {
name: string; // 用户名还是机器人名字
text: string; // 消息文本
timestamp: number; // 日期时间戳
type?: string; // 消息类型
isEnd?: boolean; // 会话是否结束
}
为什么要有消息类型type字段?
答: 我们可以根据消息类型来决定会话的渲染,例如消息类型是一个markdown的字符串,我们就以markdown的方式来渲染,又比如想要消息类型是一个json-schema的字符串,也就是渲染成表单,那我们同样也可以根据Type来判断。
为什么要有isEnd字段?
每一条消息,我们应该都需要添加这个字段,然后我们需要轮询请求结束的接口,在真实的业务场景之下,会话是有时间的,当到达了这个时间之后,会话会变成已结束,然后如果用户再次询问问题,那就是新一轮的会话,我们也可以根据这个字段来进行分组,这也是数据分组工具函数的由来。
2. mock数据
根据mock.js的api文档,我们可以mock一些消息数据,方便我们来做渲染,如下所示:
在src目录下新建mock目录,并新建mock.ts文件,代码如下:
ts 代码解读复制代码import Mock from "mockjs";
import { Message } from "../types/message";
import dayjs from "dayjs";
const generateMessage = () => {
const messages = ["你好!", "你好吗?", "我能为你做什么?", "再见!"];
const names = ["夕水", "机器人-毛毛"];
return Mock.mock({
"messages|5": [
{
"name|1": names,
"text|1": messages,
timestamp: "@datetime",
},
],
});
};
export const getMockMessages = () => {
return generateMessage().messages.map((message: Message) => ({
...message,
timestamp: dayjs(message.timestamp).unix() * 1000,
isEnd: false,
}));
};
主要是在没有对接ai服务的时候,我们可以先自己模拟数据来做渲染。
3. 工具函数
这里也涉及到了一些工具函数的定义,例如会话消息的分组,还有就是我们需要缓存数据,因此这里也会涉及到字符串解析成数组,以下是所有工具函数的代码:
ts 代码解读复制代码import { Message } from "../types/message";
export const groupByInterval = (
arr: Message[],
filterFn = (item: Message) => item.isEnd
) => {
if (arr.length === 0) {
return [arr];
}
const result: Message[][] = [[arr[0]]];
for (let i = 1; i < arr.length; i++) {
const item = arr[i];
if (filterFn(item)) {
result.push([item]);
} else {
result[result.length - 1].push(item);
}
}
return result;
};
export enum parseStrType {
EVAL = "eval",
JSON = "json",
}
export const parseStr = (
str: string,
type: parseStrType = parseStrType.JSON
) => {
const parseMethod = {
[parseStrType.EVAL]: (v: string): T => new Function(`return ${v}`)(),
[parseStrType.JSON]: JSON.parse,
};
let res: T | null = null;
try {
const method = parseMethod[type];
if (method) {
res = method(str);
}
} catch (error) {
console.error(`[parse data error]:${error}`);
}
return res;
};
export const isValidJSON = (val: string) => {
try {
const res = JSON.parse(val);
return res !== null;
} catch (error) {
console.log("isValidJSON:", error);
return false;
}
};
第一个工具函数,我们在前文已经讲到过,这里不做过多解释。后面2个工具函数也很好理解,我们先来看parseStr工具函数。
该工具函数用于根据指定的解析类型(eval
或 json
)将传入的字符串 str
解析为相应的 JavaScript 数据类型。具体来说,它提供了两种解析方式:
- 使用
eval
解析字符串 - 使用
JSON.parse
解析字符串
详细解读:
1. parseStrType
枚举
typescript 代码解读复制代码export enum parseStrType {
EVAL = "eval",
JSON = "json",
}
parseStrType
是一个枚举,定义了两个解析类型:EVAL
:使用eval
来解析字符串。JSON
:使用JSON.parse
来解析字符串。
枚举的作用是让代码更加可读,避免硬编码字符串(如 "eval"
或 "json"
)出现在多个地方,使得代码的意图更清晰,并提高可维护性。
2. parseStr
函数
typescript 代码解读复制代码export const parseStr = (
str: string,
type: parseStrType = parseStrType.JSON
) => {
parseStr
是一个泛型函数,接收两个参数:str
: 要解析的字符串(string
类型)。type
: 指定解析类型的枚举,默认为parseStrType.JSON
,即使用JSON.parse
解析。
泛型 T
使得返回值可以根据调用时的需要动态推断出类型,提供类型安全。
3. parseMethod
对象
typescript 代码解读复制代码const parseMethod = {
[parseStrType.EVAL]: (v: string): T => new Function(`return ${v}`)(),
[parseStrType.JSON]: JSON.parse,
};
parseMethod
是一个对象,存储了两种解析方法:- 对于
parseStrType.EVAL
,使用new Function('return ${v}')()
来动态解析字符串。Function
构造函数可以将一个字符串作为 JavaScript 代码执行,实际上类似于使用eval
,但是使用Function
是一种更安全的方式,因为它不会访问当前的作用域,只能访问全局作用域。 - 对于
parseStrType.JSON
,直接使用JSON.parse
方法来解析 JSON 字符串。
- 对于
4. 解析逻辑
typescript 代码解读复制代码let res: T | null = null;
try {
const method = parseMethod[type];
if (method) {
res = method(str);
}
} catch (error) {
console.error(`[parse data error]:${error}`);
}
- 定义了一个变量
res
来存储解析结果,初始值为null
。 - 在
try
块中,函数首先根据type
获取相应的解析方法(parseMethod[type]
)。 - 如果找到了对应的解析方法(即
method
不为null
或undefined
),则调用该方法来解析传入的str
字符串,并将结果赋值给res
。 - 如果解析过程中发生异常(例如,字符串格式不正确),则会进入
catch
块,打印错误信息。
5. 返回解析结果
typescript 代码解读复制代码return res;
- 返回最终解析的结果。如果解析成功,返回解析后的值;如果出现异常或没有正确的解析结果,返回
null
。
代码示例:
typescript 代码解读复制代码const jsonString = '{"name": "John", "age": 30}';
const result1 = parseStr(jsonString, parseStrType.JSON);
console.log(result1); // { name: "John", age: 30 }
const evalString = '2 + 2';
const result2 = parseStr(evalString, parseStrType.EVAL);
console.log(result2); // 4
接下来,我们来看第二个工具函数。
该工具函数用于验证一个字符串是否是有效的 JSON 格式。以下是逐行解读:
函数签名:
typescript 代码解读复制代码export const isValidJSON = (val: string) => {
//...
}
isValidJSON
是一个箭头函数,它接受一个参数val
,类型是string
,代表需要验证的字符串。export
表明该函数可以被导入到其他文件中使用。
解析字符串并检查其有效性:
typescript 代码解读复制代码try {
const res = JSON.parse(val);
return res !== null;
} catch (error) {
console.log("isValidJSON:", error);
return false;
}
-
try
块:- 在
try
块中,函数尝试通过JSON.parse(val)
将传入的字符串val
解析成一个 JavaScript 对象。JSON.parse(val)
会尝试将val
解析为一个 JSON 对象。如果字符串是有效的 JSON 格式,它将返回一个对应的 JavaScript 对象或数据结构。
- 在
-
return res !== null;
:- 如果
JSON.parse
没有抛出错误(即字符串是有效的 JSON 格式),接下来会检查解析结果res
是否为null
。JSON.parse
会成功解析有效的 JSON 字符串,返回对应的 JavaScript 对象或值。如果res
是null
,则返回false
(这意味着 JSON 解析结果是null
,例如{}
或其他有效的 JSON 对象,不能单纯地认定为有效 JSON)。
- 如果
res
不是null
(比如一个合法的对象、数组、数字等),则返回true
,表示该字符串是有效的 JSON。
- 如果
-
catch
块:- 如果
JSON.parse(val)
解析过程中抛出错误(例如,字符串格式不符合 JSON 规范),会进入catch
块。catch
捕获到的error
会被打印出来,输出信息为"isValidJSON:"
后跟错误内容。- 在
catch
块中返回false
,表示传入的字符串不是有效的 JSON。
- 如果
使用示例:
typescript 代码解读复制代码console.log(isValidJSON('{"name": "John", "age": 30}')); // true
console.log(isValidJSON('{"name": "John", age: 30}')); // false (invalid JSON format)
console.log(isValidJSON('null')); // false (valid JSON but is `null`)
4. 缓存数据
由于真实业务场景中,我们需要缓存数据,因此在这里,我封装了一个响应式监听会话存储的hooks。代码如下所示:
ts 代码解读复制代码import { useState, useEffect } from "react";
import { parseStr } from "../utils/utils";
export enum StorageType {
LOCAL = "local",
SESSION = "session",
}
function useStorage(
key: string,
initialValue: T,
storage: StorageType = StorageType.LOCAL
) {
const currentStorage =
storage === StorageType.LOCAL ? localStorage : sessionStorage;
const getStoredValue = () => {
const saved = currentStorage.getItem(key);
if (saved !== null) {
return parseStr(saved);
} else {
return initialValue;
}
};
const [storedValue, setStoredValue] = useState(() => getStoredValue());
useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
setStoredValue(event.newValue ? parseStr(event.newValue) : null);
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
const setValue = (value: T) => {
setStoredValue(value);
currentStorage.setItem(key, JSON.stringify(value));
};
return [storedValue, setValue] as const;
}
export default useStorage;
这段代码定义了一个名为 useStorage
的 React 自定义 Hook,用于在浏览器的 localStorage
或 sessionStorage
中存储和读取数据。它支持类型安全,并提供了一些自动同步的功能。以下是对每一部分的详细解读:
1. StorageType 枚举:
typescript 代码解读复制代码export enum StorageType {
LOCAL = "local",
SESSION = "session",
}
- 定义了一个
StorageType
枚举,用于表示存储的类型。LOCAL
:表示使用localStorage
,即数据在浏览器关闭后依然存在。SESSION
:表示使用sessionStorage
,即数据只在当前会话中存在,浏览器关闭后数据会丢失。
2. useStorage
自定义 Hook:
typescript 代码解读复制代码function useStorage(
key: string,
initialValue: T,
storage: StorageType = StorageType.LOCAL
)
useStorage
是一个泛型函数,接受以下参数:key
:存储数据的键名。initialValue
:如果在存储中没有找到对应的值,使用的默认值。storage
:指定使用哪种存储类型(localStorage
或sessionStorage
),默认使用localStorage
。
3. currentStorage
选择存储类型:
typescript 代码解读复制代码const currentStorage =
storage === StorageType.LOCAL ? localStorage : sessionStorage;
- 根据传入的
storage
参数,决定使用localStorage
还是sessionStorage
。
4. getStoredValue
函数:
typescript 代码解读复制代码const getStoredValue = () => {
const saved = currentStorage.getItem(key);
if (saved !== null) {
return parseStr(saved);
} else {
return initialValue;
}
};
getStoredValue
函数从currentStorage
中获取数据:- 如果存储中找到了对应的
key
,则解析存储的字符串(通过parseStr
)并返回值。 - 如果存储中没有数据(即
saved === null
),则返回initialValue
作为默认值。 parseStr
函数用来将存储的字符串反序列化为 JavaScript 对象,代码在前面有说明。
- 如果存储中找到了对应的
5. useState
用来管理存储值:
typescript 代码解读复制代码const [storedValue, setStoredValue] = useState(() => getStoredValue());
useState
用来管理存储的值。初始值通过getStoredValue
函数获取,storedValue
存储实际值,setStoredValue
是更新该值的函数。useState
使用懒初始化,getStoredValue
只在组件首次渲染时执行一次。
6. useEffect
监听 Storage 事件:
typescript 代码解读复制代码useEffect(() => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === key) {
setStoredValue(event.newValue ? parseStr(event.newValue) : null);
}
};
window.addEventListener("storage", handleStorageChange);
return () => {
window.removeEventListener("storage", handleStorageChange);
};
}, [key]);
useEffect
用来监听storage
事件,以便在其他窗口或标签页中更改了相同key
对应的存储值时,自动同步更新当前窗口或标签页中的存储值。handleStorageChange
函数处理存储变化,检查event.key
是否与当前key
匹配。如果匹配,就通过setStoredValue
更新值。useEffect
会在组件挂载时添加事件监听器,在组件卸载时移除事件监听器,避免内存泄漏。key
作为依赖项,意味着只有当key
发生变化时,useEffect
才会重新执行。
7. setValue
函数更新存储值:
typescript 代码解读复制代码const setValue = (value: T) => {
setStoredValue(value);
currentStorage.setItem(key, JSON.stringify(value));
};
setValue
函数更新存储值:- 首先通过
setStoredValue
更新 React 状态(storedValue
)。 - 然后通过
currentStorage.setItem(key, JSON.stringify(value))
将新值存储到localStorage
或sessionStorage
中。这里使用JSON.stringify
将值转化为 JSON 字符串存储。
- 首先通过
8. 返回值:
typescript 代码解读复制代码return [storedValue, setValue] as const;
- 该 Hook 返回一个元组,包含当前存储的值和更新存储值的函数。
as const
用于确保返回的元组类型是固定的(即返回的是一个元组类型而不是普通数组),这样调用时可以保证类型安全。
9. 默认导出:
typescript 代码解读复制代码export default useStorage;
- 默认导出
useStorage
函数,允许在其他地方使用它。
使用示例:
typescript 代码解读复制代码const [user, setUser] = useStorage('user', { name: 'John', age: 30 });
// 获取当前存储的值
console.log(user); // { name: 'John', age: 30 }
// 更新存储的值
setUser({ name: 'Jane', age: 25 });
这个 Hook 使得在 React 中使用浏览器的存储(localStorage
或 sessionStorage
)更加简单和方便,同时保证了类型的安全性。
接下来,我们就需要实现一个聊天界面,由于篇幅比较长,所以分成了2篇,让我们继续在下一篇中相会,感谢阅读,如果觉得有用,望不吝啬点赞收藏。
评论记录:
回复评论: