一.目標定位express
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.
做爲一個Redux中間件,想讓Redux應用中的反作用(即依賴/影響外部環境的不純的部分)處理起來更優雅redux
二.設計理念
Saga像個獨立線程同樣,專門負責處理反作用,多個Saga能夠串行/並行組合起來,redux-saga負責調度管理api
Saga來頭不小(1W star不是浪得的),是某篇論文中提出的一種分佈式事務機制,用來管理長期運行的業務進程promise
P.S.關於Saga背景的更多信息,請查看Background on the Saga concept併發
三.核心實現
利用generator,讓異步流程控制易讀、優雅、易測試app
In redux-saga, Sagas are implemented using Generator functions. To express the Saga logic we yield plain JavaScript Objects from the Generator.
實現上,關鍵點是:框架
以generator形式組織邏輯序列(function* + yield),把一系列的串行/並行操做經過yield拆分開異步
利用iterator的可「暫停/恢復」特性(iter.next())分步執行async
經過iterator影響內部狀態(iter.next(result)),注入異步操做結果分佈式
利用iterator的錯誤捕獲特性(iter.throw(error)),注入異步操做異常
用generator/iterator實現是由於它很是適合流程控制的場景,體如今:
yield讓描述串行/並行的異步操做變得很優雅
以同步形式獲取異步操做結果,更符合順序執行的直覺
以同步形式捕獲異步錯誤,優雅地捕獲異步錯誤
P.S.關於generator與iterator的關係及generator基礎用法,能夠參考generator(生成器)_ES6筆記2
例如:
const ts = Date.now(); function asyncFn(id) { return new Promise((resolve, reject) => { setTimeout(() => { console.log(`${id} at ${Date.now() - ts}`); resolve(id); }, 1000); }); } function* gen() { // 串行異步 let A = yield asyncFn('A'); console.log(A); let B = yield asyncFn('B'); console.log(B); // 並行異步 let C = yield Promise.all([asyncFn('C1'), asyncFn('C2')]); console.log(C); // 串行/並行組合異步 let D = yield Promise.all([ asyncFn('D1-1').then(() => { return asyncFn('D1-2'); }), asyncFn('D2') ]); console.log(D); } // test let iter = gen(); // 尾觸發順序執行iter.next let next = function(prevResult) { let {value: result, done} = iter.next(prevResult); if (result instanceof Promise) { result.then((res) => { if (!done) next(res); }, (err) => { iter.throw(err); }); } else { if (!done) next(result); } }; next();
實際結果符合預期:
A at 1002 A B at 2012 B C1 at 3015 C2 at 3015 ["C1", "C2"] D1-1 at 4019 D2 at 4020 D1-2 at 5022 ["D1-2", "D2"]
執行順序爲:A -> B -> C1,C2 -> D1-1 -> D2 -> D1-2
redux-saga的核心控制部分與上面示例相似(沒錯,就是這麼像co),從實現上看,其異步控制的關鍵是尾觸發順序執行iter.next。示例沒添Effect這一層描述對象,從功能上講Effect並不重要(Effect的做用見下面術語概念部分)
Effect層要實現的東西包括2部分:
業務操做 -> Effect
以Effect creator API形式提供,提供各類語義的用來生成Effect的工具函數,例如把dispatch action包裝成put、把方法調用包裝成call/apply
Effect -> 業務操做
在執行時內部進行轉換,例如把[Effect1, Effect2]轉換爲並行調用
相似於裝箱(把業務操做用Effect包起來)拆箱(執行Effect裏的業務操做),此外,完整的redux-saga還要實現:
做爲middleware接入到Redux
提供讀/寫Redux state的接口(select/put)
提供監聽action的接口(take/takeEvery/takeLatest)
Sagas組合、通訊
task順序控制、取消
action併發控制
…
差很少是一個大而全的異步流程控制庫了,從實現上看,至關於一個加強版的co
四.術語概念
Effect
Effect指的是描述對象,至關於redux-saga中間件可識別的操做指令,例如調用指定的業務方法(call(myFn))、dispatch指定action(put(action))
An Effect is simply an object which contains some information to be interpreted by the middleware.
Effect層存在的主要意義是爲了易測試性,因此用簡單的描述對象來表示操做,多這樣一層指令
雖然能夠直接yield Promise(好比上面核心實現裏的示例),但測試case中沒法比較兩個promise是否等價。因此添一層描述對象來解決這個問題,測試case中能夠簡單比較描述對象,實際起做用的Promise由redux-saga內部生成
這樣作的好處是單測中不用mock異步方法(通常單測中會把全部異步方法替換掉,只比較傳入參數是否相同,而不作實際操做),能夠簡單比較操做指令(Effect)是否等價。從單元測試的角度來看,Effect至關於把參數提出去了,讓「比較傳入參數是否相同」這一步能夠在外面統一進行,而不用逐個mock替換
P.S.關於易測試性的更多信息,請查看Testing Sagas
另外,mock測試不但比較麻煩,還不可靠,畢竟與真實場景/流程有差別。經過框架約束,多一層描述對象來避免mock
這樣作並不十分完美,還存在2個問題:
業務代碼稍顯麻煩(不直接yield promise/dispatch action,而都要用框架提供的creator(call, put)包起來)
有額外的學習成本(理解各個creator的語義,適應先包一層的玩法)
例如:
// 直接 const userInfo = yield API.fetch('user/info', userId); // 包一層creator const userInfo = yield call(API.fetch, 'user/info', userId); // 並指定context,默認是null const userInfo = yield call([myContext, API.fetch], 'user/info', userId);
形式上與fn.call相似(實際上也提供了一個apply creator,形式與fn.apply相似),內部處理也是相似的:
// call返回的描述對象(Effect) { @@redux-saga/IO: true, CALL: { args: ["user/info", userId], context: myContext, fn: fetch } } // 實際執行 result = fn.apply(context, args)
寫起來不那麼直接,但比起易測試性帶來的好處(不用mock異步函數),這不很過度
注意,不須要mock異步函數只是簡化了單元測試的一個環節,即使使用這種對比描述對象的方式,仍然須要提供預期的數據,例如:
// 測試場景直接執行 const iterator = fetchProducts() // expects a call instruction assert.deepEqual( iterator.next().value, call(Api.fetch, '/products'), "fetchProducts should yield an Effect call(Api.fetch, './products')" ) // 預期接口返回數據 const products = {} // expects a dispatch instruction assert.deepEqual( iterator.next(products).value, put({ type: 'PRODUCTS_RECEIVED', products }), "fetchProducts should yield an Effect put({ type: 'PRODUCTS_RECEIVED', products })" )
P.S.這種描述對象的套路,和Flux/Redux的action一模一樣:Effect至關於Action,Effect creator至關於Action Creator。區別是Flux用action描述消息(發生了什麼),而redux-saga用Effect描述操做指令(要作什麼)
Effect creator
redux-saga/effects提供了不少用來生成Effect的工具方法。經常使用的Effect creator以下:
阻塞型方法調用:call/apply 詳見Declarative Effects
非阻塞型方法調用:fork/spawn 詳見redux-saga’s fork model
並行執行task:all/race 詳見Running Tasks In Parallel,Starting a race between multiple Effects
讀寫state:select/put 詳見Pulling future actions
task控制:join/cancel/cancelled 詳見Task cancellation
大多creator語義都很直白,只有一個須要額外說明下:
join用來獲取非阻塞的task的返回結果
其中fork與spawn都是非阻塞型方法調用,兩者的區別是:
經過spawn執行的task徹底獨立,與當前saga無關
當前saga無論它執行完了沒,發生cancel/error也不會影響當前saga
效果至關於讓指定task獨立在頂層執行,與middleware.run(rootSaga)相似
經過fork執行的task與當前saga有關
fork所在的saga會等待forked task,只有在全部forked task都執行結束後,當前saga纔會結束
fork的執行機制與all徹底一致,包括cancel和error的傳遞方式,因此若是任一task有未捕獲的error,當前saga也會結束
另外,cancel機制比較有意思:
對於執行中的task序列,全部task天然完成時,把結果向上傳遞到隊首,做爲上層某個yield的返回值。若是task序列在處理過程當中被cancel掉了,會把cancel信號向下傳遞,取消執行全部pending task。另外,還會把cancel信號沿着join鏈向上傳遞,取消執行全部依賴該task的task
簡言之:complete信號沿調用鏈反向傳遞,而cancel信號沿task鏈正向傳遞,沿join鏈反向傳遞
注意:yield cancel(task)也是非阻塞的(與fork相似),而被cancel掉的任務在完成善後邏輯後會當即返回
P.S.經過join創建依賴關係(取task結果),例如:
function* rootSaga() { // Returns immediately with a Task object const task = yield spawn(serverHello, 'world'); // Perform an effect in the meantime yield call(console.log, "waiting on server result..."); // Block on the result of serverHello const result = yield join(task); }
Saga
術語Saga指的是一系列操做的集合,是個運行時的抽象概念
redux-saga裏的Saga形式上是generator,用來描述一組操做,而generator是個具體的靜態概念
P.S.redux-saga裏所說的Saga大多數狀況下指的都是generator形式的一組操做,而不是指redux-saga自身。簡單理解的話:在redux-saga裏,Saga就是generator,Sagas就是多個generator
Sagas有2種順序組合方式:
yield* saga() call(saga)
一樣,直接yield iterator運行時展開也面臨不便測試的問題,因此經過call包一層Effect。另外,yield只接受一個iterator,組合起來不很方便,例如:
function* saga1() { yield 1; yield 2; } function* saga2() { yield 3; yield 4; } function* rootSaga() { yield 0; // 組合多個generator不方便 yield* (function*() { yield* saga1(); yield* saga2(); })(); yield 5; } // test for (let val of rootSaga()) { console.log(val); // 0 1 2 3 4 5 }
注意:實際上,call(saga)返回的Effect與其它類型的Effect沒什麼本質差別,也能夠經過all/race進行組合
Saga Helpers
Saga Helper用來監聽action,API形式是takeXXX,其語義至關於addActionListener:
take:語義至關於once
takeEvery:語義至關於on,容許併發action(上一個沒完成也當即開始下一個)
takeLatest:限制版的on,不容許併發action(pending時又來一個就cancel掉pending的,只作最新的)
takeEvery, takeLatest是在take之上的封裝,take纔是底層API,靈活性最大,能手動知足各類場景
P.S.關於3者關係的更多信息,請查看Concurrency
pull action與push action
從控制方式上講,take是pull的方式,takeEvery, takeLatest是push的方式
pull與push是指:
pull action:要求業務方主動去取action(yeild take()會返回action)
push action:由框架從外部注入action(takeEvery/takeLatest註冊的Saga會被注入action參數)
pull方式的優點在於:
容許更精細的控制
好比能夠手動實現takeN的效果(只關注某幾回action,用完就釋放掉)
以同步形式描述控制流
takeEvery, takeLatest只支持單action,若是是action序列的話要拆開,用take能保留關聯邏輯塊的完整性,好比登陸/註銷
別人更容易理解
控制邏輯在業務代碼裏,而不是藏在框架內部機制裏,必定程度上下降了維護成本
P.S.關於pull/push的更多信息,請查看Pulling future actions
五.場景示例
有幾個印象比較深的場景,充分體現出了redux-saga的優雅
接口訪問
function* fetchProducts() { try { const products = yield call(Api.fetch, '/products') yield put({ type: 'PRODUCTS_RECEIVED', products }) } catch(error) { yield put({ type: 'PRODUCTS_REQUEST_FAILED', error }) } }
除了須要知道put表示dispatch action外,幾乎不須要什麼註釋,實際狀況就是你想的那樣
登陸/註銷 function* loginFlow() { while (true) { yield take('LOGIN') // ... perform the login logic yield take('LOGOUT') // ... perform the logout logic } }
pull action能保持關聯action的處理順序,而不須要額外外部狀態控制。這樣保證了LOGOUT老是在執行過LOGIN以後的某個時刻發生的,代碼看起來至關漂亮
特定操做提示 // 在建立第3條todo的時候,給出提示消息 function* watchFirstThreeTodosCreation() { for (let i = 0; i < 3; i++) { const action = yield take('TODO_CREATED') } yield put({type: 'SHOW_CONGRATULATION'}) } // 接口訪問異常重試 function* updateApi(data) { for(let i = 0; i < 5; i++) { try { const apiResponse = yield call(apiRequest, { data }); return apiResponse; } catch(err) { if(i < 4) { yield call(delay, 2000); } } } // attempts failed after 5 attempts throw new Error('API request failed'); }
即takeN的示例,這樣就把本應該存在於reducer中的反作用提到了外面,保證了reducer的純度
六.優缺點
優勢:
易測試,提供了各類case的測試方案,包括mock task,分支覆蓋等等
大而全的異步控制庫,從異步流程控制到併發控制應有盡有
完備的錯誤捕獲機制,阻塞型錯誤可try-catch,非阻塞型會通知所屬Saga
優雅的流程控制,可讀性/精煉程度不比async&await差多少,很容易描述並行操做
缺點:
體積略大,1700行,min版24KB,實際上併發控制等功能很難用到
依賴ES6 generator特性,可能須要polyfill
P.S.redux-saga也能夠接入其它環境(不與Redux綁定),詳細見Connecting Sagas to external Input/Output
參考資料
JavaScript Power Tools Part II: Composition Patterns In Redux-Saga
API Reference
Reference 6: A Saga on Sagas