前言
大家经常使用的滑动条组件如下图所示
下面我教大家如何自己写一个滑动条组件。
组件分析
在写组件的第一步,我们先做一个组件的拆分,思考一下一个滑动条锁必备的基本要素是什么。
从图上我们可以看出,一个滑动条分为左右两个部分:左边一个 Range 组件,右边是一个 input输入框。Range组件又可以细分为 Container 组件(整体长度)和 Track 组件(进度条部分,在 Container 组件内部,children 传进去)还有一个 Point 组件(鼠标点的那个点)。
组件设计如下图所示
看完组件的设计,我们可以考虑下组件需要传入什么参数:
参数 | 说明 | 是否必填 |
---|---|---|
value | 输入值 | 是 |
onChange | change事件 | 否 |
range | 选择范围 | 否 |
max | 最大范围 | 否 |
min | 最小范围 | 否 |
step | 步长 | 否 |
withInput | 是否带输入框 | 否 |
disabled | 禁用 | 否 |
className | 自定义额外类名 | 否 |
width | 宽度 | 否 |
prefix | 自定义前缀 | 否 |
开始开发
Slider.js
主要代码
export default class Slider extends (PureComponent || Component) {
static propTypes = {
className: PropTypes.string,
prefix: PropTypes.string,
max: PropTypes.number,
min: PropTypes.number,
value: PropTypes.oneOfType([
PropTypes.number,
PropTypes.arrayOf(PropTypes.number),
]).isRequired,
disabled: PropTypes.bool,
range: PropTypes.bool,
step: PropTypes.number,
withInput: PropTypes.bool,
onChange: PropTypes.func,
width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
};
static defaultProps = {
min: 0,
max: 100,
step: 1,
prefix: 'zent',
disabled: false,
withInput: true,
range: false,
value: 0,
};
constructor(props) {
super(props);
}
onChange = value => {
const { range, onChange } = this.props;
value = range
? value.map(v => Number(v)).sort((a, b) => a - b)
: Number(value);
onChange && onChange(value);
};
render() {
const { withInput, className, width, ...restProps } = this.props;
const wrapClass = classNames(
`${restProps.prefix}-slider`,
{ [`${restProps.prefix}-slider-disabled`]: restProps.disabled },
className
);
return (
<div className={wrapClass} style={getWidth(width)}>
<Range {...restProps} onChange={this.onChange} />
{withInput &&
(
<InputField onChange={this.onChange} {...restProps} />
)}
</div>
);
}
}
主要逻辑和上文讲的一样,组件主要构成是一个 Range 和 一个 Input,我们主要看下 Range 的实现
export default class Range extends (PureComponent || Component) {
clientWidth = null;
getClientWidth = () => {
if (this.clientWidth === null) {
this.handleResize();
}
return this.clientWidth;
};
handleResize = () => {
const $root = ReactDOM.findDOMNode(this);
this.clientWidth = $root.clientWidth;
};
render() {
const { value, ...restProps } = this.props;
const warpClass = cx(`${restProps.prefix}-slider-main`, {
[`${restProps.prefix}-slider-main-with-marks`]: marks,
});
return (
<div className={warpClass}>
<Container
getClientWidth={this.getClientWidth}
{...restProps}
value={value}
>
<Track {...restProps} value={value} />
</Container>
<Point
dots={dots}
marks={marks}
getClientWidth={this.getClientWidth}
{...restProps}
value={value}
/>
<WindowEventHandler eventName="resize" callback={this.handleResize} />
</div>
);
}
}
Range 组件里的 Point 就是滑动条鼠标可以拖动的小点, container 是滑动条主要部分,传进去的 track组件,是滑动条的有效部分。这里有一个WindowEventHandler 组件,这个组件的目的是在组件mount的时候给 window 绑定了一个 {eventName} 事件,然后unmount的时候停止监听 {eventName} 事件。更加优雅的实现在这篇文章中有介绍,如何在react组件中监听事件。
我们看下 Container 组件内部实现, 其实很简单,只需要做两件事
1.处理点击滑动条事件
2.渲染 Track 组件
export default class Container extends (PureComponent || Component) {
handleClick = e => {
const {
getClientWidth,
dots,
range,
value,
onChange,
max,
min,
step,
} = this.props;
let newValue;
let pointValue =
(e.clientX - e.currentTarget.getBoundingClientRect().left) /
getClientWidth();
pointValue = getValue(pointValue, max, min);
pointValue = toFixed(pointValue, step);
newValue = pointValue;
if (range) {
newValue = getClosest(value, pointValue);
}
onChange && onChange(newValue);
};
render() {
const { disabled, prefix } = this.props;
return (
<div
className={`${prefix}-slider-container`}
onClick={!disabled ? this.handleClick : noop}
>
{this.props.children}
</div>
);
}
}
Track 组件也很简单,其实就是根据传入的参数,计算出 left 值和有效滑动条长度
export default class Track extends (PureComponent || Component) {
getLeft = () => {
const { range, value, max, min } = this.props;
return range ? getLeft(value[0], max, min) : 0;
};
getWidth = () => {
const { max, min, range, value } = this.props;
return range
? (value[1] - value[0]) * 100 / (max - min)
: getLeft(value, max, min);
};
render() {
const { disabled, prefix } = this.props;
return (
<div
style={{ width: `${this.getWidth()}%`, left: `${this.getLeft()}%` }}
className={calssNames(
{ [`${prefix}-slider-track-disabled`]: disabled },
`${prefix}-slider-track`
)}
/>
);
}
}
Point 组件则着重处理了拖动状态的变化,以及拖动边界的处理, 代码比较简单易读
export default class Points extends (PureComponent || Component) {
constructor(props) {
super(props);
const { range, value } = props;
this.state = {
visibility: false,
conf: range ? { start: value[0], end: value[1] } : { simple: value },
};
}
getLeft = point => {
const { max, min } = this.props;
return getLeft(point, max, min);
};
isLeftButton = e => {
e = e || window.event;
const btnCode = e.button;
return btnCode === 0;
};
handleMouseDown = (type, evt) => {
evt.preventDefault();
if (this.isLeftButton(evt)) {
this.left = evt.clientX;
this.setState({ type, visibility: true });
let { value } = this.props;
if (type === 'start') {
value = value[0];
} else if (type === 'end') {
value = value[1];
}
this.value = value;
return false;
}
};
getAbsMinInArray = (array, point) => {
const abs = array.map(item => Math.abs(point - item));
let lowest = 0;
for (let i = 1; i < abs.length; i++) {
if (abs[i] < abs[lowest]) {
lowest = i;
}
}
return array[lowest];
};
left = null;
handleMouseMove = evt => {
const left = this.left;
if (left !== null) {
evt.preventDefault();
const { type } = this.state;
const {
max,
min,
onChange,
getClientWidth,
step,
dots,
marks,
range,
} = this.props;
let newValue = (evt.clientX - left) / getClientWidth();
newValue = (max - min) * newValue;
newValue = Number(this.value) + Number(newValue);
if (dots) {
newValue = this.getAbsMinInArray(keys(marks), newValue);
} else {
newValue = Math.round(newValue / step) * step;
}
newValue = toFixed(newValue, step);
newValue = checkValueInRange(newValue, max, min);
let { conf } = this.state;
conf[type] = newValue;
this.setState({ conf });
onChange && onChange(range ? [conf.start, conf.end] : newValue);
}
};
handleMouseUp = () => {
this.left = null;
this.setState({ visibility: false });
};
componentWillReceiveProps(props) {
const { range, value } = props;
if (this.left === null) {
this.setState({
conf: range ? { start: value[0], end: value[1] } : { simple: value },
});
}
}
render() {
const { visibility, type, conf } = this.state;
const { disabled, prefix } = this.props;
return (
<div className={`${prefix}-slider-points`}>
{map(conf, (value, index) => (
<ToolTips
prefix={prefix}
key={index}
content={value}
visibility={index === type && visibility}
left={this.getLeft(value)}
>
<span
onMouseDown={
!disabled ? this.handleMouseDown.bind(this, index) : noop
}
className={classNames(
{ [`${prefix}-slider-point-disabled`]: disabled },
`${prefix}-slider-point`
)}
/>
</ToolTips>
))}
{!disabled && (
<WindowEventHandler
eventName="mousemove"
callback={this.handleMouseMove}
/>
)}
{!disabled && (
<WindowEventHandler
eventName="mouseup"
callback={this.handleMouseUp}
/>
)}
</div>
);
}
}
结语
以上代码采样自 zent,从组件的设计可以看出,组件的设计采用了单一指责原则,把一个滑动条拆分为 Range 和 Input,Range 有拆分为 Point、 Container、 Track 三个子组件,每个组件互不干扰,做自己组件的事情,状态都在组件内部维护,状态改变统一触发根组件 onchange 事件通过 props 改变其他受影响的组件,例如点击 Container 改变了value的同时触发了 onchange 改变了 Points 的 left 值,一切井然有序。这值得我们在项目中写业务组件时借鉴。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。