今天学习的源码是vant的stepper组件,也就是步进器:

感觉这玩意儿在电商项目中是很常用的,商品个数增加减少就靠它。如果我们自己写一个stepper组件需要注意哪些事情呢?通过阅读本文一起涨知识吧!
1.准备工作
把代码clone下来,通过阅读文档了解stepper步进器的使用。
代码:
https://github.com/vant-ui/vant/blob/main/packages/vant/src/stepper/Stepper.tsx
文档:Stepper 步进器
2.目录结构

- demo 组件使用示例
- test 组件的测试文件
- index.less 组件样式文件
- index.ts 组件注册、组件导出、组件类型声明
- README.md 和 README.zh-CN.md 中英文的readme文档
- Stepper.tsx组件定义的文件
- var.less 定义组件使用的less变量
从入口文件index.ts开始看
import { withInstall } from '../utils';
import _Stepper from './Stepper';
export const Stepper = withInstall(_Stepper);
export default Stepper;
export type { StepperTheme, StepperProps } from './Stepper';
declare module 'vue' {export interface GlobalComponents {VanStepper: typeof Stepper;}
}
withInstall用于注册组件,实际为组件添加了install方法,withInstall源码如下:
export function withInstall<T extends Component>(options: T) {(options as Record<string, unknown>).install = (app: App) => {const { name } = options;if (name) {app.component(name, options);app.component(camelize(`-${name}`), options);}};return options as WithInstall<T>;
}
可见给options增加了install方法,在install方法中就是为app定义组件, 更多内容请参考vue插件。
下面详细学习Stepper.tsx文件。
3.组件剖析
Stepper.tsx文件里面全是ts代码,自然和我们平时开发定义组件的写法不一样。我们写的业务组件有template部分、有script部分可能还会有style部分,如下所示:
<template><a-card title="数据筛选" style="margin-bottom: 10px" class="search-card"><slot></slot></a-card>
</template>
<script lang="ts"> import { defineComponent } from 'vue'
export default defineComponent({name: 'Search'
}) </script>
<style lang="less" scoped>
</style>
但是Stepper.tsx中只有导出通过defineComponent定义的组件的逻辑,没有template也没有style,如下图所示:

代码只有300多行,比较简单,下面将分成三个部分来分析这个组件:属性和值、事件、布局和样式。
3.1 属性和值
3.1.1 props属性定义
stepperProps定义了22个属性,在文档中有对这些属性清楚的介绍:
const stepperProps = {min: makeNumericProp(1),max: makeNumericProp(Infinity),name: makeNumericProp(''),step: makeNumericProp(1),theme: String as PropType<StepperTheme>,integer: Boolean,disabled: Boolean,showPlus: truthProp,showMinus: truthProp,showInput: truthProp,longPress: truthProp,allowEmpty: Boolean,modelValue: numericProp,inputWidth: numericProp,buttonSize: numericProp,placeholder: String,disablePlus: Boolean,disableMinus: Boolean,disableInput: Boolean,beforeChange: Function as PropType<Interceptor>,defaultValue: makeNumericProp(1),decimalLength: numericProp,
};
定义属性的时候调用了几个工具函数,makeNumericProp、truthProp以及numericProp,这样做使代码变得简洁。
makeNumericProp的源码:
export const makeNumericProp = <T>(defaultVal: T) => ({type: numericProp,default: defaultVal,
});
调用了numericProp,返回值是一个包含type和default的对象。
numericProp的源码:
export const numericProp = [Number, String];
numericProp是一个由Number和String组成的数组,表述属性可以是Number也可以是String。
truthProp的源码:
export const truthProp = {type: Boolean,default: true as const,
};
3.1.2 内部值
current:
const getInitialValue = () => {const defaultValue = props.modelValue ?? props.defaultValue;const value = format(defaultValue);if (!isEqual(value, props.modelValue)) {emit('update:modelValue', value);}return value;
};
const current = ref(getInitialValue());
const setValue = (value: Numeric) => {if (props.beforeChange) {callInterceptor(props.beforeChange, {args: [value],done() {current.value = value;},});} else {current.value = value;}
};
current持有stepper组件的当前值,初始值通过getInitialValue方法的返回值指定。props.modelValue ?? props.defaultValue
的含义是当左侧的值为null或者undefined时返回符号右侧的值。getInitialValue方法用于当v-model绑定的值和defaultValue绑定的值不同时则以defaultValue为准。
setValue在多个函数中有用到,其作用是给current赋值。如果父组件定义了beforeChange则执行callInterceptor方法,在done回调中给current赋予新的值。
minusDisabled,plusDisabled:
const minusDisabled = computed(() => props.disabled || props.disableMinus || current.value <= +props.min
);
const plusDisabled = computed(() => props.disabled || props.disablePlus || current.value >= +props.max
);
这两个计算属性用于控制增加和减少按钮是否禁用。props.disabled是禁用整个stepper, props.disableMinus是禁用减少按钮,props.disablePlus是禁用增加按钮, current.value <= +props.min
控制当前值小于等于最小值时也禁用减少按钮,current.value >= +props.max
控制当前值大于等于最大值时禁用增加按钮。
3.2 事件
3.2.1组件与父组件交互的事件
vant的文档中列出了6个事件,但在源码中通过emits声明了7个事件:
emits: ['plus','blur','minus','focus','change','overlimit','update:modelValue',
],
其中’update:modelValue’是用于v-model双向绑定的,在vue文档中说明了当在一个组件上使用v-model时,在编译阶段会对v-model进行展开:
<CustomInput:modelValue="searchText"@update:modelValue="newValue => searchText = newValue"
/>
默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。
3.2.2组件dom解构绑定的事件
stepper组件的dom结构如下如所示:由两个button和一个input标签组成。

