[譯]如何讓你的React應用完全的函式式,響應式,並且能處理所有令人發狂的副作用

玄學醬發表於2017-10-17
本文講的是[譯] 如何讓你的 React 應用完全的函式式,響應式,並且能處理所有令人發狂的副作用,

如何讓你的 React 應用完全的函式式,響應式,並且能處理所有令人發狂的副作用

1*lD7IVk_sCcOcgVDOJPn7cA.jpeg

函式響應式程式設計 (FRP) 是一個在最近獲得了無數關注的程式設計正規化,尤其是在 JavaScript 前端領域。它是一個有很多含義的術語,卻描述了一個極為簡單的想法:

所有的事物都應該是純粹的以便於測試和推理 (函式式),並且使用隨時變化的值給非同步行為建模 (響應式)

React 本身並非完全的函式式,也不是完全的響應式。但是它受到了一些來自 FRP 背後理念的啟發。例如 函式式元件 就是一些依賴他們 props 的純函式。 並且 他們響應了 prop 和 state 的變化.
(譯者注:無狀態元件只接收 props ,這裡的 state 應該是指父元素的)

但是一談到副作用的處理(side effects),僅作為檢視層的 React 就需要一些其他庫的幫助了,比如說Redux

在這篇文章裡我會談談 redux-cycles,它是一個 Redux 中介軟體,藉助 Cycle.js 框架的優勢,幫助你以一種函式式和響應式的方法處理你 React 應用中的副作用和非同步程式碼,這是一個尚未被其他 Redux 副作用模型共享的特徵。

1*G_eskQOkhm6nv-NDylvbjw.jpeg

什麼是副作用?

副作用即是改變了外部世界的行為。你的應用裡所有發出 HTTP 請求,寫入 localStorage 的操作,或者甚至操作 DOM 都被認為是副作用。

副作用是不好的,他們很難去測試,維護起來很複雜,並且通常你的 bug 都出現在這裡。因此你的目標就是最小化或者定位他們。

1*GENmEdK1Rq2dB6H4uxzVNw.jpeg

“由於有副作用的存在,一個程式的行為依賴於歷史記錄,即程式碼執行的順序,因為理解一個有效的程式需要考慮到所有可能的歷史記錄,副作用經常會使一個程式很難理解。” — Norman Ramsey

以下是幾種現今用來處理 Redux 中的副作用比較流行的方法:

  1. redux-thunk — 將你有副作用的程式碼放在 action creators 中
  2. redux-saga — 使用 saga 宣告你的副作用邏輯
  3. redux-observable — 使用響應式程式設計來給副作用建模

然而問題是以上方法中沒有一個既是純函式式的又是響應式的。他們中有的(redux-saga)是純函式有些(redux-observable)則是響應式的,但是沒有一個擁有我們前文介紹的 FRP 所擁有的所有的概念。

Redux-cycles 既是純函式又是響應式的

1*KJuc4SE0zrxXuxBrfOpGjA.png

首先我們會更詳細地解釋這些函式式和響應式的概念以及為什麼你需要關心這些,然後會詳細介紹 redux-cycles 是如何工作的。


使用 Cycle.js 以純函式的方式處理副作用

HTTP 請求大概是最常見的副作用了。下面是一個使用 redux-thunk 發出 HTTP 請求的例子:

function fetchUser(user) {
  return (dispatch, getState) => 
  fetch(`https://api.github.com/users/${user}`)
}

這個函式是命令式的。雖然它返回了一個 promise 並且你可以使用其他 promises 來鏈式呼叫它,但是 fetch() 已經執行了,在這個特定時刻它已經不是一個純函式了。

這同樣適用於 redux-observable:

const fetchUserEpic = action$ =>
  action$.ofType(FETCH_USER)
    .mergeMap(action =>
  ajax.getJSON(`https://api.github.com/users/${action.payload}`)
        .map(fetchUserFulfilled)
    );

ajax.getJSON() 使得這段程式碼是命令式的。

為了保證一個 HTTP 請求是純粹的,你不應該去想“立刻傳送一個 HTTP 請求”而是應該“描述一下我希望 HTTP 請求是什麼樣的”並且不要擔心它何時發出去或者誰呼叫了它

這就是你在 Cycle.js 中編寫所有程式碼的本質。你使用這個框架所做的每件事都是建立你想做某事的描述。這些描述之後會被髮送給那些實際關心 HTTP 請求的 drivers (通過響應式資料流)。

function main(sources) {
  const request$ = xs.of({
    url: `https://api.github.com/users/foo`,
  });

  return {
    HTTP: request$
  };
}

就像你在上面這個程式碼片段中看到的,我們並沒有呼叫函式去發出請求。如果你執行這段程式碼你會發現請求立即就發出了,那麼背後究竟發生了什麼呢?

