背景
大家好,这里是略懂一点项目管理和前端开发的狗子。
今天发呆的时候想起了之前在和后端对接时的一个小故事,顺便和大家聊聊我在项目开发过程中是怎么做的,开发模块的流程,如果有不好的地方,大家在评论区留言。
开完模块启动会之后,前后端需要对各个功能做出一个工作量的粗评。
在搜索功能中,有几种混合的搜索功能:
- 筛选 + 输入框搜索
- 筛选 + 选择框搜索
- 高级搜索
后端沉思了一小会,说这块大概1d就可以做完,并且开始进行接口的对接。
我考虑了一会,掐指一算,还是按照2d的时间计算比较好,这个里面的逻辑还是有点东西的。
模块工作量评估
不知道大家之前是如何做模块工作量的评估,我简单说说我上一家公司的方案。
由于之前项目是走瀑布流程,所以最开始的估算是用的三点估算法 ,主要由最悲观时间 、最可能时间 和最乐观时间来算平均值。
不了解的同学,我简单找了一个介绍 三点估算法 ,很了解的就不用看了。
后续逐渐走向敏捷流程,我就和组里的小伙伴改用了敏捷扑克 来做基础的估算。 这里我也找了一个链接来介绍:扑克牌法。
我的工作量粗估
当时在开会肯定不可能用扑克来估算(会被 ** 哦),由于组件库已有需求所述的业务组件,只需要我写代码的组装逻辑,所以我大概想了一下任务用时:
一天按照6小时有效工作时间来算(其余时间不是摸鱼哈,只是做了无用功)
单次任务的最低计算时间是0.5h
任务 | 时间 |
---|---|
静态代码(vue Template) | 2h |
静态代码(less ZH和EN版本的微调) | 1h |
TS代码及类型 | 6h |
接口联调掰扯 | 1h |
风险时间 | 2h |
接下来一切商定就绪,准备开干。
开发过程中
在开发之前,我大概想了想整个逻辑的流程是什么样,流程图如下:
通过流程图可知,无论走哪个搜索,最终都需要到整合数据的流程里面,所以它就是核心函数。
获得后端接口数据结构
众所周知,后端会出设计文档,前端可有可无,整个模块的设计均由后端完成(之前的公司是这样),所以我在第一时间就可以从API接口平台获取到后端的接口字段类型。
这里插一句嘴,不管大家之前用的什么API接口平台,能够搭建在内网的 Eolinker 还是很好用的,有需要的可以了解一下。
然后,我就拿到了请求的数据结构,我直接转为TS,命名我随意写个 (〃'▽'〃):
ts
/** 搜索的数据结构 */
interface SearchDataParams {
type: string;
platform: string;
system: string;
}
/** 返回的数据结构 */
interface GetDataParams {
ipOrName: string;
group: string;
search: SearchDataParams;
}
我想通过这两个数据结构大家肯定能明白每个字段的意思吧?不明白的话,后续会有说明。
诶,这样一看,整体还算是比较简单的,没有什么复杂的结构,其实这里就埋下了一个雷,不知道有没有小伙伴结合之前的需求看懂的。
开始正式进入编码阶段
静态界面整体大概长这个样子:
按照这个静态界面,我就得到了一个简单的静态逻辑:
ts
const formData = ref({
select: 'ipOrName',
group: '',
searchData: ''
});
const placeholder = computed(() => {
if (formData.value.select === 'ipOrName') {
return '请输入ip或名称';
}
if (formData.value.select === 'group') {
return '请输入所属组';
}
return '';
});
处理简单搜索数据
然后顺其自然开始写submit的逻辑,然后就有意思了,result报错(必定报错哈),类型不对。
不对就添加所需属性吧,突然意识到属性应该都是可选的,于是赶快和后端掰扯一下ヾ(=・ω・=)o
ts
async function handleSubmit() {
if (!formData.value.input) return;
// 处理提交逻辑
const result: GetDataParams = {}
// 模拟一个请求
await Promise.resolve(result);
}
修改后的interface如下 (〃´-ω・) :
ts
/** 搜索的数据结构 */
interface SearchDataParams {
type?: string;
platform?: string;
system?: string;
}
/** 返回的数据结构 */
interface GetDataParams {
ipOrName?: string;
group?: string;
search?: SearchDataParams;
}
于是第一个逻辑链中的代码就完成了,当时想了想,应该是没问题了!( • ̀ω•́ )✧
处理选择所属组的数据
接下来在不管高级搜索的情况下,我们需要持续扩展handleSubmit
函数,让其能够实现所属组的数据处理。
于是就加入了处理代码:
ts
if (formData.value.select === 'ipOrName') {
result.ipOrName = formData.value.input;
}
if (formData.value.select === 'group') {
result.group = formData.value.group;
}
所属组的显示也让输入框无法选择(没看到readonly的选项,改成disabled了),后面的按钮也变为了选择按钮(找了个相似的)。
input里面显示的内容也需要小调整,在选择了n个数据之后,需要在输入框内显示已选择n个组。
OK,这就衍生出来了一个函数handleSelectGroup。
ts
...
<select-group @select="handleSelectGroup"/>
...
function handleSelectGroup(value: number[]) {
formData.value.input = `已选择${value.length}个组`;
formData.value.group = value.join(',');
}
具体select-group
组件是如何处理的,这里我们不做分析,毕竟是已经写好的组件。
如果用了国际化组件,input里面需要加替换符,这里是Demo就不管了。
处理高级搜索的数据
这一块最麻烦,咱们需要开发一个form的例子来截图,但是我想说的重点不是form,所以直接跳过代码展示,咱们直接到数据处理中,简单画一个图表示一下:
大概就是这个样子
因为高级搜索是需要涵盖普通搜索的所以最终输出的结果是一个包含所有字段的对象,我个人比较偷懒,所以直接和GetDataParams
类型一致,于是有了handleSearchPro
方法:
ts
function handleSearchPro(search: GetDataParams) {}
但是,反观上面的缓存数据结构,我们并没有做到直接存储search对象中的数据,那么怎么办呢?
还能咋办,凉拌,加字段(有时候这就是我们一点一点开发的无奈,字段越加越多)。
formData改为:
ts
const formData = ref({
select: 'ipOrName',
group: '',
input: '',
search: {} as SearchDataParams,
});
同时在 handleSubmit
方法的result中加入search属性(这也是一个坑,后面补了):
ts
const result: GetDataParams = {
search: formData.value.search,
}
然后补全handleSearchPro
方法(暂时的):
ts
function handleSearchPro(params: GetDataParams) {
formData.value.search = params.search || {};
if (params.ipOrName) {
formData.value.input = params.ipOrName;
}
if (params.group) {
formData.value.group = params.group;
formData.value.input = params.ipOrName || `已选择${params.group.split(',').length}个组`;
}
}
写到这里就会有聪明的小伙伴发现了,我们的formData.value.select
咋办,怎么处理呢?
ipOrName
在提交的时候又依赖于input
的输入
那就只能拆分了,于是我们的formData
拆分出来一个searchInputData
ts
const formData = ref({
ipOrName: '',
group: '',
search: {} as SearchDataParams,
});
const searchInputData = ref({
select: 'ipOrName',
input: '',
});
formData
不再绑定搜索框,改由searchInputData
。
最后handleSearchPro
方法改为:
ts
function handleSearchPro(params: GetDataParams) {
Object.assign(formData.value, params);
// 清空简单搜索
searchInputData.value.input = '';
searchInputData.value.select = 'ipOrName';
}
整体逻辑就基本完成了罒ω罒
异常处理
接下来到了每个模块都有的异常处理,在高级搜索这个需求里面,我简单的写了几个例子:
- 输入框边界及特殊字符报错提示
- 刷新时,如果输入框异常,则过滤
- 高级搜索表单校验
- 返回数据异常提示
简单的处理
其中,问题1、3、4最为好做
问题1在输入框输入时判断输入内容是否合理即可:
问题4在await 请求
之后做toast提示即可:
稍微不简单的处理
问题2比较有意思,是之前开发时发现的一个问题,在输入后,如果出现特殊字符不允许提交,但是此时我点击了刷新。
众所周知,每次点击刷新时是会触发请求并且带上搜索数据,在我这里就是触发handleSubmit
方法。
如果是你,你会把搜索数据直接带上去吗?Σ(っ°Д°;)っ
如果不带,那你会用上一次搜索的input
内容,还是一个空的字符串给到ipOrName
中呢?
我选择带上上一次请求的数据过去,所以需要将上一次的数据作为缓存,避免input中的v-modal将数据修改,代码如下:
联调
最终开始联调,进入模块开发的尾声,果然还是出现了问题,高级搜索提交时报错了。╮(╯﹏╰)╭
后端为了安全强校验输入参数
后端由go进行开发,为了接口的安全,对前端的传参进行了校验。
-
场景校验:
如果判断不是高级搜索,则不需要
search
字段。 -
字符内容不为空校验
如果这个字段存在,那它里面一定有数据,没有数据则会报错。
这两个条件。。。。哭死,又是没有预测到的工作量(〃>皿<)。
于是,在请求发出去之前,将空字段进行过滤的函数就出现了:
ts
// 写一个函数,传入一个对象,返回一个过滤掉所有空值的key的对象
function filterEmptyKey<T extends object>(obj: T) {
return Object.keys(obj).reduce((acc, key) => {
const content = obj[key as keyof T];
if (
content === null ||
content === undefined ||
content === '' ||
(Array.isArray(content) && content.length === 0) ||
(content instanceof Object && Object.keys(content).length === 0)
) {
return acc;
}
acc[key as keyof T] = content;
return acc;
}, {} as Pick<T, keyof T>);
}
该函数简单过滤了非空的情况,将请求对象中存在的空数据过滤了出去。
到此,我终于松了一口气,可以提测了(。◕ˇ∀ˇ◕)!
结尾
哈哈,终于写完了,你问我这篇文章到底想表达什么?其实是带不会的同学走了一个开发流程,会的同学看个故事,每个人都有自己的故事,不喜勿喷~
完结,撒花\( ̄︶ ̄)/
Demo示例用的 antdv 组件库。
画图用的 drow.io,打不开的同学自己想想这几年是不是没有努力?( ̄▽ ̄)~*