JS多步骤多分步StepJump组件实现详解

作者:青柠

时间:2026-10-30

浏览:335

本文详细介绍一个基于JavaScript的多步骤多分步表单组件StepJump。该组件支持纯静态切换与复杂业务逻辑结合,通过配置数组管理大步骤与小步骤的层级关系,并提供完善的回调机制与API接口,实现步骤间的顺序跳转、状态控制及业务数据交互,具有较高的通用性与灵活性。

文章目录

StepJump组件概述

近期开发工作中涉及一个多步骤、多分步的表单页面。所谓多步骤多分步,是指业务流程由多个大步骤组成,每个大步骤内部又包含若干小步骤,各步骤间存在严格的顺序依赖关系。起初认为此类功能实现较为简单,类似于Tab页切换,但实际开发中发现逻辑较为复杂,尤其是与表单交互及业务数据状态结合时。为此,我将相关逻辑抽象为一个名为StepJump的组件,该组件既支持纯静态的分步切换与跳转,也能处理与业务紧密结合的复杂逻辑,具备较好的通用性和灵活性。本文将重点阐述该组件的功能需求及实现思路。

实现效果展示如下:

StepJump组件概述

示例包含两个页面:demo.html和regist.html,对应的JS文件分别为demo.js和regist.js,核心组件封装在stepJump.js中,采用seajs进行模块化加载。demo.html演示纯静态的多步骤多分步内容切换;regist.html则展示了与业务结合的实际效果,其中业务数据状态通过常量STEP_STATUS进行模拟,源自近期实际工作场景。

StepJump组件概述

需求分析

1. 需求分析

前文提供的效果图并不完整,且原始设计图过大不便直接展示。为清晰描述功能要求,需通过文字详细阐述每个细节:

1)页面共包含四个大步骤,其中第1、3、4大步骤各对应一个小步骤,而第2大步骤对应三个小步骤。即第1、3、4大步骤只需一步完成,第2大步骤需分三步完成;

2)所有步骤按顺序执行,必须先完成当前大步骤才能进入下一大步骤;同理,同一内的大步骤中,必须先完成当前小步骤才能进入下一小步骤;

3)每个大步骤的第一个小步骤可能包含一个“返回上一大步骤”的按钮;

4)每个大步骤中间的(非首尾)小步骤可能包含两个按钮:一个“返回上一小步骤”,另一个“跳转到下一小步骤”;

5)每个大步骤的最后一个小步骤可能包含一个“跳转到下一大步骤”的按钮;

6)若某大步骤仅包含一个小步骤,则该小步骤同时视为第一个和最后一个;

7)每个大步骤内的各小步骤显示内容不同,且同一时间仅显示一个小步骤;

8)已完成的大步骤、正在进行的大步骤及待执行的大步骤应具有不同的UI表现(实际实现中,已完成与正在执行的步骤UI效果合并);

9)待执行的大步骤只能通过上一大步骤最后一个小步骤中的按钮点击跳转;已完成和正在执行的大步骤可通过点击步骤名称直接跳转;

10)点击大步骤名称时,自动跳转至该大步骤的第一个小步骤。

上述为页面静态功能分析,接下来分析实际业务需求:

1)该页面面向登录用户,用于平台用户入驻申请。只有完成整个入驻流程,用户才能正式使用平台其他功能;

2)主要业务数据与用户相关,按入驻流程,用户状态可分为:

a. 待填写资料:每次进入页面若为此状态,则显示【1 入住须知】大步骤,表示当前正在进行该步骤;

b. 待提交资料:每次进入页面若为此状态,则显示【2 公司信息提交】大步骤,默认显示其第一个小步骤;

c. 审核未通过:每次进入页面若为此状态,则显示【3 等待审核】大步骤;

d. 审核已通过:每次进入页面若为此状态,则显示【3 等待审核】大步骤;

e. 待确认合同:每次进入页面若为此状态,则显示【4 合同签订】大步骤;

3)需注意,【3 等待审核】和【4 合同签订】分别包含3个和2个内容项,这些内容项互斥显示,但并非分步关系,具体显示哪个完全由业务状态决定。例如【3 等待审核】有以下三种可能效果:

StepJump组件概述
StepJump组件概述
StepJump组件概述 

当从【2 公司信息提交】跳转到【3 等待审核】时,显示第一个效果; 若进入页面时状态为审核已通过,则显示第二个效果; 若进入页面时状态为审核未通过,则显示第三个效果,且此情况下步骤名称有特殊UI要求:

当直接点击步骤名称时(如点击【2 公司信息提交】),效果需还原为默认状态;当再次点击【3 等待审核】时,需恢复为这种特殊效果;只有通过【2 公司信息提交】的小步骤跳转至【3 等待审核】时,才能完全撤销这种特殊效果。

大体需求如上所述,可能仍有细节未详尽描述,因文字难以完全表达,需结合实际效果体会。从最终实现来看,静态功能需求是组件实现的核心,业务需求不具备通用性。开发此组件的初衷是基于静态功能需求构建基本功能,再结合业务需求设计合理的API与回调,并尽可能将JS与HTML、组件与业务分离,以最大化灵活性。

实现思路

2. 实现思路

首先讨论HTML结构:基于前述需求,有两个重要概念:大步骤和小步骤,且二者存在包含关系,步骤间有顺序约定。因此需设计两个集合分别存放所有大步骤和小步骤相关项,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即为所有小步骤项。这两个集合仅解决步骤项的存放和顺序问题,尚未解决包含关系。在需求中,大步骤与小步骤的包含关系如下:

StepJump组件概述

如此,只需通过一个简单的配置数组即可体现这种关系,例如以上结构可用[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: {
//配置小步骤相关的东西
}
//配置大步骤相关的东西
}
}

相当于将前述包含结构抽象为:

StepJump组件概述

该结构有两个优点:一是无需考虑集合索引从0开始的问题,STEP_PANES_CONFIG[2]即表示第2个大步骤;二是小步骤索引也无需从0开始,且相对于大步骤标识,例如STEP_PANES_CONFIG[2][1]表示第2个大步骤的第一个小步。这样,大步骤和小步骤均能通过索引良好识别,配置表更稳定。即组件对外提供索引相关接口或参数时,按常规思维提供,内部需解决逻辑索引(如[2][1])与物理索引的转换关系,以及物理索引与逻辑索引的转换关系。例如外部调用时告知组件初始化需显示第2大步的第1个小步,组件需根据此信息找到相应大小步骤项显示;外部已知步骤项物理索引位置时,组件需提供方法将物理索引转换为逻辑索引。

再讨论效果实现:

1)每个步骤内容只需控制显示哪个,若用CSS控制状态,则仅有两种:默认态(不显示)和active态(显示);

2)每个步骤的边角可用CSS边框画三角原理实现;

3)为正确控制步骤效果,若用CSS控制状态,则有三种:默认态、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组件,希望对大家有所帮助!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章