浅谈前端响应式设计

Posted 有赞coder

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈前端响应式设计相关的知识,希望对你有一定的参考价值。

文 | jinzhixin on 前端

现实世界有很多是以响应式的方式运作的,例如我们会在收到他人的提问,然后做出响应,给出相应的回答。在开发过程中我也应用了大量的响应式设计,积累了一些经验,希望能抛砖引玉。

响应式编程(Reactive Programming)和普通的编程思路的主要区别在于,响应式以推( push)的方式运作,而非响应式的编程思路以拉( pull)的方式运作。例如,事件就是一个很常见的响应式编程,我们通常会这么做:

 
   
   
 
  1. button.on('click', () => {

  2.    // ...

  3. })

而非响应式方式下,就会变成这样:

 
   
   
 
  1. while (true) {

  2.    if (button.clicked) {

  3.        // ...

  4.    }

  5. }

显然,无论在是代码的优雅度还是执行效率上,非响应式的方式都不如响应式的设计。

Event Emitter

EventEmitter是大多数人都很熟悉的事件实现,它很简单也很实用,我们可以利用 EventEmitter实现简单的响应式设计,例如下面这个异步搜索:

 
   
   
 
  1. class Input extends Component {

  2.    state = {

  3.        value: ''

  4.    }

  5.    onChange = e => {

  6.        this.props.events.emit('onChange', e.target.value)

  7.    }

  8.    afterChange = value => {

  9.        this.setState({

  10.            value

  11.        })

  12.    }

  13.    componentDidMount() {

  14.        this.props.events.on('onChange', this.afterChange)

  15.    }

  16.    componentWillUnmount() {

  17.        this.props.events.off('onChange', this.afterChange)

  18.    }

  19.    render() {

  20.        const { value } = this.state

  21.        return (

  22.            <input value={value} onChange={this.onChange} />

  23.        )

  24.    }

  25. }

  26. class Search extends Component {

  27.    doSearch = (value) => {

  28.        ajax(/* ... */).then(list => this.setState({

  29.            list

  30.        }))

  31.    }

  32.    componentDidMount() {

  33.        this.props.events.on('onChange', this.doSearch)

  34.    }

  35.    componentWillUnmount() {

  36.        this.props.events.off('onChange', this.doSearch)

  37.    }

  38.    render() {

  39.        const { list } = this.state

  40.        return (

  41.            <ul>

  42.                {list.map(item => <li key={item.id}>{item.value}</li>)}

  43.            </ul>

  44.        )

  45.    }

  46. }

这里我们会发现用 EventEmitter的实现有很多缺点,需要我们手动在 componentWillUnmount里进行资源的释放。它的表达能力不足,例如我们在搜索的时候需要聚合多个数据源的时候:

 
   
   
 
  1. class Search extends Component {

  2.    foo = ''

  3.    bar = ''

  4.    doSearch = () => {

  5.        ajax({

  6.            foo,

  7.            bar

  8.        }).then(list => this.setState({

  9.            list

  10.        }))

  11.    }

  12.    fooChange = value => {

  13.        this.foo = value

  14.        this.doSearch()

  15.    }

  16.    barChange = value => {

  17.        this.bar = value

  18.        this.doSearch()

  19.    }

  20.    componentDidMount() {

  21.        this.props.events.on('fooChange', this.fooChange)

  22.        this.props.events.on('barChange', this.barChange)

  23.    }

  24.    componentWillUnmount() {

  25.        this.props.events.off('fooChange', this.fooChange)

  26.        this.props.events.off('barChange', this.barChange)

  27.    }

  28.    render() {

  29.        // ...

  30.    }

  31. }

显然开发效率很低。

Redux

Redux采用了一个事件流的方式实现响应式,在 Redux中由于 reducer必须是纯函数,因此要实现响应式的方式只有订阅中或者是在中间件中。

如果通过订阅 store的方式,由于 Redux不能准确拿到哪一个数据放生了变化,因此只能通过脏检查的方式。例如:

 
   
   
 
  1. function createWatcher(mapState, callback) {

  2.    let previousValue = null

  3.    return (store) => {

  4.        store.subscribe(() => {

  5.            const value = mapState(store.getState())

  6.            if (value !== previousValue) {

  7.                callback(value)

  8.            }

  9.            previousValue = value

  10.        })

  11.    }

  12. }

  13. const watcher = createWatcher(state => {

  14.    // ...

  15. }, () => {

  16.    // ...

  17. })

  18. watcher(store)

这个方法有两个缺点,一是在数据很复杂且数据量比较大的时候会有效率上的问题;二是,如果 mapState函数依赖上下文的话,就很难办了。在 react-redux中, connect函数中 mapStateToProps的第二个参数是 props,可以通过上层组件传入 props来获得需要的上下文,但是这样监听者就变成了 React的组件,会随着组件的挂载和卸载被创建和销毁,如果我们希望这个响应式和组件无关的话就有问题了。

