电商系统刚需的stepper组件该如何编写? 看看vant如何做的
本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。这是源码共读的第38期,链接: 【若川视野 x 源码共读】第38期 | 经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学!
今天学习的源码是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 safari
preventDefault(event);
actionType = type;
onChange();
},
onTouchstartPassive: () => {
actionType = type;
onTouchStart();
},
onTouchend: onTouchEnd,
onTouchcancel: onTouchEnd,
});
createListeners函数返回了一个由4个事件处理函数组成的对象。在button上调用并对返回值进行解构:
<button
v-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标签上绑定的事件:
<input
v-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 decimal
if (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 length
if (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 type
const 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 safari
if (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/7690
if (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
<input
class={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 中调用createBEM
createBEM(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中调用genBem
return `${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组件的方法,有助于自己提升编码和设计能力。
转载自:https://juejin.cn/post/7143898303715868685