跳到內容
關於我 數位花園

Vue

2 篇文章擁有標籤:“Vue”

[筆記] 以狀態機實作多階段表單

基礎知識: 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 的資料

畫面

所有程式碼請參考這裡

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

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

感謝收看 🙏

遞迴元件與巢狀列表

工作上遇到一個頁面,是要呈現一個巢狀的 list

每個 list 底下,還會有自己的屬於自己的 list

而每個 list item 都有一個 checkbox 在前面

HTML 會長得像以下結構:

<ul>
<li>
<input type="checkbox" />
<label>1</label>
<ul>
<li>
<input type="checkbox" />
<label>1-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2</label>
<ul>
<li>
<input type="checkbox" />
<label>1-2-1</label>
</li>
<li>
<input type="checkbox" />
<label>1-2-2</label>
</li>
{/* 繼續往下長.... */}
</ul>
</li>
</ul>
</li>
<li>
<input type="checkbox" />
<label>2</label>
</li>
</ul>

目前只有固定的三層,可以直接寫死三層的 list 結構

但這不是一個靈活的結構,因爲未來某一天若要添加第四層,就得在 HTML 再添加下一層的 <li>

我希望的是用 data-driven 的方式去做渲染

也就是 data 有幾層巢狀,HTML 的部分就會依據 data 長出幾層

巢狀的資料結構,我第一個想到的就是「遞迴」

某些場合我們會使用遞迴函式

函式可以遞迴,也就是在自己裡面呼叫自己,那元件是否也可行呢?

於是我查了一下關於 recursive component 的文章

發現 Vue 與 React 雖然寫法不太一樣,但都有遞迴元件的方式達成不斷巢狀渲染的目的

這裡就用 Vue 來示範

以下分三個方向來討論,分別是:

  • 資料面
  • 顯示面
  • 互動面

首先,先定義一下 data 的型別:

type Item = {
id: string;
isCheck: boolean;
children: Item[]; // <-- 這裡又是 type Item,不斷往下長⋯⋯
};
type List = Item[];

從 children 的型別可以看出,type Item 也是一個遞迴的結構

因此,我們的 data 的結構就會是 type List 的樣子:

var nestedList: List = [
{ id: "1", isCheck: true, children: [] },
{
id: "2",
children: [
{ id: "2-1", isCheck: true, children: [] },
{
id: "2-2",
isCheck: true,
children: [{ id: "2-2-1", isCheck: true, children: [] }],
},
],
isCheck: true,
},
];

我們在這裡只寫三層的 list 結構就好,三層若可以正常顯示,四層、五層、⋯⋯、n 層也不會有問題

接下來就是 HTML 的部分,我們定義一個元件,就叫做RecursiveList吧:

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" />
<label>{{ item.id }}</label>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "RecursiveList",
props: {
list: {
type: Array,
default: () => [],
},
},
};
</script>

這裡可以渲染第一層的 item 了,因此我們著手進行第二層以下

如果每個 <li> 有 children,就要再跑一次 v-for 去渲染

遞迴函式呼叫自己,就是爲了避免寫重複的邏輯、做重複的工作,遞迴元件也是同樣的道理

因此,我們在元件裡建立遞迴的結構:

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" />
<label>{{ item.id }}</label>
{/* ⬇️ 這裡使用遞迴 ⬇️ */}
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
// ...
</script>

從此,無論是第三層、第四層、……、第 n 層,都可以渲染出來了~

nested template

到這裡爲止,僅只是顯示方面,別忘了每一個 item 裡面還有一個 checkbox

接下來,我們要為這個 checkbox 增加互動行爲

我們做出的巢狀的 <ul><li> 結構之後,每個 item 的 checkbox 互動行爲又是如何呢?

這裡有兩個條件要符合:

  1. 當任一 checkbox 被 check/uncheck 時,該 checkbox 底下的所有 checkbox 都要被 check/uncheck
  2. 每個 checkbox 的下一層有任一 checkbox 為 check 狀態,它自身就要被 check

當 checkbox 點擊的當下,以這個 list item 的元件為主體(以下簡稱當事人),分為兩個方向:

  1. 向下的操作:改變所有子層(及子層的子層 ⋯⋯ 等) checkbox 的值
  2. 向上的檢查:確認父層是否要變動

首先,我們來做第一個條件的互動:

當事人若爲 check,底下所有子孫都要 check,也就是全選;反之,則全不選

所以我們新增一個 handleChildren 的方法

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input type="checkbox" v-bind:checked="item.isCheck" v-on:change="handleChildren($event, item)" />
<label>{{ item.id }}</label>
<RecursiveList v-bind:list="item.children" v-if="item.children" />
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
handleChildren(event, item) {
const newValue = event.target.checked;
function changeAll(item, value) {
item.children.forEach((child) => {
child.isCheck = value;
changeAll(child, value);
});
}
changeAll(item, newValue);
item.isCheck = newValue; // 更新自身的值
},
},
};
</script>