神奇之處就在於 drivers。當你的函式返回了一個包含 HTTP 鍵值的物件時,Cycle.js 知道需要處理它從資料流收到的訊息,並且執行相應的 HTTP 請求(通過 HTTP driver)。

1*2eF9bIE5BQExjIg1navQ-Q.png

關鍵的一點是,你雖然沒有擺脫副作用,HTTP 請求依然要發出,但是你將它定位在了你的應用程式碼之外

你的函式更加容易理解,尤其是更容易測試,因為你只要測試你的函式是否發出了正確的訊息,不需要浪費那些無用的 mock 時間。

響應式副作用

在之前的例子裡我們提到了響應式。這需要有一種和這些 drivers 溝通“在外部世界做某事”和被告知“外部世界有某事已經發生了”的方式。

Observables (aka streams) 是對於這類非同步互動的完美抽象。

1*Y9HjN7iA7k6QQm_l7MaP9w.png

每當你想“做某事”時,你會向輸出流發出你想做什麼的描述。在 Cycle.js 裡這些輸出流被稱作sinks

每當你想“被通知某事”你只要使用一個輸入流(被稱作sources)並且遍歷一次流的值就能知道發生了什麼。

這形成一種 反應式 迴圈,相比於一般的命令式程式碼,你需要一個不同的思維來理解它。
讓我們使用這個範例來建模一個HTTP請求/響應生命週期:

function main(sources) {
  const response$ = sources.HTTP
    .select(`foo`)
    .flatten()
    .map(response => response);

  const request$ = xs.of({
    url: `https://api.github.com/users/foo`,
    category: `foo`,
  });

  const sinks = {
  HTTP: request$
  };
  return sinks;
}

HTTP driver 知道這個函式返回的 HTTP 鍵值。這是一個包含請求 GitHub 連結的 HTTP 請求流描述。它正在告訴 HTTP driver :“我想要請求這個地址”。

之後這個 dirver 知道要執行請求,並且將返回值作為 sources(sources.HTTP)返回給 main 函式 — 注意 sinks 和 sources 使用相同的鍵值。

讓我們再解釋一次:我們用 sources.HTTP 來 “被通知 HTTP 已經返回了”,並且我們返回了sinks.HTTP 來“傳送 HTTP請求”

這裡有一個動畫來解釋這一重要的響應式迴圈:

1*RfpxAyyI0h0itIABMZ9TfA.gif

相比於一般的指令式程式設計,這似乎是反直覺的:為什麼讀取響應值的程式碼在發出請求的程式碼之前?

這是因為在 FRP 中程式碼在哪是不重要的。所有你要做的就是傳送描述,並且監聽變化,程式碼的順序並不重要。

這使得程式碼非常容易重構。


介紹 redux-cycles

1*_iikpPfUOR9f04iFGDJQLA.png

此時你可能會問,所有的這些和我的 React 應用有什麼關係?

僅僅通過寫一些你想做某事的描述,你已經學習到了使用純函式的優勢,並且學習了用觀察者去和外部世界交流的優勢。

現在,你將看到如何在你當前的 React 應用裡使用這些概念去變成完全的函式式和響應式。

攔截並且排程 Redux 行為

使用 Redux 時你需要 dispatch actions 來告訴你的 reducers 你需要一個新的state。

這是一個同步的流程,意味著一旦你想執行非同步行為(為了副作用)你需要使用一些中介軟體來攔截這些 actions,相應的,你要觸發其他的 actions 來執行這個非同步副作用。

這正是 redux-cycles 所做的。它是一箇中介軟體,攔截了 redux actions 後進入 Cycle.js 的響應式迴圈,並且允許你使用 drivers 去執行其他副作用。然後它基於你函式裡的非同步資料流描述 dispatch 一個新的 action。

function main(sources) {
  const request$ = sources.ACTION
    .filter(action => action.type === FETCH_USER)
    .map(action => ({
      url: `https://api.github.com/users/${action.payload}`,
      category: `users`,
    }));

  const action$ = sources.HTTP
    .select(`users`)
    .flatten()
    .map(fetchUserFulfilled);

  const sinks = {
  ACTION: action$,
    HTTP: request$
  };
  return sinks;
}

在上面這個例子裡有一個新的 source 和 sink – ACTION。但是資料通訊的模式是一致的。

它使用 sources.ACTION 來監聽被 Redux 呼叫的 actions。並且通過返回 sinks.ACTION 來dispatch 新的 actions。

具體點說它是觸發了標準的 Flux Actions objects

最酷的事情是你可以結合其他 drivers 發生的事。在之前的例子裡 在 HTTP 域裡發生的事確實觸發了 ACTION 域,反之亦然

— 注意,與 Redux 的通訊完全通過 ACTION 的 source 和 sink。Redux-cycle 的 drivers 負責處理實際的 dispatch。