createListeners
button标签上绑定事件是通过createListeners来实现:
const createListeners = (type: typeof actionType) => ({onClick: (event: MouseEvent) => {// disable double tap scrolling on mobile safaripreventDefault(event);actionType = type;onChange();},onTouchstartPassive: () => {actionType = type;onTouchStart();},onTouchend: onTouchEnd,onTouchcancel: onTouchEnd,
});
createListeners函数返回了一个由4个事件处理函数组成的对象。在button上调用并对返回值进行解构:
<buttonv-show={props.showMinus}type="button"{...createListeners('minus')}
/>
在createListeners中使用到了onChange函数,onTouchStart函数,onTouchEnd函数。我们逐一看一下:
onChange函数:
const onChange = () => {if ((actionType === 'plus' && plusDisabled.value) ||(actionType === 'minus' && minusDisabled.value)) {emit('overlimit', actionType);return;}const diff = actionType === 'minus' ? -props.step : +props.step;const value = format(addNumber(+current.value, diff));setValue(value);emit(actionType);
};
- 如果按钮不可用则会触发overlimit,通知父组件。
- diff用于计算增加或者减少的差值,也就是正数或者负数的步长step。
- 变化后的值value就是current当前值加上步长step。
- 最后调用setValue将value赋值给current,并触发plus或者plus通知父组件。
onTouchStart和onTouchEnd:
let isLongPress: boolean;
let longPressTimer: NodeJS.Timeout;
const longPressStep = () => {longPressTimer = setTimeout(() => {onChange();longPressStep();}, LONG_PRESS_INTERVAL);
};
const onTouchStart = () => {if (props.longPress) {isLongPress = false;clearTimeout(longPressTimer);longPressTimer = setTimeout(() => {isLongPress = true;onChange();longPressStep();}, LONG_PRESS_START_TIME);}
};
const onTouchEnd = (event: TouchEvent) => {if (props.longPress) {clearTimeout(longPressTimer);if (isLongPress) {preventDefault(event);}}
};
onTouchStart中判断是否是长按按钮,如果是长按则进行累计增加或者减少。
input标签上绑定的事件:
<inputv-show={props.showInput}onBlur={onBlur}onInput={onInput}onFocus={onFocus}onMousedown={onMousedown}
/>
onBlur
const onBlur = (event: Event) => {const input = event.target as HTMLInputElement;const value = format(input.value);input.value = String(value);current.value = value;nextTick(() => {emit('blur', event);resetScroll();});
};
- 首选获取input事件的目标对象input;
- 调用format对目标对象input的value值进行规范化;
- 规范化的值重新赋值给目标对象;
- 规范化后的值赋值给current;
- input的dom发生改变后触发blur事件通知父组件
这里调用了format方法:
const format = (value: Numeric) => {const { min, max, allowEmpty, decimalLength } = props;if (allowEmpty && value === '') {return value;}value = formatNumber(String(value), !props.integer);value = value === '' ? 0 : +value;value = Number.isNaN(value) ? +min : value;value = Math.max(Math.min(+max, value), +min);// format decimalif (isDef(decimalLength)) {value = value.toFixed(+decimalLength);}return value;
};
主要是对空值,整数与小数,是否超过最大值,小于最小值,以及小数保留位数做判断。Math.max(Math.min(+max, value), +min)
这句就是max 和value之间取最小的(记为temp), 再在temp和min之间取最大的。
onInput
const onInput = (event: Event) => {const input = event.target as HTMLInputElement;const { value } = input;const { decimalLength } = props;let formatted = formatNumber(String(value), !props.integer);// limit max decimal lengthif (isDef(decimalLength) && formatted.includes('.')) {const pair = formatted.split('.');formatted = `${pair[0]}.${pair[1].slice(0, +decimalLength)}`;}if (props.beforeChange) {input.value = String(current.value);} else if (!isEqual(value, formatted)) {input.value = formatted;}// prefer number typeconst isNumeric = formatted === String(+formatted);setValue(isNumeric ? +formatted : formatted);
};
- 从事件对象中获取目标对象,从目标对象获取值
- 调用formatNumber对输入值格式进行修正
- 根据decimalLength确定小数保留到小数点后几位,并计算出值为formatted
- 根据beforeChange这个输入值变化前的回调函数是否存在判断如何修改目标对象的值。存在则赋值为current的值,不存在则判断value和formatted是否相等。如果不相等则赋值为formatted的值。
- 最后调用setValue给current赋值
onFocus
const onFocus = (event: Event) => {// readonly not work in legacy mobile safariif (props.disableInput) {inputRef.value?.blur();} else {emit('focus', event);}
};
onFocus是input元素获得焦点后触发。如果当前禁止输入则通过ref主动调用失去焦点方法;否则emit一个focus通知父组件。
onMousedown
const onMousedown = (event: MouseEvent) => {// fix mobile safari page scroll down issue// see: https://github.com/vant-ui/vant/issues/7690if (props.disableInput) {preventDefault(event);}
};
主要是解决移动端safari浏览器上页面的bug
3.3 布局和样式
const inputStyle = computed(() => ({width: addUnit(props.inputWidth),height: addUnit(props.buttonSize),
}));
const buttonStyle = computed(() => getSizeStyle(props.buttonSize));
inputWidth规定了输入框的宽度,buttonSize规定了按钮大小以及输入框的高度。
addUnit
export function addUnit(value?: Numeric): string | undefined {if (isDef(value)) {return isNumeric(value) ? `${value}px` : String(value);}return undefined;
}
如果父组件使用stepper组件时传的单位是数字则需要变为字符串。
getSizeStyle
export function getSizeStyle( originSize?: Numeric | Numeric[] ): CSSProperties | undefined {if (isDef(originSize)) {if (Array.isArray(originSize)) {return {width: addUnit(originSize[0]),height: addUnit(originSize[1]),};}const size = addUnit(originSize);return {width: size,height: size,};}
}
判断originSize是Numeric数组还是Numeric类型的单个值,如果是数组则分别取数组的第一个和第二个元素作为宽高;否则宽高值都一样。
bem
<inputclass={bem('input')}
/>
在button和input标签上给元素绑定样式都使用了bem,bem用于生成bem规范的类名,其源码如下:
// bem是通过函数调用返回值中结构出来
const [name, bem] = createNamespace('stepper');
export function createNamespace(name: string) {const prefixedName = `van-${name}`;return [prefixedName,// createNamespace 中调用createBEMcreateBEM(prefixedName),createTranslate(prefixedName),] as const;
}
export function createBEM(name: string) {return (el?: Mods, mods?: Mods): Mods => {if (el && typeof el !== 'string') {mods = el;el = '';}el = el ? `${name}__${el}` : name;
// createBEM中调用genBemreturn `${el}${genBem(el, mods)}`;};
}
function genBem(name: string, mods?: Mods): string {if (!mods) {return '';}if (typeof mods === 'string') {return ` ${name}--${mods}`;}if (Array.isArray(mods)) {return (mods as Mod[]).reduce<string>(// genBem是递归方法(ret, item) => ret + genBem(name, item),'');}return Object.keys(mods).reduce((ret, key) => ret + (mods[key] ? genBem(name, key) : ''),'');
}
createNamespace 调用 createBEM,createBEM调用 genBem, genBem是一个递归函数。
另外关于原生input标签的属性也可以做更进一步的了解,比如role属性、mode属性以及aria-valuemax属性等。
4.总结
本文分析了vant UI组件库的stepper组件,分析了其整体的目录结构,从属性和值、事件以及布局和样式三个方面分析了组件的实现。
通过对stepper组件源码的学习,更深入了解到使用vue3开发UI组件的方法,有助于自己提升编码和设计能力。
最后
最近找到一个VUE的文档,它将VUE的各个知识点进行了总结,整理成了《Vue 开发必须知道的36个技巧》。内容比较详实,对各个知识点的讲解也十分到位。
有需要的小伙伴,可以点击下方卡片领取,无偿分享