表单字段A失焦异步获取B,输完A之后,快速点保存,如何保证提交正确的B呢?

287 阅读2分钟

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所示:

image.png

图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数据展示在页面上:

image.png

图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,本人尚在学习中,分享一些学习资料:

rxjs-dev.firebaseapp.com/guide/overv…

zhuanlan.zhihu.com/p/25383159

rxmarbles.com/