基礎知識: VueTypeScriptXStatevee-validatezod

初步構想

時常看到「多階段」的表單流程,也就是會將一個本來很長的表單拆分成好幾個部分來填寫,相較冗長的一整頁表單,使用者在填寫時比較沒有心理上的負擔。

跟表單相關的開源套件很多,而我選用以下幾種來處理跟表單相關的任務:

  • 表單狀態管理:這裡因為範例使用 Vue,所以以 vee-validate 管理表單所有 inputselect 等元件的渲染狀態
  • 表單驗證:表單驗證的部分,搭配 vee-validate 官方推薦的 zod 來處理驗證邏輯

以上是表單的部分,再來的重點是,要如何設計這個「多階段」的架構?

若將所階段裡面的欄位都合在一個表單,如下圖:

chart 01

在切換階段的時候決定顯示哪些欄位,然而在切換下一階段時,又要只驗證當下的那幾個欄位,這樣會造成複雜的驗證邏輯。

於是我想到將每個階段獨立成自己的表單,所以所有驗證都不再是局部驗證,而是對表單的所有欄位(例如階段一表單的所有欄位)來做驗證:

chart 02

由於拆分成多個表單,簡化了表單的驗證邏輯,不用再做多餘的判斷(局部欄位驗證)了。每個表單元件的職責,如下:

  1. 欄位狀態管理
  2. 所有欄位驗證

將上述任務委派給表單元件處理之後,剩下待處理的邏輯是:

  1. 顯示現在是何種階段表單元件的狀態
  2. 送出表單資料,執行非同步請求

由於「第一階段」的下一步只能到「第二階段」,而不行到「第三階段」或是「確認階段(Step Confirm)」,因此我想到利用有限狀態機來解決上述的兩個問題,而嘗試使用以實作有限狀態機很有名的 XState 套件來處理這些任務。

因此,職責分配圖如下:

chart 03

職責分配

前一段大致描述了初步的想法,這裡整理一下職責分配的規劃,分為兩個部分:

狀態機

  1. 階段流程控制(要顯示哪一階段的表單)
  2. 儲存所有表單資料
  3. 送出資料,執行非同步請求
  4. 非同步請求的等待狀態(loading)、錯誤狀態

以上皆由 XState 實作

各階段表單元件

  1. 表單欄位狀態管理(由 vee-validate 實作)
  2. 表單欄位驗證(由 zod 實作)
  3. 表單送出(submit)事件與表單資料(由 vee-validate 實作)

實作簡述

表單元件

表單元件,以第一階段 Form1.vue 舉例,大致如下:

<template>
  <div>
    <h2 class="formTitle">Choose channels you like</h2>
    <form class="form" @submit="onSubmit">
      <div>
        <input type="checkbox" id="discovery" :value="1" v-model="channels" />
        <label class="label" for="discovery">Discovery</label>
      </div>

      <!-- other inputs... -->

      <div v-if="errors.channels" class="error">{{ errors.channels }}</div>

      <div class="buttonGroup">
        <button class="button" type="submit">next step</button>
      </div>
    </form>
  </div>
</template>

<script setup lang="ts">
  import type { Form1Model } from "@/types";
  import { toTypedSchema } from "@vee-validate/zod";
  import { useForm, useField } from "vee-validate";
  import z from "zod";

  interface Props {
    initialValues: Form1Model;
  }

  interface Emits {
    (event: "next", values: Form1Model): void;
  }

  const props = withDefaults(defineProps<Props>(), {
    class: "",
  });

  const emits = defineEmits<Emits>();

  const validationSchema = toTypedSchema(
    z.object({
      channels: z.number().array().nonempty("Please choose at least one channel."),
    })
  );

  const { handleSubmit, errors, values } = useForm<Form1Model>({
    initialValues: props.initialValues,
    validationSchema,
  });

  const { value: channels } = useField<number[]>("channels");

  const onSubmit = handleSubmit((values) => emits("next", values));
</script>

表單的欄位初始值(initial values)由狀態機提供,經由 props 傳進來;然後表單在送出的時候,再發事件出去讓狀態機處理,一個人只負責一件事,可謂「單一職責原則」的精神。

外層元件

各階段的表單的顯示控制,在外層 MultiStepForm.vue 實作,導入狀態機並決定顯示邏輯。各表單會發出各種事件(下一步、上一步、送出等),然後交付給狀態機來執行。

<template>
  <div :class="`h-full w-full ${props.class}`">
    <h1 class="title">Multi Step Form Example</h1>

    <div class="grid grid-cols-2 gap-x-6">
      <div>
        <h2 class="subTitle">Form Component</h2>

        <div class="p-4 border border-slate-700 rounded-lg">
          <Form1
            v-if="state.matches('step1')"
            @next="send('NEXT_TO_STEP_2', { formValues: $event })"
            @prev="send('PREV')"
            :initial-values="state.context.form1Values"
          />

          <Form2
            v-if="state.matches('step2')"
            @next="send('NEXT_TO_STEP_3', { formValues: $event })"
            @prev="send('PREV')"
            :initial-values="state.context.form2Values"
          />

          <Form3
            v-if="state.matches('step3')"
            @next="send('NEXT_TO_STEP_CONFIRM', { formValues: $event })"
            @prev="send('PREV')"
            :initial-values="state.context.form3Values"
          />

          <FormConfirm
            v-if="state.matches('stepConfirm')"
            @prev="send('PREV')"
            @submit="send('SUBMIT')"
            :is-submitting="state.matches('stepConfirm.submitting')"
            :error="state.context.error"
            :machine-context="state.context"
            :payload="state.context.payload"
          />

          <FormComplete v-if="state.matches('complete')" @restart="send('RESTART')" />
        </div>
      </div>
      <div>
        <p class="subTitle">Current Machine Context</p>
        <pre class="preBlock">{{ state.context }}</pre>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
  import { useMachine } from "@xstate/vue";
  import Form1 from "@/components/Form1.vue";
  import Form2 from "@/components/Form2.vue";
  import Form3 from "@/components/Form3.vue";
  import FormConfirm from "@/components/FormConfirm.vue";
  import FormComplete from "@/components/FormComplete.vue";
  import { multiStepFormMachine } from "@/multiStepFormMachine";

  interface Props {
    class?: string;
  }

  interface Emits {
    (event: "click"): void;
  }

  const props = withDefaults(defineProps<Props>(), {
    class: "",
  });

  const emits = defineEmits<Emits>();

  const { state, send } = useMachine(multiStepFormMachine);
