[Redux] Middleware Redux-saga

Xin chào mọi người, chúng ta lại gặp nhau trong loạt bài viết về Redux. Ở bài viết trước chúng ta đã cũng tìm hiểu về middleware trong redux là gì và sử dụng thử redux-thunk. Trong bài viết này chúng ta sẽ đề cập đến Redux-saga những câu chuyện cổ tích hoành tráng về redux (yaoming).

Saga In a Nutshell

Đầu tiên, redux-saga là cái gì ?

Nghe có vẻ liên quan đến truyện cổ tích, nhưng thực ra thì cũng chẳng liên quan lắm. Theo Yassine Elouafi , tác giả của redux-saga:

redux-saga is a library that aims to make side effects (i.e. asynchronous things like data fetching and impure things like accessing the browser cache) in React/Redux applications easier and better.

Đơn giản thì nó cũng cũng là một thư viện hỗ trợ việc xử lí các side effect trong ứng dụng React/Redux, ví dụ như các xử lí bất đồng bộ khi load data chẳng hạn. Thế thì nó cũng giống redux-thunk à ?? À tất nhiên là về mục tiêu thì nó chẳng khác gì cả (yaoming) nhưng phương pháp tiếp cận và xử lí side effect thì có khác chút chút.

Redux saga trông như thế nào

Một saga của chúng ta thì thường trông như thế này:

import { put, call, takeEvery } from 'redux-saga/effects';

function* someSaga() {
  // Wait for (every) SOME_ACTION action
  takeEvery('SOME_ACTION', doSomeThing);
}
function* doSomeThing(action}) {
  try {
    // Tell redux-saga to call fetchSomeThing with the param from action
    yield call(fetchSomeThing, action.payload)
    // Tell redux-saga to dispatch the someThingSuccess action
    yield put(someThingSuccess())
  }
  catch (err) {
    // You get it
    yield put (someThingFailed(err))
  }
}

Và để sử dụng saga thì cũng giống như với các middleware khác

...
import createSagaMiddleware from 'redux-saga';

const sagaMiddleware = createSagaMiddleware();
const createStoreWithSagas = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(someSaga); // run saga

Ý tưởng của Redux saga là gì ?

Redux-saga có một giải pháp thực sự tốt cho vấn đề side effect. Nó tách side effect khỏi action bằng cách đặt chúng vào các saga. Có thể coi những saga như những mẩu truyện nhỏ mô tả hành vi của 1 hay nhiều action.

Saga hoạt động như các daemon (chương trình chạy nền) được điều khiển bởi sagaMiddleware của redux-saga. Và như các bạn thấy cú pháp định nghĩa 1 saga:

function* someSaga() 

Đây là cú pháp khai báo một generator function (ES6). Bản thân các saga là một generator function, điều này cho phép redux-saga có thể điều khiển từng bước mà saga hoạt động.

Để hiểu hơn về cách mà redux-saga điều khiển từng bước của saga, chúng ta cần có một chút khái niệm về generator function, các bạn có thể đọc thêm tại đây.

The Flight Dashboard Case

Để cùng xem cách dùng saga trong một ứng dụng thực tế thế nào chúng ta sẽ xây dựng một bảng điều khiển chuyến bay. Source code ở đây và demo ở đây. Kịch bản của chúng ta như sau:

Setup

Như chúng ta thấy, một chuỗi 3 APIs được gọi: getDeparture -> getFlight ->getForecast , nên API của chúng ta sẽ trông như thế này:

class TravelServiceApi {
 static getUser() {
   return new Promise((resolve) => {
     setTimeout(() => {
       resolve({
            email : "[email protected]",
            repository: "http://github.com/username"
       });
     }, 3000);
   });
 }
 static getDeparture(user) {
  return new Promise((resolve) => {
   setTimeout(() => {
    resolve({
      userID : user.email,
      flightID :AR1973,
      date :10/27/2016 16:00PM”
     });
    }, 2500);
   });
 }
 static getForecast(date) {
  return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
            date: date,
            forecast: "rain"
        });
      }, 2000);
   });
  }
}

API này chứa một số thông tin cho phép chúng ta thiết lập kịch bản. Đầu tiên chúng ta sẽ có thông tin một người dùng. Sau đó với thông tin người dùng, chúng ta sẽ có giờ khởi hành, chuyến bay và dự báo thời tiết từ đó chúng ta có thể tạo ra một số bảng điều khiển trông như thế này: Chúng là 3 component khác nhau với 3 đại diện trong store được tạo ra bởi 3 reducer (dashboard, dashboard2, dashboard3). Mỗi reducer sẽ trông như thế này:

export const dashboard = (state = {}, action)  => {
  switch(action.type) {
    case 'FETCH_DASHBOARD_SUCCESS':
    return Object.assign({}, state, action.payload);
    default :
      return state;
  }
};

Chúng ta sử dụng các reducer khác nhau cho mỗi bảng điều khiển, với 3 kịch bản khác nhau và cung cấp cho component vào user data chung:

const mapStateToProps =(state) => ({
 user : state.user,
 dashboard : state.dashboard
});

Và chúng ta mới xong phần setup... giờ thì mới bắt đầu vào việc chính.

Show me the Sagas

William Deming từng nói:

Nếu bạn không thể mô tả những gì bạn đang làm như quá trình, thì bạn không hề biết mình đang làm gì.

Chúng ta sẽ đi từng bước để xem cách làm việc với Redux Saga

1. Đăng ký Saga

Đầu tiên chúng ta sẽ tạo ra root saga:

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced)
  ];
}

Redux-saga cung cấp một số method gọi là effect , chúng ta sẽ định nghĩa một số chúng:

  • Fork: thực hiện một hoạt động non-blocking trên function được truyền cho nó.
  • Take: tạm dừng cho đến khi nhận được action
  • Race: chạy nhiều effect đồng thời, sau đó hủy tất cả nếu một trong số đó kết thúc.
  • Call: gọi function. Nếu nó return về một promise, tạm dừng saga cho đến khi promise được giải quyết.
  • Put: dispatch một action.
  • Select: chạy một selector function để lấy data từ state.
  • takeLatest: có nghĩa là nếu chúng ta thực hiện một loạt các actions, nó sẽ chỉ thực thi và trả lại kết quả của của actions cuối cùng.
  • takeEvery: thực thi và trả lại kết quả của mọi actions được gọi.

Hiện tại chúng ta đăng kí cho loadUser sử dụng fork và một effect takeLatest chờ actions "LOAD_DASHBOARD" để thực thi. Chúng ta sẽ tiếp tục với các step tiếp theo.

  1. Inject Saga Middleware vào Redux Store
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, [], compose(
      applyMiddleware(sagaMiddleware)  
);
sagaMiddleware.run(rootSaga); /* inject our sagas into the middleware*/
  1. Tạo các Saga Đầu tiên chúng ta sẽ định nghĩa loadUser saga:
function* loadUser() {
  try {
   //1st step
    const user = yield call(getUser);
   //2nd step
    yield put({type: 'FETCH_USER_SUCCESS', payload: user});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error});
  }
}

Chúng ta có thể đọc đoạn code như sau:

  • Gọi function getUser và gán kết quả vào hằng user.
  • Sau đó, dispatch actions "FETCH_USER_SUCCESS" với payload là user để store sử dụng.
  • Nếu xảy ra lỗi dispatch action "FETCH_FAILED". Như các bạn thấy, chúng ta có thể gán kết quả của yeild cho một biến.

Bây giờ chúng ta sẽ tạo ra loadDashboardSequenced saga:

function* loadDashboardSequenced() {
 try {
  
  yield take(FETCH_USER_SUCCESS);
  const user = yield select(state => state.user);
  
  const departure = yield call(loadDeparture, user);
  const flight = yield call(loadFlight, departure.flightID);
  const forecast = yield call(loadForecast, departure.date);
  yield put({type:FETCH_DASHBOARD_SUCCESS, payload: {forecast,  flight, departure} });
  } catch(error) {
    yield put({type:FETCH_FAILED, error: error.message});
  }
}

