84

浅谈前端响应式设计(一)

 5 years ago
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 引入了 gettersetter ,我们可以通过 gettersetter 实现一种响应式。

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() {
        // ...
    }
}

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

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

class Model {  
    @observable hour = '00'
    @observable minute = '00'

    @computed get time() {
        return `${this.hour}:${this.minute}`
    }
}

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

但是这里也有一个缺点,基于 gettercomputed 属性只能描述 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) 处理异步事件的实践。

欢迎关注我们的公众号

bqyERrE.png!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK