1. 程式人生 > >跟著例子一步步學習redux+react-redux

跟著例子一步步學習redux+react-redux

前言

本文不會拿redux、react-redux等一些react的名詞去講解,然後把各自用法舉例說明,這樣其實對一些react新手或者不太熟悉redux模式的開發人員不夠友好,他們並不知道這樣使用的原因。本文通過一個簡單的例子展開,一點點自己去實現一個redux+react-redux,讓大家充分理解redux+react-redux出現的必要。

預備知識

在閱讀本文之前,希望大家對以下知識點能提前有所瞭解並且上好廁所(文章有點長):

  1. 狀態提升的概念
  2. react高階元件(函式)
  3. es6基礎
  4. pure 元件(純函式)
  5. Dumb 元件

React.js的context

這一節的內容其實是講一個react當中一個你可能永遠用不到的特性——context,但是它對你理解react-redux很有好處。那麼context是幹什麼的呢?看下圖:
clipboard.png


假設現在這個元件樹代表的應用是使用者可以自主換主題色的,每個子元件會根據主題色的不同調整自己的字型顏色。“主題色”這個狀態是所有元件共享的狀態,根據狀態提升中所提到的,需要把這個狀態提升到根節點的 Index 上,然後把這個狀態通過 props 一層層傳遞下去:
clipboard.png
如果要改變主題色,在 Index 上可以直接通過 this.setState({ themeColor: 'red' }) 來進行。這樣整顆元件樹就會重新渲染,子元件也就可以根據重新傳進來的 props.themeColor 來調整自己的顏色。

但這裡的問題也是非常明顯的,我們需要把 themeColor 這個狀態一層層手動地從元件樹頂層往下傳,每層都需要寫 props.themeColor。如果我們的元件樹很層次很深的話,這樣維護起來簡直是災難。

如果這顆元件樹能夠全域性共享這個狀態就好了,我們要的時候就去取這個狀態,不用手動地傳:
clipboard.png
就像這樣,Index 把 state.themeColor 放到某個地方,這個地方是每個 Index 的子元件都可以訪問到的。當某個子元件需要的時候就直接去那個地方拿就好了,而不需要一層層地通過 props 來獲取。不管元件樹的層次有多深,任何一個元件都可以直接到這個公共的地方提取 themeColor 狀態。

React.js 的 context 就是這麼一個東西,某個元件只要往自己的 context 裡面放了某些狀態,這個元件之下的所有子元件都直接訪問這個狀態而不需要通過中間元件的傳遞。一個元件的 context 只有它的子元件能夠訪問。
下面我們看看 React.js 的 context 程式碼怎麼寫,我們先把整體的元件樹搭建起來。
用create-react-app建立工程:

create-react-app react-redux-demo1

現在我們修改 App,讓它往自己的 context 裡面放一個 themeColor:

import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Header from './header';
import Main from './main';
import './App.css';

class App extends Component {
  static childContextTypes = {
    themeColor :PropTypes.string
  }
  constructor () {
    super()
    this.state = {
      themeColor : 'red'
    }
  }
  getChildContext () {
    return {
      themeColor : this.state.themeColor
    }
  }
  render () {
    return (
      <div>
        <Header />
        <Main />
      </div>
    )
  }
}

export default App;

建構函式裡面的內容其實就很好理解,就是往 state 裡面初始化一個 themeColor 狀態。getChildContext 這個方法就是設定 context 的過程,它返回的物件就是 context(也就是上圖中處於中間的方塊),所有的子元件都可以訪問到這個物件。我們用 this.state.themeColor 來設定了 context 裡面的 themeColor。

接下來我們要看看子元件怎麼獲取這個狀態,修改 App 的孫子元件 Title和Content:

//title.js
class Title extends Component {
  static contextTypes = {
    themeColor: PropTypes.string
  }

  render () {
    return (
      <h1 style={{ color: this.context.themeColor }}>React.js 小書標題</h1>
    )
  }
}
//content.js
import React, { Component } from 'react';
class Content extends Component {
    render () {
        return (
        <div>
            <h2>this is 內容</h2>
        </div>
        )
    }
}

export default Content;

一個元件可以通過 getChildContext 方法返回一個物件,這個物件就是子樹的 context,提供 context 的元件必須提供 childContextTypes 作為 context 的宣告和驗證。

如果一個元件設定了 context,那麼它的子元件都可以直接訪問到裡面的內容,它就像這個元件為根的子樹的全域性變數。任意深度的子元件都可以通過 contextTypes 來宣告你想要的 context 裡面的哪些狀態,然後可以通過 this.context 訪問到那些狀態。

context 打破了元件和元件之間通過 props 傳遞資料的規範,極大地增強了元件之間的耦合性。而且,就如全域性變數一樣,context 裡面的資料能被隨意接觸就能被隨意修改,每個元件都能夠改 context 裡面的內容會導致程式的執行不可預料。

動手實現Redux

上節內容講了React.js的content的特性,這個跟redux和react-redux什麼關係呢?看下去就知道了,這邊先賣個關子:)。Redux 和 React-redux 並不是同一個東西。Redux 是一種架構模式(Flux 架構的一種變種),它不關注你到底用什麼庫,你可以把它應用到 React 和 Vue,甚至跟 jQuery 結合都沒有問題。而 React-redux 就是把 Redux 這種架構模式和 React.js 結合起來的一個庫,就是 Redux 架構在 React.js 中的體現。這節主要講如何自己手動實現一個redux模式。

“大張旗鼓”的修改共享狀態

用 create-react-app 新建一個專案:react-redux-demo2:

create-react-app react-redux-demo2

修改 public/index.html 裡面的 body 結構為:

<body>
    <div id='title'></div>
    <div id='content'></div>
</body>

刪除 src/index.js 裡面所有的程式碼,新增下面程式碼,代表我們應用的狀態:

const appState = {
  title: {
    text: 'this is title',
    color: 'red',
  },
  content: {
    text: 'this is content',
    color: 'blue'
  }
}

我們新增幾個渲染函式,它會把上面狀態的資料渲染到頁面上:

function renderApp (appState) {
  renderTitle(appState.title)
  renderContent(appState.content)
}

function renderTitle (title) {
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = title.text
  titleDOM.style.color = title.color
}

function renderContent (content) {
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = content.text
  contentDOM.style.color = content.color
}
renderApp(appState)

很簡單,renderApp 會呼叫 rendeTitle 和 renderContent,而這兩者會把 appState 裡面的資料通過原始的 DOM 操作更新到頁面上。

clipboard.png
這是一個很簡單的 App,但是它存在一個重大的隱患,我們渲染資料的時候,使用的是一個共享狀態 appState,每個人都可以修改它。這裡的矛盾就是:“模組(元件)之間需要共享資料”,和“資料可能被任意修改導致不可預料的結果”之間的矛盾。

為了解決這個問題,我們可以學習 React.js 團隊的做法,把事情搞複雜一些,提高資料修改的門檻:模組(元件)之間可以共享資料,也可以改資料。但是我們約定,這個資料並不能直接改,你只能執行某些我允許的某些修改,而且你修改的必須大張旗鼓地告訴我。

我們定義一個函式,叫 dispatch,它專門負責資料的修改:

function dispatch (action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      appState.title.text = action.text
      break
    case 'UPDATE_TITLE_COLOR':
      appState.title.color = action.color
      break
    default:
      break
  }
}

所有對資料的操作必須通過 dispatch 函式。它接受一個引數 action,這個 action 是一個普通的 JavaScript 物件,裡面必須包含一個 type 欄位來宣告你到底想幹什麼。dispatch 在 swtich 裡面會識別這個 type 欄位,能夠識別出來的操作才會執行對 appState 的修改。

任何的模組如果想要修改 appState.title.text,必須大張旗鼓地呼叫 dispatch:

dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'this is dispatch' }) // 修改標題文字
dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改標題顏色

我們再也不用擔心共享資料狀態的修改的問題,我們只要把控了 dispatch,所有的對 appState 的修改就無所遁形,畢竟只有一根箭頭指向 appState 了。
clipboard.png

構建共享狀態倉庫

上一節我們有了 appState 和 dispatch,現在我們把它們集中到一個地方,給這個地方起個名字叫做 store,然後構建一個函式 createStore,用來專門生產這種 state 和 dispatch 的集合,這樣別的 App 也可以用這種模式了:

function createStore (state, stateChanger) {
  const getState = () => state
  const dispatch = (action) => stateChanger(state, action)
  return { getState, dispatch }
}

createStore 接受兩個引數,一個是表示應用程式狀態的 state;另外一個是 stateChanger,它來描述應用程式狀態會根據 action 發生什麼變化,其實就是相當於本節開頭的 dispatch 程式碼裡面的內容。

createStore 會返回一個物件,這個物件包含兩個方法 getState 和 dispatch。getState 用於獲取 state 資料,其實就是簡單地把 state 引數返回。

dispatch 用於修改資料,和以前一樣會接受 action,然後它會把 state 和 action 一併傳給 stateChanger,那麼 stateChanger 就可以根據 action 來修改 state 了。

現在有了 createStore,我們可以這麼修改原來的程式碼,保留原來所有的渲染函式不變,修改資料生成的方式:

let appState = {
  title: {
    text: 'this is title',
    color: 'red',
  },
  content: {
    text: 'this is content',
    color: 'blue'
  }
}

function stateChanger (state, action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      state.title.text = action.text
      break
    case 'UPDATE_TITLE_COLOR':
      state.title.color = action.color
      break
    default:
      break
  }
}

const store = createStore(appState, stateChanger)

renderApp(store.getState()) // 首次渲染頁面
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'this is dispatch' }) // 修改標題文字
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改標題顏色
renderApp(store.getState()) // 把新的資料渲染到頁面上

針對每個不同的 App,我們可以給 createStore 傳入初始的資料 appState,和一個描述資料變化的函式 stateChanger,然後生成一個 store。需要修改資料的時候通過 store.dispatch,需要獲取資料的時候通過 store.getState。

監控資料變化

上面程式碼有個問題,就是每次dispatch修改資料的時候,其實只是資料發生了變化,如果我們不手動呼叫renderApp,頁面不會發生變化。如何資料變化的時候程式能夠智慧一點地自動重新渲染資料,而不是手動呼叫?

往 dispatch裡面加 renderApp 就好了,但是這樣 createStore 就不夠通用了。我們希望用一種通用的方式“監聽”資料變化,然後重新渲染頁面,這裡要用到觀察者模式。修改 createStore:

function createStore (state, stateChanger) {
  const listeners = []
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    stateChanger(state, action)
    listeners.forEach((listener) => listener())
  }
  return { getState, dispatch, subscribe }
}

我們在 createStore 裡面定義了一個數組 listeners,還有一個新的方法 subscribe,可以通過 store.subscribe(listener) 的方式給 subscribe 傳入一個監聽函式,這個函式會被 push 到陣列當中。每當 dispatch 的時候,監聽函式就會被呼叫,這樣我們就可以在每當資料變化時候進行重新渲染:

const store = createStore(appState, stateChanger)
store.subscribe(() => renderApp(store.getState()))

renderApp(store.getState()) // 首次渲染頁面
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'this is dispatch' }) // 修改標題文字
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改標題顏色
// ...後面不管如何 store.dispatch,都不需要重新呼叫 renderApp

共享結構的物件來提高效能

其實我們之前的例子當中是有比較嚴重的效能問題的。我們在每個渲染函式的開頭打一些 Log 看看:

function renderApp (appState) {
  console.log('render app...')
  renderTitle(appState.title)
  renderContent(appState.content)
}

