iT邦幫忙

2017 iT 邦幫忙鐵人賽
DAY 25
1
Modern Web

React - DOM界的彼方系列 第 25

Day 25: Redux篇 - 介紹 與 第一個範例

intro

今天的主題是Redux,一開始我們先看它是如何運作的,Redux並不是只能在React應用中使用,而是可以在一般的應用中使用。第一個例子是一個簡單的JavaScript應用,它與最早之前的我們的TodoList應用的功能有點像。

這個程式最後的呈現結果,就像下面的動態圖片這樣,重點是在於下面有個Redux DevTools,它有時光旅行除錯的功能,可以倒帶重播你作過的任何資料上的變動:

Redux範例一展示

註: 本文章同步放置於Github庫的這裡,所有的程式碼也在裡面。

這支簡單的應用是讓你學習Redux整個運作的過程用的,它只是個範例,在實際的應用中雖然會比較複雜,但基本的運作流程都是一樣的。本章的下面附了一些詳細的說明,建議你一定要看。Redux裡面有很多基本的概念與專有名詞,不學是很難看得到在說什麼東西。而且我覺得也是要再多思考一下,它的設計是集合了許多來自不同技術的合體。

程式碼說明

首先我們要使用的是用於寫ES6練習用的那個樣版文件webpack-es6-startkit,而不是create-react-app這個,因為這個範例中並沒有要用到React。之前的文章中有說明如何安裝。

接著我們要多安裝redux套件進來,在專案目錄裡用命令列工具(終端機)輸入以下的指令:

npm install --save redux

另外你也需要安裝Chrome瀏覽器的外掛 - Redux DevTools,這可以讓你使用Redux中的時光旅行除錯功能。

我們在index.html中加了一個文字框itemtext、按鈕itemadd,以及一個準備要顯示項目列表的div區域itemlist,程式碼如下:

index.html

<div>
  <p>
    <input type="text" id="itemtext" />
    <button id="itemadd">Add</button>
  </p>
</div>

<div id="itemlist">
</div>

程式碼檔案只有一個,就是index.js,它是在src/目錄下的,程式碼如下:

src/index.js

// @flow
import { createStore } from 'redux'

// @Reducer
//
// action payload = action.text
// 使用純粹函式的陣列unshift,不能有副作用
// state(狀態)一開始的值是空陣列`state=[]`
function addItem(state = [], action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return [action.text, ...state]
    default:
      return state
  }
}

// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
              window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())

// @Render
//
// render(渲染)是從目前store中取出state資料,然後輸出呈現在網頁上
function render() {
  const items = store.getState().map(item => (
    (item) ? `<li>${item}</li>` : ''
  ))
  document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}

// 第一次要呼叫一次render,讓網頁呈現資料
render()

// 訂閱render到store,這會讓store中如果有新的state(狀態)時,會重新呼叫一次render()
store.subscribe(render)

// 監聽事件到 "itemadd" 按鈕,
// 點按事件會觸發store dispatch Action(store發送動作),例如
// `store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
  .addEventListener('click', () => {
    const itemText = document.getElementById('itemtext')

    // flowtype檢查用的
    if (itemText instanceof HTMLInputElement) {
      // 呼叫store dispatch方法
      store.dispatch({ type: 'ADD_ITEM', text: itemText.value })

      // 清空文字輸入框中的字
      itemText.value = ''
    }
  })

這個程式碼中我有排順序與加上中文註解,因為你要啟用Redux中的作用,是有順序的。我們一步步看下來:

第一步,是要從redux中匯入createStore方法,這很簡單吧,如下面的程式碼:

import { createStore } from 'redux'

第二步,是要建立一個reducer(歸納函式),reducer要求一定要是純粹函式。那麼到底什麼是reducer的作用,就是傳入之前的state(狀態)與一個action(動作)物件,然後要回傳一個新的state(狀態)。

對我們這個簡單的應用來說,它只會有一種這種行為,就是在文字框輸入一些文字,按下按鈕後,把這串文字值加到state中。

所以它的state(狀態)是個陣列,每次一作動作,就加到這個陣列的最上面(索引值為0)一個,動作呢就是像下面這樣的一個物件描述:

{
  type: 'ADD_ITEM',
  text: text
}

reducer裡面通常會用動作的類型(action.type)用switch來區分要執行哪一段的程式碼,因為動作有可能會有很多不同的,像刪除項目、更新項目等等。程式碼如下:

// @Reducer
//
// action payload = action.text
// 使用純粹函式的陣列unshift,不能有副作用
// state(狀態)一開始的值是空陣列`state=[]`
function addItem(state = [], action) {
  switch (action.type) {
    case 'ADD_ITEM':
      return [action.text, ...state]
    default:
      return state
  }
}

上面的[action.text, ...state],它就是純粹函式寫法的陣列unshift方法,之前在TodoList中你應該也有見過。

第三步,是由寫好的reducer,建立store,其實這沒什麼好說的,就用匯入的createStore方法把reducer傳入就行了。正常情況下是用const store = createStore(addItem),因為你要使用瀏覽器中的Redux DevTools,所以要改寫成下面這樣的程式碼:

// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(addItem)
const store = createStore(addItem,
              window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__())

第四步,是寫一個render(渲染函式),這個函式是在如果狀態上有新的變化時,要作輸出呈現的動作。說穿了,這大概是仿照React應用的機制的作法,不過它這設計實際上與React差了十萬八千里,這個渲染函式裡最重要的是用store.getState()方法取出目前store裡面的狀態值,因為我們現在只有記一個state值,所以直接取出來就是剛剛在reducer裡記錄狀態值的那個陣列。剩下的就是一些格式的調整與輸出工作而已。程式碼如下:

// @Render
//
// render(渲染)是從目前store中取出state資料,然後輸出呈現在網頁上
function render() {
  const items = store.getState().map(item => (
    (item) ? `<li>${item}</li>` : ''
  ))
  document.getElementById('itemlist').innerHTML = `<ul>${items.join('')}</ul>`
}

第五步,第一次呼叫一下render,讓目前的資料呈現在網頁上。因為我們一開始在state裡並沒有資料(空陣列),但也有可能原本是有一些資料的,這只是一個初始化資料的動作而已,也很簡單,程式碼如下:

// 第一次要呼叫一次render,讓網頁呈現資料
render()

第六步,訂閱render函式到store中,用的是store.subscribe方法,這訂閱的動作會讓store中如果有新的state(狀態)時,就會重新呼叫一次render()。這也是一個很像是從React中抄來的設計吧?"當React中的state值改變(用setState),就會觸發重新渲染",不過在React中,setState你要自己作,沒有自動的機制。實際上這是從一個設計模式學來的作法,這種設計模式稱為pub-sub(發佈-訂閱)系統,在Flux架構中就有這個設計,Redux中也有,不過它更簡化了整個流程。程式碼也只有一行:

// 訂閱render到store,這會讓store中如果有新的state(狀態)時,會重新呼叫一次render()
store.subscribe(render)

第七步,觸發事件的時候要呼叫store.dispatch(action)。在我們的這個簡單的範例中,唯一會觸發事件就是按下那個加入文字的按鈕,按下後除了要抓取文字框的文字外,另外就是要呼叫store要進行哪一個action,這個動作用的是store.dispatch方法,把action值傳入,action的格式上面有看到過了。程式碼如下:

// 監聽事件到 "itemadd" 按鈕,
// 點按事件會觸發store dispatch Action(store發送動作),例如
// `store.dispatch({ type: 'ADD_ITEM', textValue })
document.getElementById('itemadd')
  .addEventListener('click', () => {
    const itemText = document.getElementById('itemtext')

    // flowtype檢查用的
    if (itemText instanceof HTMLInputElement) {
      // 呼叫store dispatch方法
      store.dispatch({ type: 'ADD_ITEM', text: itemText.value })

      // 清空文字輸入框中的字
      itemText.value = ''
    }
  })

以上就是這七個步驟,這個簡單的小程式,你要套用Redux這個規模化的架構,自然是有些殺雞用牛刀的感覺,但我們的目的是要學習它是怎麼運作的,你可以看到整個運作的核心就是store,資料(state)在裡面,要與裡面的資料(state)更動,也是得用store的方法才行。實際上到React中也是類似的運作方式,不過因為又加了一些額外的輔助套件,會比目前看到的還複雜些,基本的運作邏輯都差不多。


其他補充部份

Redux是什麼

Redux的官網中用一句話來說明Redux是什麼:

Redux是針對JavaScript應用的可預測狀態容器

這句話說來簡短,其實是有幾個涵義的:

  • 可預測的(predictable): 因為Redux用了reducer與pure function的概念,每個新的state都會由舊的state建來一個全新的state,這樣可以作所謂的時光旅行除錯。因此,所有的狀態修改都是"可預測的"。
  • 狀態容器(state container): state是集中在單一個物件樹狀結構下的單一storestore即是應用程式領域(app domain)的狀態集合。
  • JavaScript應用: 這說明Redux並不是單指設計給React用的,它是獨立的一個函式庫,可通用於各種JavaScript應用。

有些人可能會認為Redux一開始就是Facebook所建立的專案,其實並不是,它主要是由Dan Abramov所開始的,Dan Abramov進入Facebook的React小組工作是最近的事情。他還有建立另外還有其他的相關專案,像React Hot Loader、React DnD,可能比當時的Redux專案還更廣為人知,在Facebook發表Flux架構不久之後,許多Flux的週邊函式庫,不論是加強版、進化版、大改版…非常的多。Redux一開始的對外展示的大型活動,是在2015年的React-Europe,影片Live React: Hot Reloading with Time Travel。影片中就有簡單的說明,Redux用了"Flux + Elm"的概念。

Elm是一個程式語言,它也是一個年輕的語言,與原本的JavaScript的設計本質上有很多不同之處,但最後可以編譯為JavaScript執行。Redux中有很多設計是學習Elm而來的,例如:

  • Immutability(不可改變性): 所有的值在Elm中都是不可改變的,Redux中的pure function(純粹函式)與Reducer的設計很類似,但React的設計中也有這類的概念。
  • Time Traveling Debugger(時光旅行除錯): 這在Elm有這個設計,Redux學了過來。

當然除了Flux與Elm之外,還有其他的主要像RxJS中的概念與設計方式,Redux融合了各家的技術於一身,除了更理想的使用在Flux要解決的問題上之外,更延伸了一些不同的設計方式。

但是對初學者來說,它也不容易學習,網路上常常見到初學者報怨Redux實在有夠難學,這也並不是完全是Redux的問題,基本上來說Flux的架構原本就不是很容易理解,Redux還簡化了Flux的流程與開發方式。

如果你已經是剛說的幾種技術的熟練開發者,那當然沒什麼問題,Redux用起來很理想,也很容易上手。對初學者可不是那麼一回事,光Immutability、pure function、reducer要搞懂就要花點時間。另外有些來自Flux架構的store、dispatcher、action、action creator等技術名詞,雖然沒幾個,其實它們也是來自一些設計模式的東西,也需要花時間才能掌握。另一個要注意的是,Redux裡用的都是最新的ES6(ES2015)或甚至ES.next的語法,就算你之前有學過JavaScript或jQuery,這些語法如果沒學過也會很陌生。最後,Redux要套用到React中的規則還有很多,你可以看看它的官方手冊中,有很多規則都要遵守才行,要得心應手需要一些時間。

而且原本的Flux有Flux自己的問題,Redux也有自己的問題: Redux的Immutability設計除了時光旅行除錯,當然也有很多好處,函式保持純粹(pure),容易除錯是一個。但當你要使用不純粹的函式時,例如要用ajax往網路上的伺服器抓取資料時該怎麼作?這就是Redux自己本身的問題了。另一個很明顯的問題,是Redux只用單一個物件大樹結構的store,記錄大大小小的state(狀態)變化,如果在會持續更新狀態的情況下,這個樹會長得滿可怕的,時光旅行當然是很好的功能,但也有可能是變成一個效能上的問題,這效能的最佳化問題又該怎麼作?當然,我說的只是兩個很明顯的問題,而且你也猜得到Redux中早就已經有對應的解決方案,畢竟用得人多,你會遇到的問題大概都有人遇過,也都能找得到解決方式,這是開放原始碼生態圈的紅利。

我只能說如果你真的要學會React,並用它來開發一個稍有規模的應用,學習Redux說是一條必經之路,當然也有其他的Flux類函式庫可以選擇,但目前來說Redux的使用社群是最龐大也是最活躍的,而且也不見得其他的函式庫就會更容易學習與使用。

你心中應該要先有一個概念,這種架構或框架,本身就是為React應用規模化而設計的,這是大應用的架構,所以有很多強硬規則是必然的。雖然在這裡能提供一些較為簡易的入門知識,但真正要套用到實際的應用上,仍然有很多的基礎知識是要再深入與學習的。如果,你會覺得學起來很痛苦或常看不懂範例,大部份是因為基礎不足夠造成的,學習本來就要由淺入深循序漸進,是急不得的。

Redux的三大原則

這三大基本原則是寫給React或Flux不熟的人看的,因為有可能怕你不理解這些函式庫中的一些限制或設計,所以會先寫出來提醒一下,當然它裡面就說明了Redux的設計原理基礎是如何,所以你要用之前要先理解一下。

單一真相來源(Single source of truth)

你的整個應用中的state(狀態)會儲存在單一個store(儲存)之中的一個物件樹狀結構裡。

Redux中只有用單一個物件大樹結構來的儲存狀態,稱之為store,但它並不是只有單純的儲存資料而已。store就是應用程式領域的state(狀態),已經有像MVC中的Model設計的概念,這名稱是由Flux來的,Flux架構原本是多個store的結構,Redux簡化它為單一個。

我們之前的React範例中也到最後,也是只有一個最上層的state來管控應用程式中的狀態,但我們能作到的只有單純的保存資料而已,更動這些資料的所有方法,雖然都是寫在上層元件之中,但是它們並沒有一個很標準的寫法。

不過Redux中的store儲存資料的結構與React中的state不同的地方,就是它會保存所有的更動,任何只要對狀態進行更動,都會保存進去,這是它的特色。

因為只有一個store,但如果store裡要儲放多個不同的狀態物件,以及每次的更動資料,自然就會變成了物件樹狀結構(object tree)。

狀態是唯讀的(State is read-only)

唯一能更動狀態的是發送(emit)一個action(動作),action是一個描述發生了什麼事的物件。

這與原先的React中的statesetState的概念有點像,Redux的意思是你不能直接更動store裡面記錄的狀態值,只能"間接"地透過發送action物件來叫store更動狀態。

發送(emit)一個action,用的是store.dispatch(action)語法,下面這個範例就是一個要更動狀態的程式碼:

store.dispatch({
  type: 'COMPLETE_TODO',
  index: 1
})

中間的那個物件,就叫作action(動作),它是一個單純用於描述發生了什麼事的物件:

{
  type: 'COMPLETE_TODO',
  index: 1
}

還記得我們在React中的statesetState方法的設計嗎?state也是不能直接更動的,一定要透過setState方法才能更動它。那這是為什麼呢?因為setState不光只是更動state值,它還要作重新渲染的動作。

也就是說假設Redux的設計中store是會對應到React中的state設計的話,它們之間有一些聯結,因為React中的state值不能直接更動,所以Redux中的store也不能直接更動。

store實際上是一種state擴充的結構,原先的state只能記錄單純的物件資料結構,store不止如此,它已經接近MVC的Model(模型)的設計。對於Flux的架構解說,在我的部落格裡有以下的對store的說明:

store(儲存)的角色並非只是元件中的state(狀態)而已,它也不會只有單純的記錄資料,可能在現今的每種不同的Flux延伸的函式庫,對於store的定義與設計都有所不同。在Flux的架構中的store中,它包含了對資料更動的函式,Flux稱這些函式為Store Queries(儲存查詢),也把它的角色定位為類似傳統MVC的Model(模型),但與傳統的Model(模型)明顯不同的是,store只能透過Action(動作)以"間接"的方式來自我更新(self-updates through Actions)。

更動只能由純粹函式來進行(Changes are made with pure functions)

要指示狀態樹要如何依actions(動作)來改變,你需要寫純粹的歸納函式(reducers)。

Redux中的reducer的原型會長得像下面這樣,你可以把它當作就是 之前的狀態 + 動作 = 新的狀態 的公式:

(previousState, action) => newState

註: 你可以參考Redux中Reducers這一章的內容,裡面有實例。

不過,Redux中的reducer要求的是一定是pure function(純粹函式),也就是不能有副作用(side effect)的函式。因此由reducer所產生的新狀態,並不是直接修改之前的狀態而來,而是一個複製之前狀態,然後加上動作改變的部份,產生的一個新的物件,它這樣設計是有原因的。


reducer(歸納器)與pure function(純粹函式)

如果你已經看過Flux的設計與運作原理,那麼在Redux中你需要學習的第一個基礎是reducer與pure function。Flux架構的一些簡單的說明,我在部落格中有一篇Flux - 為React打造的單向資料流架構,裡面有說一些概念的部份,你可以先看。

Pure function(純粹函式)的概念在之前的章節已經說明過了,就不再重覆說明,你也要先了解。

reducer(歸納器)這種函式的名稱,是由陣列的一個迭代方法reduce(歸納)而來,你可以參考MDN中的相關說明,以下的內容與範例出自從ES6開始的JavaScript學習生活這本電子書中:


reduce(歸納)這個方法是一種應用於特殊情況的迭代方法,它可以藉由一個回調(callback)函式,來作前後值兩相運算,然後不斷縮減陣列中的成員數量,最終回傳一個值。reduce(歸納)並不會更動作為傳入的陣列(呼叫reduce的陣列),所以它也沒有副作用。

const aArray = [0, 1, 2, 3, 4]

const total = aArray.reduce(function(pValue, value, index, array){
    return pValue + value
})

console.log(aArray) // [0, 1, 2, 3, 4]
console.log(total) // 10

按照這個邏輯,reduce(歸納)具有分散運算的特點,可以用於下面幾個應用之中:

  • 兩相比較最後取出特定的值(最大或最小值)
  • 計算所有成員(值),總合或相乘
  • 其它需要兩兩處理的情況(組合巢狀陣列等等)

store(儲存)

store也是由Flux架構來的產物,在Flux中的store的意義是用來對應元件中的state字詞,大概有幾個重點,Redux中的設計也是有類似的概念:

  • store代表的是應用程式領域的持續性儲存資料
  • state的容器,經常會對應到最上層元件的state,或是各個元件state的集合體
  • 接近於MVC中的Model,不過只能透過動作"間接"作自我更新(self-updates through Actions)

與Flux的設計最大不同,Redux只有單一個store,它是一個樹狀結構的物件。而且,在Flux中的AppDispatcher工作,會由store中的方法來取代,大幅度地簡化原本複雜的流程。store中有四個主要方法:

  • dispatch(action) 發送一個Action(動作)。這也是唯一可以觸發狀態改變的方式。常用於元件要用來觸發事件時。
  • getState() 回傳目前store的狀態樹。常用於最上層元件取得目前狀態值。
  • subscribe(listener) 加入一個更動的監聽者(函式)。它會在當有一個Action(動作)被發送時呼叫,或是當狀態樹被更動時呼叫。
  • replaceReducer(nextReducer) 更換目前store用來計算state使用的reducer。屬於進階的API。

註: 不用redux-react時,需要用subscribe(listener)來作更新時重新渲染。

所以在React與Redux配合時,只會用到前面兩個dispatch(action)getState()而已,不需要用到subscribe,也不適合使用subscribe,這一點要特別注意。

最後,store(儲存)一開始的建立是用createStore(reducer)來建立實體,所以你要先寫出一個reducer,才有辦法建立store。

Action與Action Creator

這兩個是Flux架構中的參與成員,redux中有說明Action的定義:

Actions are payloads of information that send data from your application to your store.
中譯: Actions(動作)是從你的應用送往store(儲存)的資訊負載

你可能會一直在Action(動作)這裡看到payload這個字詞,它是負載有效資料的意思,這個字詞的意思解說你可以看一下,不難理解:

Payload用在電腦科學的意思,是指在資料傳輸時的"有效資料"部份,也就是不包含傳輸時的頭部資訊或metadata等等用於傳輸其他資料。它的英文原本是指是飛彈或火箭的搭載的真正有效的負載部份,例如炸藥或核子彈頭,另外的不屬於payload的部份當然就是火箭傳送時用的燃料或控制零件。

這個Action是有一個固定格式的,叫作FSA, Flux Standard Action(Flux標準動作),格式的範例會像下面這樣,是個JavaScript的物件字面定義:

{
  type: 'ADD_TODO',
  payload: {
    text: 'Do something.'
  }
}

這樣一個用於描述動作的單純物件字面定義,就稱為Action(動作)。

為什麼要先寫出明確的Actions(動作),也就是把所有的元件會用到的Actions(動作),全部集中寫到一個檔案中?這也是個硬規則,就像你如果參加奧運的體操比賽,每種項目都有規定的動作,在一定的時間內只能作這些動作,按照表定執行。主要還是因為Redux並不知道你的應用程式裡會作什麼動作,需要有一個明確說明有哪些動作的地方,在運作時以這個對照表為基準。

當然,聰明如你一定知道,Actions(動作)必需要有type(類型),而且在同一個應用中的type(類型)名稱是不能重覆的,它的概念有點類似於資料表中的主鍵屬性。

那麼Action Creator(動作生成器)又是什麼?

在程式語言的函式庫中,如果是個英文的名詞,通常都是代表某種物件或資料格式,例如Action(動作)就是個單純的物件。如果叫什麼xxxxter或xxxxtor的,中文翻譯是"器"、"者",通常就是個函式或方法,像上面的reducer和這裡的Action Creator,都是一種函式。

Action creator的設計也是由Flux架構來的產物,它是一種輔助用的函式,用來建立Action的。因為設計的不同,在Redux中的Action creator比在Flux更簡單,它通常只用來回傳Action物件而已,當然它本身是個函式,在回傳前是可以再針對回傳的動作資料先進行運算或整理的,例如像下面這樣的函式:

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

這個addTodo函式,有一個傳入參數,這個傳入參數就會用於組成Action物件中的payload(有效資料)。

如果一個Action物件簡單到連payload(有效資料)都沒有,通常會是個固定payload(有效資料)的動作,例如每動作一次+1或-1,或是每動作一次在true或false值切換,那麼在Redux中允許連Action或Action Creator都可以不用寫了。但是這種情況大概只有在很小的應用,或是學習階段的範例才會這樣,如果應用還是有一定程度的複雜度,一定都是要寫出來的。

註: Action Creator在Redux中並沒有要求一定要是個純粹函式,只是不建議在裡面直接執行有副作用的函式,之後的章節會有說明。請參考這篇在stackoverflow的Reduce作者的回答


上一篇
Day 24: React篇 - 路由器(router) 與 本篇小結
下一篇
Day 26: Redux篇 - 第一次使用Redux於React應用
系列文
React - DOM界的彼方30

尚未有邦友留言

立即登入留言