1*A30wroaUd6WiLjq5c-fxYw.gif

更復雜的應用程式?

如果只寫那些轉換資料流的純函式該如何開發一個複雜的應用呢?

使用已有的 drivers你已經可以做很多事了。或者你可以建立你自己的 drivers — 下面是一個簡單的 driver,它在控制檯上輸出了寫入其 sink 的訊息。

run(main, {
  LOG: msg$ => msg$.addListener({
    next: msg => console.log(msg)
  })
});

run 是 Cycle.js 的一部分,它執行你的 main 函式(第一個引數)並且傳入其他所有的 drivers(第二個引數)。

Redux-cycles 推薦了兩個你可以和 Redux 通訊的 drivers, makeActionDriver() &makeStateDriver():

import { createCycleMiddleware } from `redux-cycles`;

const cycleMiddleware = createCycleMiddleware();
const { makeActionDriver, makeStateDriver } = cycleMiddleware;

const store = createStore(
  rootReducer,
  applyMiddleware(cycleMiddleware)
);

run(main, {
  ACTION: makeActionDriver(),
  STATE: makeStateDriver()
})

makeStateDriver() 是一個只讀的 driver。這意味著在你的 main 函式裡只能讀取sources.STATE。你不能讓它做什麼,只能從它讀取資料。

每當 Redux 的 state 發生了變化,sources.STATE 流就會觸發產生一個新的 state 物件。當你需要基於當前應用的資料寫一些特定邏輯時 非常有用

1

複雜的非同步資料流

1*7OmEwOnki2v-cR7mESwD7w.gif

響應式程式設計的另一個巨大優勢就是能夠使用運算子將流組成其他流,可以隨時將它們當做資料對待:你可以對它們進行 map filter 甚至 reduce 這些操作。

運算子使得顯式的資料流圖(即操作符之間的依賴邏輯)成為可能。允許你通過各種操作符將資料流視覺化,就像上面的動畫一樣。

Redux-observable 也允許你寫複雜的非同步流,他們用一個複雜的 WebSocket 例子作為它們的賣點,然而以純函式的方式編寫這些流才是 Cycle.js 真正區別於其他方式的強大之處。

由於一切都是純資料流,我們可以想象到未來的程式設計將只是將操作符塊連線到一起。

使用彈子圖(marble diagrams)測試

1*2uZuH38HrfZwZNgjJB3eNg.png

最後但也值得關注的是測試。這才是 redux-cycles(和通常所有的 Cycle.js 應用一樣)真正閃耀的地方。

因為你的應用程式碼裡都是純函式,要測試你的主要功能,你只需要將其作為輸入流,並將特定流作為輸出即可。

使用這個很棒的 @cycle/time 專案,你甚至可以畫一個 彈子圖 並且以一種視覺化的方式去測試你的函式:

assertSourcesSinks({
  ACTION: { `-a-b-c----|`: actionSource },
  HTTP:   { `---r------|`: httpSource },
}, {
  HTTP:   { `---------r|`: httpSink },
  ACTION: { `---a------|`: actionSink },
}, searchUsers, done);

這段程式碼 執行了 searchUsers 函式,將特定源作為輸入(以第一個引數的方式)。給定的這些 sources 期望函式返回所提供的 sinks(以第二個引數的方式)。如果不是,斷言就會失敗。

當你需要測試非同步行為時,以圖形的方式定義流特別有用。

當 HTTP 源發出一個 r (響應),你會立刻看到 a(action)出現在 ACTION sink 中 — 他們同時發生。然而,當 ACTION source 發出一段 -a-b-c,你不要指望此時 HTTP sink 會發生什麼。

這是因為 searchUsers 去抖了他接收到的 actions。它只會在 ACTION source 流停止活動 800 毫秒後傳送 HTTP 請求,這是一個自動完成的功能。

測試這種非同步行為對於純函式和響應式函式來說是微不足道的。

結論

在這篇文章裡我們介紹了 FRP 的真正力量。我們介紹了 Cycle.js 和它新穎的正規化。如果你想學習更多的關於 FRP 的知識,Cycle.js awesome list 是一個很重要的資源。

只使用 Cycle.js 本身而不使用 React 或者 Redux 可能有點痛苦, 但是如果你願意放棄一些來自 React 或 Redux 社群的技術和資源的話還是可以做到的。

另一方面,Redux-cycles 允許你繼續使用所有的偉大的 React 的內容並且使用 FRP 和 Cycles.js 使你更加輕鬆。

也十分感謝 Gosha Arinich 以及 Nick Balestra 和我一起維護這個專案,也謝謝 Nick Johnstone 校對這篇文章。





原文釋出時間為:2017年3月23日

本文來自雲棲社群合作伙伴掘金,瞭解相關資訊可以關注掘金網站。


相關文章