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

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

上一篇部落格提到了幾種響應式的方案,以及它們的缺點。本文將介紹Observable以及它的一個實現,以及它在處理響應式時相對於上篇部落格中的方案的巨大優勢(推薦兩篇部落格對比閱讀)。

Observable是一個集合了觀察者模式、迭代器模式和函式式的庫,提供了基於事件流的強大的非同步處理能力,並且已在Stage 1草案中。本文介紹的RxjsObservable的一個實現,它是ReactiveX眾多語言中的JavaScript版本。

JavaScript中,我們可以使用T | null去處理一個單值,使用Iterator去處理多個值得情況,使用Promise處理非同步的單個值,而Observable則填補了缺失的“非同步多個值”。

單個值 多個值
同步 T | null Iterator<T>
非同步 Promise<T> Observable<T>

使用Rxjs

上文提到使用Event Emitter做響應式處理,在Rxjs中稍有些不同:

/*

const change$ = new Subject();

<Input change$={change$} />

<Search change$={change$} />

*/

class Input extends Component {
  state = {
    value: ''
  };

  onChange = e => {
    this.props.change$.next(e.target.value);
  };

  componentDidMount() {
    this.subscription = this.props.change$.subscribe(value => {
      this.setState({
        value
      });
    });
  }

  componentWillUnmount() {
    this.subscription.ubsubscribe();
  }

  render() {
    const { value } = this.state;

    return <input value={value} onChange={this.onChange} />;
  }
}

class Search extends Component {
  // ...

  componentDidMount() {
    this.subscription = this.props.change$.subscribe(value => {
      ajax(/* ... */).then(list =>
        this.setState({
          list
        })
      );
    });
  }

  componentWillUnmount() {
    this.subscription.ubsubscribe();
  }

  render() {
    const { list } = this.state;

    return <ul>{list.map(item => <li key={item.id}>{item.value}</li>)}</ul>;
  }
}
複製程式碼

在這裡,我們雖然也需要手動釋放對事件的訂閱,但是得益於Rxjs的設計,我們不需要像Event Emitter那樣去存下回撥函式的例項,用於釋放訂閱,因此我們很容易就可以通過高階元件解決這個問題。例如:

const withObservables = observables => ChildComponent => {
  return class extends Component {
    constructor(props) {
      super(props);
      this.subscriptions = {};
      this.state = {};
      Object.keys(observables).forEach(key => {
        this.subscriptions[key] = observables[key].subscribe(value => {
          this.setState({
            [key]: value
          });
        });
      });
    }

    onNext = (key, value) => {
      observables[key].next(value);
    };

    componentWillUnmount() {
      Object.keys(this.subscriptions).forEach(key => {
        this.subscriptions[key].unsubscribe();
      });
    }

    render() {
      return (
        <ChildComponent {...this.props} {...this.state} onNext={this.onNext} />
      );
    }
  };
};
複製程式碼

這樣在需要聚合多個資料來源時,也不會像Event Emitter那樣手動釋放資源造成麻煩。同時,在Rxjs中我們還有專用於聚合資料來源的方法:

Observable.combineLatest(foo$, bar$)
  .pipe(
      // ...
  );
複製程式碼

顯然相對於Event Emitter的方式十分高效,同時它相對於Mobx也有巨大的優勢。在Mobx中,我們提到需要聚合多個資料來源的時候,採用autoRun的方式容易收集到不必要的依賴,使用observe則不夠高效。在Rxjs中,顯然不會有這些問題,combineLatest可以以很簡練的方式宣告需要聚合的資料來源,同時,得益於Rxjs設計,我們不需要像Mobx一個一個去呼叫observe返回的析構,只需要處理每一個subscribe返回的subscription

class Foo extends Component {
  constructor(props) {
    super(props);
    this.subscription = Observable.combineLatest(foo$, bar$)
      .pipe(
        // ...
      )
      .subscribe(() => {
        // ...
      });
  }

  componentWillUnmount() {
    this.subscription.unsubscribe();
  }
}
複製程式碼

非同步處理

Rxjs使用操作符去描述各種行為,每一個操作符會返回一個新的Observable,我們可以對它進行後續的操作。例如,使用map操作符就可以實現對資料的轉換:

foo$.map(event => event.target.value);
複製程式碼

Rxjs 5.5之後所有的Observable上都引入了一個pipe方法,接收若干個操作符,pipe方法會返回一個Observable。因此,我們可以很容易配合tree shaking實現對操作符的按需引入,而不是把整個Rxjs引入進來:

import { map } from 'rxjs/operators';

foo$.pipe(map(event => event.target.value));
複製程式碼

推薦使用這種寫法。

在討論物件導向的響應式的響應式中,我們提到對於非同步的問題,物件導向的方式不好處理。在Observable中我們可以通過switchMap操作符處理非同步問題,一個非同步搜尋看起來會是這樣:

input$.pipe(switchMap(keyword => Observable.ajax(/* ... */)));
複製程式碼

在處理非同步單值時,我們可以使用Promise,而Observable用於處理非同步多個值,我們可以很容易把一個Promise轉成一個Observable,從而複用已有的非同步程式碼:

input$.pipe(switchMap(keyword => fromPromise(search(/* ... */))));
複製程式碼

switchMap接受一個返回Observable的函式作為引數,下游的流就會切到這個返回的Observable。 而要聚合多個資料來源並做非同步處理時:

combineLatest(foo$, bar$).pipe(
  switchMap(keyword => fromPromise(someAsyncOperation(/* ... */)))
);
複製程式碼

同時,由於標準制定的Promise是沒有cancel方法的,有時候我們要取消非同步方法的時候就有些麻煩(主要是為了解決一些併發安全問題)。switchMap當上遊有新值到來時,會忽略結束已有未完成的Observable然後呼叫函式返回一個新的Observable,我們只使用一個函式就解決了併發安全問題。當然,我們可以根據實際需要選用switchMapmergeMapconcatMapexhaustMap等。

而對於時間軸的操作,Rxjs也有巨大優勢。上篇部落格中提到當我們需要延時 5 秒做操作時,無論是Event Emitter還是物件導向的方式都力不從心,而在Rxjs中我們只需要一個delay操作符即可解決問題:

input$.pipe(
  delay(5000) // 下游會在input$值到來後5秒才接到資料
);
複製程式碼

用 Rxjs 處理資料

在實際開發過程中,事件不能解決所有問題,我們往往會需要儲存資料,而Observable被設計成用於處理事件,因此它有很多符合事件直覺的設計。

Observable被設計為懶(lazy)的,噹噹沒有訂閱者時,一個流不會執行。對於事件而言,沒有事件的消費者那麼不執行也不會有問題。而在 GUI 中,訂閱者可能是View

class View extends Component {
  state = {
    input: ''
  };

  componentDidMount() {
    this.subscription = input$.subscribe(input => {
      this.setState({
        input
      });
    });
  }

  componentWillUnmount() {
    this.subscription.unsubscribe();
  }

  render() {
    // ...
  }
}
複製程式碼

由於這個View可能不存在,例如路由被切走了,那麼我們的事件源就沒有了訂閱者,他就不會執行。但是我們希望在路由被且走後,後臺的資料依然會繼續。

對於事件而言,在事件發生之後的訂閱者不會受到訂閱之前的邏輯。例如在EventEmitter中:

eventEmitter.emit('hello', 1);
// ...
eventEmitter.on('hello', function listener() {});
複製程式碼

由於listener是在hello事件發生後在監聽的,不會收到值為1的事件。但是這在處理資料的時候會造成麻煩,我們的資料在View被解除安裝(例如路由切走)後丟失。

同時,由於Observable沒有提供直接取到內部狀態的方法,當我們使用Observable處理資料時,我們不方便隨時拿到資料。那有辦法解決這個問題,從而使Observable強大抽象能力去賦能資料層呢?

回到ReduxRedux的事件(Action)其實是一個事件流,那麼我們就可以很自然地把Redux的事件流融入到Rxjs流中:

() => next => {
  const action$ = new Subject();

  return action => {
    action$.next(action);
    // ...
  };
};
複製程式碼

通過這樣的封裝,redux-observable就能讓我們把Observable強大的事件描述和處理能力和Redux結合。我們可以非常方便地根據Action去處理副作用:

action$.pipe(
  ofType('ACTION_1'),
  switchMap(() => {
    // ...
  }),
  map(res => ({
    type: 'ACTION_2',
    payload: res
  }))
);

action$.pipe(
  ofType('ACTION_3'),
  mergeMap(() => {
    // ...
  }),
  map(res => ({
    type: 'ACTION_4',
    payload: res
  }))
);
複製程式碼

Redux Observable使我們可以結合ReduxObservable。在這裡,Action被視作一個流,ofType相當於filter(action => action.type === 'SOME_ACTION'),從而得到需要監聽的Action,得益於Redux的設計,我們可以通過監聽Action去完成副作用的處理或者監聽資料變化。最後這個流返回一個新的Action流,Redux Observable會把這個新的Action流中的Action dispatch出去。由此,我們在使用Redux儲存資料的基礎上獲得了Rxjs對非同步事件的強大處理能力。

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

相關文章