复杂表单的模块化处理

工作中或多或少都会遇到一些很复杂的表单,通常的特点是每个表单项自身都有一大坨逻辑,好几百行代码。如果将所有表单项都放到一个文件里,那么没人能看得懂它,维护起来是一个噩梦。为了解决这个问题,小组内之前做过一些封装和拆分:

  • 将每个表单项封装成独立组件
  • 在业务上将表单项分类,功能内聚的一组表单项放到一个form group下,这样整个表单被拆分为多个form group,同时每个form group也封装成组件:

simple-modularity

  • form汇集下属所有form group的互操作和业务逻辑,同时form group汇集下属所有form item的互操作和业务逻辑。例如收集表单提交时的接口数据,表单项显隐的控制
  • 统一数据流管理,放到vuex中,但不够彻底,组件内部仍然有维护小部分数据

上面这样做在表单复杂度不是那么高时可以很好应对,但随着业务越来越复杂,遇到了新的问题:

  • formform group由于需要汇集下属所有组件的互操作和业务逻辑,导致组件变的非常大,代码可以很轻易的增长到上千行
  • 每个表单项的逻辑散落在多处,难以整理出其具体是怎么工作的
  • 维护困难,虽然可能单独一个表单项的逻辑只有几百行,但是混杂在了父组件上千行的代码中,轻易不敢乱动

思路

仔细思考了困境,发现其实formform group并不需要维护具体的业务逻辑,它们应当只是做一些简单的汇总工作。如果将业务逻辑全部内聚到单独的表单项,那么维护起来将会非常方便。表单项的核心逻辑有:

  • 为了能够工作,需要从完整的form data中获取哪些数据
  • 当表单提交时,自身需要贡献哪些数据到form data
  • 是否展示、隐藏的控制逻辑,一个表单项的显隐通常是受到某些其他表单项、上层formform group组件的状态影响
  • 所有数据完全由vuex托管,不再散落多处
  • 每个表单项独立的moduleform group也有独立module,与组件树一一对应形成module tree

表单项完全自治内聚,form只用在初始化时将后端拉取的完整form data传递给表单项,由表单项自行关心的字段;在表单提交时,由于表单项内部已经准备好需要贡献哪些数据,form只用直接拿过来即可。

基于以上思路,势必有很多通用逻辑,提取出来后就可以方便的复用了, demo代码放在最后,下面具体说说实现细节。

总体架构

compTree-moduleTree

如上,组件树与vuex module树一一对应,formform groupform item都有各自的module

vuex中涉及多层次module时上下层通信不是很方便,必须知道namespace才行, 蛋疼的是vuex并没有提供很好的机制方便上层module了解其下属module的情况。

为此参考组件注册的做法做了一个约定,modulestate中以_moduleKey作为注册时的namespace,父module将所有子modulenamespace放入自身的state属性中。例如:

