Redux 入門教程(一):基本用法

阮一峰發表於2016-09-18

一年半前,我寫了《React 入門例項教程》,介紹了 React 的基本用法。

React 只是 DOM 的一個抽象層,並不是 Web 應用的完整解決方案。有兩個方面,它沒涉及。

  • 程式碼結構
  • 元件之間的通訊

對於大型的複雜應用來說,這兩方面恰恰是最關鍵的。因此,只用 React 沒法寫大型應用。

為了解決這個問題,2014年 Facebook 提出了 Flux 架構的概念,引發了很多的實現。2015年,Redux 出現,將 Flux 與函數語言程式設計結合一起,很短時間內就成為了最熱門的前端架構。

本文詳細介紹 Redux 架構,由於內容較多,全文分成三個部分。今天是第一部分,介紹基本概念和用法。

零、你可能不需要 Redux

首先明確一點,Redux 是一個有用的架構,但不是非用不可。事實上,大多數情況,你可以不用它,只用 React 就夠了。

曾經有人說過這樣一句話。

"如果你不知道是否需要 Redux,那就是不需要它。"

Redux 的創造者 Dan Abramov 又補充了一句。

"只有遇到 React 實在解決不了的問題,你才需要 Redux 。"

簡單說,如果你的UI層非常簡單,沒有很多互動,Redux 就是不必要的,用了反而增加複雜性。

  • 使用者的使用方式非常簡單
  • 使用者之間沒有協作
  • 不需要與伺服器大量互動,也沒有使用 WebSocket
  • 檢視層(View)只從單一來源獲取資料

上面這些情況,都不需要使用 Redux。

  • 使用者的使用方式複雜
  • 不同身份的使用者有不同的使用方式(比如普通使用者和管理員)
  • 多個使用者之間可以協作
  • 與伺服器大量互動,或者使用了WebSocket
  • View要從多個來源獲取資料

上面這些情況才是 Redux 的適用場景:多互動、多資料來源。

從元件角度看,如果你的應用有以下場景,可以考慮使用 Redux。

  • 某個元件的狀態,需要共享
  • 某個狀態需要在任何地方都可以拿到
  • 一個元件需要改變全域性狀態
  • 一個元件需要改變另一個元件的狀態

發生上面情況時,如果不使用 Redux 或者其他狀態管理工具,不按照一定規律處理狀態的讀寫,程式碼很快就會變成一團亂麻。你需要一種機制,可以在同一個地方查詢狀態、改變狀態、傳播狀態的變化。

總之,不要把 Redux 當作萬靈丹,如果你的應用沒那麼複雜,就沒必要用它。另一方面,Redux 只是 Web 架構的一種解決方案,也可以選擇其他方案。

一、預備知識

閱讀本文,你只需要懂 React。如果還懂 Flux,就更好了,會比較容易理解一些概念,但不是必需的。

Redux 有很好的文件,還有配套的小視訊(前30集後30集)。你可以先閱讀本文,再去官方材料詳細研究。

我的目標是,提供一個簡潔易懂的、全面的入門級參考文件。

二、設計思想

Redux 的設計思想很簡單,就兩句話。

(1)Web 應用是一個狀態機,檢視與狀態是一一對應的。

(2)所有的狀態,儲存在一個物件裡面。

請務必記住這兩句話,下面就是詳細解釋。

三、基本概念和 API

3.1 Store

Store 就是儲存資料的地方,你可以把它看成一個容器。整個應用只能有一個 Store。

Redux 提供createStore這個函式,用來生成 Store。


import { createStore } from 'redux';
const store = createStore(fn);

上面程式碼中,createStore函式接受另一個函式作為引數,返回新生成的 Store 物件。

3.2 State

Store物件包含所有資料。如果想得到某個時點的資料,就要對 Store 生成快照。這種時點的資料集合,就叫做 State。

當前時刻的 State,可以通過store.getState()拿到。


import { createStore } from 'redux';
const store = createStore(fn);

const state = store.getState();

Redux 規定, 一個 State 對應一個 View。只要 State 相同,View 就相同。你知道 State,就知道 View 是什麼樣,反之亦然。

3.3 Action

State 的變化,會導致 View 的變化。但是,使用者接觸不到 State,只能接觸到 View。所以,State 的變化必須是 View 導致的。Action 就是 View 發出的通知,表示 State 應該要發生變化了。

Action 是一個物件。其中的type屬性是必須的,表示 Action 的名稱。其他屬性可以自由設定,社群有一個規範可以參考。


const action = {
  type: 'ADD_TODO',
  payload: 'Learn Redux'
};

上面程式碼中,Action 的名稱是ADD_TODO,它攜帶的資訊是字串Learn Redux

可以這樣理解,Action 描述當前發生的事情。改變 State 的唯一辦法,就是使用 Action。它會運送資料到 Store。

3.4 Action Creator

View 要傳送多少種訊息,就會有多少種 Action。如果都手寫,會很麻煩。可以定義一個函式來生成 Action,這個函式就叫 Action Creator。


const ADD_TODO = '新增 TODO';

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}

const action = addTodo('Learn Redux');

上面程式碼中,addTodo函式就是一個 Action Creator。

3.5 store.dispatch()

store.dispatch()是 View 發出 Action 的唯一方法。


import { createStore } from 'redux';
const store = createStore(fn);

store.dispatch({
  type: 'ADD_TODO',
  payload: 'Learn Redux'
});

上面程式碼中,store.dispatch接受一個 Action 物件作為引數,將它傳送出去。

結合 Action Creator,這段程式碼可以改寫如下。


store.dispatch(addTodo('Learn Redux'));

3.6 Reducer

Store 收到 Action 以後,必須給出一個新的 State,這樣 View 才會發生變化。這種 State 的計算過程就叫做 Reducer。

Reducer 是一個函式,它接受 Action 和當前 State 作為引數,返回一個新的 State。


const reducer = function (state, action) {
  // ...
  return new_state;
};

整個應用的初始狀態,可以作為 State 的預設值。下面是一個實際的例子。


const defaultState = 0;
const reducer = (state = defaultState, action) => {
  switch (action.type) {
    case 'ADD':
      return state + action.payload;
    default: 
      return state;
  }
};

const state = reducer(1, {
  type: 'ADD',
  payload: 2
});

上面程式碼中,reducer函式收到名為ADD的 Action 以後,就返回一個新的 State,作為加法的計算結果。其他運算的邏輯(比如減法),也可以根據 Action 的不同來實現。

實際應用中,Reducer 函式不用像上面這樣手動呼叫,store.dispatch方法會觸發 Reducer 的自動執行。為此,Store 需要知道 Reducer 函式,做法就是在生成 Store 的時候,將 Reducer 傳入createStore方法。


import { createStore } from 'redux';
const store = createStore(reducer);

上面程式碼中,createStore接受 Reducer 作為引數,生成一個新的 Store。以後每當store.dispatch傳送過來一個新的 Action,就會自動呼叫 Reducer,得到新的 State。

為什麼這個函式叫做 Reducer 呢?因為它可以作為陣列的reduce方法的引數。請看下面的例子,一系列 Action 物件按照順序作為一個陣列。


const actions = [
  { type: 'ADD', payload: 0 },
  { type: 'ADD', payload: 1 },
  { type: 'ADD', payload: 2 }
];

const total = actions.reduce(reducer, 0); // 3

上面程式碼中,陣列actions表示依次有三個 Action,分別是加0、加1和加2。陣列的reduce方法接受 Reducer 函式作為引數,就可以直接得到最終的狀態3

3.7 純函式

Reducer 函式最重要的特徵是,它是一個純函式。也就是說,只要是同樣的輸入,必定得到同樣的輸出。

純函式是函數語言程式設計的概念,必須遵守以下一些約束。

  • 不得改寫引數
  • 不能呼叫系統 I/O 的API
  • 不能呼叫Date.now()或者Math.random()等不純的方法,因為每次會得到不一樣的結果

由於 Reducer 是純函式,就可以保證同樣的State,必定得到同樣的 View。但也正因為這一點,Reducer 函式裡面不能改變 State,必須返回一個全新的物件,請參考下面的寫法。


// State 是一個物件
function reducer(state, action) {
  return Object.assign({}, state, { thingToChange });
  // 或者
  return { ...state, ...newState };
}

// State 是一個陣列
function reducer(state, action) {
  return [...state, newItem];
}

最好把 State 物件設成只讀。你沒法改變它,要得到新的 State,唯一辦法就是生成一個新物件。這樣的好處是,任何時候,與某個 View 對應的 State 總是一個不變的物件。

3.8 store.subscribe()

Store 允許使用store.subscribe方法設定監聽函式,一旦 State 發生變化,就自動執行這個函式。


import { createStore } from 'redux';
const store = createStore(reducer);

store.subscribe(listener);

顯然,只要把 View 的更新函式(對於 React 專案,就是元件的render方法或setState方法)放入listen,就會實現 View 的自動渲染。

store.subscribe方法返回一個函式,呼叫這個函式就可以解除監聽。


let unsubscribe = store.subscribe(() =>
  console.log(store.getState())
);

unsubscribe();

四、Store 的實現

上一節介紹了 Redux 涉及的基本概念,可以發現 Store 提供了三個方法。

  • store.getState()
  • store.dispatch()
  • store.subscribe()

import { createStore } from 'redux';
let { subscribe, dispatch, getState } = createStore(reducer);

createStore方法還可以接受第二個引數,表示 State 的最初狀態。這通常是伺服器給出的。


let store = createStore(todoApp, window.STATE_FROM_SERVER)

上面程式碼中,window.STATE_FROM_SERVER就是整個應用的狀態初始值。注意,如果提供了這個引數,它會覆蓋 Reducer 函式的預設初始值。

下面是createStore方法的一個簡單實現,可以瞭解一下 Store 是怎麼生成的。


const createStore = (reducer) => {
  let state;
  let listeners = [];

  const getState = () => state;

  const dispatch = (action) => {
    state = reducer(state, action);
    listeners.forEach(listener => listener());
  };

  const subscribe = (listener) => {
    listeners.push(listener);
    return () => {
      listeners = listeners.filter(l => l !== listener);
    }
  };

  dispatch({});

  return { getState, dispatch, subscribe };
};

五、Reducer 的拆分

Reducer 函式負責生成 State。由於整個應用只有一個 State 物件,包含所有資料,對於大型應用來說,這個 State 必然十分龐大,導致 Reducer 函式也十分龐大。

請看下面的例子。


const chatReducer = (state = defaultState, action = {}) => {
  const { type, payload } = action;
  switch (type) {
    case ADD_CHAT:
      return Object.assign({}, state, {
        chatLog: state.chatLog.concat(payload)
      });
    case CHANGE_STATUS:
      return Object.assign({}, state, {
        statusMessage: payload
      });
    case CHANGE_USERNAME:
      return Object.assign({}, state, {
        userName: payload
      });
    default: return state;
  }
};

上面程式碼中,三種 Action 分別改變 State 的三個屬性。

  • ADD_CHAT:chatLog屬性
  • CHANGE_STATUS:statusMessage屬性
  • CHANGE_USERNAME:userName屬性

這三個屬性之間沒有聯絡,這提示我們可以把 Reducer 函式拆分。不同的函式負責處理不同屬性,最終把它們合併成一個大的 Reducer 即可。


const chatReducer = (state = defaultState, action = {}) => {
  return {
    chatLog: chatLog(state.chatLog, action),
    statusMessage: statusMessage(state.statusMessage, action),
    userName: userName(state.userName, action)
  }
};

上面程式碼中,Reducer 函式被拆成了三個小函式,每一個負責生成對應的屬性。

這樣一拆,Reducer 就易讀易寫多了。而且,這種拆分與 React 應用的結構相吻合:一個 React 根元件由很多子元件構成。這就是說,子元件與子 Reducer 完全可以對應。

Redux 提供了一個combineReducers方法,用於 Reducer 的拆分。你只要定義各個子 Reducer 函式,然後用這個方法,將它們合成一個大的 Reducer。


import { combineReducers } from 'redux';

const chatReducer = combineReducers({
  chatLog,
  statusMessage,
  userName
})

export default todoApp;

上面的程式碼通過combineReducers方法將三個子 Reducer 合併成一個大的函式。

這種寫法有一個前提,就是 State 的屬性名必須與子 Reducer 同名。如果不同名,就要採用下面的寫法。


const reducer = combineReducers({
  a: doSomethingWithA,
  b: processB,
  c: c
})

// 等同於
function reducer(state = {}, action) {
  return {
    a: doSomethingWithA(state.a, action),
    b: processB(state.b, action),
    c: c(state.c, action)
  }
}

總之,combineReducers()做的就是產生一個整體的 Reducer 函式。該函式根據 State 的 key 去執行相應的子 Reducer,並將返回結果合併成一個大的 State 物件。

下面是combineReducer的簡單實現。


const combineReducers = reducers => {
  return (state = {}, action) => {
    return Object.keys(reducers).reduce(
      (nextState, key) => {
        nextState[key] = reducers[key](state[key], action);
        return nextState;
      },
      {} 
    );
  };
};

你可以把所有子 Reducer 放在一個檔案裡面,然後統一引入。


import { combineReducers } from 'redux'
import * as reducers from './reducers'

const reducer = combineReducers(reducers)

六、工作流程

本節對 Redux 的工作流程,做一個梳理。

首先,使用者發出 Action。


store.dispatch(action);

然後,Store 自動呼叫 Reducer,並且傳入兩個引數:當前 State 和收到的 Action。 Reducer 會返回新的 State 。


let nextState = todoApp(previousState, action);

State 一旦有變化,Store 就會呼叫監聽函式。


// 設定監聽函式
store.subscribe(listener);

listener可以通過store.getState()得到當前狀態。如果使用的是 React,這時可以觸發重新渲染 View。


function listerner() {
  let newState = store.getState();
  component.setState(newState);   
}

七、例項:計數器

下面我們來看一個最簡單的例項。


const Counter = ({ value }) => (
  <h1>{value}</h1>
);

const render = () => {
  ReactDOM.render(
    <Counter value={store.getState()}/>,
    document.getElementById('root')
  );
};

store.subscribe(render);
render();

上面是一個簡單的計數器,唯一的作用就是把引數value的值,顯示在網頁上。Store 的監聽函式設定為render,每次 State 的變化都會導致網頁重新渲染。

下面加入一點變化,為Counter新增遞增和遞減的 Action。


const Counter = ({ value, onIncrement, onDecrement }) => (
  <div>
  <h1>{value}</h1>
  <button onClick={onIncrement}>+</button>
  <button onClick={onDecrement}>-</button>
  </div>
);

const reducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    default: return state;
  }
};

const store = createStore(reducer);

const render = () => {
  ReactDOM.render(
    <Counter
      value={store.getState()}
      onIncrement={() => store.dispatch({type: 'INCREMENT'})}
      onDecrement={() => store.dispatch({type: 'DECREMENT'})}
    />,
    document.getElementById('root')
  );
};

render();
store.subscribe(render);

完整的程式碼請看這裡

Redux 的基本用法就介紹到這裡,下一次介紹它的高階用法:中介軟體和非同步操作。

(完)

相關文章