Chúng ta có thể đọc flow của saga như sau:

  • Chờ action "FETCH_USER_SUCCESS". Về cơ bản thì nó sẽ chờ đến khi event được dispatch.
  • Lấy user từ store, select effect nhận 1 function có thể truy cập vào store. Chúng ta gán thông tin user vào hằng số user.
  • Thực thi async loadDeparture để load giờ khởi hành
  • Sau khi loadDeparture hoàn thành, thực thi loadFlight với kết quả của loadDeparture.
  • Tiếp tục thực thi loadForecast với kết quả của loadFlight.
  • Cuối cùng, dispatch action "FETCH_DASHBOARD_SUCCESS" với payload là kết quả của 3 thực thi bên trên.

Như các bạn thấy, saga là một tập hợp các bước chờ đợi các hành động trước nó để thay đổi hành vi. Sau khi tất cả hoàn thành, thông tin đã sẵn sàng để sử dụng trọng store.

Khá là rõ ràng, như đọc văn miêu tả (yaoming).

Giờ chúng ta sẽ kiểm tra một trường hợp khác. Xem xét getFlightgetForecast có thể gọi cùng lúc. Chúng không phải chờ một cái khác hoàn thành để bắt đầu cái còn lại, chúng ta có thể tạo 1 bảng điều khiển khác cho trường hợp đó

Non-blocking Saga

Để thực thi 2 hoạt động non-blocking, chúng ta cần một sửa đổi nhỏ với saga trước đó.

function* loadDashboardNonSequenced() {
  try {
    //Wait for the user to be loaded
    yield take('FETCH_USER_SUCCESS');
    //Take the user info from the store
    const user = yield select(getUserFromState);
    //Get Departure information
    const departure = yield call(loadDeparture, user);
    //Here is when the magic happens
    const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];
    //Tell the store we are ready to be displayed
    yield put({type: 'FETCH_DASHBOARD2_SUCCESS', payload: {departure, flight, forecast}});
} catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

Sự thay đổi ở đây là chúng ta sẽ đăng ký yield như một array:

const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];

Vì thế, cả 2 hoạt động được gọi song song, nhưng cuối cùng chúng ta vẫn đợi cả 2 hoàn thành để cappj nhật UI nếu cần thiết.

Sau đó chúng ta cần đăng ký saga với rootSaga:

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced),
    takeLatest('LOAD_DASHBOARD2' loadDashboardNonSequenced)
  ];
}

Vậy nếu chúng ta muốn cập nhật UI ngay khi load xong dữ liệu thì sao ?

Non-Sequenced và Non-Blocking Saga

Chúng ta có thể cô lập các saga và kết hợp chúng, có nghĩa là chúng có thể hoạt động độc lập. Đây chính xác là những gì chúng ta cần.

Bước #1: Chúng ta sẽ cô lập ForecastFlight saga. Cả 2 đều phụ thuộc vào departure

/* **************Flight Saga************** */
function* isolatedFlight() {
  try {
    /* departure will take the value of the object passed by the put*/
    const departure = yield take('FETCH_DEPARTURE3_SUCCESS');
 
    const flight = yield call(loadFlight, departure.flightID);
 
    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {flight}});
  } catch (error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

/* **************Forecast Saga************** */
function* isolatedForecast() {
    try {
      /* departure will take the value of the object passed by the put*/
     const departure = yield take('FETCH_DEPARTURE3_SUCCESS');
     const forecast = yield call(loadForecast, departure.date);
     
     yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { forecast, }});
} catch(error) {
      yield put({type: 'FETCH_FAILED', error: error.message});
    }
}

Điều quan trọng ở đây là gì? Đây là cách mà chúng ta kiến trúc các saga:

  • Cả 2 nhóm đều đang chờ action "FETCH_DEPARTURE3_SUCCESS" để bắt đầu.
  • Chúng sẽ nhận được một giá trị khi event này được kích hoạt. (Chi tiết hơn ở bước tiếp theo)
  • Chúng sẽ cùng thực thi call và cả 2 sẽ cùng dispatch 1 action "FETCH_DASHBOARD3_SUCCESS" sau khi hoàn thành. Nhưng cả 2 sẽ gửi đến dữ liệu khác nhau, nhờ sức mạnh của Redux chúng ta có thể làm việc này mà ko phải sửa đổi reducer.

Bước #2: Chúng ta thay đổi với saga ban đầu 1 chút, chắc chắn nó sẽ gửi departure đến cho 2 saga phía trên:

function* loadDashboardNonSequencedNonBlocking() {
  try {
    //Wait for the action to start
    yield take('FETCH_USER_SUCCESS');
    //Take the user info from the store
    const user = yield select(getUserFromState);
    //Get Departure information
    const departure = yield call(loadDeparture, user);
    //Update the store so the UI get updated
    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { departure, }});
    //trigger actions for Forecast and Flight to start...
    //We can pass and object into the put statement
    yield put({type: 'FETCH_DEPARTURE3_SUCCESS', departure});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

Không có gì khác biệt ở đây cho đến phần put effect. Chúng ta sẽ thêm 1 action "FETCH_DEPARTURE3_SUCCESS" và kèm theo departure cho 2 saga phía trên.

Thế còn testing thì sao?

Saga rất dễ test, nhưng chúng bị ràng buộc với các bước bên trong, được thiết lập theo trình tự do tính chất của generator. Chúng ta cùng xem ví dụ sau (hoặc test trong repo demo):

describe('Sequenced Saga', () => {
  const saga = loadDashboardSequenced();
  let output = null;
it('should take fetch users success', () => {
      output = saga.next().value;
      let expected = take('FETCH_USER_SUCCESS');
      expect(output).toEqual(expected);
  });
it('should select the state from store', () => {
      output = saga.next().value;
      let expected = select(getUserFromState);
      expect(output).toEqual(expected);
  });
it('should call LoadDeparture with the user obj', (done) => {
    output = saga.next(user).value;
    let expected = call(loadDeparture, user);
    done();
    expect(output).toEqual(expected);
  });
it('should Load the flight with the flightId', (done) => {
    let output = saga.next(departure).value;
    let expected = call(loadFlight, departure.flightID);
    done();
    expect(output).toEqual(expected);
  });
it('should load the forecast with the departure date', (done) => {
      output = saga.next(flight).value;
      let expected = call(loadForecast, departure.date);
      done();
      expect(output).toEqual(expected);
    });
it('should put Fetch dashboard success', (done) => {
       output = saga.next(forecast, departure, flight ).value;
       let expected = put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast, flight, departure}});
       const finished = saga.next().done;
       done();
       expect(finished).toEqual(true);
       expect(output).toEqual(expected);
    });
});

Chúng ta cần 1 số lưu ý ở đây:

  • Đảm bảo import tất cả các effect và các helpers mà chúng ta sẽ test.
  • Khi lưu một giá trị vào yeild, cần truyền dữ liệu mô phỏng (mock) tới function tiếp theo. Hãy chú ý tới ví dụ 3, 4, 5 phía trên.
  • Generator du chuyển đến dòng tiếp theo sau yeild khi next() được gọi. Đây là lí do vì sao chúng ta sử dụng saga.next().value ở đây.
  • Trình tự bên trong saga là 1 set ràng buộc, nếu thay đổi các bước bên trong, test sẽ fail.

Kết luận

So sánh với Thunk, tiếp cận Redux-Saga khó khăn hơn 1 chút, do chúng ta cần hiểu về generator đồng thời cần phải hiểu các khái niệm mới mà saga đưa ra. Thế nhưng sau khi làm quen với Saga, những hiệu quả mà nó mang lại thật sự khác biệt. Việc tách side effect hoàn toàn khỏi action khiến việc testing và maintain trở nên dễ dang hơn.

Source

https://wecodetheweb.com/2016/10/01/handling-async-in-redux-with-sagas/ https://medium.freecodecamp.com/async-operations-using-redux-saga-2ba02ae077b3 https://github.com/andresmijares/async-redux-saga http://async-redux-saga.surge.sh/