我正在参加「掘金·启航计划」
背景说明
你在公司看到的界面一般都是跟后台强绑定的,也就是你的界面很依赖后台的数据格式;所以在开发的时候我们比较喜欢倾向于等后台接口好了以后再进行,这样接起来就很轻松,而且不容易返工。我们做前端的知道,如果前面是各做各的,那么后期后端的接口如果跟你想的结构差异很大,那么你的界面可能也会相应的进行改变;即便差异不大,我们也要去界面里面修改定义的字段名称,如果界面很多,这个时候改起来就不那么通透。
解决方案
为了解决上面的问题,一般公司开发都是两方共同把接口的格式定义好,然后前后端才开始开发,而前端一般在这个时间节点先做准备工作,比如先封装好公共组件,也就是把跟业务关联少的先做出来,然后后台先写接口,接口的具体实现先不做,先模拟数据,这样前端就可以开始编写界面了,这个的步骤如下:
- 跟后端先定义接口,包括传入的参数和返回的参数;
- 前端先整理界面,编写公共部分组件;
- 后台先定义好接口,不写具体逻辑,直接返回假数据;
- 前端根据后端返回的数据进行具体的业务编写,如果有变动再跟后端协调修改接口;
- 后端接口编写完了再进行联调,对其中的细节进行查缺补漏。
进一步分析
一般前后端的沟通都是取决于公司,或者取决于业务,有些公司就是按照上面的“解决方案”进行开发的,这样挺好的,协调越默契,效果越好;但业务有时候并不是这样的,比如后台可能在忙更紧急的事情,没办法前期就参与到接口定义等工作中,这个时候前端不可能一直等着,那么前端就需要先编写界面,只要出现这种情况就可能出现最初的问题,其实也不是什么大问题,但是根据我的经验返工是最容易出现bug的,如果公司还没有测试的话,这样更是没保险的,后面的逻辑变了,可能前面有一些逻辑变了,但是不一定都能想到,导致没有测试到,这样就很容易出现 bug 。
既然如此前后端开发的分离就显得很重要,而前端怎样才能做到不依赖于后端呢,当然完全不依赖于是不现实的;我们要尽可能不依赖于后端;对于前端来说跟后台最主要的依赖是数据,也就是我们的数据来源于后端;那前端只要做到数据与界面分离就可以了;也就是我们把界面都当做组件来编写,而不是依赖于后端的数据来展示;这样的话前后端只需要在最后的联调阶段进行联调即可,而对于前端来说联调的过程就是将后台的数据转换成组件所需要的数据即可。
这样步骤就可以变成:
- 前端根据UI进行编写组件(不是界面),这样在编写的时候就好把控些;
- 后台编写接口;
- 前端根据后台的接口编写网络请求层和数据转换;
- 联调,对数据进行核对与测试。
这个流程界面层面是很难返工的,除非 UI 也变了,或者交互逻辑变了。
依赖性界面展示
我使用 react-native
进行展示。
假如详情界面如下:
这种界面一般我们第一时间想到的是先封装每一条的组件展示,下面是我进行普通的封装,仅展示 props
的定义:
export type LabelLineNameProps = {
leftTitle?: string
color?: any
rightTitle?: string | number | any | null
percentage?: string
leftStyle?: TextStyle
rightStyle?: TextStyle
icon?: ReactNode
labelIcon?: ReactNode
valuePress?: () => void
onLabelPress?: () => void
style?: ViewStyle
onLayout?: ((event: LayoutChangeEvent) => void) | undefined
textRightProps?: TextProps
}
// 具体就不展示了,这里是组件具体的实现
export default LabelLineName
这样我们在编写业务的时候就简单了很多,像下面这样(部分展示):
<LabelLineName leftTitle="Region" color="white" rightTitle={region} />
<LabelLineName
leftTitle="Device Type"
color="white"
rightTitle={detail?.deviceType}
/>
我们看到我们编写的组件是依赖于后端的,其中 detail?.deviceType
就是后端返回的数据,包括 Device Type
也是写死到界面上的,如果有一天要改变这些名称或者字段,我们都需要去界面里面进行调整的。
假如我们最初开发的时候并没有约定字段,前端认为后台返回的数据格式像下面这样:
{
...
deviceType: "1"
...
}
结果实际上后台返回的数据格式是这样的:
{
...
deviceInfo: {
...
deviceType: "1"
...
}
...
}
这个时候我们就需要将上面的改变成 detail?.deviceInfo?.deviceType
这样的代码;这样子的例子,在这种复杂度来说也是可以接受的,但要是你们后台考虑很周到,是下面这样的:
[
...
{
labelName: "Device Type",
value: "1"
}
...
]
那你的界面就需要变化了;或者你强行将后台的数据变化成你的数据结构,这样也是可以的;如果做到这样也就变成了我们最初说的数据与界面分离;其实也就是说这样也是可以做到界面与数据分离的,但是这样将来维护就不好了,比如你写的 deviceType
,但实际上后台叫 type
,其他人看到就很纳闷,或者说都叫 deviceType ,但突然有一天字段名称改了,或者用新的字段代替,这个时候别人可能直接改了你界面里面的字段名称,这种情况下就会出现拿不到值或者拿到值值也是不准确的情况。所以我们在编写组件的时候尽量使用通用的名称。
大家应该知道 react-native
中的 SectionList
组件,这个组件对其数据格式是有要求的,并不是随便什么二维数组都可以的,而是像下面这样的结构:
export interface SectionBase<ItemT, SectionT = DefaultSectionT> {
data: ReadonlyArray<ItemT>;
key?: string;
renderItem?: SectionListRenderItem<ItemT, SectionT>;
ItemSeparatorComponent?: React.ComponentType<any> | null;
keyExtractor?: (item: ItemT, index: number) => string;
}
export type SectionListData<ItemT, SectionT = DefaultSectionT> = SectionBase<ItemT, SectionT> & SectionT;
ReadonlyArray<SectionListData<ItemT, SectionT>>
我们可以看到必须是有 data
属性的,具体就是:
[
{
// 其他属性
data: [
// 子选项的属性
]
}
]
可以看到必须包含 data
属性。同样我们在编写业务组件的时候也可以遵循这样的规则,只不过相对于上面的要更加严格,精确到每一个属性,每一个被看到的文字图片等。
分离性界面展示
还是之前的例子,还是会用到 LabelLineName
这个组件,首先我们看界面部分的编写:
type DataItemBusinessProps<
T extends [number, string, string, string] | [number, string, string],
> = {
data: T[]
uniqueIndex: number
}
function DataItemBusiness<
T extends [number, string, string, string] | [number, string, string],
>({ data, uniqueIndex }: DataItemBusinessProps<T>) {
const navigation = useAppNavigation()
const colors = useColors()
const onCopyPress = (text: string) => {
return () => {
Clipboard.setString(text)
SimpleToast.show('Copy success')
}
}
const onJumpAPress = (url: string, title: string) => {
return () => {
navigation.navigate('WebView', { url, navTitle: title })
}
}
const Lable = ({
lTitle,
rTitle,
icon,
valuePress,
trProps,
rStyle,
}: any) => {
if (rTitle === undefined) return null
return (
<LabelLineName
leftTitle={lTitle}
rightTitle={rTitle === null ? '--' : rTitle}
leftStyle={{ fontSize: 14 }}
rightStyle={{ fontSize: 14, ...rStyle }}
valuePress={valuePress}
icon={icon}
textRightProps={trProps}
/>
)
}
return (
<>
{data.map(item => {
const [type, label, valueOrProgress, proportionOrUrl = ''] = item
const itemsComponent = {
[TextRenderType.text]: (
<Lable lTitle={label} rTitle={valueOrProgress} />
),
[TextRenderType.textCopy]: (
<Lable
lTitle={label}
rTitle={valueOrProgress}
valuePress={onCopyPress(valueOrProgress)}
trProps={{
numberOfLines: 1,
ellipsizeMode: 'middle',
}}
icon={
<ImageBox
source={require('@/assets/images/copy.png')}
height={16}
width={16}
resizeMethod="resize"
marginStart="d_3"
alignSelf="flex-end"
/>
}
/>
),
[TextRenderType.textA]: (
<Lable
lTitle={label}
rTitle={valueOrProgress}
rStyle={{ color: colors.color_0C79B9 }}
valuePress={onJumpAPress(proportionOrUrl, valueOrProgress)}
/>
),
[TextRenderType.progress]: (
<LabelProgress
title={label || ''}
value={{
proportion: proportionOrUrl,
progress: valueOrProgress ?? '0%',
}}
/>
),
}
return (
<Fragment key={item[uniqueIndex]}>
{itemsComponent[type] || null}
</Fragment>
)
})}
</>
)
}
export default DataItemBusiness
可以看到我们把每一项组装成数组,数组中的每一项的显示有 TextRenderType
控制,也就是我所描述的渲染类型,根据渲染类型的不同,展示不同的组件,可以看到我们这里的颗粒度很小的,小到一个颜色都是不同的类型,这样的话基本上我们 app
详情类的显示都是能包含的。
这个拿到界面里面就是一个完整的界面,接下来我们就是等待后台的接口好,我们就可以开始编写从后台接口到我们渲染的数据转换了,下面看我的数据转换:
export const getDeviceDetail = (data: 后台返回数据类型): (
| [number, string, string, string]
| [number, string, string]
)[] => {
// 这里将后台的数据转换成下面的数据格式即可
return [
[TextRenderType.text, 'Device Name', 'This is a device name'],
[TextRenderType.textCopy, 'SN', '11099186520229900015'],
[TextRenderType.text_23B797, 'Device Online Status', 'Online'],
[TextRenderType.text, 'MAC', '2c:f7:20:07:f7'],
[TextRenderType.text, 'Public IP', '20.20.20.1'],
[TextRenderType.text, 'Private IP', '192.168.181.2'],
[TextRenderType.text, 'Temperature', '44.8℃'],
[TextRenderType.progress, 'CPU Usage', '50%'],
[TextRenderType.progress, 'RAM Usage', '50%', '5/10 GB'],
[TextRenderType.progress, 'Storage Usage', '50%', '5/10 GB'],
[TextRenderType.text, 'Collect Time', '2021-08-01 12:00:00'],
]
}
这样就可以在相当程度上与后台脱钩了,当然不能完全脱钩。
这样做还有一个好处就是更好的模拟数据,也就是我们经常说的假数据;而且由于我编写的数据结果不包含具体的字段名称,到时候的修改也会更小。
对于我这样的编写,如果不是样式经常变的情况下,这个地方完全可以做到,让后台控制界面的,也就是让后台返回这样的数据,这样前端都不需要改动就可以了;当然如果界面变化很大,这种方式是不好的,而且会成为阻碍;因为人在改变界面的时候更倾向于直接改已有的,其实大家也看到我这个地方是暴露给外面修改了的,也就是当样式不同的情况下是自己增加 type
的,但是通常情况人们可能是直接修改已有的 type
,而不是新建,这样就导致原本可维护的项目,变得更加不可维护,所以根据我的经验,尽量只在 ui
变动小的地方使用这样的方法;要么就跟相关人说清楚。
总结
这样的方案只适合变动 ui
变动比较小的地方使用,或者可以说在弱 ui
环境下使用;如果要在变化比较大的地方使用,那么就需要对编写代码的人说清楚这一块的逻辑;同时这样的编写方式也是符合编写代码人的思维的,也就是增量原则,我们在写代码的时候一般都是采用加法,而不是减法,因为不管是业务还是代码,加法更兼容,减法可能会导致很多无法预料的后果;业务也是如此,你可以看到公司的业务很少会做减法,大部分都是加法,当然出现这种结果有很多原因,我们这里不具体讨论。当然这样也不符合大部分人的编程思维,也就是最小化加法原则,我们这里的加法主要是组件不同的加法,加法的基本单位是组件,而大部分人的加法思维是像素或者字号等更小的加法。
如果你们公司本身就是流程很清晰的,那么我是不推荐这种方法的,毕竟这种方法不符合大部分的思维方式,有时候你写的代码好坏不是取决于你写的好或坏而是取决于你的同事,他们都难以看懂的代码,即便你写的真的很好,在他们看来也是垃圾代码,除非真的是在性能上相差很大,说白了没有质的优良差异在别人看来就是哗众取宠;大家都是只看短期的人,或者说都是关注短期的人,至于将来是说不准的,即便你真的把项目写得很好,领导看不到,即便将来真的让公司受益,领导也会觉得是理所应当的事情;更或者,公司不能经营到哪天,或者项目经营不到哪天。
根据我的经验,如果你真的想写这种代码,那么就跟相关人沟通好。我写这个代码在以前的公司得到实践,由于那一块的 ui
很少变,虽然后台经常增加字段啥的,但是对于我来说都是改动很小的,所以那一块的代码每次后台有改动,我这边基本上都是不改就支持,或者改动很小就支持。