淺談前端響應式設計(一)

有贊前端發表於2018-06-25

現實世界有很多是以響應式的方式運作的,例如我們會在收到他人的提問,然後做出響應,給出相應的回答。在開發過程中筆者也應用了大量的響應式設計,積累了一些經驗,希望能拋磚引玉。

響應式程式設計(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秒再進行搜尋。

在下一篇部落格中,將介紹Observable處理非同步事件的實踐。

本文首發於(https://tech.youzan.com/reactive1/)[有贊技術部落格]。

相關文章