用RxJS和react开发mac地址输入框

项目简介

本次使用了RxJS和react开发了一个mac地址输入框,主要实现的功能有限制输入符合条件的字符1-9,a-f,并每隔两位可以自动添加用于分割的冒号。项目屏蔽了react的事件处理,同时使用setSelectionRange来手动控制光标。可以查看项目的demo,项目地址

RxJS简介

RxJS 是 Reactive Extensions 在 JavaScript 上的实现,具体来说是一系列工具库,包括事件处理,函数节流,延时等函数,RxJS应用了’流‘的思想,同时具有事件和时间的概念。RxJS也可以用于处理异步流程,比起Promise具有可取消和可延迟,重试等优点。Promise vs Observable
RxJS中有两个比较重要的概念,分别是Observable和observer。Observable可以使用create,of,from,fromEvent等方法来产生流,而Observer可以对流进行观察。最后两者通过subscribe来结合,例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var Observable = Rx.Observable.create(observer => {
observer.next(2);
observer.complete();
return () => console.log('disposed');
});
var Observer = Rx.Observer.create(
x => console.log('Next:', x),
err => console.log('Error:', err),
() => console.log('Completed')
);
var subscription = Observable.subscribe(Observer);

来自构建流式应用—RxJS详解

更多关于RxJS,可以阅读Introduction | RxJS - Javascript library for functional reactive programming.

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 监听事件,发起流和处理流
componentDidMount () {
this.t = ReactDOM.findDOMNode(this.refs.t)
let keydownValue = Rx.Observable.fromEvent(this.t,'keydown').map(e => e.key.toUpperCase())
this.sa = keydownValue.filter(value => value.length === 1 && value.match(/[0-9A-F]/)).subscribe(value => {this.setColon('before');this.insertValue(value); this.setColon();this.setDomValue()})
// 省略类似的部分
}
// 取消订阅
componentWillUnmount()
this.sa.dispose()
// 类似的部分省略
}
// 一些用到的方法,这里省略
// 取消原生的事件监听
render() {
return (
<div className="App">
<input type="text" onKeyDown={e => e.preventDefault()} ref="t"/>
</div>
);
}

项目详解

首先使用Rx.Observable.fromEvent来监听输入框的按键事件,并获取按键的key值,保存为keydownValue

1
2
let keydownValue = Rx.Observable.fromEvent(this.t,'keydown')
.map(e => e.key.toUpperCase())

接着首先考虑输入字符的情况,在这里,显示筛选出按键符合要求的情况,接着在subscribe中对数据进行处理。在插入新的字符之前和之后,都需要判断是否在前面加上冒号,最后使用setDomValue来让保存在state中的value显示到输入框上。

1
2
3
4
5
6
7
8
this.sa = keydownValue
.filter(value => value.length === 1 && value.match(/[0-9A-F]/))
.subscribe(value => {
this.setColon('before');
this.insertValue(value);
this.setColon();
this.setDomValue()
})

判断是否需要插入冒号的函数setColon,需要排除前面没有字符和周围已经有冒号的情况。

1
2
3
4
setColon = type => this.state.value.length &&
(type !== 'before' ? !this.isNearColon() : !this.isLastColon()) &&
!(this.state.value.slice(0, this.state.pos).replace(/:/g, '').length%2) &&
this.insertValue(':')

插入新字符的函数。在记录的光标位置pos值上插入新的字符,然后改变光标位置。如果在字符末尾有未完成的字符对(即1f:的形式)又在中间插入新的字符串且字符对已经到达六个,则删掉最后一个字符对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
insertValue = value => {
if (this.state.value.length !== 17) {
this.setState({
...this.state,
value: this.state.value.slice(0, this.state.pos) +
value + this.state.value.slice(this.state.pos, this.state.value.length)
})
this.setPos(this.state.pos + 1)
if (this.state.value.split(':').length === 7) {
this.setState({
...this.state,
value: this.state.value.slice(0, this.state.value.lastIndexOf(':'))
})
}
}}

接着是讲解关于删除的流,筛选按键值为’BACKSPACE’的流,执行deleteValue方法和setDomValue

1
2
3
4
5
this.sb = keydownValue.filter(value => value === 'BACKSPACE')
.subscribe(() => {
this.deleteValue()
this.setDomValue()
})

deleteValue,在value和位置都大于零时才执行,如果删除后字符后,新的最后一个字符是冒号,则自动删掉该冒号。

1
2
3
4
5
6
7
8
9
10
11
12
13
deleteValue = () => {
if (this.state.value.length && this.state.pos) {
this.setState({
...this.state,
value: this.state.value.slice(0, this.state.pos - 1) +
this.state.value.slice(this.state.pos, this.state.value.length)
})
this.setPos(this.state.pos - 1)
if (this.isLastColon()) {
this.deleteValue()
}
}
}

