前言
- 本文参加了由公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第38期,链接:【若川视野 x 源码共读】第38期 | 经常用 vant-weapp 开发小程序,却不知道如何开发一个组件?学!
- 今天终于下定决心耐心阅读这期源码了,拖延症就是不想开始,但是一旦开始投入的也很快。因为工作中没有用到小程序,就选择vant2.x版本阅读啦,我们开始吧~
stepper步进器
首先来看看步进器的样子吧,它可以左右调整输入框中的数值,递增 or 递减,也可以输入值,官网还有其他功能的说明
克隆项目
刚开始调试项目的遇到了小问题,启动一直报错,后来看到根目录.github/CONTRIBUTING.md
的最后有启动项目的提示,才能顺利进行下去,调试起来就方便多了
git clone https://github.com/youzan/vant.git
cd vant // 切换分支到vant 2.x版本
yarn
yarn dev
// open http://localhost:8080
关于【pnpm】
在开始阅读源码前,想聊聊pnpm
,去看了官方的描述,阅读3分钟的英文文档,意思大概是在性能上npm
、yarn
在install
、update
等方面更优秀,并配上了对比图,不容反驳哈哈哈,可以查看这里
pnpm
最大的不同,是它生成的node_modules
结构与众不同,不是相较于npm
等包管理器,他是非扁平结构的。
pnpm
有统一的存储地址,且通过链接方式进行文件共享。可以阅读这篇文章扁平的node_modules不是唯一的方法,里面有author对pnpm
独特的依赖包的结构的讲解。
这里大致了解一下他的作用、和优势就可以啦
组件template
render()
函数提供了组件渲染模板,里面结构清晰,createListeners(type)
根据传入的type
构造事件监听器,分别监听click
、touchstart
、touchend
、touchcancel
几个事件,即在pc与移动端的事件类型。type
分别为minus
(减)、plus
(加),左右两个按钮共享这一组事件。
中间部分是input
控件,表单属性type
根据integer
控制在移动端展示是否只输入数值,分别为tel
与text
,其他例如禁用、只读、inputStyle
等都与暴露出来属性有关,input
上也有一组事件,后面会接着分析
computed: {
inputStyle() {
const style = {};
if (this.inputWidth) { // 输入框宽度
style.width = addUnit(this.inputWidth);
}
if (this.buttonSize) { // 按钮大小,控制输入框高度
style.height = addUnit(this.buttonSize);
}
return style;
},
},
render() {
const createListeners = (type) => ({
on: {
click: (e) => {
// disable double tap scrolling on mobile safari
e.preventDefault();
this.type = type;
this.onChange();
},
touchstart: () => {
this.type = type;
this.onTouchStart();
},
touchend: this.onTouchEnd,
touchcancel: this.onTouchEnd,
},
});
return (
<div class={bem([this.theme])}>
<button
vShow={this.showMinus}
type="button"
style={this.buttonStyle}
class={bem('minus', { disabled: this.minusDisabled })}
{...createListeners('minus')}
/>
<input
vShow={this.showInput}
ref="input"
type={this.integer ? 'tel' : 'text'}
role="spinbutton"
class={bem('input')}
value={this.currentValue}
style={this.inputStyle}
disabled={this.disabled}
readonly={this.disableInput}
// set keyboard in modern browsers
inputmode={this.integer ? 'numeric' : 'decimal'}
placeholder={this.placeholder}
aria-valuemax={this.max}
aria-valuemin={this.min}
aria-valuenow={this.currentValue}
onInput={this.onInput}
onFocus={this.onFocus}
onBlur={this.onBlur}
onMousedown={this.onMousedown}
/>
<button
vShow={this.showPlus}
type="button"
style={this.buttonStyle}
class={bem('plus', { disabled: this.plusDisabled })}
{...createListeners('plus')}
/>
</div>
);
}
addUnit()
有没有注意到addUnit
方法,大家写控件,很多时候会有类似inputWidth
这样的样式属性,这种属性类型,会有number
与string
。自己之前写的时候,没有想到可以将其拆分为公共的方法,可以借鉴一下
export function addUnit(value?: string | number): string | undefined {
if (!isDef(value)) {
return undefined;
}
value = String(value);
// 校验是number就拼接单位:px
return isNumeric(value) ? `${value}px` : value;
}
export function isNumeric(val: string): boolean {
// 整数 or 浮点数
return /^\d+(\.\d+)?$/.test(val);
}
onChange()
onChange() {
const { type } = this;
// type是minus or plus,如果按钮状态不可点击,会触发overlimit事件 并返回
if (this[`${type}Disabled`]) {
this.$emit('overlimit', type);
return;
}
// 调整步长
const diff = type === 'minus' ? -this.step : +this.step;
// addNumber 防止相加的数值出现浮点数超限问题,并格式化数值
const value = this.format(addNumber(+this.currentValue, diff));
// 同步currentValue
this.emitChange(value);
// 两侧按钮点击会触发事件
this.$emit(type);
},
emitChange(value) {
// asyncChange异步修改,不会立即修改currentValue,而是触发自定义事件,使用者可自己决定修改currentValue的时机
if (this.asyncChange) {
this.$emit('input', value);
this.$emit('change', value, { name: this.name });
} else {
// 给currentValue重新赋值,在watch里会监听currentValue值变化,也会触发input、change事件
this.currentValue = value;
}
},
format(value) {
// allowEmpty 允许输入框是空字符
if (this.allowEmpty && value === '') {
return value;
}
// 格式化value
value = this.formatNumber(value);
// format range
value = value === '' ? 0 : +value; // 这里还悄悄进行类型转换
value = isNaN(value) ? this.min : value;
value = Math.max(Math.min(this.max, value), this.min); // 修正大小值的限制
// format decimal
// decimalLength 保留几位小数
if (isDef(this.decimalLength)) {
value = value.toFixed(this.decimalLength);
}
return value;
},
// formatNumber illegal characters
formatNumber(value) {
return formatNumber(String(value), !this.integer);
},
export function formatNumber(
value: string,
allowDot = true,
allowMinus = true
) {
// allowDot是否允许存在小数点
if (allowDot) {
// 修正第一个小数点.的位置,及过滤掉其余位置不适当的小数点
value = trimExtraChar(value, '.', /\./g);
} else {
value = value.split('.')[0];
}
// allowMinus 是否允许存在-
if (allowMinus) {
// 过滤掉位置不适当的-
value = trimExtraChar(value, '-', /-/g);
} else {
value = value.replace(/-/, '');
}
// 去除数字中的非-、0-9、.的字符
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);
let prefix = '';
// 没找到直接返回
if (index === -1) {
return value;
}
// -出现在中间,返回-之前的数值
if (char === '-' && index !== 0) {
return value.slice(0, index);
}
// 这里匹配.123 or -.123,prefix增加0 or -0
if (char === '.' && value.match(/^(\.|-\.)/)) {
prefix = index ? '-0' : '0';
}
return (
prefix + value.slice(0, index + 1) + value.slice(index).replace(regExp, '')
);
}
onInput()
onInput(event) {
const { value } = event.target;
// 同样需要格式化
let formatted = this.formatNumber(value);
// limit max decimal length
// 输入的过程中限制小数位数
if (isDef(this.decimalLength) && formatted.indexOf('.') !== -1) {
const pair = formatted.split('.');
formatted = `${pair[0]}.${pair[1].slice(0, this.decimalLength)}`;
}
if (!equal(value, formatted)) {
event.target.value = formatted;
}
// prefer number type
// 更期望是数值类型
if (formatted === String(+formatted)) {
formatted = +formatted;
}
// 触发change、input事件
this.emitChange(formatted);
},
onBlur()
onBlur(event) {
// 格式化数值
const value = this.format(event.target.value);
// 重新给输入框赋值
event.target.value = value;
// 触发change、input事件
this.emitChange(value);
// 触发blur事件
this.$emit('blur', event);
resetScroll();
},
onFocus()
onFocus(event) {
// readonly not work in legacy mobile safari
// 为了兼容处理部分老机型不能识别readonly属性
if (this.disableInput && this.$refs.input) {
this.$refs.input.blur();
} else {
// 触发focus事件
this.$emit('focus', event);
}
},
onMouseDown()
onMousedown(event) {
// fix mobile safari page scroll down issue
// see: https://github.com/vant-ui/vant/issues/7690
// 上面这个issues链接 是处理的兼容逻辑 在禁用输入框的状态下 仍触发了某些滚动行为
if (this.disableInput) {
event.preventDefault();
}
},
currentValue
我们来看看currentValue
,外部传入的value
,会赋值给data
上的currentValue
变量
data() {
// ?? 是判断左侧如果是null 或者 undefined 就会返回右侧的值
// value没有值 就会使用defaultValue默认值
const defaultValue = this.value ?? this.defaultValue;
// 格式化value
const value = this.format(defaultValue);
// 判断前后两个值不一致 就会立即触发input事件
if (!equal(value, this.value)) {
this.$emit('input', value);
}
return {
currentValue: value, // props上的value会赋值给内部的变量currentValue
};
},
watch: {
max: 'check', // 思考为什么要监听这四个值的变化 又要做什么处理呢?
min: 'check',
integer: 'check',
decimalLength: 'check',
value(val) {
// 监听外部传入的value 如果两个值不一样 就会赋值给currentValue 同时格式化
if (!equal(val, this.currentValue)) {
this.currentValue = this.format(val);
}
},
currentValue(val) { // 内部修改currentValue会触发input、change事件
this.$emit('input', val);
this.$emit('change', val, { name: this.name });
},
},
methods: {
check() {
// 因为一旦min、max、decimalLength、integer的变化 都会引起currentValue的值发生变化,详细可以看format函数,例如mix、max发生变化,可能会修正range
const val = this.format(this.currentValue);
if (!equal(val, this.currentValue)) {
this.currentValue = val;
}
},
}
computed相关
最后再关注一下computed
属性,
computed: {
// 减号禁用状态,这些属性都会有优先级,依次是整个控件禁用>只禁用减号>当前值已经是最小值
minusDisabled() {
return (
this.disabled || this.disableMinus || this.currentValue <= +this.min
);
},
// 加号禁用状态,同上
plusDisabled() {
return (
this.disabled || this.disablePlus || this.currentValue >= +this.max
);
},
// 输入框的style属性,最好不要直接设置dom上,就写成计算属性
inputStyle() {
const style = {};
// 输入框宽度
if (this.inputWidth) {
style.width = addUnit(this.inputWidth);
}
// 按钮大小决定输入框高度
if (this.buttonSize) {
style.height = addUnit(this.buttonSize);
}
return style;
},
buttonStyle() {
if (this.buttonSize) {
const size = addUnit(this.buttonSize);
// 按钮大小:宽与高
return {
width: size,
height: size,
};
}
},
},
总结
我们今天学习vant2.x
的stepper
组件,源码300多行,但是内容很丰富。
- 关注新的打包工具【
pnpm
】 - 从头到尾了解组件的结构、功能以及一些处理细节,个人觉得里面对于
format
格式的处理非常细致,还抽离成了公共函数。 - 在代码中也会发现开源项目中会解决一些issue问题,以及一些兼容处理,也很巧妙
在工作中如果常常封闭自己,没有接触到一些优秀源码,就会坐井观天,固步自封。 积极拥抱开源,从阅读开源代码中能够获取成长。
今天依然是希望提高写作能力与前端技术的小刘同学( ̄︶ ̄*))
下期源码继续💡