web后台的实现离不开表格和表单,在表单中,数据联动是我们常常遇到的功能。当产生复杂的异步联动时,我们提交表单时,如何保证数据的完整性和准确性呢?相信很多人都遇到过这种问题,在这里记录一下我的探索之旅...核心实现逻辑可以从方案三开始看
问题的产生
如下表单,存在字段A,字段B,以及一个保存按钮,当A失去焦点之后异步获取B,点击保存按钮,为了方便看到效果,将要提交的数据以JSON字符串的形式展示出来。
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="字段A">
<el-input v-model="formData.fieldA" @change="handleAChange"></el-input>
</el-form-item>
<el-form-item label="字段B">
<el-input v-model="formData.fieldB"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
</el-form-item>
<el-form-item> 此时保存的数据:{{ submitDataJSON }} </el-form-item>
</el-form>
</template>
<script>
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
},
};
},
methods: {
handleAChange() {
this.getFieldB();
},
getFieldB() {
setTimeout(() => {
this.formData.fieldB = this.formData.fieldA + "fieldB";
}, 2000);
},
handleSave() {
this.submitDataJSON = JSON.stringify(this.formData);
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
由于字段B异步获取,所以当我们输入字段A,马上点保存,保存的数据就会是上一次的字段B,如下图1所示:
图1
显然,这不是我们想要的结果,我们希望,点保存可以等到字段B获取到新值之后再触发保存操作。
方案一:字段B为必填时,使用必填项校验拦截
考虑到字段A变化引发字段B联动变化,那么字段A每次变化时,我们可以先把字段B清空。如果字段B必填,那保存时,我们会进行必填项校验,使保存操作被中断,从而避免提交错误的数据到后台。代码如下:
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="字段A">
<el-input v-model="formData.fieldA" @change="handleAChange"></el-input>
</el-form-item>
<el-form-item label="字段B" required>
<el-input v-model="formData.fieldB"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
</el-form-item>
<el-form-item> 此时保存的数据:{{ submitDataJSON }} </el-form-item>
</el-form>
</template>
<script>
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
},
};
},
methods: {
handleAChange() {
this.formData.fieldB = "";
this.getFieldB();
},
getFieldB() {
setTimeout(() => {
this.formData.fieldB = this.formData.fieldA + "fieldB";
}, 2000);
},
handleSave() {
if (!this.formData.fieldB) {
this.$message.warning("字段B必填");
return;
}
this.submitDataJSON = JSON.stringify(this.formData);
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
但是如果字段B非必填呢?前端就无法通过必填项校验来拦截错误数据的提交了。而且,必填项校验的限制,会导致保存动作中断,需要等字段B获取到之后再次手动触发保存才可实现保存操作。
方案二:既然保存的数据不对,那就让保存按钮禁用
字段A的变化触发字段B的查询,在字段B查询的过程中,我们将保存按钮禁用,如下代码:
...
<el-button type="primary" :disabled="disabled" @click="handleSave">保存</el-button>
...
...
getFieldB() {
this.disabled = true;
setTimeout(() => {
this.formData.fieldB = this.formData.fieldA + "fieldB";
this.disabled = false;
}, 2000);
},
...
同方案一:输入A之后迅速点保存,保存动作不会生效,需要等到字段B获取到之后二次手动触发保存操作
方案三:引入Promise监听获取字段B的状态
上面两个方案都需要两步操作,即获取字段B和保存需要单独触发。那我们能不能输入字段A后点一次保存,等到最新的字段B获取到后主动提交呢?问题的关键在于我们需要知道字段B的获取状态,如下代码,我们引入Promise实现:
...
<script>
let promiseStatus = Promise.resolve();
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
},
};
},
methods: {
handleAChange() {
this.formData.fieldB = "";
this.getFieldB();
},
getFieldB() {
promiseStatus = new Promise((resolve) => {
setTimeout(() => {
this.formData.fieldB = this.formData.fieldA + "fieldB";
resolve();
}, 2000);
});
},
handleSave() {
promiseStatus.finally(() => {
this.submitDataJSON = JSON.stringify(this.formData);
});
},
},
};
</script>
如上:我们将获取字段B的操作包装成一个promise,并把它赋值给一个变量,那它的状态就会变的可跟踪。我们点保存之后就可以保证在获取到最新的B之后执行保存操作了。
将方案三通用化
上述案例,A字段变更后只会触发B的查询,在现实需求中,我们往往会有更复杂的联动逻辑,比如A字段变更后,触发B、C、D...字段的异步查询(B、C、D...无依赖关系);或者A字段变更后,触发B字段的异步查询,查到B字段后,再触发C字段的异步查询...(B、C、D...有依赖关系)。为了方便管理和使用这种涉及到多个Promise的场景,我们实现了一个异步队列,引入Promise.all,如下:
// requestQueue.js
import { Loading } from "element-ui";
export default class RequestQueue {
constructor() {
this.requests = [];
this.isProcessing = false;
// 保存在requests执行过程中添加进来的新Promise
this.newRequests = [];
this.loadingInstance = null;
}
// 默认添加进来的异步任务为Promise
addRequest(requestFn) {
if (!this.isProcessing) {
this.requests.push(requestFn);
return;
}
this.newRequests.push(requestFn);
}
clearQueue() {
this.requests = [];
this.newRequests = [];
}
requestsCompleted(saveFn) {
this.isProcessing = true;
if (!this.loadingInstance) {
this.loadingInstance = Loading.service({ fullscreen: true });
}
Promise.all(this.requests).finally(() => {
this.isProcessing = false;
// 处理暂存的新请求
if (this.newRequests.length > 0) {
this.requests = this.newRequests;
this.newRequests = [];
this.requestsCompleted(saveFn);
return;
}
this.loadingInstance.close();
this.loadingInstance = null;
saveFn();
this.clearQueue();
});
}
}
使用方式如下:
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="字段A">
<el-input v-model="formData.fieldA" @change="handleAChange"></el-input>
</el-form-item>
<el-form-item label="字段B">
<el-input v-model="formData.fieldB" @change="handleBChange"></el-input>
</el-form-item>
<el-form-item label="字段C">
<el-input v-model="formData.fieldC"></el-input>
</el-form-item>
<el-form-item label="字段D">
<el-input v-model="formData.fieldD"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
</el-form-item>
<el-form-item> 此时保存的数据:{{ submitDataJSON }} </el-form-item>
</el-form>
</template>
<script>
import RequestQueue from "../utils/requestQueue.js";
const requestQueue = new RequestQueue();
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
fieldC: "",
fieldD: "",
},
};
},
methods: {
handleAChange() {
this.getFieldB();
this.getFieldC();
},
handleBChange() {
this.getFieldD();
},
getFieldB() {
const promise = new Promise((resolve) => {
setTimeout(() => {
this.formData.fieldB = this.formData.fieldA + ":fieldB";
this.getFieldD();
resolve();
}, 2000);
});
requestQueue.addRequest(promise);
},
getFieldC() {
const promise = new Promise((resolve) => {
setTimeout(() => {
this.formData.fieldC = this.formData.fieldA + ":fieldC";
resolve();
}, 2000);
});
requestQueue.addRequest(promise);
},
getFieldD() {
const promise = new Promise((resolve) => {
setTimeout(() => {
this.formData.fieldD = this.formData.fieldB + ":fieldD";
resolve();
}, 2000);
});
requestQueue.addRequest(promise);
},
handleSave() {
requestQueue.requestsCompleted(this.handleRequest);
},
handleRequest() {
this.submitDataJSON = JSON.stringify(this.formData);
// 保存数据
},
},
created() {
requestQueue.clearQueue();
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
如下图2,输入A,马上点保存,B、C、D都获取到,才会执行保存操作,将查询到的B、C、D数据展示在页面上:
图2
难度升级
假设有如下需求,表单中有A、B、C、D、E字段和保存按钮:
- A变更获取B和C
- 获取B的时候先执行getFieldB,获取不到执行getFieldB1
- 获取到B之后,获取D
- 获取到B和C之后获取E
- A变更后马上点保存按钮,要求数据都获取到之后触发保存操作
基于以上通用化的RequestQueue,以如下代码实现:
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="字段A">
<el-input v-model="formData.fieldA" @change="handleAChange"></el-input>
</el-form-item>
<el-form-item label="字段B">
<el-input v-model="formData.fieldB" disabled></el-input>
</el-form-item>
<el-form-item label="字段C">
<el-input v-model="formData.fieldC" disabled></el-input>
</el-form-item>
<el-form-item label="字段D">
<el-input v-model="formData.fieldD" disabled></el-input>
</el-form-item>
<el-form-item label="字段E">
<el-input v-model="formData.fieldE" disabled></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
</el-form-item>
<el-form-item> 此时保存的数据:{{ submitDataJSON }} </el-form-item>
</el-form>
</template>
<script>
import RequestQueue from "../utils/requestQueue.js";
const requestQueue = new RequestQueue();
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
fieldC: "",
fieldD: "",
fieldE: "",
},
};
},
methods: {
initData() {
this.formData.fieldB = "";
this.formData.fieldC = "";
this.formData.fieldD = "";
this.formData.fieldE = "";
this.submitDataJSON = "";
},
/**
* A变更获取B和C
* 获取B的时候先执行getFieldB,获取不到执行getFieldB1
* 获取到B之后,获取D
* 获取到B和C之后获取E
* A变更后马上点保存按钮,要求数据都获取到之后触发保存操作
*/
async handleAChange() {
this.initData();
await Promise.all([this.getFieldB(), this.getFieldC()]);
this.getFieldE();
},
getFieldB() {
const promise = new Promise((resolve) => {
setTimeout(async () => {
console.log("b");
this.formData.fieldB =
Math.random() > 0.5 ? this.formData.fieldA + ":fieldB" : "";
if (!this.formData.fieldB) {
await this.getFieldB1();
return resolve();
}
this.getFieldD();
return resolve();
}, 2000);
});
requestQueue.addRequest(promise);
return promise;
},
getFieldB1() {
const promise = new Promise((resolve) => {
setTimeout(() => {
console.log("b1");
this.formData.fieldB = this.formData.fieldA + ":fieldB1";
this.getFieldD();
return resolve();
}, 1000);
});
requestQueue.addRequest(promise);
return promise;
},
getFieldC() {
const promise = new Promise((resolve) => {
setTimeout(() => {
console.log("c");
this.formData.fieldC = this.formData.fieldA + ":fieldC";
return resolve();
}, 2000);
});
requestQueue.addRequest(promise);
return promise;
},
getFieldD() {
const promise = new Promise((resolve) => {
setTimeout(() => {
console.log("d");
this.formData.fieldD = this.formData.fieldB + ":fieldD";
return resolve();
}, 2000);
});
requestQueue.addRequest(promise);
return promise;
},
getFieldE() {
const promise = new Promise((resolve) => {
setTimeout(() => {
console.log("e");
this.formData.fieldE =
this.formData.fieldA +
this.formData.fieldB +
this.formData.fieldC +
":fieldE";
return resolve();
}, 200);
});
requestQueue.addRequest(promise);
return promise;
},
handleSave() {
requestQueue.requestsCompleted(this.handleRequest);
},
handleRequest() {
this.submitDataJSON = JSON.stringify(this.formData);
// 保存数据
},
},
created() {
requestQueue.clearQueue();
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
如上代码,基于我们封装好的RequestQueue,以上需求也可以实现。但是我们会发现,当表单字段之间的关系变得复杂时,除了保证获取到所有数据之后保存外,管理它们之间的关系本身也变成了一件复杂的事情。如上面的实现中,A变更后,从handleAChange中我们没办法很直观的看出A变更引起的其它字段的变化,此外,获取数据的方法如getFieldB也引入了一些字段联动的逻辑,从而变得不再纯粹,导致代码的可读性和可维护性都变差了。
详细代码见:codesandbox.io/p/sandbox/r…
使用rxjs实现
代码如下:可见handleAChange中的逻辑清晰了很多
<template>
<el-form label-width="80px" :model="formData">
<el-form-item label="字段 A">
<el-input v-model="formData.fieldA" @change="handleAChange"></el-input>
</el-form-item>
<el-form-item label="字段 B">
<el-input v-model="formData.fieldB" disabled></el-input>
</el-form-item>
<el-form-item label="字段 C">
<el-input v-model="formData.fieldC" disabled></el-input>
</el-form-item>
<el-form-item label="字段 D">
<el-input v-model="formData.fieldD" disabled></el-input>
</el-form-item>
<el-form-item label="字段 E">
<el-input v-model="formData.fieldE" disabled></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
</el-form-item>
<el-form-item> 此时保存的数据:{{ submitDataJSON }} </el-form-item>
</el-form>
</template>
<script>
import { forkJoin, of, from, mergeMap, switchMap, shareReplay } from "rxjs";
export default {
data() {
return {
submitDataJSON: "",
formData: {
fieldA: "",
fieldB: "",
fieldC: "",
fieldD: "",
fieldE: "",
},
dataChanging: false,
saving: false,
};
},
methods: {
initData() {
this.formData.fieldB = "";
this.formData.fieldC = "";
this.formData.fieldD = "";
this.formData.fieldE = "";
this.submitDataJSON = "";
},
/**
* A变更获取B和C
* 获取B的时候先执行getFieldB,获取不到执行getFieldB1
* 获取到B之后,获取D
* 获取到B和C之后获取E
* 若点了保存按钮,数据都获取到之后触发保存操作
*/
handleAChange() {
this.initData();
this.dataChanging = true;
const obC$ = from(this.getFieldC());
const obB$ = from(this.getFieldB()).pipe(
switchMap(() => {
if (this.formData.fieldB) {
return of(this.formData.fieldB);
}
return from(this.getFieldB1());
}),
shareReplay(1)
);
const obD$ = obB$.pipe(mergeMap(() => from(this.getFieldD())));
const obE$ = forkJoin([obB$, obC$]).pipe(
mergeMap(() => from(this.getFieldE()))
);
forkJoin([obD$, obE$]).subscribe(() => {
this.dataChanging = false;
if (this.saving) {
this.saving = false;
this.handleSave();
}
});
},
getFieldE() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("e");
this.formData.fieldE =
this.formData.fieldA +
this.formData.fieldB +
this.formData.fieldC +
":fieldE";
resolve();
}, 1000);
});
},
getFieldB() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("b");
this.formData.fieldB =
Math.random() > 0.5 ? this.formData.fieldA + ":fieldB" : "";
resolve();
}, 500);
});
},
getFieldB1() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("b1");
this.formData.fieldB = this.formData.fieldA + ":fieldB1";
resolve();
}, 1000);
});
},
getFieldC() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("c");
this.formData.fieldC = this.formData.fieldA + ":fieldC";
resolve();
}, 1000);
});
},
getFieldD() {
return new Promise((resolve) => {
setTimeout(() => {
console.log("d");
this.formData.fieldD = this.formData.fieldB + ":fieldD";
resolve();
}, 1000);
});
},
handleSave() {
if (this.saving) return;
this.saving = true;
if (this.dataChanging) {
return;
}
this.submitDataJSON = JSON.stringify(this.formData);
},
},
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped></style>
详细代码见: codesandbox.io/p/sandbox/r…
关于rxjs,本人尚在学习中,分享一些学习资料: