最近的工作在做一個多步驟多分步的表單頁面,這個多步驟多分步的意思是說這個頁面的業務是分多個步驟完成的,每個步驟可能又分多個小步驟來處理,大步驟之間,以及小步驟之間都是一種順序發生的業務關係。起初以為這種功能很好做,就跟tab頁的實現原理差不多,真做下來才發現,這裡面的相關邏輯還是挺多的(有可能是我沒... ...
最近的工作在做一個多步驟多分步的表單頁面,這個多步驟多分步的意思是說這個頁面的業務是分多個步驟完成的,每個步驟可能又分多個小步驟來處理,大步驟之間,以及小步驟之間都是一種順序發生的業務關係。起初以為這種功能很好做,就跟tab頁的實現原理差不多,真做下來才發現,這裡面的相關邏輯還是挺多的(有可能是我沒想到更好地辦法~),尤其是當這個功能跟表單,還有業務數據的狀態結合起來的時候。我把這個功能相關的一些邏輯抽象成了一個組件StepJump,這個組件能夠實現純靜態的分步切換和跳轉,以及跟業務相結合的複雜邏輯,有一定的通用性和靈活性,本文主要介紹它的功能要求和實現思路。
代碼下載,裡面有兩個效果頁面:demo.html和regist.html,相關js分別是demo.js和regist.js,組件封裝在stepJump.js裡面,採用seajs做的模塊化。demo.html演示的是一個純靜態的多步驟多分步的內容切換,regist.html是一個完整地跟業務結合起來的效果,是我從最近的工作中抽出來的,只不過裡面的業務數據狀態是用一個常量(STEP_STATUS)來模擬的。
1. 需求分析
前面給的效果圖不完整,但是設計圖太大,不方便貼出。為了把這個頁面的功能要求描述清楚,我只能儘可能地在文字上多花功夫,儘量把每一個細節都講清楚:
1)這個頁面一共有四個大步驟,其中1,3,4都只對應了一個小步驟,而2對應了三個小步驟,也就說1,3,4分別是1步就能完成的,而2需要3步才能完成;
2)這些步驟是順序發生的關係,必須先完成第1大步,才能進行第2大步;必須先完成第1個小步,才能進行第2個小步;
3)每個大步驟的第一個小步可能有一個按鈕能夠返回到上一大步;
4)每個大步驟位於中間的小步可能有2個按鈕,一個返回上一小步,一個跳轉到下一小步;
5)每個大步驟的最後一個小步可能有一個按鈕能夠跳轉到下一大步;
6)如果一個大步驟只包含一個小步驟,那麼它既是第一個小步,也是最後一個小步;
7)每個大步驟的每個小步驟要顯示的內容都是不一樣的,每次只能顯示一個小步驟;
8)已經完成的大步驟,正在進行的大步驟,後面待執行的大步驟,應該具有不同的UI效果;(不過從實現效果來說,已經完成的跟正在執行做成了一個效果)
9)後面待執行的大步驟必須通過上一個大步驟最後一個小步驟裡面的按鈕點擊才能跳轉;已經完成的和正在執行的大步驟可通過點擊步驟名稱跳轉;
10)點擊大步驟名稱時,跳轉至該大步驟的第一個小步。
以上部分是頁面的靜態功能分析,下麵要分析的是該頁面實際的業務需求:
1)這個頁面是開放給登錄用戶使用的,用來給某個平臺做用戶入住申請用的,只有完成這個入住流程,才能正式進入平臺使用其它功能;
2)主要的業務數據都是跟用戶相關的,按入住流程來說,用戶的入住流程狀態可以分為:
a. 待填寫資料,如果每次進入這個頁面時是這個狀態值,那麼就顯示【1 入住須知】這個大步驟,表示正在進行該步驟;
b. 待提交資料,如果每次進入這個頁面時是這個狀態值,那麼就顯示【2 公司信息提交】這個大步驟,小步驟預設顯示它的第一個;
c. 審核未通過,如果每次進入這個頁面時是這個狀態值,那麼就顯示【3 等待審核】這個大步驟;
d. 審核已通過,如果每次進入這個頁面時是這個狀態值,那麼就顯示【3 等待審核】這個大步驟;
e. 待確認合同,如果每次進入這個頁面時是這個狀態值,那麼就顯示【4 合同簽訂】這個大步驟;
3)需要註意的是【3 等待審核】和【4 合同簽訂】各包含3個和2個內容,它們各自的這幾個內容是互斥顯示的關係,但是它們不是分步的關係,具體要顯示哪個完全是由業務狀態決定的。比如說【3 等待審核】有下麵3種可能的效果:
當從【2 公司信息提交】跳轉到【3 等待審核】顯示第一個效果;
如果進入頁面時是審核已通過的狀態顯示第2個效果;
如果進入頁面時是審核未通過的狀態顯示第3個效果,而且這個情況下,步驟名稱還有特殊效果要求:
當直接點擊步驟名稱時,比如點擊【2 公司信息提交】,這個效果得還原成預設的效果;當再點擊【3 等待審核】,又得設置成這種特殊的效果;只有通過【2 公司信息提交】的小步跳轉到【3 等待審核】時,才能完全撤銷這種特殊效果。
大體上的需求就是以上這些部分,可能還有一些細節沒有描述,因為用文字不太容易說清楚,所以只能根據實際效果去體會了。 從最終實現來說,前面的需求中,靜態功能需求才是組件實現的核心,後面的業務需求並不具備通用性,我開發這個組件的出發點是根據靜態功能需求寫出組件的基本功能,然後再結合業務需求設計合理的API跟回調,並且儘可能地將js與html分離,組件與業務分離,以便最終的實現能夠將靈活性最大化。
2. 實現思路
首先說html結構:我考慮在前面的需求中,有2個很重要的概念:大步驟,小步驟,並且這些大步驟跟小步驟有包含關係,步驟之間還有順序的約定,所以得設計兩個集合,分別用來存放所有大步驟相關項和所有小步驟相關項,html結構可以設計如下:
<nav class="nav-step"> <ul id="steps" class="steps"> <li><a href="javascript:;">1<span class="gap"></span>入住須知</a></li> <li><a href="javascript:;">2<span class="gap"></span>公司信息提交</a></li> <li><a href="javascript:;">3<span class="gap"></span>等待審核</a></li> <li><a href="javascript:;">4<span class="gap"></span>合同簽訂</a></li> </ul> </nav> <div id="step-content" class="step-content"> <div class="step-pane" > </div> <div class="step-pane"> </div> <div class="step-pane"> </div> <div class="step-pane"> </div> <div class="step-pane"> </div> <div class="step-pane" </div> </div>
其中#steps li 就是所有的大步驟項,所有的#step-content .step-pane就是所有的小步驟項。這兩個集合僅僅解決了步驟項的存放和順序問題,對於它們之間的包含關係還沒有解決。在需求當中,大步驟與小步驟是這樣的包含關係:
這樣的話,我們只要通過一個簡單的配置數組就能把這種關係體現出來,比如以上這個結構就可以用[1,3,1,1]來說明,表示一共有4個大步驟,其中1,3,4都只有一個小步驟,2有3個小步驟。由於大步驟與小步驟是分開存放在兩個集合裡面的,所以我們在對這兩個集合進行存取的時候,都用的是相對集合的索引位置,但是在實際使用過程中:大步驟的位置是比較好識別的,小步驟的絕對位置就不好識別了,而且相對集合的位置都是從0開始,如果每個小步的內容裡面都有定義其它的一些組件,比如表單相關的組件,我們肯定會把這些組件的實例存放到一個配置表裡:
var STEP_CONFIG = { 0: { form: { //.... } }, 1: { form: { //.... } }, 2: { form: { //.... } }, 3: { form: { //.... } } //... }
這種配置表是按小步驟的絕對索引來標識的,在實際使用的時候會很不方便,而且當小步的html結構有調整的時候,這個結構就得改,所有引用到它來獲取相關組件的地方都得改。最好是完成下麵這種配置方式:
var STEP_PANES_CONFIG = { //2,1表示第二個步驟的第一個小步內容 //2,2表示第二個步驟的第二個小步內容 //2,3表示第二個步驟的第三個小步內容 2: { 1: { //配置小步驟相關的東西 }, 2: { //配置小步驟相關的東西 }, 3: { //配置小步驟相關的東西 } //配置大步驟相關的東西 } }
相當於把前面的包含結構抽象成:
這個結構有兩個好處:一是不考慮集合索引從0開始的問題,STEP_PANES_CONFIG[2]就表示第2個大步驟;二是小步驟的索引也不考慮從0開始的問題,而且是相對大步驟來標識的,比如STEP_PANES_CONFIG[2][1]就表示第2個大步驟的第一個小步,這樣的話,大步驟跟小步驟就都能很好的通過索引來識別,配置表也更穩定一點。也就是說組件在對外提供索引相關的介面或參數的時候,都是按常規思維方式提供的,在組件內部得解決邏輯索引(比如[2][1])跟物理索引的轉化關係,以及物理索引跟邏輯索引的轉換關係。比如外部調用的時候,告訴組件初始化需要顯示第2大步的第1個小步,那麼組件就得根據這個信息找到相應的大小步驟項去顯示;外部已知步驟項的物理索引位置時,組件得提供方法能夠將物理索引位置轉換成邏輯索引。
再來說效果:
1)每個步驟的內容只要控制顯示哪個即可,所以步驟內容如果用css來控制狀態的話就只有2種,預設態和active態,預設態不顯示,active態顯示;
2)每個步驟的邊角可以用css邊框畫三角的原理實現;
3)為了正確控制步驟的效果,每個步驟如果用css來控制狀態的話有3種,預設態,done態和current態,分別表示未執行,已執行和正在執行的步驟。另外第三大步還有一個alerts態,不過這是一個跟業務相關的狀態,跟組件倒是沒有關係。這三個狀態的控制實現,跟網上那種評分組件是類似的。
大概的思路就是這些,另外還有關於API和回調的設計,我會在下一部分的實現細節里去描述。
3. 實現細節
先來看看組件的配置項:
var DEFAULTS = { config: [], //必傳參數,步驟項與步驟內容項的配置,如[1,2,3]表示一共有三個(config.length)步驟,第1個步驟有1個(config[0])內容項,第2個步驟有2個(config[1])內容項,第3個步驟有3個(config[2])內容項 stepPanes: '', //必傳參數,步驟內容項的jq 選擇器 navSteps: '', //必傳參數,步驟項的jq 選擇器 initStepIndex: 1, //初始時顯示的步驟位置,如果一共有4個步驟,該參數可選值為:1,2,3,4 initPaneIndex: 1, //初始時顯示的步驟內容項位置,基於initStepIndex,如果initStepIndex設置成2,且該步驟有3個內容項,則該參數可選值為:1,2,3 onStepJump: $.noop, //步驟項跳轉時候的回調 onBeforePaneChange: $.noop, //步驟內容項切換之前的回調 onPaneChange: $.noop, //步驟內容項切換之後的回調 onPaneLoad: $.noop //步驟內容項第一次顯示時的回調 };
註釋部分已經說得比較清楚了,前5個在實現思路裡面都有相關內容提及。下麵我把那四個回調作用和調用做一個詳細說明:
1)onStepJump(oldStepIndex, targetStep)
這個回調是在大步驟跳轉的時候觸發的,作用很清楚,就是為了在步驟跳轉的時候做一些邏輯處理,比如業務需求中【3 等待審核】的特殊效果控制就得藉助這個回調,傳遞有兩個參數oldStepIndex表示跳轉前的步驟的物理索引(從0開始), targetStep表示要跳轉到的步驟的物理索引。
2)onBeforePaneChange(currentPane, targetPane, currentStep)
這個回調的作用是在切換小步驟的時候,可能小步驟裡面有的表單校驗未通過,此時就得取消小步驟的切換,通過在這個回調里返回false就能達到這個效果。傳遞的三個參數都是物理索引,分別表示當前小步驟的位置,要切換的小步驟位置和當前大步驟的位置。
3)onPangeChange(currentPane, targetPane, currentStep)
這個跟第二個是差不多的,只不過發生在小步驟切換完成之後調用。
4)onPaneLoad(e,currentStep, currentPane)
這個回調作用很大,小步驟裡面的其它組件,比如表單組件等,都可以在這個回調里定義,目的是為了實現延遲初始化的功能。同時這個回調在執行的時候已經把this指向了當前小步驟對應的DOM元素,以便可以快速地通過該DOM元素找到其它組件初始化需要的子元素,比如form等。這個回調對於每個小步驟來說都只會觸發一次。傳遞三個參數e表示相關的jq事件,後面兩個分別表示當前大步驟和小步驟的物理索引。
回調觸發順序是:onBeforePaneChange,onPangeChange,onPaneLoad,onStepJump。另外onPaneLoad在組件初始化完成的時候也會調用一次。
通過以上這些回調基本上就能解決前面業務需求的那些問題。
再來看看API,我根據前面的需求只考慮3個API實例方法:
return { goStep: function(step) { goStep(step - 1); }, goNext: function() { go(currentPane + 1); }, goPrev: function() { go(currentPane - 1); } }
goStep可以跳轉到指定步驟的第一個小步,goNext跳轉到下一個小步,goPrev跳轉到上一個小步。另外還有一個靜態方法:
//根據步驟內容項的絕對索引位置,獲取相對於步驟項的位置 //step從0開始,pane表示絕對索引位置,比如stepPanes一共有6個,那麼pane可能的值就是0-5 //舉例:config: [1,3,1,1], step: 2, pane: 4,就會返回1,表示第三個步驟的第1個步驟內容項的位置 StepJump.getRelativePaneIndex = function(config, step, pane) { return pane - getPaneCountBeforeStep(config, step) + 1; };
因為前面那些回調傳遞的參數都是物理索引,外部如果需要把物理索引轉換成邏輯索引的話,就得使用這個方法。
其它細節說明:
1)maxStepIndex
這個變數也很關鍵,通過它來控制哪些大步驟不能通過直接點擊的方式來跳轉。
2)大步驟項的UI控制
//步驟項UI控制 function showStep(targetStep) { $navSteps.each(function(i) { var cname = this.className; cname = $.trim(cname.replace(/current|done/g, '')); if (i < targetStep) { //當前步驟之前的狀態全部設置為done cname += ' done'; } else if (i == targetStep) { //當前步驟項狀態設置為current cname += ' current'; } this.className = cname; }); }
整體實現如下,代碼優化程度受水平限制,但是邏輯還是很清楚的:
define(function(require, exports, module) { var $ = require('jquery'); //step: 表示步驟項 //pane: 表示步驟內容項 var DEFAULTS = { config: [], //必傳參數,步驟項與步驟內容項的配置,如[1,2,3]表示一共有三個(config.length)步驟,第1個步驟有1個(config[0])內容項,第2個步驟有2個(config[1])內容項,第3個步驟有3個(config[2])內容項 stepPanes: '', //必傳參數,步驟內容項的jq 選擇器 navSteps: '', //必傳參數,步驟項的jq 選擇器 initStepIndex: 1, //初始時顯示的步驟位置,如果一共有4個步驟,該參數可選值為:1,2,3,4 initPaneIndex: 1, //初始時顯示的步驟內容項位置,基於initStepIndex,如果initStepIndex設置成2,且該步驟有3個內容項,則該參數可選值為:1,2,3 onStepJump: $.noop, //步驟項跳轉時候的回調 onBeforePaneChange: $.noop, //步驟內容項切換之前的回調 onPaneChange: $.noop, //步驟內容項切換之後的回調 onPaneLoad: $.noop //步驟內容項第一次顯示時的回調 }; function StepJump(options) { var opts = $.extend({}, DEFAULTS, options), $stepPanes = $(opts.stepPanes), $navSteps = $(opts.navSteps), config = opts.config, stepPaneCount = sum.apply(null, config), //步驟內容項的總數 currentStep = opts.initStepIndex - 1, //當前步驟項的索引 currentPane = sum.apply(null, config.slice(0, currentStep)) + (opts.initPaneIndex - 1), //當前內容項的索引 maxStepIndex = currentStep, //允許通過直接點擊步驟項跳轉的最大步驟項位置 $activePane = $stepPanes.eq(currentPane); //註冊僅觸發一次的stepLoad事件 $stepPanes.each(function() { $(this).one('stepLoad', $.proxy(function() { opts.onPaneLoad.apply(this, [].slice.apply(arguments).concat([currentStep, currentPane])); }, this)); }); //初始化UI showStep(currentStep); $activePane.addClass('active').trigger('stepLoad'); //註冊點擊步驟項的回調 $navSteps.on('click.step.jump', function() { var $this = $(this), step = $this.index(opts.navSteps); //找到當前點擊步驟項在所有步驟項中的位置 if (step > maxStepIndex || $this.hasClass('current')) return; //跳轉到該步驟項的第一個步驟內容項 goStep(step); }); //步驟項UI控制 function showStep(targetStep) { $navSteps.each(function(i) { var cname = this.className; cname = $.trim(cname.replace(/current|done/g, '')); if (i < targetStep) { //當前步驟之前的狀態全部設置為done cname += ' done'; } else if (i == targetStep) { //當前步驟項狀態設置為current cname += ' current'; } this.className = cname; }); } function goStep(step) { go(getPaneCountBeforeStep(config, step)); } //通過步驟內容項查找步驟項的位置 function getStepByPaneIndex(targetPane) { var r = 0, targetStep = 0; for (var i = 0; i < stepPaneCount; i++) { r = r + config[i]; if (targetPane < r) { targetStep = i; break; } } return targetStep; } function go(targetPane) { if (targetPane < 0 || targetPane >= stepPaneCount) { return; } //在切換步驟內容項之前提供給外部的回調,以便外部可以對當前步驟內容項做一些校驗之類的工作 //如果回調返回false則取消切換 var ret = opts.onBeforePaneChange(currentPane, targetPane, currentStep); if (ret === false) return; var $targetPane = $stepPanes.eq(targetPane), targetStep = getStepByPaneIndex(targetPane); $activePane.removeClass('active'); $targetPane.addClass('active'); opts.onPaneChange(currentPane, targetPane, currentStep); $activePane = $targetPane; currentPane = targetPane; var oldStepIndex = currentStep; currentStep = targetStep; currentStep > maxStepIndex && (maxStepIndex = currentStep); $targetPane.trigger('stepLoad'); if (targetStep !== oldStepIndex) { showStep(targetStep); opts.onStepJump(oldStepIndex, targetStep); } } return { goStep: function(step) { goStep(step - 1); }, goNext: function() { go(currentPane + 1); }, goPrev: function() { go(currentPane - 1); } } } //根據步驟內容項的絕對索引位置,獲取相對於步驟項的位置 //step從0開始,pane表示絕對索引位置,比如stepPanes一共有6個,那麼pane可能的值就是0-5 //舉例:config: [1,3,1,1], step: 2, pane: 4,就會返回1,表示第三個步驟的第1個步驟內容項的位置 StepJump.getRelativePaneIndex = function(config, step, pane) { return pane - getPaneCountBeforeStep(config, step) + 1; }; //求和 //註:slice(start,end)返回的數據不包含end索引對應的元素 function sum() { var a = [].slice.apply(arguments), r = 0; a.forEach(function(n) { r = r + n; }); return r; } //統計在指定的步驟項之前一共有多少個步驟內容項,step從0開始,比如config: [1,3,1,1], 當step=2,就會返回4 function getPaneCountBeforeStep(config, step) { return sum.apply(null, config.slice(0, step)); } return StepJump; });
4. 調用舉例
demo.html里的使用方式:
define(function(require, exports, module) { var $ = require('jquery'); var StepJump = require('components/stepJump'), stepJump = new StepJump({ config: [1,3,1,1], stepPanes: '#step-content .step-pane', navSteps: '#steps > li', initStepIndex: 1 }); $(document).on('click.stepPane.switch', '.btn-step', function(e) { var $this = $(this); if ($this.hasClass('next')) { stepJump.goNext(); } else { stepJump.goPrev(); } }); });
由於這是個靜態的功能,所以不用加任何回調。
regist.html里的使用方式:
//STEP_STATUS取值: //0 待填寫資料,如果每次進入這個頁面時是這個狀態值,那麼就顯示【1 入住須知】這個大步驟,表示正在進行該步驟; //1 待提交資料,如果每次進入這個頁面時是這個狀態值,那麼就顯示【2 公司信息提交】這個大步驟,小步驟預設顯示它的第一個; //2 審核未通過,如果每次進入這個頁面時是這個狀態值,那麼就顯示【3 等待審核】這個大步驟; //3 審核已通過,如果每次進入這個頁面時是這個狀態值,那麼就顯示【3 等待審核】這個大步驟; //4 待確認合同,如果每次進入這個頁面時是這個狀態值,那麼就顯示【4 合同簽訂】這個大步驟; var STEP_STATUS = 3, MODE = STEP_STATUS == 2 || STEP_STATUS == 4 ? 3 : 2, //3表示只讀,在公司信息提交步驟只能看不能改 STEP_AUDIT_ALERTS = STEP_STATUS == 3, //這個變數用來控制在等待審核步驟的時候是否給步驟項添加alerts樣式 STEP_STATUS_MAP = { 0: 1, 1: 2, 2: 3, 3: 3, 4: 4 }, initStepIndex = STEP_STATUS_MAP[STEP_STATUS], STEP_PANES_DATA = [1, 3, 1, 1], STEP_PANES_CONFIG = { //3,1表示第三個步驟的第一個步驟內容 3: { 1: { onPaneLoad: function(e, currentStep, currentPane, conf) { var $stepPane = $(this); conf.vc = new VisibleController($stepPane.children('div')); if (STEP_AUDIT_ALERTS) { $auditStep = $('#audit-step'); $auditStep.addClass('alerts'); conf.vc.show('#audit-no'); } else if (STEP_STATUS == 2 || STEP_STATUS == 4) { conf.vc.show('#audit-yes'); } else { conf.vc.show('#audit-wait'); } } }, onLeaveStep: function() { STEP_AUDIT_ALERTS && $auditStep.removeClass('alerts'); }, onEnterStep: function(step, conf) { if (STEP_AUDIT_ALERTS) { $auditStep.addClass('alerts'); } else { conf[1].vc.show('#audit-wait'); } } }, 4: { 1: { onPaneLoad: function(e, currentStep, currentPane, conf) { var $stepPane = $(this); conf.vc = new VisibleController($stepPane.children('div')); conf.vc.show('#contract-confirm'); } } } }, GET_STEP_PANES_CONFIG = function(step, pane) { if (pane == undefined) return STEP_PANES_CONFIG[step + 1]; return STEP_PANES_CONFIG[step + 1] && STEP_PANES_CONFIG[step + 1][StepJump.getRelativePaneIndex(STEP_PANES_DATA, step, pane)]; }; var $auditStep, stepJump = new StepJump({ config: STEP_PANES_DATA, stepPanes: '#step-content .step-pane', navSteps: '#steps > li', initStepIndex: initStepIndex, onBeforePaneChange: function(currentPane, targetPane, currentStep) { var conf = GET_STEP_PANES_CONFIG(currentStep, currentPane); return conf && conf.onBeforePaneChange && conf.onBeforePaneChange.apply(this, [].slice.apply(arguments).concat[conf]); }, onPaneChange: function() { window.scrollTo(0, 0); }, onPaneLoad: function(e, currentStep, currentPane) { var conf = GET_STEP_PANES_CONFIG(currentStep, currentPane); conf && conf.onPaneLoad && conf.onPaneLoad.apply(this, [].slice.apply(arguments).concat([conf])); }, onStepJump: function(currentStep, targetStep) { var conf = GET_STEP_PANES_CONFIG(currentStep); conf && conf.onLeaveStep && conf.onLeaveStep(currentStep, conf); conf = GET_STEP_PANES_CONFIG(targetStep); conf && conf.onEnterStep && conf.onEnterStep(targetStep, conf); } });
StepJump組件的初始化在最後面,前面都是一些配置相關的內容。更換STEP_STAUS這個變數的值,就能模擬實際業務中的不同業務狀態,就能看到不同狀態進入頁面時這個組件的顯示的效果。
5. 小結
本文把最近工作的一部分成果總結了一下,提供了一個StepJump組件,也許在你的工作中也有用得著的地方,當然每個人的思路跟做法都不一定相同,我也僅僅是分享的目的。其實這幾天的工作思考的東西還是挺多的,除了這個組件之外,更多的想法都集中在樣式分離,CSS命名跟表單組件的分離這一塊,只不過現在這些思想還不夠系統,還不到總結分享的水平,這些工作方法層面的理論,很少人去總結跟分享,我目前只見到張鑫旭的博客上有較完整的一套思路,學習下來,確實有不少收穫跟體會,但是這畢竟是別人的,有一些只可意會不可言傳的精華,還是掌握不到,只能一步步去積累才行,等將來我自己的思路成形了,我會把我的想法全部分享出來,相信這件事情會成為我今年分享的最有價值的內容。
謝謝閱讀:)