- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第38期,链接:经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学
前言
vant 不仅有 小程序版本,也有
vue2
和vue3
这次我是用的使用vue3
用法
看一个组件库,要先看他的用法 只保留了个人认为常用的 属性
Props
Attribute | Description | Type | Default |
---|---|---|---|
v-model | Current value | number | string | - |
min | Min value | number | string | 1 |
max | Max value | number | string | - |
step | Value change step | number | string | 1 |
decimal-length | Decimal length | number | string | - |
theme | Theme, can be set to round | string | - |
integer | Whether to allow only integers | boolean | false |
before-change | Callback function before changing, return false to prevent change, support return Promise | (value: number | string) => boolean | Promise<boolean> | false |
Events
Event | Description | Arguments |
---|---|---|
change | Emitted when value changed | value: string, detail: { name: string } |
blur | Emitted when the input is blurred | event: Event |
功能
- 步长设置
- 设置输入范围
- 限制输入整数
- 禁用两边按钮点击
- 禁用输入框输入
- 固定小数位数
- 异步变更
样式
- 自定义大小
- 圆角大小
源码解读
源码位置处于 packages/vant/src/stepper
使用了 tsx
来构建这个组件,也是考虑到 tsx
比 template
的这种方式更有利于 ts
,灵活度也更高,能够提供使用者更好的使用体验
以下函数只提供核心内容
核心
<button
v-show={props.showMinus}
type="button"
{[HAPTICS_FEEDBACK]: !minusDisabled.value},
]}
{...createListeners('minus')}
/>
<input
v-show={props.showInput}
ref={inputRef}
type={props.integer ? 'tel' : 'text'}
value={current.value}
style={inputStyle.value}
disabled={props.disabled}
readonly={props.disableInput}
// set keyboard in modern browsers
inputmode={props.integer ? 'numeric' : 'decimal'}
placeholder={props.placeholder}
onBlur={onBlur}
onInput={onInput}
onFocus={onFocus}
onMousedown={onMousedown}
/>
<button
v-show={props.showPlus}
type="button"
style={buttonStyle.value}
class={[
bem('plus', { disabled: plusDisabled.value }),
{ [HAPTICS_FEEDBACK]: !plusDisabled.value },
]}
{...createListeners('plus')}
/>
如果经常看源码的同学,可以基本上可以通过变量名
把功能猜的差不多了,只有这个 createListeners
有点疑问
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
的主要函数就是 onClick
onClick
的主要功能就是把 actionType
设置为 plus
或者 minus
和 触发 onChange
方法
继续追究 onChange
方法做了什么
onChange
const onChange = () => {
const diff = actionType === 'minus' ? -props.step : +props.step;
const value = format(addNumber(+current.value, diff));
setValue(value);
emit(actionType);
};
判断了 全局变量 actionType
的类型,props.step
就是步长,用户传入,默认为 1,把 步长 与 当前值 做一个转换,把 转换结果 作为 setValue
的实参。
format
和 addNumber
都是 工具函数,后面会讲
先看 setValue
这个函数
setValue
const setValue = (value: Numeric) => {
if (props.beforeChange) {
callInterceptor(props.beforeChange, {
args: [value],
done() {
current.value = value;
},
});
} else {
current.value = value;
}
};
props.beforeChange
是用户传进来在改变数值之前的拦截器,它被 callInterceptor
包裹, 我们来看一下 callInterceptor
callInterceptor
接收 2个参数
-
一个函数
-
一个对象
- args:[value]
- done 对象函数
function callInterceptor(
interceptor: Interceptor | undefined,{
args = [],
done,
canceled,
}: {
args?: unknown[];
done: () => void;
canceled?: () => void;
}
) {
if (interceptor) {
const returnVal = interceptor.apply(null, args);
if (isPromise(returnVal)) {
returnVal
.then((value) => {
if (value) {
done();
} else if (canceled) {
canceled();
}
})
.catch(noop);
} else if (returnVal) {
done();
} else if (canceled) {
canceled();
}
} else {
done();
}
}
首先判断是否传入有 interceptor
拦截器
graph TD
是否有拦截器 --> 有
是否有拦截器 --> 没有
有-->是Promise
是Promise-->执行then方法
执行then方法-->执行done或者canceled方法
没有-->有返回值执行done
其中isPromise
函数是自己封装,见名知意,判断是否是Promise
isPromise
export const isFunction = (val: unknown): val is Function =>
typeof val === 'function';
export const isObject = (val: unknown): val is Record<any, any> =>
val !== null && typeof val === 'object';
export const isPromise = <T = any>(val: unknown): val is Promise<T> =>
isObject(val) && isFunction(val.then) && isFunction(val.catch);
大概的两侧按钮点击事件就这么多了,剩下的是 中间的 input
的各种事件
input
<input
....
value={current.value}
onInput={onInput}
>
</input>
当前的input
的绑定的是 value
和 onInput
事件,这样便于处理 步长,小数位数 等问题
onInput 事件
对输入的值进行转化,构造成符合条件后的数字,如果是小数,要根据传入的
decimalLength
进行切割, 然后赋值给 current.value
const onInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const { value } = input;
// input value 是当前输入的 value 值,current.value 是绑定的值
const { decimalLength } = props;
// 转换当前输入的值 integer 整数
let formatted = formatNumber(String(value), !props.integer);
// limit max decimal length
if (isDef(decimalLength) && formatted.includes('.')) {
const pair = formatted.split('.');
// 截取后面的小数位数,比如你输入的是 1.23456,但是23456 会被 decimalLength 截断
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
工具函数 formatNumber
function trimExtraChar(value: string, char: string, regExp: RegExp) {
const index = value.indexOf(char);
if (index === -1) {
return value;
}
if (char === '-' && index !== 0) {
return value.slice(0, index);
}
return value.slice(0, index + 1) + value.slice(index).replace(regExp, '');
}
export function formatNumber(
value: string,
allowDot = true,
allowMinus = true
) {
if (allowDot) {
value = trimExtraChar(value, '.', /\./g);
} else {
value = value.split('.')[0];
}
if (allowMinus) {
value = trimExtraChar(value, '-', /-/g);
} else {
value = value.replace(/-/, '');
}
const regExp = allowDot ? /[^-0-9.]/g : /[^-0-9]/g;
return value.replace(regExp, '');
}
trimExtraChar
是对一个字符value
用char
分割成两部分,第二部分用传入的 regExp
进行替换成 ''
然后判断是否需要小数点allowDot
,如果需要,则需要正则 /[^-0-9.]/g
把 非 -,数字 小数点 全部替换 成 空字符,保证全部是数字
同理,失去焦点也是差不多的逻辑,只要等输入框失去焦点,就要执行判断逻辑
onBlur
const onBlur = (event: Event) => {
const input = event.target as HTMLInputElement;
const value = format(input.value);
input.value = String(value);
current.value = value;
};
又出现了一个 工具函数 format
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;
};
比 formatNumber
要判断的多,因为是最终显示的结果,判断
- 写入的 value 是不是 空,空的话转成数字 0,不是的话 +value 转成数字
- 是不是一个数字类型(Number.isNaN),不过不是,value 是最小值
- 限制范围
Math.max(Math.min(+max, value), +min)
要把 value 限制在min
和max
之间,经典 比 最大值比较,取小值,与最小值比较,取最大值 - 如果是小数位数限制,还要
toFixed
设置长度
最后一点
当然用户使用是 v-model
双向绑定,所以需要触发父元素的事件来改变自身的值
watch(current, (value) => {
emit('update:modelValue', value);
emit('change', value, { name: props.name });
});
一个特殊的ts
类型 ExtractPropTypes
,抽取 props 类型,形成
export const makeNumericProp = <T>(defaultVal: T) => ({
type: numericProp,
default: defaultVal,
});
export const truthProp = {
type: Boolean, // 这里作为 构造函数,不能是 boolean
default: true as const,
};
const stepperProps = {
min: makeNumericProp(1),
max: makeNumericProp(Infinity),
name: makeNumericProp(''),
theme: String as PropType<StepperTheme>,
integer: Boolean,
showPlus: truthProp,
allowEmpty: Boolean,
modelValue: numericProp,
};
// 为了暴露给使用者
export type StepperProps = ExtractPropTypes<typeof stepperProps>;
defineComponent({
props: stepperProps,
setup(props,{emit}){
...
}
})
暴露给使用者
type StepperProps = {
name: string | number;
min: string | number;
max: string | number;
....
}
最后
通过这个简单vue3的步进器,学到了很多,包括但不限于 vue3
中的 tsx
写法,自己平时很少用这种方式,确实还可以,更趋近于react
的写法, 主要是 学会了 对 用户传入的 各种情况进行判断,不断的矫正用户输入