[{"data":1,"prerenderedAt":424},["ShallowReactive",2],{"blog-\u002Fblog\u002Frefactor-multistep-form":3},{"id":4,"title":5,"body":6,"description":400,"extension":401,"meta":402,"navigation":154,"ogImage":407,"path":409,"robots":410,"schemaOrg":411,"seo":419,"sitemap":420,"stem":422,"__hash__":423},"content\u002Fblog\u002Frefactor-multistep-form.md","重構多步驟表單流程：從 Pinia 管理到局部狀態綁定的實踐",{"type":7,"value":8,"toc":390},"minimark",[9,13,16,19,23,39,43,105,108,111,114,117,124,134,284,288,314,333,374,377,386],[10,11,12],"p",{},"在開發互動式網頁應用程式時，我們經常會遇到需要分步執行的複雜表單（例如：建立商店、安裝流程等）。這類功能通常包含多個步驟，每個步驟的資料可能互相依賴，且允許使用者暫時儲存或中途跳離。",[10,14,15],{},"在面對這類需求時，我們應該如何設計狀態與資料流，才能在擴充與維護之間取得平衡呢？",[10,17,18],{},"本文分享我遇到的情況及作法。",[20,21,22],"h2",{"id":22},"原始架構回顧與痛點",[10,24,25,26,30,31,34,35,38],{},"在最初的開發中，原作者採用了集中式的狀態管理。主要結構是一個主視窗檔案（",[27,28,29],"code",{},"x.vue","），並搭配一個主 Store（",[27,32,33],{},"x.store.js","）以及五個對應步驟的 Store（",[27,36,37],{},"step1~5.store.js","）。",[40,41,42],"h3",{"id":42},"原作者的處理方式與設計邏輯",[44,45,46,78,88,91],"ul",{},[47,48,49,50,53,54,58,59,62,63,66,67,69,70,73,74,77],"li",{},"集中式初始化與賦值：初始化時，呼叫 ",[27,51,52],{},"API"," 取得所有表單的初始值並存入 ",[55,56,57],"strong",{},"主 Store"," 。接著透過 ",[27,60,61],{},"watch"," 監聽 ",[55,64,65],{},"currentStep（當前步驟)"," 的變化。當使用者切換步驟時，會自動將 ",[55,68,57],{}," 裡的狀態，直接賦值給對應 ",[55,71,72],{},"子步驟的 Store","（ ",[55,75,76],{},"不透過內部 action 更新"," ）。",[47,79,80,81,84,85,87],{},"跨步驟資料傳遞：若 Step 1 填寫的資料需要帶入 Step 2，則預先在 ",[27,82,83],{},"step2.store.js"," 中宣告承接欄位，並在 ",[27,86,61],{}," 切換步驟時，將資料從 Step 1 的狀態傳遞過去。",[47,89,90],{},"流程控制：使用陣列記錄步驟順序，並根據陣列的 Index 來判斷目前處於哪個步驟。",[47,92,93,94,97,98,101,102,104],{},"業務邏輯處理：各步驟的 ",[55,95,96],{},"驗證規則"," 與 ",[55,99,100],{},"提交"," 都封裝在個別的 Store 裡面；圖片上傳則是在使用者操作當下直接呼叫 ",[27,103,52],{},"。",[40,106,107],{"id":107},"痛點分析",[10,109,110],{},"雖然這種方式將各步驟的邏輯模組化，但隨著步驟增加，全域狀態與監聽器（watch）的交互會變得相當複雜。",[10,112,113],{},"當我們需要追蹤特定資料的變動來源時，容易產生過高的認知負擔，且隱式的狀態賦值容易造成非預期的副作用。",[20,115,116],{"id":116},"重構後的架構與設計",[10,118,119,120,123],{},"為了讓架構更輕量且易於追蹤，我捨棄了不需要全域共享的 Store 機制，改為在主檔案中統一管理狀態，並透過 ",[27,121,122],{},"v-model"," 將資料雙向綁定至各個子組件。\n重構後的核心程式碼結構",[125,126,131],"pre",{"className":127,"code":129,"language":130},[128],"language-text","\u003Ctemplate>\n  \u003CStep1\n    v-if=\"currentStep === 'S1'\"\n    ref=\"componentRef\"\n    :initial-data=\"getInitialData()\"\n    v-model=\"step1Data\"\n  \u002F>\n  \u003CStep2\n    v-if=\"currentStep === 'S2'\"\n    ref=\"componentRef\"\n    :initial-data=\"getInitialData()\"\n    v-model=\"step2Data\"\n  \u002F>\n\u003C\u002Ftemplate>\n","text",[27,132,129],{"__ignoreMap":133},"",[125,135,139],{"className":136,"code":137,"language":138,"meta":133,"style":133},"language-javaScript shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import { ref, reactive, computed } from 'vue';\n\n\u002F\u002F 宣告所有表單所需資料\nconst componentRef = ref(null);\nconst xData = reactive({}); \nconst step1Data = reactive({});\nconst step2Data = reactive({});\n\n\u002F\u002F 根據後端資料或初始狀態計算出流程\nconst stepFlow = computed(() => {\n  \u002F\u002F 處理流程判斷邏輯\n});\n\n\u002F\u002F 整理每個表單所需的初始值\nconst getInitialData = (stepName) => {\n  \u002F\u002F 回傳初始化資料\n};\n\nconst prevStep = () => { \u002F* 切換至上一步 *\u002F };\nconst nextStep = async () => {\n  if (componentRef.value?.submitForm) {\n    await componentRef.value.submitForm();\n  }\n};\n","javaScript",[27,140,141,149,156,162,168,174,180,186,191,197,203,209,215,220,226,232,238,244,249,255,261,267,273,279],{"__ignoreMap":133},[142,143,146],"span",{"class":144,"line":145},"line",1,[142,147,148],{},"import { ref, reactive, computed } from 'vue';\n",[142,150,152],{"class":144,"line":151},2,[142,153,155],{"emptyLinePlaceholder":154},true,"\n",[142,157,159],{"class":144,"line":158},3,[142,160,161],{},"\u002F\u002F 宣告所有表單所需資料\n",[142,163,165],{"class":144,"line":164},4,[142,166,167],{},"const componentRef = ref(null);\n",[142,169,171],{"class":144,"line":170},5,[142,172,173],{},"const xData = reactive({}); \n",[142,175,177],{"class":144,"line":176},6,[142,178,179],{},"const step1Data = reactive({});\n",[142,181,183],{"class":144,"line":182},7,[142,184,185],{},"const step2Data = reactive({});\n",[142,187,189],{"class":144,"line":188},8,[142,190,155],{"emptyLinePlaceholder":154},[142,192,194],{"class":144,"line":193},9,[142,195,196],{},"\u002F\u002F 根據後端資料或初始狀態計算出流程\n",[142,198,200],{"class":144,"line":199},10,[142,201,202],{},"const stepFlow = computed(() => {\n",[142,204,206],{"class":144,"line":205},11,[142,207,208],{},"  \u002F\u002F 處理流程判斷邏輯\n",[142,210,212],{"class":144,"line":211},12,[142,213,214],{},"});\n",[142,216,218],{"class":144,"line":217},13,[142,219,155],{"emptyLinePlaceholder":154},[142,221,223],{"class":144,"line":222},14,[142,224,225],{},"\u002F\u002F 整理每個表單所需的初始值\n",[142,227,229],{"class":144,"line":228},15,[142,230,231],{},"const getInitialData = (stepName) => {\n",[142,233,235],{"class":144,"line":234},16,[142,236,237],{},"  \u002F\u002F 回傳初始化資料\n",[142,239,241],{"class":144,"line":240},17,[142,242,243],{},"};\n",[142,245,247],{"class":144,"line":246},18,[142,248,155],{"emptyLinePlaceholder":154},[142,250,252],{"class":144,"line":251},19,[142,253,254],{},"const prevStep = () => { \u002F* 切換至上一步 *\u002F };\n",[142,256,258],{"class":144,"line":257},20,[142,259,260],{},"const nextStep = async () => {\n",[142,262,264],{"class":144,"line":263},21,[142,265,266],{},"  if (componentRef.value?.submitForm) {\n",[142,268,270],{"class":144,"line":269},22,[142,271,272],{},"    await componentRef.value.submitForm();\n",[142,274,276],{"class":144,"line":275},23,[142,277,278],{},"  }\n",[142,280,282],{"class":144,"line":281},24,[142,283,243],{},[40,285,287],{"id":286},"架構解析重構亮點與狀態機的關聯","架構解析：重構亮點與狀態機的關聯",[289,290,291],"ol",{},[47,292,293,294,297,298,301,302,313],{},"狀態機（Finite State Machine, FSM）概念的引入",[295,296],"br",{},"\n在重構後的架構中，我 ",[55,299,300],{},"捨棄了依賴陣列索引（Index）"," 的控制方式，改為使用與後端配合的 ",[55,303,304,305,308,309,312],{},"字串標識（如 ",[27,306,307],{},"S1",", ",[27,310,311],{},"S2"," 等）"," 來標示步驟。\n這種做法與有限狀態機（FSM）的概念不謀而合：",[44,315,316,324],{},[47,317,318,319,321,322,104],{},"明確的狀態節點：系統只會處於明確定義的節點（State）中。例如 ",[27,320,307],{},"、",[27,323,311],{},[47,325,326,327,329,330,332],{},"受控的轉換：流程控制只允許合法的轉換發生（例如從 ",[27,328,307],{}," 進入 ",[27,331,311],{},"）。這避免了因陣列順序變動或索引計算錯誤而產生的非預期狀態，大幅降低了除錯的難度。",[289,334,335,347,368],{"start":151},[47,336,337,338,340,341,97,344,346],{},"移除不必要的全域狀態管理",[295,339],{},"\n當資料不需要跨頁面共享時，引入全域 Store 反而會增加耦合度。改用 ",[27,342,343],{},"reactive",[27,345,122],{}," 後，資料流向變得非常明確，開發者能直覺地掌握哪個變數在何處被修改。",[47,348,349,350,352,353,355,356,359,360,363,364,367],{},"職責分離與單一職責原則",[295,351],{},"\n我刻意避免在主檔案 ",[27,354,29],{}," 中處理各步驟的細節邏輯。 ",[55,357,358],{},"主檔案只專注於流程控制（下一步、上一步、暫存狀態）"," ，而表單的 ",[55,361,362],{},"內部驗證與細部處理則由各子組件負責","。\n子組件統一向外暴露（expose）",[27,365,366],{},"submitForm"," 方法，讓父層只需呼叫該方法即可觸發驗證或請求，無須判斷當前步驟的型態。",[47,369,370,371,373],{},"優化圖片上傳體驗",[295,372],{},"\n在原架構中，圖片會在操作當下直接上傳，如果使用者中途取消或未完成流程，可能會產生未被使用的孤立檔案。新做法改為在最終按下「送出」時統一上傳，確保資料的完整性，也省去處理取消上傳（abort）的額外負擔。",[20,375,376],{"id":376},"結語",[10,378,379,380,382,383,385],{},"這次重構的目的是為了讓程式碼更具可讀性與可維護性。",[295,381],{},"\n透過將局部邏輯交還給各個步驟組件、統一表單提交行為，並結合狀態機的設計思維，我成功降低了多步驟表單的維護成本。",[295,384],{},"\n在此記錄自己的處理方法，希望這個實踐能為未來的我及觀看者處理類似複雜表單時提供一些靈感！",[387,388,389],"style",{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":133,"searchDepth":151,"depth":151,"links":391},[392,396,399],{"id":22,"depth":151,"text":22,"children":393},[394,395],{"id":42,"depth":158,"text":42},{"id":107,"depth":158,"text":107},{"id":116,"depth":151,"text":116,"children":397},[398],{"id":286,"depth":158,"text":287},{"id":376,"depth":151,"text":376},"將依賴 Pinia 的多步驟表單重構，提升程式碼的可讀性與職責清晰度。","md",{"date":403,"category":404,"tags":405},"2026-05-07","Frontend",[406,404],"Vue3",{"url":408},"https:\u002F\u002Fimage.jamespig.com\u002FMulti-Step-Form.png","\u002Fblog\u002Frefactor-multistep-form",null,[412],{"@type":413,"headline":414,"author":415,"datePublished":418},"TechArticle","多步驟表單流程",{"@type":416,"name":417},"Person","Jamespig","206-05-07",{"title":5,"description":400},{"loc":409,"lastmod":421},"2026-07-25","blog\u002Frefactor-multistep-form","FfqCH8PmhpdnkhaHDhO53cRe8XKwu3RqS4IxWrS8WJQ",1784961132500]