前端基建 ——— 接口、数据、类型在前端开发中的管理及应用
背景
前端基建,包括构建工具、规范与质量保障、自动化测试、组件库、前后端协作
等等
目的是为了实现标准化 + 规范化 + 工具化 + 自动化,业务支撑是活在当下,技术基建是活好未来
本文主要针对前后端协作及质量保障展开。
接口、数据、类型贯穿了开发流程的始终:
开发过程中的痛点:
- 前端 mock 数据写在项目代码里,如果没有及时清理,会增加不必要的项目体积
- 前期开发阶段没有真实的接口调用全流程,影响联调
- 后端给出的接口文档不具备修改通知和 diff 功能,需要仔细询问核对哪些经过修改,且后续无历史记录
- TS 使用不高效,没有起到应有的作用
- 后端多个服务互相调用时,频繁出现数据类型变动的问题,未及时发现可能导致 bug
如何改进?
- 可追溯、可 mock、交互友好的接口文档、测试平台
- 更高效、规范地使用 TS
- 建立运行时数据校验体系
接口测试平台
yapi 简介
yapi 是一个开源的接口管理平台,目前 star 数量 27.3k,具备以下特性:
- 权限管理
- 项目管理
- 可视化接口管理,接口修改记录可追溯
- 基于 mock.js,方便的 mock 数据生成器
- 接口自动化测试
- 数据导入,支持 swagger、postman、har 数据格式,便于迁移
快速上手
新建项目->新建分类->添加接口
手动演示
配置接口代理,使用nginx
或者代理工具
配置代理
举例 nginx 代理配置
server {
listen 443 ssl;
server_name test.jarrett.com;
ssl_certificate SSL/test.jarrett.com.crt;
ssl_certificate_key SSL/test.jarrett.com.key;
location / {
proxy_pass https://xx.xx.x.xxx:443/;
proxy_set_header Host https://xx.xx.x.xxx:443/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Photo $scheme;
}
location /api/getUsers{
proxy_pass http://xx.xx.x.xxx:3000/mock/83/api/getUsers;
proxy_set_header Host http://xx.xx.x.xxx:3000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Photo $scheme;
}
}
vite 热更新配置
{
"server": {
"hmr": {
"port": 8888 // 对应nginx监听的端口
}
}
}
接口测试
创建模拟数据
mockjs 使用语法
常用语法
{
"age|1-100": 100,
"value|123.3": 123.111,
"isVip|1-2": true,
"list|1-100": [{ "name": "test" }],
"address|2": {
"310000": "上海市",
"320000": "江苏省",
"330000": "浙江省",
"340000": "安徽省"
}
}
json-schema
高级 mock
Mock 期望
- 自定义过滤规则,返回自定义数据,支持 mock
- 可定义接口延时
- 可定义 http 状态码
自定义 Mock 脚本
全局变量
请求
header
请求的 HTTP 头params
请求参数,包括 Body、Query 中所有参数cookie
请求带的 Cookies
响应
mockJson
接口定义的响应数据 Mock 模板resHeader
响应的 HTTP 头httpCode
响应的 HTTP 状态码delay
Mock 响应延时,单位为 msRandom
Mock.Random 方法,可以添加自定义占位符,详细使用方法请查看
示例 1,根据请求参数重写 mockJson
if (params.type == 1) {
mockJson.errcode = 400;
mockJson.errmsg = 'error';
}
if (header.token == 't') {
mockJson.errcode = 300;
mockJson.errmsg = 'error';
}
if (cookie.type == 'a') {
mockJson.errcode = 500;
mockJson.errmsg = 'error';
}
自动化测试
传统的接口自动化测试成本高,大量的项目没有使用自动化测试保证接口的质量,仅仅依靠手动测试,是非常不可靠和容易出错的。
YApi 为了解决这个问题,开发了可视化接口自动化测试功能,只需要配置每个接口的入参和对 RESPONSE 断言,即可实现对接口的自动化测试,大大提升了接口测试的效率。
用例之间可以互相引用数据
竞品
市面上还有一些商业化接口管理平台,如 Apifox,提供了更加丰富的功能,可付费私有化部署
数据模型
组件
10 分钟速通 TS
TS 必知必会
用 20% 的知识解决 80% 的日常开发
type & interface
- type 类型别名。适用于定义复杂的类型组合,如联合类型和交叉类型。当需要表示一个值可能是多种类型之一,或者一个类型需要同时满足多种类型的特征时,类型别名非常方便
- interface 接口。更适合用于定义对象的形状,尤其是在面向对象编程或者定义 API 的返回值和参数类型时。当需要描述一个类应该实现的契约(如具有哪些方法和属性)时,接口是很好的选择
条件类型
extends 既可以用于继承,也可用于类型约束
namespace
用于防止类型重命名冲突,比如微信小程序提供了全局类型的 namespace
泛型
合理使用泛型是 ts 的精髓,能够更好的封装通用方法
学会泛型能够轻松阅读各类社区库的类型声明,良好的变量命名、类型声明很多时候可以替代文档
枚举 & 常量枚举
业务代码中常有关于枚举的判断,比如data.status===1
,这可能分散在各个项目文件,并且缺失语义
建议
如果不想在业务变动时一处处地逐个修改枚举判断,建议所有使用常量或枚举而不是硬编码
编译结果如下
'use strict';
var Status;
(function (Status) {
Status[(Status['SUCCESS'] = 0)] = 'SUCCESS';
Status[(Status['FAIL'] = 1)] = 'FAIL';
Status[(Status['PENDING'] = 2)] = 'PENDING';
})(Status || (Status = {}));
const _ConstStatus = {
SUCCESS: 0,
FAIL: 1,
PENDING: 2
};
console.log(Status.SUCCESS, 0 /* ConstStatus.SUCCESS */, _ConstStatus.SUCCESS);
Utility Types 实用工具类型
1. Partial<T>
定义和用途: Partial<T>
用于将一个类型 T
的所有属性变为可选的。这在处理对象类型时非常有用,比如在创建对象的部分属性更新函数时。 示例:
interface User {
name: string;
age: number;
email: string;
}
let partialUser: Partial<User> = {
name: 'John'
};
在这个例子中,partialUser
可以只包含 User 接口中部分属性,因为 Partial<User>
将 User
接口的所有属性都变成了可选属性。
2. Required<T>
定义和用途: 与 Partial<T>
相反,Required<T>
将类型 T
的所有可选属性变为必选属性。这有助于确保在某些场景下对象的完整性。 示例:
interface PartialProduct {
name?: string;
price?: number;
}
let requiredProduct: Required<PartialProduct> = {
name: 'Phone',
price: 599
};
这里 Required<PartialProduct>
强制要求 requiredProduct
必须包含 name
和 price
属性,不能有缺失。
3. Readonly<T>
定义和用途: Readonly<T>
用于创建一个新的类型,其所有属性都是只读的。这在需要确保对象属性不被意外修改的场景下非常有用,比如配置对象或者常量对象。 示例:
interface Settings {
theme: string;
fontSize: number;
}
let readonlySettings: Readonly<Settings> = {
theme: 'dark',
fontSize: 14
};
// readonlySettings.theme = 'light'; // 这行代码会报错,因为属性是只读的
4. Pick<T, K>
定义和用途: Pick<T, K>
从类型 T 中挑选出属性集 K 所指定的属性,创建一个新的类型。K 是一个联合类型,代表要选择的属性名。 示例:
interface Car {
brand: string;
model: string;
year: number;
color: string;
}
type CarInfo = Pick<Car, 'brand' | 'model'>;
let myCarInfo: CarInfo = {
brand: 'Toyota',
model: 'Corolla'
};
在这个例子中,CarInfo 类型只包含 Car 接口中的 brand 和 model 属性。
5. Omit<T, K>
定义和用途: 与 Pick<T, K>
相反,Omit<T, K>
从类型 T
中排除属性集 K
所指定的属性,生成一个新的类型。这在想要去除某些不需要的属性时很有用。 示例:
interface Person {
name: string;
age: number;
address: string;
phone: string;
}
type PersonWithoutAddress = Omit<Person, 'address'>;
let person: PersonWithoutAddress = {
name: 'Alice',
age: 30,
phone: '1234567890'
};
这里 PersonWithoutAddress
类型是从 Person
接口中排除了 address
属性后的新类型。
6. Record<K, T>
定义和用途: Record<K, T>
用于创建一个新的类型,其属性键的类型为 K
,属性值的类型为 T
。K
通常是一个字符串字面量类型或者联合类型,T
可以是任何类型。 示例:
type Colors = 'red' | 'green' | 'blue';
type ColorMap = Record<Colors, string>;
let colorMap: ColorMap = {
red: '#FF0000',
green: '#00FF00',
blue: '#0000FF'
};
这个例子中,ColorMap
类型的对象以 Colors
联合类型中的字符串作为键,以 string
类型作为值。
7. Exclude<T, U>
定义和用途: Exclude<T, U>
用于从类型 T
中排除可以赋值给类型 U
的元素,返回剩余的类型。它主要用于处理联合类型。 示例:
type Numbers = 1 | 2 | 3 | 4 | 5;
type OddNumbers = Exclude<Numbers, 2 | 4>;
let oddNumber: OddNumbers = 1;
在这里,OddNumbers
是从 Numbers
联合类型中排除了偶数 2 和 4 后剩下的奇数类型。
8. Extract<T, U>
定义和用途: 与 Exclude<T, U>
相反,Extract<T, U>
从类型 T
中提取可以赋值给类型 U
的元素,生成一个新的类型。 示例:
type AllNumbers = 1 | 2 | 3 | 4 | 5;
type EvenNumbers = Extract<AllNumbers, 2 | 4>;
let evenNumber: EvenNumbers = 2;
此例中,EvenNumbers
是从 AllNumbers
联合类型中提取出偶数 2 和 4 后的类型。
业务应用实践
以上是静态类型校验,那么如何过渡到动态类型校验?
运行时数据校验
解决什么问题
- 后端服务互相调用,数据类型不确定
- 可能出现的属性空值
- 可能由类型导致的问题,比如 falsy 判断、字符串和数字相加
碰到的挑战
经过调研,选用了 zod 库作为方案实现的核心库
该库较为成熟,社区活跃,原生支持 TS,且能够完全对标 TS 语法,一份 schema 同时生成校验器和 TS 静态类型,使用简单、直观、轻便
const safeString = () =>
string().catch(ctx => {
log2wx(ctx.error.message);
try {
const data = JSON.stringify(ctx.input);
return data || '';
} catch (e) {
return '';
}
});
// coerce number 解析如'测试文本'这样的文本字符串,无法类型强转,会parse error
const safeNumber = () =>
number().catch(ctx => {
log2wx(ctx.error.message);
const { success, data } = coerce.number().safeParse(ctx.input);
if (success) return data;
else return 0;
});
const safeBoolean = () =>
boolean().catch(ctx => {
log2wx(ctx.error.message);
const { success, data } = coerce.boolean().safeParse(ctx.input);
if (success) return data;
else return false;
});
const safeArray = <T extends ZodTypeAny>(
schema: T,
params?: RawCreateParams & { filter?: (val: unknown[]) => unknown[] }
) => {
return array(schema).catch(ctx => {
log2wx(ctx.error.message);
if (Array.isArray(ctx.input)) {
return params?.filter ? params.filter(ctx.input) : ctx.input; // 避免混合类型数组报错 [{name:'test'},null]
} else {
return [];
}
});
};
type ObjectParams = ZodObject<ZodRawShape>;
const safeObject = <
T extends
| ObjectParams
| ZodDefault<ObjectParams>
| ZodNullable<ObjectParams>
| ZodDefault<ZodNullable<ObjectParams>>
>(
schema: T
) => {
return schema.catch((ctx: { error: ZodError }) => {
log2wx(ctx.error.message);
return {};
}) as ZodCatch<typeof schema>;
};
const safeLiteral = <T extends Primitive>(schema?: T) =>
literal(schema).catch(ctx => {
log2wx(ctx.error.message);
return schema;
});
const safeNativeEnum = <T extends EnumLike>(schema: T) =>
nativeEnum(schema).catch(ctx => {
log2wx(ctx.error.message);
return Object.values(ctx.input)[0];
});
const safeNull = () =>
zodNull().catch(ctx => {
log2wx(ctx.error.message);
return null;
});
const safeUnion = <T extends Readonly<[ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]>>(schema: T) => {
return union(schema).catch(ctx => {
log2wx(ctx.error.message);
return '';
});
};
const createBaseResponseValidator = <Data extends ZodType, Meta extends ZodType>(
data?: Data,
meta?: Meta
) => {
return safeObject(
object({
code: number(),
msg: string(),
data: preprocess(val => val || null, data || safeNull()),
meta: preprocess(val => val || null, meta || safeNull())
}).partial({
meta: true,
msg: true,
data: true
})
);
};
import { createDetailValitor } from './validator'
class DetailService extends BaseServices {
constructor() {
super();
}
getDetail(query: { orderId: string }) {
return this.sendRequest(
{
url:'/api/detail'
method: 'POST',
query,
validator: createDetailValitor
},
);
}
}
export const detailService = new DetailServices();
/*
enum Status {
SUCCESS,
FAIL
}
export interface ListItem {
name: string;
age: number;
status: Status;
children?: ListItem[];
}
export interface DetailResponse extends BaseResponse {
data: {
id: string;
name: string;
tags: string[];
list: ListItem[];
};
}
*/
const listItem = safeObjectWrap(
object({
status: safeNativeEnum(Status).default(Status.FAIL),
name: safeString().default(''),
age: safeNumber().default(20)
}).passthrough()
);
const createDetailValidator = () => {
type Input = input<typeof listItem> & {
children?: Input[];
};
type Output = output<typeof listItem> & {
children?: Output[];
};
const schema: ZodType<Output, ZodTypeDef, Input> = listItem
.extend({
children: lazy(() => schema.array())
})
.partial({ children: true });
return createBaseResponseValidator(
safeObjectWrap(
object({
id: safeString(),
name: safeString(),
tags: safeArray(safeString()).default([]),
list: safeArray(schema)
}).partial({ list: true })
)
);
};
export const detailSchema = createDetailValidator();
import { detailSchema } from './validator';
type DetailSchema = z.infer<typeof detailSchema>;
export type Data = DetailSchema['data'];
export type List = DetailSchema['data']['list'];
export type DataWithoutId = Omit<Data, 'id'>;