相似的业务代码

俗话说, 人活得久, 什么都能遇到, 前端搬砖久了, 各种代码也就遇到了, 在这几年的搬砖生活中,
尤其是 To B 的项目, 不难发现, 有很多需求都是重复类似的

比如:

  • 一个输入框, BA(业务分析师) 告诉你 不能输入空格, 第二天突然说需求改成 中间不能有空格

  • 一个展示数据的表格, 有一些过滤项和分页, 需要将过滤条件和分页页码保存起来, 以支持刷新之后保持上一次搜索条件

  • 一个表单项, 点击新增按钮, 点击一次, 出现一个 input 框, 还可以删除

是不是非常的熟悉且怀念,似乎还有一点马上打开编辑器写上几行代码的冲动

怎么办?

程序员有三宝, 复制粘贴头发少, 遇到类似的需求, 也许会打开以前的项目,复制粘贴代码过来, 修修补补搞定,
亦或是封装一些 高阶组件 来搞定需求, 还有没有其他的解决方案呢? 众所周知, react hook 解决了逻辑复用的问题, 似乎不错的样子,
那么就来尝试一下

去除空格

去除空格 这个常见的需求来说, 我们自定义一个 useTrimInput 的 hook`

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useState, useCallback } from 'react'

const useTrimInput = (fullTrim: boolean = false): UseTrimInputReturn => {
const [value, setValue] = useState<string>('')
const setTrimValue = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
},
[setValue],
)
}

export default useTrimInput

首先定义一个 value state, 用来保存当前 input 的 值, 使其是一个受控组件, 再定义 setTrimValue 接受当前 eventvalue 值,
逻辑非常简单

接下来就需要对当前 state 的 value 进行 trim, fullTrim 参数表示是否去除 首尾和中间所有的空格

定义 trim 方法

1
2
3
4
5
6
7
// utils/string.ts
export const trim = (target: string, fullTrim: boolean = false) => {
if (!fullTrim) {
return target.trim()
}
return target.replace(/\s/gm, '')
}

最后我们使用一个函数版本的 gettervalue 进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useState, useCallback, useMemo } from 'react'
import { trim } from '../../utils/string'

export type UseTrimInputReturn = [
string,
(event: React.ChangeEvent<HTMLInputElement>) => void,
]

const useTrimInput = (fullTrim: boolean = false): UseTrimInputReturn => {
const [value, setValue] = useState<string>('')
const setTrimValue = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
},
[setValue],
)

const trimValue: string = useMemo(() => {
return trim(value, fullTrim)
}, [value, fullTrim])

return [trimValue, setTrimValue]
}

export default useTrimInput

经过简单的封装, 这样一个常见的业务场景的自定义 hook 就写好了

效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React from 'react'
import { Form, Input } from 'antd'

const Example = () => {
const [trimValue, setTrimValue] = useTrimInput()
const [fullTrimValue, setFullTrimValue] = useTrimInput(true)
return (
<>
<input
value={trimValue}
onChange={setTrimValue}
placeholder="请输入姓名"
/>
<input
value={fullTrimValue}
onChange={setFullTrimValue}
placeholder="请输入姓名"
/>
</>
)
}

export default Example

https://cdn.lijinke.cn/2020-03-09%2021.19.26.gif

另一个例子: 分页和过滤项同步到url

梳理一下这个需求常见的步骤

  1. 默认第一页 (page = 1)
  2. 单击页码 (第二页), 发起ajax请求 (page = 2)
  3. 将当前页码 (2) 同步到 url 上, 这时候地址栏为 http://xxx.com/list?page=2
  4. 如果用户刷新, 需要拿到当前地址栏的 { page: 2 } 赋值给 Table 组件, 并且请求( page = 2 ) 的数据
  5. 特殊情况, 由于地址栏拿到的都是 String 类型的值, 过滤项往往除了 page=2 这种数字之外, 还有常见的各种类型
  6. 可能会有默认值,比如一进页面就显示 http://xxx.com/list?filter=xxx
1
2
3
4
5
6
7
{
name: 'test',
like: false,
success: true,
age: 18,
friends: ['a', 'b'],
}

更恐怖的是从地址栏拿到后, 还需要转换成设置之前的数据类型, 也就是

  • ?name=test => { name: 'test' }
  • ?age=18 => { name: 18 }
  • ?success=true => { success: true }

细思极恐, 仔细梳理之后, 分析出几个关键点

  1. 数据类型之间的互相转换, 提供一个类似 mongooseSchema 机制, 预先声明好每一个字段的类型和默认值
  2. 增加删除指定的字段

第1点的灵感其实来自项目中一个很厉害同事的灵感, 这里借鉴(抄袭)了一下 :)

首先看实现效果

页码同步
https://cdn.lijinke.cn/2020-03-09%2021.35.06.gif

不同类型的参数自动转换
https://cdn.lijinke.cn/2020-03-09%2021.35.53.gif

代码实现

首先定义支持的数据类型

1
2
3
4
5
6
export enum UseSearchParamsSchemaType {
STRING = 'STRING',
NUMBER = 'NUMBER',
ARRAY = 'ARRAY',
BOOLEAN = 'BOOLEAN',
}

然后是返回类型

1
2
3
4
5
6
export interface UseSearchParamsReturn<T> {
searchParams: T // 当前搜索参数
remove: (keys?: Array<keyof T>) => void // 移除, 如果没有指定 keys 就移除全部
reset: () => void // 重置搜索参数 (保留默认值)
set: (values: Partial<T>) => void // 新增一个值, 如果已存在就更新
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useCallback, useMemo, useEffect, useState } from 'react'
import { useHistory, useLocation } from 'react-router-dom'

const useSearchParams = <T extends {}>({
schema,
pathname: _pathname,
}: UseSearchParamsParams): UseSearchParamsReturn<T> => {
const history = useHistory()
const location = useLocation()
const [paramKeys, setParamKeys] = useState<(keyof T)[]>([])
const urlSearchParams = useMemo(
() => new URLSearchParams(location.search),
[],
)
const pathname = _pathname || location.pathname
}
  • 我们使用浏览器提供的 URLSearchParams API 来处理 URLSearchParams
  • 使用 react-router-dom 提供的 useHistoryuseLocation 方便的获取到当前应用的 historylocation, 用于跳转

接下来是 处理 默认值, 我们规定一个 schema 长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const schema = {
name: UseSearchParamsSchemaType.STRING,
like: UseSearchParamsSchemaType.BOOLEAN,
success: UseSearchParamsSchemaType.BOOLEAN,
age: {
type: UseSearchParamsSchemaType.NUMBER,
default: 10,
},
test: {
type: UseSearchParamsSchemaType.STRING,
default: 'defaultValue',
},
friends: UseSearchParamsSchemaType.ARRAY,
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 将两种定义方式 统一成一种数据结构
const getFieldByKey = useCallback((key: string) => {
const { type, default: defaultValue } = Object.is(
typeof schema[key],
'object',
)
? (schema[key] as UseSearchParamsSchemaDetail)
: { type: schema[key], default: null }
return {
type,
defaultValue,
}
}, [])

// 得到有默认值的一组 map, 用于还原
const getDefaultValues = useCallback(() => {
return Object.keys(schema).reduce((obj, key) => {
const { defaultValue } = getFieldByKey(key)
if (defaultValue) {
obj[key] = defaultValue
}
return obj
}, {} as UseSearchParamsSchema)
}, [schema])

// 使用 useMemo 得到默认值的 getter
const defaultValues = useMemo<UseSearchParamsSchema>(getDefaultValues, [])

通过 schema 解析了默认值后, 我们需要将 defaultValues 同步设置到 urlSearchParams, urlSearchParams 不支持 set 一个数组, 所以只能遍历

1
2
3
4
5
 const setDefaultValues = useCallback(() => {
Object.keys(defaultValues).forEach(key => {
urlSearchParams.set(key, defaultValues[key] as string)
})
}, [defaultValues])

接下来实现 set 方法: 用于增加或更新一个字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 // 调用 history.push 更新路由的 search
const updateLocation = useCallback(() => {
urlSearchParams.sort()
history.push({
pathname,
search: urlSearchParams.toString(),
})
}, [history, pathname, urlSearchParams])

const set = useCallback(
(values: Partial<T>) => {
Object.keys(values).forEach(key => {
const value = ((values as unknown) as UseSearchParamsSchema)[key]
if (value) {
if (Array.isArray(value)) {
value.forEach(v => {
urlSearchParams.append(key, v)
})
} else {
urlSearchParams.set(key, value as string)
}
}
})
updateLocation()
},
[updateLocation],
)

set(values)values 同步到 urlSearchParams 后, 我们调用 history.push 即可更新 url 的 search 参数,

值得注意的是,数组的展开方式是 通过 repeat 的方式, 也就是说:

1
2
3
4
5
const a = [1,2]

// 等于

'a=1&a=2'

所以这里要区别对待

1
2
3
4
5
6
7
if (Array.isArray(value)) {
value.forEach(v => {
urlSearchParams.append(key, v)
})
} else {
urlSearchParams.set(key, value as string)
}

完成了设置, 还差关键的一部,就是数据类型之间的互相转换,有了之前定义好的 Schema, 这非常容易实现

数据类型解析并转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const parseValue = useCallback(
(key: string, value: string) => {
const { type } = getFieldByKey(key)
let newValue: any

switch (type) {
case UseSearchParamsSchemaType.STRING:
newValue = value !== 'undefined' ? String(value) : ''
break
case UseSearchParamsSchemaType.NUMBER:
newValue = Number(value)
break
case UseSearchParamsSchemaType.BOOLEAN:
newValue = value === 'true'
break
case UseSearchParamsSchemaType.ARRAY:
newValue = urlSearchParams.getAll(key)
break
default:
break
}
return newValue
},
[getFieldByKey],
)
  • 对于 String 类型, 我们进行强制类型转换即可 String(value), 值得注意, 由于url是字符串, 所以会出现字符串的 "undefined", 转成空字符串即可
  • 对于 NUMBER 类型,同样强制类型转换即可 Number(value)
  • 对于 BOOLEAN 类型, 得到是字符串的boolean值, 也就是 true | false, 所以这里用 value === 'true' (三个等号) 进行不做隐私类型转换的比较, 得到真正的 boolean
  • 对于 ARRAY 类型, 很方便, 使用 urlSearchParams 提供的 getAll api 即可

这样疯狂操作一波, 一个常见的业务需求就搞定了, 是不是可以早点下班了呢? 其余 removereset 方法等参不多类似, 就不一一列举了,

为了方便使用, 我发布了一个 npm 包 react-7h-hooks, 感兴趣的可以查看源码

结语

相对于 高阶组件, hooks 的方式更便于我们封装逻辑, 同时代码层级是扁平化的,便于维护, 如果不是使用了 hooks, 节省了我1个小时的时间,也不会有空写这篇文章了

完.