浅谈前端响应式设计(一)
source link: https://tech.youzan.com/reactive1/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
现实世界有很多是以响应式的方式运作的,例如我们会在收到他人的提问,然后做出响应,给出相应的回答。在开发过程中我也应用了大量的响应式设计,积累了一些经验,希望能抛砖引玉。
响应式编程(Reactive Programming)和普通的编程思路的主要区别在于,响应式以推( push
)的方式运作,而非响应式的编程思路以拉( pull
)的方式运作。例如,事件就是一个很常见的响应式编程,我们通常会这么做:
button.on('click', () => { // ... })
而非响应式方式下,就会变成这样:
while (true) { if (button.clicked) { // ... } }
显然,无论在是代码的优雅度还是执行效率上,非响应式的方式都不如响应式的设计。
Event Emitter
Event Emitter
是大多数人都很熟悉的事件实现,它很简单也很实用,我们可以利用 Event Emitter
实现简单的响应式设计,例如下面这个异步搜索:
class Input extends Component { state = { value: '' } onChange = e => { this.props.events.emit('onChange', e.target.value) } afterChange = value => { this.setState({ value }) } componentDidMount() { this.props.events.on('onChange', this.afterChange) } componentWillUnmount() { this.props.events.off('onChange', this.afterChange) } render() { const { value } = this.state return ( <input value={value} onChange={this.onChange} /> ) } } class Search extends Component { doSearch = (value) => { ajax(/* ... */).then(list => this.setState({ list })) } componentDidMount() { this.props.events.on('onChange', this.doSearch) } componentWillUnmount() { this.props.events.off('onChange', this.doSearch) } render() { const { list } = this.state return ( <ul> {list.map(item => <li key={item.id}>{item.value}</li>)} </ul> ) } }
这里我们会发现用 Event Emitter
的实现有很多缺点,需要我们手动在 componentWillUnmount
里进行资源的释放。它的表达能力不足,例如我们在搜索的时候需要聚合多个数据源的时候:
class Search extends Component { foo = '' bar = '' doSearch = () => { ajax({ foo, bar }).then(list => this.setState({ list })) } fooChange = value => { this.foo = value this.doSearch() } barChange = value => { this.bar = value this.doSearch() } componentDidMount() { this.props.events.on('fooChange', this.fooChange) this.props.events.on('barChange', this.barChange) } componentWillUnmount() { this.props.events.off('fooChange', this.fooChange) this.props.events.off('barChange', this.barChange) } render() { // ... } }
显然开发效率很低。
Redux
Redux
采用了一个事件流的方式实现响应式,在 Redux
中由于 reducer
必须是纯函数,因此要实现响应式的方式只有订阅中或者是在中间件中。
如果通过订阅 store
的方式,由于 Redux
不能准确拿到哪一个数据放生了变化,因此只能通过脏检查的方式。例如:
function createWatcher(mapState, callback) { let previousValue = null return (store) => { store.subscribe(() => { const value = mapState(store.getState()) if (value !== previousValue) { callback(value) } previousValue = value }) } } const watcher = createWatcher(state => { // ... }, () => { // ... }) watcher(store)
这个方法有两个缺点,一是在数据很复杂且数据量比较大的时候会有效率上的问题;二是,如果 mapState
函数依赖上下文的话,就很难办了。在 react-redux
中, connect
函数中 mapStateToProps
的第二个参数是 props
,可以通过上层组件传入 props
来获得需要的上下文,但是这样监听者就变成了 React
的组件,会随着组件的挂载和卸载被创建和销毁,如果我们希望这个响应式和组件无关的话就有问题了。
另一种方式就是在中间件中监听数据变化。得益于 Redux
的设计,我们通过监听特定的事件(Action)就可以得到对应的数据变化。
const search = () => (dispatch, getState) => { // ... } const middleware = ({ dispatch }) => next => action => { switch action.type { case 'FOO_CHANGE': case 'BAR_CHANGE': { const nextState = next(action) // 在本次dispatch完成以后再去进行新的dispatch setTimeout(() => dispatch(search()), 0) return nextState } default: return next(action) } }
这个方法能解决大多数的问题,但是在 Redux
中,中间件和 reducer
实际上隐式订阅了所有的事件(Action),这显然是有些不合理的,虽然在没有性能问题的前提下是完全可以接受的。
面向对象的响应式
ECMASCRIPT 5.1
引入了 getter
和 setter
,我们可以通过 getter
和 setter
实现一种响应式。
class Model { _foo = '' get foo() { return this._foo } set foo(value) { this._foo = value this.search() } search() { // ... } } // 当然如果没有getter和setter的话也可以通过这种方式实现 class Model { foo = '' getFoo() { return this.foo } setFoo(value) { this.foo = value this.search() } search() { // ... } }
Mobx
和 Vue
就使用了这样的方式实现响应式。当然,如果不考虑兼容性的话我们还可以使用 Proxy
。
当我们需要响应若干个值然后得到一个新值的话,在 Mobx
中我们可以这么做:
class Model { @observable hour = '00' @observable minute = '00' @computed get time() { return `${this.hour}:${this.minute}` } }
Mobx
会在运行时收集 time
依赖了哪些值,并在这些值发生改变(触发 setter
)的时候重新计算 time
的值,显然要比 EventEmitter
的做法方便高效得多,相对 Redux
的 middleware
更直观。
但是这里也有一个缺点,基于 getter
的 computed
属性只能描述 y = f(x)
的情形,但是现实中很多情况 f
是一个异步函数,那么就会变成 y = await f(x)
,对于这种情形 getter
就无法描述了。
对于这种情形,我们可以通过 Mobx
提供的 autorun
来实现:
class Model { @observable keyword = '' @observable searchResult = [] constructor() { autorun(() => { // ajax ... }) } }
由于运行时的依赖收集过程完全是隐式的,这里经常会遇到一个问题就是收集到意外的依赖:
class Model { @observable loading = false @observable keyword = '' @observable searchResult = [] constructor() { autorun(() => { if (this.loading) { return } // ajax ... }) } }
显然这里 loading
不应该被搜索的 autorun
收集到,为了处理这个问题就会多出一些额外的代码,而多余的代码容易带来犯错的机会。 或者,我们也可以手动指定需要的字段,但是这种方式就不得不多出一些额外的操作:
class Model { @observable loading = false @observable keyword = '' @observable searchResult = [] disposers = [] fetch = () => { // ... } dispose() { this.disposers.forEach(disposer => disposer()) } constructor() { this.disposers.push( observe(this, 'loading', this.fetch), observe(this, 'keyword', this.fetch) ) } } class FooComponent extends Component { this.mode = new Model() componentWillUnmount() { this.state.model.dispose() } // ... }
而当我们需要对时间轴做一些描述时, Mobx
就有些力不从心了,例如需要延迟5秒再进行搜索。
在下一篇博客中,将介绍 Stream(Observable)
处理异步事件的实践。
欢迎关注我们的公众号
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK