[譯] Redux 的工作過程

liushanga發表於2018-01-09

Redux 的工作過程: 一個計數器例子

在學習了一些 React 後開始學習 Redux,Redux 的工作過程讓人感到很困惑。

Actions,reducers,action creators(Action 建立函式),middleware(中介軟體),pure functions(純函式),immutability(不變性)…

這些術語看起來非常陌生。

所以在這篇文章中我將用一種有利於大家理解的反向剖析的方法去揭開 Redux 怎樣工作的神祕面紗。在 上一篇 中,在提出專業術語之前我將嘗試用簡單易懂的語言去解釋 Redux。

如果你還不明確 Redux 是幹什麼的 或者為什麼要使用它,請先移步 這篇文章 然後再回到這裡繼續閱讀。

第一:明白 React 的狀態 state

我們將從一個簡單的使用 React 狀態的例子開始,然後一點一點地新增Redux。

這是一個計數器:

計數器元件

這裡是程式碼 (為了使程式碼簡單我沒有貼出 CSS 程式碼,所以下面程式碼的效果會不會像上面圖片一樣美觀):

import React from 'react';

class Counter extends React.Component {
  state = { count: 0 }

  increment = () => {
    this.setState({
      count: this.state.count + 1
    });
  }

  decrement = () => {
    this.setState({
      count: this.state.count - 1
    });
  }

  render() {
    return (
      <div>
        <h2>Counter</h2>
        <div>
          <button onClick={this.decrement}>-</button>
          <span>{this.state.count}</span>
          <button onClick={this.increment}>+</button>
        </div>
      </div>
    )
  }
}

export default Counter;
複製程式碼

簡單的看一下他是怎樣跑起來的:

  • 這個 count 狀態被儲存在最外層元件 Counter 裡面
  • 當使用者點選 “+”,這個按鈕的 onClick 回撥函式被觸發, 也就是元件 Counter 裡面的 increment 方法被呼叫。
  • increment 方法用新的數字更新狀態 count。
  • 由於狀態被改變了, React 重新渲染 Counter 元件 (還有它的子元件), 然後顯示新的計數器的值.

如果你想要了解更多的狀態怎麼被改變的細節,去閱讀 React 中狀態的圖形化指南 然後再回到這裡。嚴格來講:如果上面的例子沒有幫助你回顧起 React 的 state ,那麼在你學習 Redux 之前應該去學習 React 的 state 是怎麼工作的。

快速開始

如果你想通過程式碼學習,現在就建立一個專案:

  • 如果你之前沒有安裝 create-react-app ,那麼先安裝 (npm install -g create-react-app)
  • 建立一個專案: create-react-app redux-intro
  • 開啟 src/index.js 然後用下面的程式碼進行替換:
import React from 'react';
import { render } from 'react-dom';
import Counter from './Counter';

const App = () => (
  <div>
    <Counter />
  </div>
);

render(<App />, document.getElementById('root'));
複製程式碼
  • 用上面的計數器程式碼建立一個 src/Counter.js

現在: 新增 Redux

第一部分中討論到,Redux 儲存應用程式的狀態 state 在單一的狀態樹 store中。然後你可以將 state 的部分抽離出來,然後以 props 的方式傳入元件。這使你可以把資料儲存在一個全域性的位置(狀態樹 store )然後將其注入到應用程式中的任何一個元件中,而不用通過多層級的屬性傳遞。

注意:你可能經常看到 “state” 和 “store” 混著使用,但是嚴格來講: state是資料,而 store 是資料儲存的地方。

我們接著往下走,利用你的編輯器繼續編輯我們下面的程式碼,它將幫助你理解 Redux 怎麼工作(我們通過講解一些錯誤來繼續)。

新增 Redux 到你的專案中:

$ yarn add redux react-redux
複製程式碼

redux vs react-redux

等等 — 這是兩個庫嗎?你可能會問 “react-redux 是什麼”?對不起,我一直在騙你。

你看,redux 給了你一個狀態樹 store,讓你可以把狀態 state 存在裡面,然後可以把狀態取出來,當狀態改變的時候可以做出響應。然而這是他它做的所有事。實際上正是 react-redux 將 state 與 React 元件聯絡起來。實際上:redux 和 React 一點兒也沒有關係。

這些庫就像豌豆莢裡面的兩粒豌豆,99.999% 的時候當有人在 React 的背景下提到 “Redux” 的時候,他們指的是這兩個庫。所以記住:當你在 StackOverflow 或者 Reddit 或者其它任何地方看到 Redux 時,他指的是這兩個庫。

最後一件事

大多數教程一開始就建立一個 store 狀態樹,設定 Redux,寫一個 reducer,等等,出現在螢幕上的任何效果在展現出來之前都會經過大量的操作。

我將採用一種反向推導的方法,使用同樣多的程式碼展現出同樣的效果。但是希望每一個步驟後面的原理都能展現地更加清楚。

回到計數器的應用程式,我們把元件的狀態轉移到 Redux。

我們把狀態從元件裡面移除,因為我們很快可以從 Redux 中獲取它們:

import React from 'react';

class Counter extends React.Component {
  increment = () => {
    // 後面填充
  }

  decrement = () => {
    // 後面填充
  }

  render() {
    return (
      <div>
        <h2>Counter</h2>
        <div>
          <button onClick={this.decrement}>-</button>
          <span>{this.props.count}</span>
          <button onClick={this.increment}>+</button>
        </div>
      </div>
    )
  }
}

export default Counter;
複製程式碼

計數器的流程

我們注意到 {this.state.count} 改變成了 {this.props.count}。當然這不會起作用,因為計數器元件還沒有接受 count 屬性,我們通過 Redux 注入這個屬性。

為了從 Redux 中獲得狀態 count,我們需要在模組的頂部匯入 connect 方法:

import { connect } from 'react-redux';
複製程式碼

然後接下來我們需要 “connect” 計數器元件到 Redux 中:

// 新增這個函式:
function mapStateToProps(state) {
  return {
    count: state.count
  };
}

// 然後這樣替換:
// 預設匯出計數器元件;

// 這樣匯出:
export default connect(mapStateToProps)(Counter);
複製程式碼

這將發生錯誤 (在第二部分會有更多錯誤)。

以前我們匯出函式本身,現在我們把它用 connect 函式包裝後呼叫。

什麼是 connect

你可能注意到這個函式呼叫看起來有一些奇怪。為什麼是 connect(mapStateToProps)(Counter) 而不是 connect(mapStateToProps, Counter) 或者 connect(Counter, mapStateToProps)?這將發生什麼呢?

之所以這樣寫是因為 connect 是一個高階函式,當你呼叫它的時候會返回一個函式,然後用一個元件做引數呼叫那個函式返回一個新的包裝過的元件。

返回的元件另一個名字叫做高階元件 (又叫做 “HOC”)。高階元件被指責有很多的缺點,但是他們仍然非常有用,connect 就是一個很好的例子。

connect 連線整個狀態到了Redux,通過你自己提供的 mapStateToProps 函式, 這需要一個自定義的函式因為只有你自己知道狀態在 Redux 中的模型。

connect 連線了所有的狀態,“嘿,告訴我你需要從混亂的狀態中得到什麼”。

mapStateToProps 函式中返回的狀態作為屬性注入到你的元件中。上面例子中的 state.count 作為 count 屬性:物件中的鍵名作為屬性名,它們對應的值作為屬性的值。所以你看,從函式的字面意思上是定義了狀態到屬性的對映

錯誤意味著有進展!

程式碼進行到這裡,你會在控制檯裡面看到下面的錯誤:

Could not find “store” in either the context or props of “Connect(Counter)”. Either wrap the root component in a , or explicitly pass "store" as a prop to "Connect(Counter)".

因為 connect 從 Redux store 樹裡面獲取狀態,而我們還沒有建立狀態樹或者說告訴 app 怎樣去找到 store 樹,這是一個合乎邏輯的錯誤,Redux 還不知道現在發生了什麼事。

提供一個狀態樹 store

Redux 控制著整個 app 的全部狀態,通過 react-redux 裡面的 Provider 元件包裹著整個 app,app 裡面的每一個元件都可以通過 connect 去進入到 Redux store 裡面獲取狀態。

這意味著最外圍的 App 元件,以及 App 的子元件(像 Counter),甚至他們子元件的子元件等等,所有的元件都可以訪問狀態樹 store,只要把他們通過 connect 函式呼叫。

我不是說要把每一個元件都用 connect 函式呼叫,那是一個很糟糕的做法(設計混亂而且太慢了)。

Provider 看起來很具有魔性,實際上在掛載的時候使用了 React 的 “context” 特性。

Provider 就像一個祕密通道連線到了每一個元件,使用 connect 開啟了通向每一個元件的大門。

想象一下,把糖漿倒在一堆煎餅上,假如你只把糖漿倒在了最上面的煎餅上,怎麼才能讓所有的煎餅都能蘸到糖漿呢。 Provider 為 Redux 做了這件事。

在檔案 src/index.js中,匯入 Provider 元件並且用它來包裹 App 元件的內容。

import { Provider } from 'react-redux';
...

const App = () => (
  <Provider>
    <Counter/>
  </Provider>
);
複製程式碼

我們仍然會遇到報錯,因為 Provider 需要一個 store 狀態樹才能起作用,它會把 store 作為屬性,所以我們首先需要建立一個 store。

建立一個 store

Redux 使用一個方便的函式來建立 stores,這個函式就是 createStore。好了,現在讓我們來建立一個 store 然後把它作為屬性傳入 Provider 元件:

import { createStore } from 'redux';

const store = createStore();

const App = () => (
  <Provider store={store}>
    <Counter/>
  </Provider>
);
複製程式碼

又產生了另外一個不同的錯誤:

Expected the reducer to be a function.

現在是 Redux 的問題了,Redux 不是那麼的智慧,你可能希望建立一個 store,它就會從 store 中 給你一箇中很好的預設的值,哪怕是一個空物件?

但是絕不會這樣,Redux 不會對你的狀態的組成做出任何的猜測,狀態的組成結構完全取決於你自己。他可以是一個物件, 一個數字, 一個字串, 或者是你需要的任何形式。所以我們必須提供一個函式去返回這個狀態,這個函式就叫做reducer(後面會解釋為什麼這麼命名)。讓我們來看看函式最簡單的情況,將它作為函式 createStore 的引數,看看會發生什麼:

function reducer() {
  // just gonna leave this blank for now
  // which is the same as `return undefined;`
}

const store = createStore(reducer);
複製程式碼

Reducer 必須要有返回值

又產生了另外的錯誤:

Cannot read property ‘count’ of undefined

產生這個錯誤是因為我們試圖去取得 state.count,但是 state 卻沒有定義。Redux 希望 reducer 函式為 state 返回一個值,而不是返回一個 undefined

reducer 函式應該返回一個狀態,實際上它應該用利用當前狀態去返回新的狀態

讓我們用 reducer 函式去返回滿足我們需要的狀態形式:一個含有 count 屬性的物件。

function reducer() {
  return {
    count: 42
  };
}
複製程式碼

嘿!這個 count 現在顯示為 “42”,神奇吧。

只是有一個問題:count 一直顯示為42。

目前為止

在我們進一步瞭解怎麼更新計數器的值之前,我們先來了解一下到目前為止我們做了些什麼:

  • 我們寫了一個 mapStateToProps 函式,該函式的作用是:把 Redux 中的狀態轉換成一個包含屬性的物件。
  • 我們用模組 react-redux 中的函式 connect 把 Redux store 狀態樹和 Counter 元件連線起來,使用 mapStateToProps 函式配置了怎麼聯絡。
  • 我們建立了一個 reducer 函式去告訴 Redux 我們的狀態應該是什麼形式的。
  • 我們使用 reducercreateStore 函式的引數,用它建立了一個 store。
  • 我們把整個元件包裹在了 react-redux 中的元件 Provider 中,向該元件傳入了 store 作為屬性。
  • 這個程式工作的很好,唯一的問題是計數器顯示停留在了42。

你跟著我做到現在了嗎?

互動起來 (讓計數器工作)

我知道到目前為止我們的程式是很差勁的,你們已經寫了一個顯示著數字 “42” 和兩個無效的按鈕的靜態的 HTML 頁面,不過你還在繼續閱讀,接下來將繼續用 React 和 Redux 和其它的一些東西讓我們的程式變得複雜起來。

我保證接下來做的事情會讓上面做的一切都值得。

事實上,我收回剛才那句話,一個簡單的計數器的例子是一個很好的教學例子,但是 Redux 讓應用變得複雜了,React 的 state 應用起來其實也很簡單,甚至一般的 JS 程式碼也能夠實現的很好,挑選正確的工具做正確的事,Redux 不總是那個合適的工具,不過我偏題了。

初始化狀態

我們需要一個方式去告訴 Redux 改變計數器的值。

還記得我們寫的 reducer 函式嗎?(當然你肯定記得,因為那是兩分鐘之前的事)。

還記得我說過它會使用當前狀態返回新的狀態嗎?好的,我再重複一次,實際上,它使用當前狀態和一個 action 作為引數,然後返回一個新的狀態,我們應該這樣寫:

function reducer(state, action) {
  return {
    count: 42
  };
}
複製程式碼

Redux 第一次呼叫這個函式的時候會以 undefined 作為實參替代 state,意味著返回的是初始狀態,對於我們來說,可能返回的是一個屬性 count 值為 0 的物件。

在 reducer 上面寫初始狀態是很常見的,當 state 引數未定義的時候,使用 ES6 的預設引數的特性為 state 引數提供一個引數。

const initialState = {
  count: 0
};

function reducer(state = initialState, action) {
  return state;
}
複製程式碼

這樣子試試呢,程式碼仍然會起作用,不過現在計數器停留在了 0 而不是 42,多麼讓人驚訝。

Action

我們最後談談 action 引數,這是什麼呢?它來自哪裡呢? 我們怎麼用它去改變不變的 counter 呢?

一個 “action” 是一個描述了我們想要改變什麼的 JS 物件,為一個要求就是物件必須要有一個 type 屬性,它的值應該是一個字串,這裡有一個例子:

{
  type: "INCREMENT"
}
複製程式碼

這是另外一個例子:

{
  type: "DECREMENT"
}
複製程式碼

你的大腦在快速運轉嗎?你知道接下來我們要做什麼嗎?

對 Actions 做出響應

還記得 reducer 的作用是用當前狀態和一個action去計算出新的狀態吧。所以如果一個 reducer 接受了一個 action 例如 { type: "INCREMENT" },你想要返回什麼作為新的狀態呢?

如果你像下面這樣想,那麼你就想對了:

function reducer(state = initialState, action) {
  if(action.type === "INCREMENT") {
    return {
      count: state.count + 1
    };
  }

  return state;
}
複製程式碼

使用 switch 語句和 case 語句處理每一個 action 是很常見的寫法把你的 reducer 函式寫成下面這樣子:

function reducer(state = initialState, action) {
  switch(action.type) {
    case 'INCREMENT':
      return {
        count: state.count + 1
      };
    case 'DECREMENT':
      return {
        count: state.count - 1
      };
    default:
      return state;
  }
}
複製程式碼

總是返回一個狀態

你會注意到函式預設返回的是 return state。這很重要,因為 action 不知道要做什麼,Redux 通過 action 去呼叫你的 reducer 函式。實際上 你接受的第一個 action 是 { type: "@@redux/INIT" }。試著在 switch 前面寫一個 console.log(action) 看看會列印出什麼。

還記得 reducer 的工作是返回一個新狀態吧,即使當前狀態沒有發生改變也要返回。 你不想從 “有一個狀態” 變成 “state = undefined” 吧? 在你忘了 default 情況的時候就會發生這樣的事,不要這樣做。

永遠不要改變狀態

永遠不要去做這件事:不要改變 state。State 是不可變的。你不可以改變它,意味著你不能這樣做:

function brokenReducer(state = initialState, action) {
  switch(action.type) {
    case 'INCREMENT':
      // 不,不要這樣做,這樣正在改變狀態
      state.count++;
      return state;

    case 'DECREMENT':
      // 不要這樣做,這也是在改變狀態
      state.count--;
      return state;

    default:
      // 這樣做是很好的.
      return state;
  }
}
複製程式碼

你也不要做這樣的事,比如寫 state.foo = 7 或者 state.items.push(newItem),或者 delete state.something

把這想象為一場遊戲,你唯一能做的事就是 return { ... },這是一個有趣的遊戲,一開始遊戲有些讓人抓狂,但是隨著你的練習你會覺得遊戲越來越有意思。

我編寫了一個簡短的指南關於怎麼去處理不可變的更新,展示了七種常見的包括物件和陣列在內的更新模式。

所有的規則…

總是返回一個狀態,不要去改變狀態,不要連線到每一個元件,吃你自己的西藍花,不要在外面待著超過 11 點...,真累啊。這就像一個規則工廠,我甚至不知道那是什麼。

是的,Redux 可能就像一個霸道的父母。但是都是出於愛。來自函數語言程式設計的愛。

Redux 建立在不變性的基礎上,因為改變全域性的狀態就是一條通向毀滅的道路。

你是否使用一個全域性物件去儲存整個 app 的狀態?一開始執行的很好,很容易,然後狀態在沒有任何預測的情況下發生了改變,而且幾乎不可能去找到改變狀態的程式碼。

Redux 使用一些簡單的規則去避免了這樣的問題,State 是隻讀的,actions 是唯一修改狀態的方式,改變狀態只有一種方式:這個方式就是:action -> reducer -> 新的狀態。reducer 必須是一個純函式,它不能修改它的引數。

有外掛可以幫助你去記錄每一個 action,追溯它們,你可以想象到的一切。從時間上追溯除錯是建立 Redux 的動機之一。

Actions 來自哪裡呢?

讓人迷惑的一部分仍然存在:我們需要一個方式去讓一個 action 進入到我們的 reducer 中,我們才能增加或者減少這個計數器。

Action 不是被生成的,它們是被dispatched的,有一個小巧的函式叫做dispatch。

dispatch 函式由 Redux store 的例項提供,也就是說,你不可以僅僅通過 import { dispatch }獲得 dispatch 函式。你可以呼叫 store.dispatch(someAction),但是那不是很方便,因為 store 的例項只在一個檔案裡面可以被獲得。

很幸運,我們還有 connect 函式。除了注入 mapStateToProps 函式的返回值作為屬性以外,connect 函式dispatch 函式作為屬性注入了元件,使用這麼一點知識,我們又可以讓計數器工作起來了。

這裡是最後的元件形式,如果你一直跟著寫到了這裡,那麼唯一要改變的實現就是 incrementdecrement:它們現在可以呼叫 dispatch 屬性,通過它分發一個 action。

import React from 'react';
import { connect } from 'react-redux';

class Counter extends React.Component {
  increment = () => {
    this.props.dispatch({ type: 'INCREMENT' });
  }

  decrement = () => {
    this.props.dispatch({ type: 'DECREMENT' });
  }

  render() {
    return (
      <div>
        <h2>Counter</h2>
        <div>
          <button onClick={this.decrement}>-</button>
          <span>{this.props.count}</span>
          <button onClick={this.increment}>+</button>
        </div>
      </div>
    )
  }
}

function mapStateToProps(state) {
  return {
    count: state.count
  };
}

export default connect(mapStateToProps)(Counter);
複製程式碼

整個專案的程式碼(它的兩個檔案)可以在 Github上面找到。

現在怎樣了呢?

利用 Counter 程式作為一個傳送帶,你可以繼續學習會更多的 Redux 知識了。

“什麼?! 還有更多?!”

還有很多的地方我沒有講到,我希望這個介紹是容易理解的 – action constants, action 建立函式, 中介軟體, thunks 和非同步呼叫, selectors, 等等。 還有很多。這個 Redux docs 文件寫的很好,覆蓋了我講到的所有知識和更多的知識。

你已經瞭解到了基本的思想,希望你理解了資料怎麼 Redux 裡面變化 (dispatch(action) -> reducer -> new state -> re-render),reducer 做了什麼,action 又做了什麼,它們是怎麼作用在一起的。

我將會釋出一個新的課程,課程涵蓋到所有的這些東西和更多的知識!這裡登入 去關注.

以循序漸進的方式學習 React,檢視我的 - 免費檢視兩個示例章節。

就我而言,即使是免費的介紹也是值得的。 — Isaac


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章