- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第38期,链接:# 【若川视野 x 源码共读】第38期 | 经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学!
这一章讲vue3组件库vant的一个步进器stepper组件,通过它来了解组件库中组件的构成
文档和预览:vant-ui.github.io/vant/#/zh-C…
功能:
- 步长设置
- 设置输入范围
- 限制输入整数
- 禁用两边按钮点击
- 禁用输入框输入
- 固定小数位数
- 异步变更
- 圆角、自定义大小
样式
两个按钮包裹一个输入框
https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/Stepper.tsx#L291-L292
return () => (
<div role="group" class={bem([props.theme])}>
<button
v-show={props.showMinus}
type="button"
style={buttonStyle.value}
class={[
bem('minus', { disabled: minusDisabled.value }),
{ [HAPTICS_FEEDBACK]: !minusDisabled.value },
]}
aria-disabled={minusDisabled.value || undefined}
{...createListeners('minus')}
/>
<input
v-show={props.showInput}
ref={inputRef}
type={props.integer ? 'tel' : 'text'}
role="spinbutton"
class={bem('input')}
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}
aria-valuemax={props.max}
aria-valuemin={props.min}
aria-valuenow={current.value}
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 },
]}
aria-disabled={plusDisabled.value || undefined}
{...createListeners('plus')}
/>
</div>
);
按钮样式
<div class={bem([props.theme])}>
<button
style={buttonStyle.value}
class={[
bem('minus', { disabled: minusDisabled.value }),
{ [HAPTICS_FEEDBACK]: !minusDisabled.value },
]}
/>
</div>
// 看看buttonStyle 是什么
const buttonStyle = computed(() => getSizeStyle(props.buttonSize));
// props也就是我们传入参数,api有这个button-size属性 按钮大小以及输入框高度,默认单位为 px
// 突然就完成了 一个功能 自定义大小
// 例子:https://vant-ui.github.io/vant/#/zh-CN/stepper#zi-ding-yi-da-xiao
// <van-stepper v-model="value" input-width="40px" button-size="32px" />
// 这里传入32px
// 看getSizeStyle
# utils/format.ts
export function getSizeStyle(
originSize?: Numeric | Numeric[]
): CSSProperties | undefined {
if (isDef(originSize)) { // 是否定义,无视
// 数组 不是 走下面
if (Array.isArray(originSize)) {
return {
width: addUnit(originSize[0]),
height: addUnit(originSize[1]),
};
}
// addUnit('32px') = '32px'
const size = addUnit(originSize);
// 所以style的样式就是width = height = '32px',方块按钮样子的
return {
width: size,
height: size,
};
}
}
// 是否定义
export const isDef = <T>(val: T): val is NonNullable<T> =>
val !== undefined && val !== null;
export function addUnit(value?: Numeric): string | undefined {
if (isDef(value)) {
// 如果是数字 就加 px后缀,否则原路返回
return isNumeric(value) ? `${value}px` : String(value);
}
return undefined;
}
// 哈哈我也不知道是什么东西,不过按猜测应该是数字或者小数点的数字
export const isNumeric = (val: Numeric): val is string =>
typeof val === 'number' || /^\d+(\.\d+)?$/.test(val);
——————————接下来 看class,
class={[
bem('minus', { disabled: minusDisabled.value }),
{ [HAPTICS_FEEDBACK]: !minusDisabled.value },
]}
看bem('minus',) 其他的不看
//f12得出按钮 class="van-stepper__minus van-haptics-feedback"
const [name, bem] = createNamespace('stepper');
# \packages\vant\src\utils\create.ts
export function createNamespace(name: string) {
//name:stepper
const prefixedName = `van-${name}`;
// van-stepper
return [
prefixedName,// van-stepper
createBEM(prefixedName),
createTranslate(prefixedName),
] as const;
}
具体看 https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/utils/create.ts
太复杂,太花时间,跳过,总之 应该 bem('minus') = "van-stepper__minus van-haptics-feedback"
<div class={bem([props.theme])}> div.class = van-stepper
样式:https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/index.less#L19-L34
@import './var.less';
:root {
--van-stepper-input-width: @stepper-input-width;
--van-stepper-input-height: @stepper-input-height;
。。。。
.van-stepper {
display: inline-block;
user-select: none;
&__minus, // 就是这个 van-stepper__minus
&__plus {
position: relative;
box-sizing: border-box;
width: var(--van-stepper-input-height);
height: var(--van-stepper-input-height);
margin: 0;
padding: 0;
color: var(--van-stepper-button-icon-color);
vertical-align: middle;
background: var(--van-stepper-background-color);
border: 0;
&::before {// 横杆 一 减号
width: 50%;
height: 1px;
}
&::after { // | 加号=before+after
width: 1px;
height: 50%;
}
&::before,
&::after { //横杆 居中
position: absolute;
top: 50%;
left: 50%;
background-color: currentColor;
transform: translate(-50%, -50%);
content: '';
}
&--disabled {
color: var(--van-stepper-button-disabled-icon-color);
background-color: var(--van-stepper-button-disabled-color);
cursor: not-allowed;
}
}
// 第一个buttom不需要 | 有减号就行。
&__minus {
&::after {
display: none;
}
}
https://github1s.com/vant-ui/vant/blob/dev/packages/vant/src/stepper/var.less
@import '../style/var.less';
@stepper-background-color: var(--van-active-color);
@stepper-button-icon-color: var(--van-text-color);
@stepper-button-disabled-color: var(--van-background-color);
@stepper-button-disabled-icon-color: var(--van-gray-5);
@stepper-button-round-theme-color: var(--van-danger-color);
@stepper-input-width: 32px;
@stepper-input-height: 28px; //于是就得出了初始的宽高是28px
@stepper-input-font-size: var(--van-font-size-md);
@stepper-input-line-height: normal;
@stepper-input-text-color: var(--van-text-color);
@stepper-input-disabled-text-color: var(--van-text-color-3);
@stepper-input-disabled-background-color: var(--van-active-color);
@stepper-border-radius: var(--van-border-radius-md);
我们传button-size,他是怎么接收驼峰的
总而言之:vue3 自转的。细节看下:
看看parse
和transform
的结果
vue-next-template-explorer.netlify.app/
<axiao x-a='abc'> </axiao>
结果:
_createVNode(_component_axiao, { "x-a": "abc" })
就是说`transform`没转,那只能在runtime
# vue3/shared.ts
若川源码有过这篇喔
https://github1s.com/vuejs/core/blob/main/packages/shared/src/index.ts#L124
// 横杆转驼峰
const camelizeRE = /-(\w)/g
export const camelize = cacheStringFunction((str: string): string => {
return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})
# \packages\runtime-core\src\componentProps.ts
https://github1s.com/vuejs/core/blob/main/packages/runtime-core/src/componentProps.ts#L521
for (let i = 0; i < raw.length; i++) {
const normalizedKey = camelize(raw[i])
。。。
我也没细看,应该是runtime模块渲染组件的时候把props的key都转成驼峰了
圆角风格
vant-ui.github.io/vant/#/zh-C…
先从感兴趣和感觉应该很简单的圆角下手
将 theme
设置为 round
来展示圆角风格的步进器。
<van-stepper v-model="value" theme="round" button-size="22" disable-input />
那么
<div role="group" class={bem([props.theme])}>
这里取到theme 为 round ,bem执行后得到
div.class = 'van-stepper van-stepper--round'
.van-stepper {
&--round {
.van-stepper__input {
background-color: transparent;
}
.van-stepper__plus,
.van-stepper__minus { // 圆角
border-radius: 100%;
&--disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
初始值
default-value 初始值,当 v-model 为空时生效
const stepperProps = {
defaultValue: makeNumericProp(1),
。。
}
<input value={current.value}
const inputRef = ref<HTMLInputElement>();
// 输入框 默认值 defaultValue:1
const current = ref(getInitialValue());
const getInitialValue = () => {
// 取model的值 ,拿不到再取默认值
const defaultValue = props.modelValue ?? props.defaultValue;
const value = format(defaultValue);
// 如果默认值和 model值不一样 ,触发父组件回调事件 update:modelValue,这个文档都没写呢
if (!isEqual(value, props.modelValue)) {
emit('update:modelValue', value);
}
return value;
};
格式化值
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;
};
写入的 value 是不是 空,空的话转成数字 0,不是的话 +value 转成数字
是不是一个数字类型(Number.isNaN)、若不是,赋值 限制的最小值
限制范围 Math.max(Math.min(+max, value), +min) 要把 value 限制在 min和 max之间,经典 比 最大值比较,取小值,与最小值比较,取最大值
如果是小数位数限制,还要 toFixed设置长度
步长设置
通过 step
属性设置每次点击增加或减少按钮时变化的值,默认为 1
。
<van-stepper v-model="value" step="2" />
step 默认是 1
const stepperProps = {
step: makeNumericProp(1),
export default defineComponent({
name,
props: stepperProps,
我们来看按钮事件
<button
{...createListeners('minus')}
/>
// 传入 type :minus
const createListeners = (type: typeof actionType) => ({
// 点击事件 触发 onChange
onClick: (event: MouseEvent) => {
// disable double tap scrolling on mobile safari
preventDefault(event);
actionType = type; //actionType = minus
onChange();
},
onTouchstartPassive: () => {
actionType = type;
onTouchStart();
},
onTouchend: onTouchEnd,
onTouchcancel: onTouchEnd,
});
//actionType === 'minus'
const onChange = () => {
if (
(actionType === 'plus' && plusDisabled.value) ||
(actionType === 'minus' && minusDisabled.value)
) {
emit('overlimit', actionType);
return;
}
//diff = -2
const diff = actionType === 'minus' ? -props.step : +props.step;
// current -2
const value = format(addNumber(+current.value, diff));
// 更新
setValue(value);
// 组件Events minus 点击减少按钮时触发
emit(actionType);
};
限制输入范围
通过 min
和 max
属性限制输入值的范围。
<van-stepper v-model="value" min="5" max="8" />
const format = (value: Numeric) => {
const { min, max, allowEmpty, decimalLength } = props;
。。。
value = Math.max(Math.min(+max, value), +min);
。。。
return value;
};
我们在改值时都会用 format
包裹,当超过时,值会变成 限制的最大值
限制输入整数
设置 integer
属性后,输入框将限制只能输入整数。
<van-stepper v-model="value" integer />
原理和上面类似,这边是正则替换
const format = (value: Numeric) => {
const { min, max, allowEmpty, decimalLength } = props;
。。。
value = formatNumber(String(value), !props.integer);
}
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, '');
}
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, '');
}
禁用状态
通过设置 disabled
属性来禁用步进器,禁用状态下无法点击按钮或修改输入框。
<van-stepper v-model="value" disabled />
const minusDisabled = computed(
// props.disabled 传入 禁用参数,或者单独禁用减号,或者 当前数小于等于 限制最小值了
() => props.disabled || props.disableMinus || current.value <= +props.min
);
<button
class={[
bem('minus', { disabled: minusDisabled.value }),
]}
样式多出 disabled
&--disabled {
color: var(--van-stepper-button-disabled-icon-color);
background-color: var(--van-stepper-button-disabled-color);
cursor: not-allowed;
}
禁用输入框
通过设置 disable-input
属性来禁用输入框,此时按钮仍然可以点击。
<van-stepper v-model="value" disable-input />
<input readonly={props.disableInput}
用的是原生自带的 readonly
下面是组件样式
&__input {
&:disabled {
color: var(--van-stepper-input-disabled-text-color);
background-color: var(--van-stepper-input-disabled-background-color);
// fix disabled color in iOS
-webkit-text-fill-color: var(--van-stepper-input-disabled-text-color);
opacity: 1;
}
&:read-only {
cursor: default;
}
固定小数位数
通过设置 decimal-length
属性可以保留固定的小数位数。
<van-stepper v-model="value" step="0.2" :decimal-length="1" />
const format = (value: Numeric) => {
// format decimal
if (isDef(decimalLength)) {
value = value.toFixed(+decimalLength);
}
异步变更
通过 before-change
属性可以在输入值变化前进行校验和拦截。
<van-stepper v-model="value" :before-change="beforeChange" />
import { ref } from 'vue';
import { Toast } from 'vant';
export default {
setup() {
const value = ref(1);
const beforeChange = (value) => {
Toast.loading({ forbidClick: true });
return new Promise((resolve) => {
setTimeout(() => {
Toast.clear();
// 在 resolve 函数中返回 true 或 false
resolve(true);
}, 500);
});
};
return {
value,
beforeChange,
};
},
};
// 更新值的调用函数,判断beforeChange是否存在,若存在,执行完后再更新值
const setValue = (value: Numeric) => {
if (props.beforeChange) {
callInterceptor(props.beforeChange, {
args: [value],
done() {
current.value = value;
},
});
} else {
current.value = value;
}
};
export function callInterceptor(
interceptor: Interceptor | undefined,
{
args = [],
done,
canceled,
}: {
args?: unknown[];
done: () => void; // 回调,更新值 current.value = value;
canceled?: () => void;
}
) {
if (interceptor) {
// eslint-disable-next-line prefer-spread
const returnVal = interceptor.apply(null, args);
// 如果是promise,执行完后执行 回调done
if (isPromise(returnVal)) {
returnVal
.then((value) => {
if (value) {
done();
// 没返回值,会去执行canceled
} else if (canceled) {
canceled();
}
})
.catch(noop);
} else if (returnVal) {
done();
} else if (canceled) {
canceled();
}
} else {
done();
}
}
export const isPromise = <T = any>(val: unknown): val is Promise<T> =>
isObject(val) && isFunction(val.then) && isFunction(val.catch);
输入框失焦事件
blur | 输入框失焦时触发 |
---|---|
<input onBlur={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();
});
};
这里我好奇为啥不用 setValue
去更新值,不用执行 before-change
吗?
然后我去看了vue2版本的源码 github.com/vant-ui/van…
还是也是没有,我又自己建了个沙盒,里面确实改数据有触发beforeChange,所以我知道是我迷糊了,不是一定要输入框失去焦点才改值的。改值的操作在onInput里
onInput
const onInput = (event: Event) => {
const input = event.target as HTMLInputElement;
const { value } = input;
const { decimalLength } = props;
// 校验是否要整数,是的话格式化成整数
let formatted = formatNumber(String(value), !props.integer);
// 限定长度,和toFixed类似,这里为啥不跟format一样我也不懂 value = value.toFixed(+decimalLength);
// 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;
}
// 赋值,setValue在这里,执行beforeChange
// prefer number type
const isNumeric = formatted === String(+formatted);
setValue(isNumeric ? +formatted : formatted);
};
总结
样式 传入的参数会作为 类或者style
替换里面的样式 ,和组件内容同一个文件夹,模块化
组件事件 通过子组件emit
回调触发
有些变量用computed
,为了能响应式的更新。
步进器由一个div包裹,其中三个儿子,div+input+div
设置一个响应式变量作为input的值,减号和加号会修改它的值,更新时加限制小数位、上下限等,格式化。