function renderTitle (title) {
  console.log('render title...')
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = title.text
  titleDOM.style.color = title.color
}

function renderContent (content) {
  console.log('render content...')
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = content.text
  contentDOM.style.color = content.color
}

依舊執行一次初始化渲染,和兩次更新,這裡程式碼保持不變:

renderApp(store.getState()) // 首次渲染頁面
store.dispatch({ type: 'UPDATE_TITLE_TEXT', text: 'this is dispatch' }) // 修改標題文字
store.dispatch({ type: 'UPDATE_TITLE_COLOR', color: 'blue' }) // 修改標題顏色

clipboard.png
可以看到問題就是,每當更新資料就重新渲染整個 App,但其實我們兩次更新都沒有動到 appState 裡面的 content 欄位的物件,而動的是 title 欄位。其實並不需要重新 renderContent,它是一個多餘的更新操作,現在我們需要優化它。

這裡提出的解決方案是,在每個渲染函式執行渲染操作之前先做個判斷,判斷傳入的新資料和舊的資料是不是相同,相同的話就不渲染了。

function renderApp (newAppState, oldAppState = {}) { // 防止 oldAppState 沒有傳入,所以加了預設引數 oldAppState = {}

if (newAppState === oldAppState) return // 資料沒有變化就不渲染了

  console.log('render app...')
  renderTitle(newAppState.title, oldAppState.title)
  renderContent(newAppState.content, oldAppState.content)
}

function renderTitle (newTitle, oldTitle = {}) {
  if (newTitle === oldTitle) return // 資料沒有變化就不渲染了
  console.log('render title...')
  const titleDOM = document.getElementById('title')
  titleDOM.innerHTML = newTitle.text
  titleDOM.style.color = newTitle.color
}

function renderContent (newContent, oldContent = {}) {
  if (newContent === oldContent) return // 資料沒有變化就不渲染了
  console.log('render content...')
  const contentDOM = document.getElementById('content')
  contentDOM.innerHTML = newContent.text
  contentDOM.style.color = newContent.color
}

然後我們用一個 oldState 變數儲存舊的應用狀態,在需要重新渲染的時候把新舊資料傳進入去:

const store = createStore(appState, stateChanger)
let oldState = store.getState() // 快取舊的 state
store.subscribe(() => {
  const newState = store.getState() // 資料可能變化,獲取新的 state
  renderApp(newState, oldState) // 把新舊的 state 傳進去渲染
  oldState = newState // 渲染完以後,新的 newState 變成了舊的 oldState,等待下一次資料變化重新渲染
})
...
function stateChanger (state, action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      state.title.text = action.text
      break
    case 'UPDATE_TITLE_COLOR':
      state.title.color = action.color
      break
    default:
      break
  }
}
...

其實上面一頓操作根本達不到我們的預期的要求,你會發現還是渲染了content,這些引用指向的還是原來的物件,只是物件內的內容發生了改變。所以即使你在每個渲染函式開頭加了那個判斷又什麼用?就像下面這段程式碼一樣自欺欺人:

let people = {
    name:'ddvdd'
}
const oldPeople = people
people.name = 'yjy'
oldPeople !== people //false 其實兩個引用指向的是同一個物件,我們卻希望它們不同。

那怎麼樣才能達到我們要的要求呢?引入共享結構的物件概念:

const obj = { a: 1, b: 2}
const obj2 = { ...obj } // => { a: 1, b: 2 }

const obj2 = { ...obj } 其實就是新建一個物件 obj2,然後把 obj 所有的屬性都複製到 obj2 裡面,相當於物件的淺複製。上面的 obj 裡面的內容和 obj2 是完全一樣的,但是卻是兩個不同的物件。除了淺複製物件,還可以覆蓋、拓展物件屬性:

const obj = { a: 1, b: 2}
const obj2 = { ...obj, b: 3, c: 4} // => { a: 1, b: 3, c: 4 },覆蓋了 b,新增了 c

我們可以把這種特性應用在 appstate 的更新上,我們禁止直接修改原來的物件,一旦你要修改某些東西,你就得把修改路徑上的所有物件複製一遍。我們修改 stateChanger,讓它修改資料的時候,並不會直接修改原來的資料 state,而是產生上述的共享結構的物件:

function stateChanger (state, action) {
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      return { // 構建新的物件並且返回
        ...state,
        title: {
          ...state.title,
          text: action.text
        }
      }
    case 'UPDATE_TITLE_COLOR':
      return { // 構建新的物件並且返回
        ...state,
        title: {
          ...state.title,
          color: action.color
        }
      }
    default:
      return state // 沒有修改,返回原來的物件
  }
}

因為 stateChanger 不會修改原來物件了,而是返回物件,所以我們需要修改一下 createStore。讓它用每次 stateChanger(state, action) 的呼叫結果覆蓋原來的 state:

function createStore (state, stateChanger) {
  const listeners = []
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = stateChanger(state, action) // 覆蓋原物件
    listeners.forEach((listener) => listener())
  }
  return { getState, dispatch, subscribe }
}

好了,我們在執行下看看結果是不是變成我們預期的那樣了?
clipboard.png

我就喜歡叫它 “reducer”

經過了這麼多節的優化,我們有了一個很通用的 createStore,主要傳入appState、stateChanger就能使用。那麼appState和stateChanger是否可以合併到一起去呢?顯然可以:

function stateChanger (state, action) {
  if (!state) {
    return {
      title: {
        text: 'this is title',
        color: 'red',
      },
      content: {
        text: 'this is content',
        color: 'blue'
      }
    }
  }
  switch (action.type) {
    case 'UPDATE_TITLE_TEXT':
      return {
        ...state,
        title: {
          ...state.title,
          text: action.text
        }
      }
    case 'UPDATE_TITLE_COLOR':
      return {
        ...state,
        title: {
          ...state.title,
          color: action.color
        }
      }
    default:
      return state
  }
}

stateChanger 現在既充當了獲取初始化資料的功能,也充當了生成更新資料的功能。如果有傳入 state 就生成更新資料,否則就是初始化資料。這樣我們可以優化 createStore 成一個引數,因為 state 和 stateChanger 合併到一起了:

function createStore (stateChanger) {
  let state = null
  const listeners = []
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = stateChanger(state, action)
    listeners.forEach((listener) => listener())
  }
  dispatch({}) // 初始化 state
  return { getState, dispatch, subscribe }
}

createStore 內部的 state 不再通過引數傳入,而是一個區域性變數 let state = null。createStore 的最後會手動呼叫一次 dispatch({}),dispatch 內部會呼叫 stateChanger,這時候的 state 是 null,所以這次的 dispatch 其實就是初始化資料了。createStore 內部第一次的 dispatch 導致 state 初始化完成,後續外部的 dispatch 就是修改資料的行為了。

我們給 stateChanger 這個玩意起一個通用的名字:reducer,不要問為什麼,它就是個名字而已,修改 createStore 的引數名字:

function createStore (reducer) {
  let state = null
  const listeners = []
  const subscribe = (listener) => listeners.push(listener)
  const getState = () => state
  const dispatch = (action) => {
    state = reducer(state, action)
    listeners.forEach((listener) => listener())
  }
  dispatch({}) // 初始化 state
  return { getState, dispatch, subscribe }
}

這是一個最終形態的 createStore,它接受的引數叫 reducer,reducer 是一個函式,細心的朋友會發現,它其實是一個純函式(Pure Function)。

Redux在React當中的實踐

看到這裡你會發現自己莫名其妙的對redux已經瞭解的差不多了,甚至還自己動手實現了一個。文章進行到這裡,偷偷告訴大家才過了一半。。。沒上過廁所的去上下,回來我們繼續:)

搭建工程

前面我們在react.js的context中提出,我們可用把共享狀態放到父元件的 context 上,這個父元件下所有的元件都可以從 context 中直接獲取到狀態而不需要一層層地進行傳遞了。但是直接從 context 裡面存放、獲取資料增強了元件的耦合性;並且所有元件都可以修改 context 裡面的狀態就像誰都可以修改共享狀態一樣,導致程式執行的不可預料。

既然這樣,為什麼不把 context 和 store 結合起來?畢竟 store 的資料不是誰都能修改,而是約定只能通過 dispatch 來進行修改,這樣的話每個元件既可以去 context 裡面獲取 store 從而獲取狀態,又不用擔心它們亂改資料了。我們還是以“主題色”這個例子來講解,假設我們有這麼一顆元件樹:
clipboard.png
Header 和 Content 的元件的文字內容會隨著主題色的變化而變化,而 Content 下的子元件 ThemeSwitch 有兩個按鈕,可以切換紅色和藍色兩種主題,按鈕的顏色也會隨著主題色的變化而變化。

用 create-react-app 新建一個工程react-redux-demo3:

create-react-app react-redux-demo3

安裝好後在 src/ 目錄下新增三個檔案:Header.js、Content.js、ThemeSwitch.js。

//./src/Header.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'

class Header extends Component {
  render () {
    return (
      <h1>this is header</h1>
    )
  }
}

export default Header

//./src/Content.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ThemeSwitch from './ThemeSwitch'

class Content extends Component {
  render () {
    return (
      <div>
        <p>this is content</p>
        <ThemeSwitch />
      </div>
    )
  }
}

export default Content

//./src/ThemeSwitch.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'

class ThemeSwitch extends Component {
  render () {
    return (
      <div>
        <button>Red</button>
        <button>Blue</button>
      </div>
    )
  }
}

export default ThemeSwitch

//修改app.js
import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import Header from './Header'
import Content from './Content'
import './index.css'

class App extends Component {
  render () {
    return (
      <div>
        <Header />
        <Content />
      </div>
    )
  }
}

export default App

這樣我們就簡單地把整個元件樹搭建起來了,用 npm start 啟動工程,然後可以看到頁面上顯示:
clipboard.png

結合 context 和 store

既然要把 store 和 context 結合起來,我們就先在 src目下建立store.js 和 reducer.js倆檔案:

//store.js
function createStore (reducer) {
    let state = null
    const listeners = []
    const subscribe = (listener) => listeners.push(listener)
    const getState = () => state
    const dispatch = (action) => {
      state = reducer(state, action)
      listeners.forEach((listener) => listener())
    }
    dispatch({}) // 初始化 state
    return { getState, dispatch, subscribe }
}

export default createStore

//reducer.js
const themeReducer = (state, action) => {
    if (!state) return {
      themeColor: 'red'
    }
    switch (action.type) {
      case 'CHANGE_COLOR':
        return { ...state, themeColor: action.themeColor }
      default:
        return state
    }
}
export default themeReducer

themeReducer 定義了一個表示主題色的狀態 themeColor,並且規定了一種操作 CHNAGE_COLOR,只能通過這種操作修改顏色。現在我們把 store 放到 App 的 context 裡面,這樣每個子元件都可以獲取到 store 了,修改 src/App.js 裡面的 App:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import Header from './Header'
import Content from './Content'

import createStore from './store'
import themeReducer from './reducer'

const store = createStore(themeReducer)

class App extends Component {
  static childContextTypes = {
    store: PropTypes.object
  }

  getChildContext () {
    return { store }
  }

  render () {
    return (
      <div>
        <Header />
        <Content />
      </div>
    )
  }
}
export default App

然後修改 src/Header.js、Content.js、ThemeSwitch.js,讓它從 App 的 context 裡面獲取 store,並且獲取裡面的 themeColor 狀態來設定自己的顏色:

//header.js
class Header extends Component {
  static contextTypes = {
    store: PropTypes.object
  }

  constructor () {
    super()
    this.state = { themeColor: '' }
  }

  componentWillMount () {
    this._updateThemeColor()
  }

  _updateThemeColor () {
    const { store } = this.context
    const state = store.getState()
    this.setState({ themeColor: state.themeColor })
  }

  render () {
    return (
      <h1 style={{ color: this.state.themeColor }}>this is header</h1>
    )
  }
}

//content.js
class Content extends Component {
  static contextTypes = {
    store: PropTypes.object
  }

  constructor () {
    super()
    this.state = { themeColor: '' }
  }

  componentWillMount () {
    this._updateThemeColor()
  }

  _updateThemeColor () {
    const { store } = this.context
    const state = store.getState()
    this.setState({ themeColor: state.themeColor })
  }

  render () {
    return (
      <div>
        <p style={{ color: this.state.themeColor }}>this is content</p>
        <ThemeSwitch />
      </div>
    )
  }
}
 
//themeswitch.js
class ThemeSwitch extends Component {
  static contextTypes = {
    store: PropTypes.object
  }

  constructor () {
    super()
    this.state = { themeColor: '' }
  }

  componentWillMount () {
    this._updateThemeColor()
  }

  _updateThemeColor () {
    const { store } = this.context
    const state = store.getState()
    this.setState({ themeColor: state.themeColor })
  }

  // dispatch action 去改變顏色
  handleSwitchColor (color) {
    const { store } = this.context
    store.dispatch({
      type: 'CHANGE_COLOR',
      themeColor: color
    })
  }

  render () {
    return (
      <div>
        <button
          style={{ color: this.state.themeColor }}
          onClick={this.handleSwitchColor.bind(this, 'red')}>Red</button>
        <button
          style={{ color: this.state.themeColor }}
          onClick={this.handleSwitchColor.bind(this, 'blue')}>Blue</button>
      </div>
    )
  }
}

我們在 constructor 裡面初始化了元件自己的 themeColor 狀態。然後在生命週期中 componentWillMount 呼叫 _updateThemeColor,_updateThemeColor 會從 context 裡面把 store 取出來,然後通過 store.getState() 獲取狀態物件,並且用裡面的 themeColor 欄位設定元件的 state.themeColor。

然後在 render 函式裡面獲取了 state.themeColor 來設定標題的樣式,頁面上就會顯示:
clipboard.png
我們給兩個按鈕都加上了 onClick 事件監聽,並繫結到了 handleSwitchColor 方法上,兩個按鈕分別給這個方法傳入不同的顏色 red 和 blue,handleSwitchColor 會根據傳入的顏色 store.dispatch 一個 action 去修改顏色。

當然你現在點選按鈕還是沒有反應的。因為點選按鈕的時候,只是更新 store 裡面的 state,而並沒有在 store.state 更新以後去重新渲染資料,我們其實就是忘了 store.subscribe 了。

給 Header.js、Content.js、ThemeSwitch.js 的 componentWillMount 生命週期都加上監聽資料變化重新渲染的程式碼:

...
  componentWillMount () {
    const { store } = this.context
    this._updateThemeColor()
    store.subscribe(() => this._updateThemeColor())
  }
...

通過 store.subscribe,在資料變化的時候重新呼叫 _updateThemeColor,而 _updateThemeColor 會去 store 裡面取最新的 themeColor 然後通過 setState 重新渲染元件,這時候元件就更新了。現在可以自由切換主題色了:
clipboard.png
我們順利地把 store 和 context 結合起來,這是 Redux 和 React.js 的第一次勝利會師,當然還有很多需要優化的地方。

connect 和 mapStateToProps

我們來觀察一下剛寫下的這幾個元件,可以輕易地發現它們有兩個重大的問題:

  1. 有大量重複的邏輯:它們基本的邏輯都是,取出 context,取出裡面的 store,然後用裡面的狀態設定自己的狀態,這些程式碼邏輯其實都是相同的。
  2. 對 context 依賴性過強:這些元件都要依賴 context 來取資料,使得這個元件複用性基本為零。想一下,如果別人需要用到裡面的 ThemeSwitch 元件,但是他們的元件樹並沒有 context 也沒有 store,他們沒法用這個元件了。

對於第一個問題我們可以用高階元件(高階元件就是一個函式,傳給它一個元件,它返回一個新的元件。)來解決,可以把一些可複用的邏輯放在高階元件當中,高階元件包裝的新元件和原來元件之間通過 props 傳遞資訊,減少程式碼的重複程度。

對於第二個問題,我們得弄清楚一件事情,到底什麼樣的元件才叫複用性強的元件。如果一個元件對外界的依賴過於強,那麼這個元件的移植性會很差,就像這些嚴重依賴 context 的元件一樣。

如果一個元件的渲染只依賴於外界傳進去的 props 和自己的 state,而並不依賴於其他的外界的任何資料,也就是說像純函式一樣,給它什麼,它就吐出(渲染)什麼出來。這種元件的複用性是最強的,別人使用的時候根本不用擔心任何事情,只要看看 PropTypes 它能接受什麼引數,然後把引數傳進去控制它就行了。

我們把這種元件叫做 Pure Component,因為它就像純函式一樣,可預測性非常強,對引數(props)以外的資料零依賴,也不產生副作用。這種元件也叫 Dumb Component,因為它們呆呆的,讓它幹啥就幹啥。寫元件的時候儘量寫 Dumb Component 會提高我們的元件的可複用性。

到這裡思路慢慢地變得清晰了,我們需要高階元件幫助我們從 context 取資料,我們也需要寫 Dumb 元件幫助我們提高元件的複用性。所以我們儘量多地寫 Dumb 元件,然後用高階元件把它們包裝一層,高階元件和 context 打交道,把裡面資料取出來通過 props 傳給 Dumb 元件。
clipboard.png
我們把這個高階元件起名字叫 connect,因為它把 Dumb 元件和 context 連線(connect)起來了:

import React, { Component } from 'react'
import PropTypes from 'prop-types'

export connect = (WrappedComponent) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object
    }

    // TODO: 如何從 store 取資料?

    render () {
      return <WrappedComponent />
    }
  }

  return Connect
}

connect 函式接受一個元件 WrappedComponent 作為引數,把這個元件包含在一個新的元件 Connect 裡面,Connect 會去 context 裡面取出 store。現在要把 store 裡面的資料取出來通過 props 傳給 WrappedComponent。

但是每個傳進去的元件需要 store 裡面的資料都不一樣的,所以除了給高階元件傳入 Dumb 元件以外,還需要告訴高階元件我們需要什麼資料,高階元件才能正確地去取資料。為了解決這個問題,我們可以給高階元件傳入類似下面這樣的函式:

const mapStateToProps = (state) => {
  return {
    themeColor: state.themeColor,
    themeName: state.themeName,
    fullName: `${state.firstName} ${state.lastName}`
    ...
  }
}

這個函式會接受 store.getState() 的結果作為引數,然後返回一個物件,這個物件是根據 state 生成的。mapStateTopProps 相當於告知了 Connect 應該如何去 store 裡面取資料,然後可以把這個函式的返回結果傳給被包裝的元件:

import React, { Component } from 'react'
import PropTypes from 'prop-types'

export const connect = (mapStateToProps) => (WrappedComponent) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object
    }

    render () {
      const { store } = this.context
      let stateProps = mapStateToProps(store.getState())
      // {...stateProps} 意思是把從store裡面所需要的屬性拿出來全部通過 `props` 方式傳遞進去
      return <WrappedComponent {...stateProps} />
    }
  }

  return Connect
}

好了既然有了connect這個高階元件,我們來看看之前的程式碼怎麼改造?我們把上面 connect 的函式程式碼單獨分離到一個模組當中,在 src/ 目錄下新建一個 react-redux.js,把上面的 connect 函式的程式碼複製進去,然後就可以在 src/Header.js 裡面使用了:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from './react-redux'

class Header extends Component {
  static propTypes = {
    themeColor: PropTypes.string
  }

  render () {
    return (
      <h1 style={{ color: this.props.themeColor }}>this is header</h1>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    themeColor: state.themeColor
  }
}
Header = connect(mapStateToProps)(Header)

export default Header

可以看到 Header 刪掉了大部分關於 context 的程式碼,它除了 props 什麼也不依賴,它是一個 Pure Component,然後通過 connect 取得資料。我們不需要知道 connect 是怎麼和 context 打交道的,只要傳一個 mapStateToProps 告訴它應該從store裡面取哪些資料就可以了。同樣的方式修改 src/Content.js,這裡不貼了,留給大家自己去完成。

現在的 connect 還沒有監聽資料變化然後重新渲染,所以現在點選按鈕只有按鈕會變顏色。我們給 connect 的高階元件增加監聽資料變化重新渲染的邏輯,稍微重構一下 connect:

export const connect = (mapStateToProps) => (WrappedComponent) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object
    }

    constructor () {
      super()
      this.state = { allProps: {} }
    }

    componentWillMount () {
      const { store } = this.context
      this._updateProps()
      store.subscribe(() => this._updateProps())
    }

    _updateProps () {
      const { store } = this.context
      let stateProps = mapStateToProps(store.getState(), this.props) // 額外傳入 props,讓獲取資料更加靈活方便
      this.setState({
        allProps: { // 整合普通的 props 和從 state 生成的 props
          ...stateProps,
          ...this.props
        }
      })
    }

    render () {
      return <WrappedComponent {...this.state.allProps} />
    }
  }

  return Connect
}

我們在 Connect 元件的 constructor 裡面初始化了 state.allProps,它是一個物件,用來儲存需要傳給被包裝元件的所有的引數。生命週期 componentWillMount 會呼叫呼叫 _updateProps 進行初始化,然後通過 store.subscribe 監聽資料變化重新呼叫 _updateProps。

為了讓 connect 返回新元件和被包裝的元件使用引數保持一致,我們會把所有傳給 Connect 的 props 原封不動地傳給 WrappedComponent。所以在 _updateProps 裡面會把 stateProps 和 this.props 合併到 this.state.allProps 裡面,再通過 render 方法把所有引數都傳給 WrappedComponent。

mapStateToProps 也發生點變化,它現在可以接受兩個引數了,我們會把傳給 Connect 元件的 props 引數也傳給它,那麼它生成的物件配置性就更強了,我們可以根據 store 裡面的 state 和外界傳入的 props 生成我們想傳給被包裝元件的引數。

現在已經很不錯了,Header.js 和 Content.js 的程式碼都大大減少了,並且這兩個元件 connect 之前都是 Dumb 元件。接下來會繼續重構 ThemeSwitch。

mapDispatchToProps

在重構 ThemeSwitch 的時候我們發現,ThemeSwitch 除了需要 store 裡面的資料以外,還需要 store 來 dispatch:

...
  // dispatch action 去改變顏色
  handleSwitchColor (color) {
    const { store } = this.context
    store.dispatch({
      type: 'CHANGE_COLOR',
      themeColor: color
    })
  }
...

目前版本的 connect 是達不到這個效果的,我們需要改進它。

想一下,既然可以通過給 connect 函式傳入 mapStateToProps 來告訴它如何獲取、整合狀態,我們也可以想到,可以給它傳入另外一個引數來告訴它我們的元件需要如何觸發 dispatch。我們把這個引數叫 mapDispatchToProps:

const mapDispatchToProps = (dispatch) => {
  return {
    onSwitchColor: (color) => {
      dispatch({ type: 'CHANGE_COLOR', themeColor: color })
    }
  }
}

和 mapStateToProps 一樣,它返回一個物件,這個物件內容會同樣被 connect 當作是 props 引數傳給被包裝的元件。不一樣的是,這個函式不是接受 state 作為引數,而是 dispatch,你可以在返回的物件內部定義一些函式,這些函式會用到 dispatch 來觸發特定的 action。

調整 connect 讓它能接受這樣的 mapDispatchToProps:

export const connect = (mapStateToProps, mapDispatchToProps) => (WrappedComponent) => {
  class Connect extends Component {
    static contextTypes = {
      store: PropTypes.object
    }

    constructor () {
      super()
      this.state = {
        allProps: {}
      }
    }

    componentWillMount () {
      const { store } = this.context
      this._updateProps()
      store.subscribe(() => this._updateProps())
    }

    _updateProps () {
      const { store } = this.context
      let stateProps = mapStateToProps
        ? mapStateToProps(store.getState(), this.props)
        : {} // 防止 mapStateToProps 沒有傳入
      let dispatchProps = mapDispatchToProps
        ? mapDispatchToProps(store.dispatch, this.props)
        : {} // 防止 mapDispatchToProps 沒有傳入
      this.setState({
        allProps: {
          ...stateProps,
          ...dispatchProps,
          ...this.props
        }
      })
    }

    render () {
      return <WrappedComponent {...this.state.allProps} />
    }
  }
  return Connect
}

在 _updateProps 內部,我們把store.dispatch 作為引數傳給 mapDispatchToProps ,它會返回一個物件 dispatchProps。接著把 stateProps、dispatchProps、this.props 三者合併到 this.state.allProps 裡面去,這三者的內容都會在 render 函式內全部傳給被包裝的元件。

這時候我們就可以重構 ThemeSwitch,讓它擺脫 store.dispatch:

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import { connect } from './react-redux'

class ThemeSwitch extends Component {
  static propTypes = {
    themeColor: PropTypes.string,
    onSwitchColor: PropTypes.func
  }

  handleSwitchColor (color) {
    if (this.props.onSwitchColor) {
      this.props.onSwitchColor(color)
    }
  }

  render () {
    return (
      <div>
        <button
          style={{ color: this.props.themeColor }}
          onClick={this.handleSwitchColor.bind(this, 'red')}>Red</button>
        <button
          style={{ color: this.props.themeColor }}
          onClick={this.handleSwitchColor.bind(this, 'blue')}>Blue</button>
      </div>
    )
  }
}

const mapStateToProps = (state) => {
  return {
    themeColor: state.themeColor
  }
}
const mapDispatchToProps = (dispatch) => {
  return {
    onSwitchColor: (color) => {
      dispatch({ type: 'CHANGE_COLOR', themeColor: color })
    }
  }
}
ThemeSwitch = connect(mapStateToProps, mapDispatchToProps)(ThemeSwitch)

export default ThemeSwitch

這時候這三個元件的重構都已經完成了,程式碼大大減少、不依賴 context,並且功能和原來一樣。

Provider

我們要把 context 相關的程式碼從所有業務元件中清除出去,現在的程式碼裡面還有一個地方是被汙染的。那就是 src/App.js 裡面的 App:

...
class Index extends Component {
  static childContextTypes = {
    store: PropTypes.object
  }

  getChildContext () {
    return { store }
  }

  render () {
    return (
      <div>
        <Header />
        <Content />
      </div>
    )
  }
}
...

其實它要用 context 就是因為要把 store 存放到裡面,好讓子元件 connect 的時候能夠取到 store。我們可以額外構建一個元件來做這種髒活,然後讓這個元件成為元件樹的根節點,那麼它的子元件都可以獲取到 context 了。

我們把這個元件叫 Provider,因為它提供(provide)了 store,把它放在react-redux.js:

export class Provider extends Component {
  static propTypes = {
    store: PropTypes.object,
    children: PropTypes.any
  }

  static childContextTypes = {
    store: PropTypes.object
  }

  getChildContext () {
    return {
      store: this.props.store
    }
  }

  render () {
    return (
      <div>{this.props.children}</div>
    )
  }
}

Provider 做的事情也很簡單,它就是一個容器元件,會把巢狀的內容原封不動作為自己的子元件渲染出來。它還會把外界傳給它的 props.store 放到 context,這樣子元件 connect 的時候都可以獲取到。

可以用它來重構我們的 src/index.js:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import createStore from './store'
import { Provider } from './react-redux'
import themeReducer from './reducer'

const store = createStore(themeReducer)

ReactDOM.render(
    <Provider store={store}>
        <App />
    </Provider>, document.getElementById('root'));

這樣我們就把所有關於 context 的程式碼從元件裡面刪除了。做完這些你其實已經自己動手完成了一個react-redux的開發,不信?怎麼可能那麼簡單?至今為止都沒用react-redux。。。那麼現在來看一件神奇的事情,把 src/ 目錄下 Header.js、ThemeSwitch.js、Content.js 的模組中的./react-redux 匯入的 connect 改成從第三方 react-redux 模組中匯入。

import { connect } from './react-redux' 
//改成
import { connect } from 'react-redux'

刪除自己寫的 createStore,改成使用第三方模組 redux 的 createStore;Provider 本來從本地的 ./react-redux 引入,改成從第三方 react-redux 模組中引入。其餘程式碼保持不變。

import { createStore } from 'redux'

import { Provider } from 'react-redux'

接著刪除 src/react-redux.js,它的已經用處不大了。最後啟動工程 npm start:

clipboard.png
我們看到專案神奇的運行了,好了文章到了這裡也算結束了,第一遍消化不了的建議多看幾篇!

總結

相關推薦

跟著例子步步學習redux+react-redux

前言 本文不會拿redux、react-redux等一些react的名詞去講解,然後把各自用法舉例說明,這樣其實對一些react新手或者不太熟悉redux模式的開發人員不夠友好,他們並不知道這樣使用的原因。本文通過一個簡單的例子展開,一點點自己去實現一個redux+reac

Redux學習筆記-React-Redux 的用法

root 方法 () Redux rom 讀取 onf 配置 復雜 參考:https://segmentfault.com/a/1190000015042646 https://www.jianshu.com/p/e3cdce986ee2

【SSH之旅】步步學習Hibernate框架():關於持久化

stc localhost 對象 schema hbm.xml java let pass [] 在不引用不論什麽框架下,我們會通過平庸的代碼不停的對數據庫進行操作,產生了非常多冗余的可是又有規律的底層代碼,這樣頻繁的操作數據庫和大量的底層代碼的反復

步步學習EF Core(1.DBFirst)

sin then tle foreach delete tro log -h num 前言 很久沒寫博客了,因為真的很忙,終於空下來,打算學習一下EF Core順便寫個系列, 今天我們就來看看第一篇DBFirst. 本文環境:VS2017 Win7 .NET

步步學習並發:了解並發是如何發生的

精益 進行 招商銀行 臟讀 銀行卡 事務 沒有 個數 余額 十年河東,十年河西,莫欺少年窮 學無止境,精益求精 數據庫操作的並發問題是沒法避免的,並發會引起如下問題: 舉例說明: 數據庫事務並發帶來的問題有:更新丟失、臟讀、不可重復讀、幻象讀。假設張三辦了一張招商銀行卡,余

步步學習Linux多任務編程

blog 緩沖 dup system pan 無名管道 gpo 重入 get 系統調用 01、什麽是系統調用? 02、Linux系統調用之I/O操作(文件操作) 03、文件描述符的復制:dup(), dup2() 多進程實現多任務 04、進程的介紹 05、Linu

No.5步步學習vuejs之事件監聽和組件

sage 應該 shift vuejs 進行 編譯器 add round mage 一監聽事件 可以用 v-on 指令監聽 DOM 事件,並在觸發時運行一些 JavaScript 代碼。 <div id="demo1"> <button v-on:cli

freeRTOS 步步學習

源自:http://www.FreeRTOS.org     文件名:  USING THE FREERTOS REAL TIME KERNEL   中文: FREERTOS實時核心實用指南  翻譯作者----》》》》》》》》》》》》》》》》》》》》》》》》》》 硬實

步步學習kotlin for android(三) kotlin省略findviewById

findViewById      今天的內容涉及到findViewByID,android語言原來這個特別繁瑣,現在好了,kotlin語言,直接拿來佈局裡面的id用,省去好多重複工作量啊 在使用kotlin的id之前,需要先在builde.gradle裡引入這個 app

React Native入門篇—redux react-redux react-navigation-redux-helpers的安裝和配置

注意:未經允許不可私自轉載,違者必究 React Native官方文件:https://reactnative.cn/docs/getting-started/ redux官方文件:https://www.redux.org.cn/ 專案地址GitHub地址:https:/

步步學習SSH框架

本人雖然上學階段接觸過SSH,但大家知道的,一知半解的。今天重新開始學習SSH框架。今天在此講的也是個人的一個學習的過程,如果錯誤,還請各位大牛指點。 首先第一步我們從第一個S開始,也就是struts2! 什麼是struts2:Struts2整合了MCV和Webwork兩種

步步學習基於Linux4.4的TINY4412開發--uboot的移植

開發板:tiny4412-1506 儲存4G、記憶體1G系統:ubuntu16.04 虛擬機器u-boot: u-boot 2010.12compiled tool: arm-none-linux-gnueabi-gcc (gcc version 4.8.3 20140320

基於asp.net + easyui框架,步步學習easyui-datagrid——實現分頁和搜尋(二)

目錄:        上篇部落格我只是將介面的部分完成了,繼續上篇部落格的內容,這篇部落格我們需要將資料庫中的記錄顯示到介面上,並實現資料的分頁顯示。        曾經我寫過分頁的部落格,分頁很簡單, 本質區別在於分頁時從資料庫讀取資訊的方式:假分頁:一次性讀取

步步學習彙編(10)之jmp指令原理分析

jmp指令 解釋: n       jmp為無條件轉移,可以只修改IP,也可以同時修改CS和IP; n       jmp指令要給出兩種資訊: n       轉移的目的地址 n       轉移的距離(段間轉移、段內短轉移,段內近轉移) 格式: 一.Jump shor

初體驗react+redux+react-redux的基本使用方法

看了阮一峰老師的 redux入門教程:http://www.ruanyifeng.com/blog/2016/09/redux_tutorial_part_one_basic_usages.html看的似懂非懂,自己動手寫個demo來的檢驗一下:這裡關於原理講解,請參照阮老師

使用react+redux+react-redux+react-router+axios+scss技術棧從0到1開發一個applist應用

先看效果圖 github地址 github倉庫 線上訪問 初始化專案 #建立專案 create-react-app applist #如果沒有安裝create-react-app的話,先安裝 npm install -g create-react-app 目錄結構改造 |--config |--n

react native 學習筆記-----理解redux的一個極其簡單例子

'use strict'; import React, { Component, PropTypes} from 'react'; import {   StyleSheet,   Text,   View,   Image,   Button,   AppRegistry,   TouchableHighl

一起學習造輪子(三):從零開始寫一個React-Redux

導致 href dispatch 判斷 som render connect mis 回調 本文是一起學習造輪子系列的第三篇,本篇我們將從零開始寫一個React-Redux,本系列文章將會選取一些前端比較經典的輪子進行源碼分析,並且從零開始逐步實現,本系列將會學習Prom

React學習之旅----Redux安裝及富文字、echarts

瀏覽器中安裝redux devtools擴充套件 yarn add redux  react-redux redux-devtools-extension 安裝依賴包即可 // 引入createStore建立store,引入applyMiddleware 來使用中介軟體 //

Reduxreact-redux學習總結

寫在最前面:這段時間一直在看前端方面的東西,之前只是瞭解HTML,CSS,JS,由於公司交代了前端的任務,所以後面又看了jQuery,Bootstrap,React,Redux,react-redux。   Bootstrap框架的優勢:(封裝CSS,移動裝置優先) 1. 移動裝置優先、響應式