最近的工作在做一個多步驟多分步的表單頁面,這個多步驟多分步的意思是說這個頁面的業務是分多個步驟完成的,每個步驟可能又分多個小步驟來處理,大步驟之間,以及小步驟之間都是一種順序發生的業務關係。起初以為這種功能很好做,就跟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結構可以設計如下:
- Hide code <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命名跟表单组件的分离这一块,只不过现在这些思想还不够系统,还不到总结分享的水平,这些工作方法层面的理论,很少人去总结跟分享,我目前只见到张鑫旭的博客上有较完整的一套思路,学习下来,确实有不少收获跟体会,但是这毕竟是别人的,有一些只可意会不可言传的精华,还是掌握不到,只能一步步去积累才行,等将来我自己的思路成形了,我会考虑把我的想法全部分享出来,相信这件事情会成为我今年分享的最有价值的内容。
以上内容给大家介绍了JS中多步骤多分步的StepJump组件,希望对大家有所帮助!