最近的工作在做一个多步骤多分步的表单页面,这个多步骤多分步的意思是说这个页面的业务是分多个步骤完成的,每个步骤可能又分多个小步骤来处理,大步骤之间,以及小步骤之间都是一种顺序发生的业务关系。起初以为这种功能很好做,就跟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组件,希望对大家有所帮助!