另一种方式就是在中间件中监听数据变化。得益于 Redux的设计,我们通过监听特定的事件(Action)就可以得到对应的数据变化。

 
   
   
 
  1. const search = () => (dispatch, getState) => {

  2.    // ...

  3. }

  4. const middleware = ({ dispatch }) => next => action => {

  5.    switch action.type {

  6.        case 'FOO_CHANGE':

  7.        case 'BAR_CHANGE': {

  8.            const nextState = next(action)

  9.            // 在本次dispatch完成以后再去进行新的dispatch

  10.            setTimeout(() => dispatch(search()), 0)

  11.            return nextState

  12.        }

  13.        default:

  14.            return next(action)

  15.    }

  16. }

这个方法能解决大多数的问题,但是在 Redux中,中间件和 reducer实际上隐式订阅了所有的事件(Action),这显然是有些不合理的,虽然在没有性能问题的前提下是完全可以接受的。

面向对象的响应式

ECMASCRIPT5.1引入了 gettersetter,我们可以通过 gettersetter实现一种响应式。

 
   
   
 
  1. class Model {

  2.    _foo = ''

  3.    get foo() {

  4.        return this._foo

  5.    }

  6.    set foo(value) {

  7.        this._foo = value

  8.        this.search()

  9.    }

  10.    search() {

  11.        // ...

  12.    }

  13. }

  14. // 当然如果没有getter和setter的话也可以通过这种方式实现

  15. class Model {

  16.    foo = ''

  17.    getFoo() {

  18.        return this.foo

  19.    }

  20.    setFoo(value) {

  21.        this.foo = value

  22.        this.search()

  23.    }

  24.    search() {

  25.        // ...

  26.    }

  27. }

MobxVue就使用了这样的方式实现响应式。当然,如果不考虑兼容性的话我们还可以使用 Proxy

当我们需要响应若干个值然后得到一个新值的话,在 Mobx中我们可以这么做:

 
   
   
 
  1. class Model {

  2.    @observable hour = '00'

  3.    @observable minute = '00'

  4.    @computed get time() {

  5.        return `${this.hour}:${this.minute}`

  6.    }

  7. }

Mobx会在运行时收集 time依赖了哪些值,并在这些值发生改变(触发 setter)的时候重新计算 time的值,显然要比 EventEmitter的做法方便高效得多,相对 Reduxmiddleware更直观。

但是这里也有一个缺点,基于 gettercomputed属性只能描述 y=f(x)的情形,但是现实中很多情况 f是一个异步函数,那么就会变成 y=awaitf(x),对于这种情形 getter就无法描述了。

对于这种情形,我们可以通过 Mobx提供的 autorun来实现:

 
   
   
 
  1. class Model {

  2.    @observable keyword = ''

  3.    @observable searchResult = []

  4.    constructor() {

  5.        autorun(() => {

  6.            // ajax ...

  7.        })

  8.    }

  9. }

由于运行时的依赖收集过程完全是隐式的,这里经常会遇到一个问题就是收集到意外的依赖:

 
   
   
 
  1. class Model {

  2.    @observable loading = false

  3.    @observable keyword = ''

  4.    @observable searchResult = []

  5.    constructor() {

  6.        autorun(() => {

  7.            if (this.loading) {

  8.                return

  9.            }

  10.            // ajax ...

  11.        })

  12.    }

  13. }

显然这里 loading不应该被搜索的 autorun收集到,为了处理这个问题就会多出一些额外的代码,而多余的代码容易带来犯错的机会。

或者,我们也可以手动指定需要的字段,但是这种方式就不得不多出一些额外的操作:

 
   
   
 
  1. class Model {

  2.    @observable loading = false

  3.    @observable keyword = ''

  4.    @observable searchResult = []

  5.    disposers = []

  6.    fetch = () => {

  7.        // ...

  8.    }

  9.    dispose() {

  10.        this.disposers.forEach(disposer => disposer())

  11.    }

  12.    constructor() {

  13.        this.disposers.push(

  14.            observe(this, 'loading', this.fetch),

  15.            observe(this, 'keyword', this.fetch)

  16.        )

  17.    }

  18. }

  19. class FooComponent extends Component {

  20.    this.mode = new Model()

  21.    componentWillUnmount() {

  22.        this.state.model.dispose()

  23.    }

  24.    // ...

  25. }

而当我们需要对时间轴做一些描述时, Mobx就有些力不从心了,例如需要延迟5秒再进行搜索。

在下一篇博客中,将介绍 Observable处理异步事件的实践。

以上是关于浅谈前端响应式设计的主要内容,如果未能解决你的问题,请参考以下文章

响应式设计之Bootstrap浅谈

三金技术浅谈响应式网页设计

浅谈移动端的自适应问题——响应式rem/em利用Js动态实现移动端自适应

VSCode自定义代码片段—— 数组的响应式方法

浅谈java响应式编程以及Reactor 3框架(内有福利)

VSCode自定义代码片段10—— 数组的响应式方法