1
2
3
4
5
6
// form item module
export default {
state: {
_moduleKey: 'form-item-id',
},
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// form group module
import formItemId from './form-item-id';

export default {
state () {
return {
_moduleKey: 'form-group-1',
_formItems: [formItemId.state._moduleKey], // 记录所有子module的namespace
}
},
modules: {
[formItemId.state._moduleKey]: formItemId, // 注册module时的key设置为子module的namespace
},
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// form module
import formGroup1 from './form-group-1';

export default {
state () {
return {
_moduleKey: 'form-demo',
_formGroups: [formGroup1.state._moduleKey], // 记录所有子module的namespace
}
},
modules: {
[formGroup1.state._moduleKey]: formGroup1, // 注册module时的key设置为子module的namespace
},
}
1
2
3
4
5
6
7
8
// vuex store
import formDemo from './form-demo';

new Vuex.Store( {
modules: {
[formDemo.state._moduleKey]: formDemo, // 注册module时的key设置为子module的namespace
},
} );

通过这种约定,父module就能非常方便的与子module通信了,例如:

1
2
3
4
state._formItems.forEach( ( formItemModuleKey ) => {
commit( `${ formItemModuleKey }/mutation1` );
dispatch( `${ formItemModuleKey }/action1` );
} );

但是受制于vuex,子module仍然无法与父module通信,因为此时vuex需要提供完整的namespace路径才能定位父module具体的mutaion/action

上层数据收集

通过上述架构,所有具体的业务数据都分散在每个表单项module里,一些“宏观”数据就需要一个收集过程,例如表单提交时传递给后端接口的数据、表单校验时传递给form组件的model属性。借助上面的架构,这一项工作比较简单。

form data

这是表单提交时传递给后端接口的数据,大体思路是每个表单项都会贡献自己的一小份数据,最后汇总到一起。

form-data-collect

每个表单项都有自己的formItemData,最后打平汇总到一起。

一个示范:

form-data-example

表单项、form group显隐的影响

在很多时候,如果某个表单项或者form group是隐藏的,那么即使其内部状态已经发生变化,也只能贡献初始状态的数据到form data。因此表单项内部的formItemData实际上需要区分成两份,一份formItemData4Show用于在展示时贡献给form data,另一份formItemData4Hide用于在隐藏时贡献。

affect-of-hide-show--for-form-data

在上面的例子里,如果贡献id属性的表单项在被隐藏时贡献的id是空串的话,

1
2
3
4
5
6
7
8
9
10
formItemData4Hide () {
return {
id: ''
}
},
formItemData4Show ( state ) {
return {
id: +state.id
};
},

那么最终的form data就是:

hidden-form-item-afftection-demo

form model

此数据通常用于表单校验,表单校验情况比较复杂,因为校验时可能不仅仅需要用表单控件自身的值,也可能获取组件内部数据,父组件数据等等,最好的办法是form model获取一份非常全的数据。

为此form model是收集各个表单项的state,同时避免为state内同名属性的影响,最终不打平数据而是以子modulenamespace作为key

form-model-collect

一个示范:

form-model-example

那么表单项校验时如何获取数据呢? 通常需要两步

  1. form组件声明model属性为formModel

    1
    2
    3
    <el-form :model="formModel">
    xxx
    </el-form>
  2. 表单项组件声明prop为对应的namespace,也就是上面定义的_moduleKey

    1
    2
    3
    <el-form-item prop="form-item-id" label="id">
    xxx
    </el-form-item>

这样在校验函数里,拿到的就是formModel中对应namespace的数据

form-validate-example

数据初始化与同步

module数据初始化

上面也有提到所有具体的业务数据都分散在每个表单项module里,那么当在编辑已有表单时势必有一个拉取后端数据并回填页面的过程。数据获取是由顶层表单组件来做的,需要在vuex中与所有下层表单项module通信传递数据。借助上面的namespace,这一步简单了很多:

1
2
3
4
5
6
7
8
// form module

fillForm ( { dispatch, getters }, backendData ) {
// 利用后端数据回填表单,分发到每个form group来做
getters.formGroups.forEach( ( formGroupModuleKey ) =>
dispatch( `${ formGroupModuleKey }/fillFormGroup`, backendData
) )
},
1
2
3
4
5
6
7
8
// form group module

fillFormGroup ( { dispatch, getters }, formData ) {
// 每个form item自身决定取哪些数据
getters.formItems.forEach( ( formItemModuleKey ) => {
dispatch( `${ formItemModuleKey }/data2State`, formData );
} );
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 某个form item module
{
mutations: {
update ( state, newState ) {
Object.keys( newState ).forEach( key => {
state[ key ] = newState[ key ];
} )
},
},
actions: {
data2State ( { commit }, formData ) {
commit( 'update', { // 只取自己关心的数据字段
id: formData.id,
name: formData.name,
} )
}
}
}

如上,每个表单项组件只需取自己关心的数据字段,对于可维护性有大大提高,因为只要看data2State函数就能明白此表单项的数据依赖

最后只需在顶层表单组件中触发fillForm即可,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
mounted() {
// 模拟数据拉取
setTimeout(() => {
this.fillForm({
id: 1,
name: 2,
desc: 3,
text: 4
});
}, 2000);
},
methods: {
...mapActions("demo", ["fillForm"]),
}
}

module数据同步

因为各个表单项的module state是在初始化时通过data2State一次性值拷贝过来的,所以当全局性的formData有变更时,表单项的module state不能响应式同步。这会带来一些问题,比如表单项A改变了formData中表单项B关心的某个属性,表单项B是不知道的。

需要有一种机制在数据改变时将最新数据同步到各个module,借助vuex提供的plugin机制可以做到这点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 同步表单项module。 namespace是form module的_moduleKey
export default function createSyncPlugin ( namespace ) {
return store => {
store.watch((state, getters) => getters[`${namespace}/formData4View`], (newVal, oldVal) => {
if (!_.isEqual(newVal, oldVal)) {
store.dispatch(`${namespace}/fillForm`, newVal);
}
}, {
deep: true,
});
};
}

new Vuex.Store( {
modules,
plugins: [ createSyncPlugin( 'demo' ) ],
} );

上面的逻辑可以用下图表示:

form-item-module-sync

注意传给data2State的是formData4View而不是formData,二者很类似,只不过前者是所有表单项的formItemData4Show合集。

组件渲染

由于vuex掌控着数据,组件显隐的判断最好也放在vuex中,如:

1
2
3
4
5
6
7
8
// form item module
{
getters: {
isVisible ( state ) {
return state.id !== 10;
},
}
}

那么上层组件在template中渲染时,就需要知道下层组件是否展示,得益于组件树与module树的一一对应关系,只需获取下层module里的isVisible属性即可。如:

1
2
3
4
5
6
7
8
9
10
// form group module

{
getters: {
// 下属某个表单项是否可见
isFormItemVisible ( state, getters ) {
return formItemName => getters[ `${ formItemName }/isVisible` ];
},
}
}

这样我们在vue template里只要调用isFormItemVisible并传入表单项module_moduleKey:

1
2
3
<!-- form group template -->

<form-item-text v-show="isFormItemVisible('form-item-text')"/>

如果约定组件的name_moduleKey一致,那么直接用v-for就能完成渲染逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- form group template -->

<template>
<!-- formItem是_moduleKey,同时与组件name相同 -->
<component
v-for="formItem in formItems"
v-show="isFormItemVisible(formItem)"
:key="formItem"
:is="formItem"
/>
</template>
<script>
export default {
name: "form-group-2",
computed: {
...mapGetters("demo/form-group-2", ["formItems", "isFormItemVisible"])
}
};
</script>

module固定属性

以上就是整个表单模块化的思路了,module层涉及到很多特有属性,在此做个总结:

module-fixed-property

加粗的部分属性值需要各module自行设置,其余部分均可以抽取成公共逻辑,这样每个表单就可以很方便复用了。

最后附上demo仓库:modulize-form