
里面有两个效果页面: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,4initPaneIndex: 1, //初始时显示的步骤内容项位置,基于initStepIndex,如果initStepIndex设置成2,且该步骤有3个内容项,则该参数可选值为:1,2,3onStepJump: $.noop, //步骤项跳转时候的回调onBeforePaneChange: $.noop, //步骤内容项切换之前的回调onPaneChange: $.noop, //步骤内容项切换之后的回调onPaneLoad: $.noop //步骤内容项第一次显示时的回调};注释部分已经说得比较清楚了,前5个在实现思路里面都有相关内容提及。下面我把那四个回调作用和调用做一个详细说明: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;};因为前面那些回调传递的参数都是物理索引,外部如果需要把物理索引转换成逻辑索引的话,就得使用这个方法。//步骤项UI控制function showStep(targetStep) {$navSteps.each(function(i) {var cname = this.className;cname = $.trim(cname.replace(/current|done/g, ""));if (i < targetStep) {//当前步骤之前的状态全部设置为donecname += " done";} else if (i == targetStep) {//当前步骤项状态设置为currentcname += " 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,4initPaneIndex: 1, //初始时显示的步骤内容项位置,基于initStepIndex,如果initStepIndex设置成2,且该步骤有3个内容项,则该参数可选值为:1,2,3onStepJump: $.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));});//初始化UIshowStep(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) {//当前步骤之前的状态全部设置为donecname += " done";} else if (i == targetStep) {//当前步骤项状态设置为currentcname += " 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,就会返回4function getPaneCountBeforeStep(config, step) {return sum.apply(null, config.slice(0, step));}return StepJump;});4. 调用举例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();}});});由于这是个静态的功能,所以不用加任何回调。//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这个变量的值,就能模拟实际业务中的不同业务状态,就能看到不同状态进入页面时这个组件的显示的效果。