接着是订阅了左右方向键移动的流,比较简单,就不详细解释了。

1
2
3
4
5
6
7
8
9
10
11
this.sc = keydownValue
.filter(value => value === 'ARROWLEFT')
.subscribe(() => this.moveLeft())
this.sd = keydownValue
.filter(value => value === 'ARROWRIGHT')
.subscribe(() => this.moveRight())
moveLeft = () => this.state.pos > 0 &&
this.setState({...this.state, pos: this.state.pos - 1})
moveRight = () => this.state.pos !== this.state.value.length &&
this.setState({...this.state, pos: this.state.pos + 1})

最后是让光标跳到pos的处理,setSelectionRange本用于文字的选择,但如果前两个参数为一样的数值,可以达到让光标跳到指定位置的效果。

1
2
this.se = keydownValue.subscribe(() => this.goPos())
goPos = () => this.t.setSelectionRange(this.state.pos, this.state.pos)

170624更新

原本的模式跟react关系较少,因此修改调整了一下,主要的变化是启用了Subject,setStateAsync,在这里先介绍一下。

Rx.Subject

Subject继承于Obserable和Observer,因此同时具有Obserable和Observer两者的方法。通过来自于Observable的multicast方法可以挂载subject,并得到拥有相同执行环境的多路的新的Observable,关于他的订阅实际上是挂载在subject上。最后需要手动connect。 RxJS 核心概念之Subject30 天精通 RxJS(24): Observable operators - multicast, refCount, publish, share

1
2
3
4
5
6
7
8
9
10
11
12
13
var source = Rx.Observable.from([1, 2, 3]);
var multicasted = source.multicast(new Rx.Subject())
// 通过`subject.subscribe({...})`订阅Subject的Observer:
multicasted.subscribe({
next: (v) => console.log('observerA: ' + v)
});
multicasted.subscribe({
next: (v) => console.log('observerB: ' + v)
});
// 让Subject从数据源订阅开始生效:
multicasted.connect();

其实可以用refCount来避免connect,用publish来代替 multicast(new Rx.Subject()),最后用share代替publish 和 refCount,因此代码可以写成

1
var multicasted = source.share()
setStateAsync

组件改为受控组件之后,setState中的异步特性展示了出来,setState后的下一步获取setState并不是最新的state,影响了程序的正常使用。
例如之前的新增函数的订阅。后面的inserValue和setColon都是需要利用最新的state来进行判断的。

1
2
3
4
5
6
7
8
this.sa = keydownValue
.filter(value => value.length === 1 && value.match(/[0-9A-F]/))
.subscribe(value => {
this.setColon('before');
this.insertValue(value);
this.setColon();
this.setDomValue()
})

可以在setState的第二个参数中传入回调函数来解决这个问题,于是函数变成了这样,一层又一层的回调,十分不美观

1
2
3
4
5
6
7
8
9
this.sa = keydownValue
.filter(value => value.length === 1 && value.match(/[0-9A-F]/))
.subscribe(value => {
this.setColon('before', () => {
this.insertValue(value, () => {
this.setColon()
})
})
})

接着在网上找到了setStateAsync的函数,原理就是将setState转换成promise的形式,接着就能愉快的使用async await的语法来修改state了。React中setState同步更新策略

1
setStateAsync = state => new Promise(resolve => this.setState(state,resolve))
实际的调整

在componentDidMount中把keydownValue设置为同时具有Observable和Observe的方法的Subject,他一方面可以使用Observer的onNext方法来添加新的数据,另一方面可以继续使用Observable的操作符来对数据进行处理。

1
2
3
4
5
6
7
8
9
10
11
this.keydownValue = new Rx.Subject()
let multicasted = this.keydownValue.map(e => e.key.toUpperCase()).share()
this.sa = multicasted
.filter(value => value.length === 1 && value.match(/[0-9A-F]/))
.subscribe(async value => {
await this.setColon('before')
await this.insertValue(value)
await this.setColon()
this.goPos()
})
//下略

组件的render函数修改为

1
2
3
<div className="App">
<input type="text" onKeyDown={this.handleE} value={this.state.value} ref="t"/>
</div>

handleE函数继续禁止默认事件,调用了新设置的Subject(keydownValue)的onNext方法,可以使得绑定在keydownValue上的订阅获得数据

1
handleE = e => {e.preventDefault();this.keydownValue.onNext(e)}
支持作者

如果我的文章对你有帮助,欢迎 关注和 star 本博客 或是关注我的 github,获取更新通知。欢迎发送邮件到hpoenixf@foxmail.com与作者交流