handleChildren 方法裡面,changeAll 這個函式也是做遞迴呼叫

handleChildren 傳進來的 item 底下所有的 children 都掃過一遍

最後也別忘了更新自己本身的值

完成了向下的操作,接下來看向上的檢查

當事人觸發 change 事件後,若為 check,則父層不用做任何檢查

但若為 uncheck,他的父層(parent)要判定其子層,也就是當事人的平輩層(sibling)是否都沒有 check,若都沒有,就要 uncheck

這個父層若 uncheck 了,那又要執行一次這個父層的父層(aka 當事人的祖父層、grandparent)的檢查 ⋯⋯

也就是會一路往上每一層都做檢查,直到最上層

在當事人的視野裡,我們只從 props 接收到 list,也就是父層的 children 這個陣列

壓根不知道自己的父層是誰

於是,他必須通知自己的父層,也就是遞迴元件的上一層去做 children 的檢查

此時,Vue 的 $emit 就派上用場了

還記得前些年,前高雄市長韓國瑜的口號嗎?

沒錯,那就是「貨出去,人進來,高雄發大財」

來改編一下這句話:

event 出去,props 進來,前端發大財

woz-in-us-festival

利用 $emit 的特性,來通知上層:

你底下的其中一個子層 uncheck 了,請檢查自己該不該繼續 check

於是,新增一個 handleParent 方法

<template>
<div>
<ul>
<li v-bind:key="item.id" v-for="item in list">
<input
type="checkbox"
v-bind:checked="item.isCheck"
v-on:change="handleChildren($event, item), handleParent()"
/>
<label>{{ item.id }}</label>
<RecursiveList
v-bind:list="item.children"
v-if="item.children"
v-on:emit-parent="handleParent(item)"
/>
</li>
</ul>
</div>
</template>
<script>
export default {
// ...
methods: {
// ...
handleChildren() {
// ...
},
handleParent(item) {
this.$emit("emit-parent");
if (!item) return; // 沒有帶參數,表示是當事人,故不檢查
const childrenHasSomeChecked = item.children.some((item) => item.isCheck);
item.isCheck = childrenHasSomeChecked;
},
},
};
</script>

有別於一般 $emit 的寫法,這個 $emit 不帶任何 payload 出去,只是發送 emit-parent 事件

遞迴到上一層之後,emit-parent 事件觸發後執行的函式,依然是 handleParent 方法

此外,還從參數帶了父層的 item 進來,以便做 check 的檢查

至此就大功告成了


當事人父層的父層檢查、父層祖父層檢查,也都是同樣的邏輯

重複的工作,就交給遞迴去做了

emit-parent 事件會一路 $emit 上去

在最上層,也就是第一次出現 RecursiveList 的 App 元件裡面

handleEmitTop 方法去接 emit-parent 事件,並用 console.log 紀錄下來:

<template>
<div id="app">
<RecursiveList v-bind:list="nestedList" v-on:emit-parent="handleEmitTop($event)" />
</div>
</template>
<script>
import RecursiveList from "./components/RecursiveList.vue";
export default {
name: "App",
components: {
RecursiveList,
},
data() {
return {
nestedList: [
// ...
],
};
},
methods: {
handleEmitTop() {
console.log("do nothing");
},
},
};
</script>

果然會跟預想的一樣,每次的點擊,無論哪個 checkbox

每次都會一路 $emit 到最上層,然後印出 do nothing

但最上層沒有 checkbox 了,所以無需檢查,因此不用做任何事

上面的所有程式碼,請參考以下 CodeSandbox:

這次是因工作上的需要,而研究了關於遞迴的寫法

雖然這個不確定會巢狀幾層的 data 結構,會造成後端定義 model 的困擾而沒有採用

但也是一個不錯的嘗試

希望未來能有機會用到這種簡潔的寫法

Vue 因為框架的特性,直接對 data 的操作比較簡單,而無需在意是哪一層、哪一個 children

雖然過程中還挺手忙腳亂的,不知道哪一層是哪一層,然後誤打誤撞的寫出可以動的東西

it works

然而其實只需要專注在當事人身上就好了

話說回來,隨意修改 data 而不透過 props 去傳遞新的 data ,確實不是個好作法,但也確實能達到目的

若換作是 React,想必寫法會很不一樣

如果你好奇:文中那張照片、站在沙灘高舉雙手的人是誰?

他是蘋果電腦的共同創辦人:史蒂夫·沃茲尼克(Steve Wozniak)

那是 1982 年沃茲尼克所舉辦的音樂節 US Festival 現場

在他的自傳中提到自己夢想著籌辦音樂節

雖然音樂節圓滿舉辦,但不僅沒賺錢,反而虧損

可是他本人還是很開心,非常享受音樂節的每一刻

這張照片的肢體語言令我印象深刻

也充分表現出他的心情

就算沒有發大財,每天還是要過得開心