</script>

表單狀態機

接著是狀態機,我將狀態機獨立成一個檔案 multiStepFormMachine.ts,以方便管理:

import { assign, createMachine } from "xstate";
import type { Form1Model, Form2Model, Form3Model, SubmitData } from "./types";
import { FORM_1_INITIAL_VALUES, FORM_2_INITIAL_VALUES, FORM_3_INITIAL_VALUES } from "./default";
import { sendFormData } from "./utils";

type MachineEvent =
  | { type: "NEXT_TO_STEP_2"; formValues: Form1Model }
  | { type: "NEXT_TO_STEP_3"; formValues: Form2Model }
  | { type: "NEXT_TO_STEP_CONFIRM"; formValues: Form3Model }
  | { type: "PREV" }
  | { type: "SUBMIT" }
  | { type: "RESTART" };

export type MachineContext = {
  form1Values: Form1Model;
  form2Values: Form2Model;
  form3Values: Form3Model;
  payload: SubmitData | null;
  error: string | null;
};

const INITIAL_MACHINE_CONTEXT: MachineContext = {
  form1Values: FORM_1_INITIAL_VALUES,
  form2Values: FORM_2_INITIAL_VALUES,
  form3Values: FORM_3_INITIAL_VALUES,
  payload: null,
  error: null,
};

type MachineState =
  | { context: MachineContext; value: "step1" }
  | { context: MachineContext; value: "step2" }
  | { context: MachineContext; value: "step3" }
  | { context: MachineContext; value: "stepConfirm" }
  | { context: MachineContext; value: "stepConfirm.submitting" }
  | { context: MachineContext; value: "complete" };

export const multiStepFormMachine = createMachine<MachineContext, MachineEvent, MachineState>(
  {
    id: "multiStepForm",
    initial: "step1",
    context: INITIAL_MACHINE_CONTEXT,
    states: {
      step1: {
        on: {
          NEXT_TO_STEP_2: {
            target: "step2",
            actions: assign({
              form1Values: (context, event) => event.formValues,
            }),
          },
        },
      },
      step2: {
        on: {
          NEXT_TO_STEP_3: {
            target: "step3",
            actions: assign({
              form2Values: (context, event) => event.formValues,
            }),
          },
          PREV: {
            target: "step1",
          },
        },
      },
      step3: {
        on: {
          NEXT_TO_STEP_CONFIRM: {
            target: "stepConfirm",
            actions: assign({
              form3Values: (context, event) => event.formValues,
            }),
          },
          PREV: {
            target: "step2",
          },
        },
      },
      stepConfirm: {
        initial: "preSubmit",
        states: {
          preSubmit: {
            entry: assign({
              payload: (context, event) => ({
                ...context.form1Values,
                ...context.form2Values,
                ...context.form3Values,
              }),
            }),
            on: {
              SUBMIT: {
                target: "submitting",
              },
            },
          },
          submitting: {
            invoke: {
              src: "formSubmit",
              onDone: {
                target: "#multiStepForm.complete",
                actions: "resetContext",
              },
              onError: {
                target: "errored",
                actions: assign({
                  error: (context, event) => event.data.error,
                }),
              },
            },
          },
          errored: {
            on: {
              SUBMIT: {
                target: "submitting",
              },
            },
          },
        },
        on: {
          PREV: {
            target: "step3",
          },
        },
      },
      complete: {
        entry: "resetContext",
        on: {
          RESTART: {
            target: "step1",
          },
        },
      },
    },
  },
  {
    actions: {
      resetContext: assign(INITIAL_MACHINE_CONTEXT),
    },
    services: {
      formSubmit: async (context, event) => {
        if (context.payload) {
          return await sendFormData(context.payload);
        } else {
          return await new Promise((resolve, reject) => reject("Context cannot be null."));
        }
      },
    },
  }
);

狀態機的流程操作,可以參考這個可視化頁面:multi-step-form | Stately

以上是狀態機的程式碼,有點冗長還請見諒 🙏

主要職責是:

  1. Form1 會發出 NEXT_TO_STEP_2 事件進到 Form2,或 Form2 發出 PREV 事件回到 Form1,以此類推。
  2. FormConfirm 會發 SUBMIT 事件告訴狀態機要執行非同步請求,將表單的資料送出。
  3. 狀態機的 context 中,存放 Form1 及其他表單元件的欄位資料,以及最後要送出請求的酬載(payload),此外還有送出非同步請求的狀態(loading、error)

最後結果

以下是最後實作出來的結果,左邊是表單元件,右邊則顯示目前狀態機 context 的狀態,能清楚知道何時會更新 context 的資料

畫面

所有程式碼請參考這裡

小記

以上是多階段表單的一些構想,利用狀態機將表單的職責單一化,也清楚將邏輯切割並分派給各部分處理。

這是第一次真正自己認真寫狀態機,也還在摸索學習的階段,若有任何問題歡迎留言告訴我 😎

感謝收看 🙏