作者簡介 joey 螞蟻金服·資料體驗技術團隊
專案中一直使用redux-saga來處理非同步action的流程。對於effect的實現原理感到很好奇。抽空去研究了一下他的實現。本文不會描述redux-saga的基礎API和優點,單純聊實現原理,歡迎大家在評論區留言討論。
前言
redux-saga監聽action的程式碼如下:
import { takeEvery } from 'redux-saga';
function* mainSaga() {
yield takeEvery('action_name', function* (action) {
console.log(action);
});
}
複製程式碼
用generator究竟是怎麼實現takeEvery
的呢?我們先來看稍微簡單一點的take
的實現原理:
take實現原理
我們嘗試寫一個demo,用saga的方式實現用generator監聽action。
$btn.addEventListener('click', () => {
const action =`action data${i++}`;
// trigger action
}, false);
function* mainSaga() {
const action = yield take();
console.log(action);
}
複製程式碼
要在$btn
點選時候,能夠讀到action的值。
channel
這裡我們需要引入一個概念——channel
。
channel是對事件源的抽象,作用是先註冊一個take方法,當put觸發時,執行一次take方法,然後銷燬他。
channel的簡單實現如下:
function channel() {
let taker;
function take(cb) {
taker = cb;
}
function put(input) {
if (taker) {
const tempTaker = taker;
taker = null;
tempTaker(input);
}
}
return {
put,
take,
};
}
const chan = channel();
複製程式碼
我們利用channel做generator和dom事件的連線,將dom事件改寫如下:
$btn.addEventListener('click', () => {
const action =`action data${i++}`;
chan.put(action);
}, false);
複製程式碼
當put觸發時,如果channel裡已經有註冊了的taker,taker就會執行。
我們需要在put觸發之前,先呼叫channel的take方法,註冊實際要執行的方法。
我們繼續看mainSaga裡的實現。
function* mainSaga() {
const action = yield take();
console.log(action);
}
複製程式碼
這個take是saga裡的一種effect型別。
先看effecttake()
的實現。
function take() {
return {
type: 'take'
};
}
複製程式碼
出乎意料,僅僅返回了一個帶型別的object。
其實redux-saga裡所有effect返回的值,都是一個帶型別的純object物件。
那究竟是什麼時候觸發channel的take方法的呢?還需要從呼叫mainSaga的程式碼上找原因。
generator的特點是執行到某一步時,可以把控制權交給外部程式碼,由外部程式碼拿到返回結果後,決定該怎麼做。
task
這裡我們又要引入一個新的概念task
。
task
是generator方法的執行環境,所有saga的generator方法都跑在task裡。
task的簡易實現如下:
function task(iterator) {
const iter = iterator();
function next(args) {
const result = iter.next(args);
if (!result.done) {
const effect = result.value;
if (effect.type === 'take) {
runTakeEffect(result.value, next);
}
}
}
next();
}
task(mainSaga);
複製程式碼
當yield take()
執行時,將take()
返回的結果交給外層的task,此時程式碼的控制權就已經從gennerator方法中轉到了task裡了。
result.value
的值就是take()
返回的結果{ type: 'take' }
。
再看runTakeEffect
的實現:
function runTakeEffect(effect, cb) {
chan.take(input => {
cb(input);
});
}
複製程式碼
到這裡,我們終於看到呼叫channel的take方法的地方了。
完整程式碼如下:
function channel() {
let taker;
function take(cb) {
taker = cb;
}
function put(input) {
if (taker) {
const tempTaker = taker;
taker = null;
tempTaker(input);
}
}
return {
put,
take,
};
}
const chan = channel();
function take() {
return {
type: 'take'
};
}
function* mainSaga() {
const action = yield take();
console.log(action);
}
function runTakeEffect(effect, cb) {
chan.take(input => {
cb(input);
});
}
function task(iterator) {
const iter = iterator();
function next(args) {
const result = iter.next(args);
if (!result.done) {
const effect = result.value;
if (effect.type === 'take') {
runTakeEffect(result.value, next);
}
}
}
next();
}
task(mainSaga);
let i = 0;
$btn.addEventListener('click', () => {
const action =`action data${i++}`;
chan.put(action);
}, false);
複製程式碼
整體流程就是,先通過mainSaga往channel裡註冊了一個taker,一旦dom點選發生,就觸發channel的put,put會消耗掉已經註冊的taker,這樣就完成了一次點選事件的監聽過程。
takeEvery實現原理
在上一節中,我們已經模仿saga實現了一次事件監聽,但是還是有問題,我們只能監聽一次點選,怎麼能做到監聽每次點選事件呢?redux-saga提供了一個helper方法——takeEvery
。我們嘗試在我們的簡易版saga中實現一下takeEvery
。
function* takeEvery(worker) {
yield fork(function* () {
while(true) {
const action = yield take();
worker(action);
}
});
}
function* mainSaga() {
yield takeEvery(action => {
$result.innerHTML = action;
});
}
複製程式碼
這裡用到了一個新的effect方法fork
。
fork
fork的作用是啟動一個新的task,不阻塞原task執行。程式碼修改如下:
function fork(cb) {
return {
type: 'fork',
fn: cb,
};
}
function runForkEffect(effect, cb) {
task(effect.fn || effect);
cb();
}
function task(iterator) {
const iter = typeof iterator === 'function' ? iterator() : iterator;
function next(args) {
const result = iter.next(args);
if (!result.done) {
const effect = result.value;
// 判斷effect是否是iterator
if (typeof effect[Symbol.iterator] === 'function') {
runForkEffect(effect, next);
} else if (effect.type) {
switch (effect.type) {
case 'take':
runTakeEffect(effect, next);
break;
case 'fork':
runForkEffect(effect, next);
break;
default:
}
}
}
}
next();
}
複製程式碼
我們通過新增了一種新的effectfork
,啟動了一個新的task takeEvery。
takeEvery的作用就是當channel的put發生後,自動往channel裡放進一個新的taker。
我們實現的channel裡同時只能有一個taker,while(true)
的作用就是每當一個put觸發消耗掉了taker後,就自動觸發runTakeEffect
中傳入的task的next方法,再次往channel裡放進一個taker,從而做到源源不斷地監聽事件。
effect的本質
通過上文的實現,我們發現所有的yield後返回的effect,都是一個純object,用來給generator外層的執行容器task傳送一個訊號,告訴task該做什麼。
基於這種思路,如果我們要新增一個effect,來cancel task,也可以很容易實現。
首先我們先定義一個cancel
方法,用來傳送cancel的訊號。
function cancel() {
return {
type: 'cancel'
};
}
複製程式碼
然後修改task的程式碼,讓他能真正執行cancel的邏輯。
function task(iterator) {
const iter = typeof iterator === 'function' ? iterator() : iterator;
...
function runCancelEffect() {
// do some cancel logic
}
function next(args) {
const result = iter.next(args);
if (!result.done) {
const effect = result.value;
if (typeof effect[Symbol.iterator] === 'function') {
runForkEffect(effect, next);
} else if (effect.type) {
switch (effect.type) {
case 'cancel':
runCancelEffect();
case 'take':
runTakeEffect(result.value, next);
break;
case 'fork':
runForkEffect(result.value, next);
break;
default:
}
}
}
}
next();
}
複製程式碼
小結
本文通過簡單實現了幾個effect方法來地介紹了redux-saga的原理,要真正做到redux-saga的所有功能,只需要再新增一些細節就可以了。大概如下圖所示:
對generator使用有興趣的同學推薦學習一下redux-saga
原始碼。在此推薦一篇使用generator實現dom事件監聽的文章 繼續探索JS中的Iterator,兼談與Observable的對比
感興趣的同學可以關注專欄或者傳送簡歷至'chaofeng.lcf####alibaba-inc.com'.replace('####', '@'),歡迎有志之士加入~