Taro 小程式開發大型實戰(四):使用 Hooks 版的 Redux 實現應用狀態管理(上篇)

tuture發表於2020-04-27

歡迎繼續閱讀《Taro 小程式開發大型實戰》系列,前情回顧:

如果你跟著敲到了這裡,你一定會發現現在 的狀態管理和資料流越來越臃腫,元件狀態的更新非常複雜。在這一篇中,我們將開始用 Redux 重構,因為此次重構涉及的改動檔案有點多,所以這一步使用 Redux 重構我們分兩篇文章來講解,這篇是上篇。

如果你不熟悉 Redux,推薦閱讀我們的《Redux 包教包會》系列教程:

如果你希望直接從這一步開始,請執行以下命令:

git clone -b redux-start https://github.com/tuture-dev/ultra-club.git
cd ultra-club

本文所涉及的原始碼都放在了 Github 上,如果您覺得我們寫得還不錯,希望您能給❤️這篇文章點贊+Github倉庫加星❤️哦~

雙劍合璧:Hooks + Redux

寫到這一步,我們發現狀態已經有點多了,而且 src/pages/mine/mine.jsx 檔案是眾多狀態的頂層元件,比如我們的普通登入按鈕 src/components/LoginButton/index.jsx 元件和我們的 src/components/Footer/index.jsx 元件,我們通過點選普通登入按鈕開啟登入彈窗的狀態 isOpened 需要在 LoginButton 裡面進行操作,然後進而影響到 Footer 元件內的 FloatLayout 彈窗元件,像這種涉及到多個子元件進行通訊,我們將狀態儲存到公共父元件中的方式在 React 中叫做 ”狀態提升“。

但是隨著狀態增多,狀態提升的狀態也隨著增多,導致儲存這些狀態的父元件會臃腫不堪,而且每次狀態的改變需要影響很多中間元件,帶來極大的效能開銷,這種狀態管理的難題我們一般交給專門的狀態管理容器 Redux 來做,而讓 React 專注於渲染使用者介面。

Redux 不僅可以保證狀態的可預測性,還能保證狀態的變化只和對應的元件相關,不影響到無關的元件,關於 Redux 的詳細剖析的實戰教程可以參考圖雀社群的:Redux 包教包會系列文章

在這一節中,我們將結合 React Hooks 和 Redux 來重構我們狀態管理。

安裝依賴

首先我們先來安裝使用 Redux 必要的依賴:

$ yarn add redux @tarojs/redux @tarojs/redux-h5  redux-logger
# 或者使用 npm
$ npm install --save redux @tarojs/redux @tarojs/redux-h5 redux-logger

除了我們熟悉的 redux 依賴,以及用來列印 Action 的中介軟體 redux-logger 外,還有兩個額外的包,這是因為在 Taro 中,Redux 原繫結庫 react-redux 被替換成了 @tarojs/redux@tarojs/redux-h5,前者用在小程式中,後者用在 H5 頁面中,Taro 對原 react-redux 進行了封裝並提供了與 react-redux API 幾乎一致的包來讓開發人員獲得更加良好的開發體驗。

建立 Redux Store

Redux 的三大核心概念為:Store,Action,Reducers:

  • Store:儲存著全域性的狀態,有著 ”資料的唯一真相來源之稱“。
  • Action:發起修改 Store 中儲存狀態的動作,是修改狀態的唯一手段。
  • Reducers:一個個的純函式,用於響應 Action,對 Store 中的狀態進行修改。

好的,複習了一下 Redux 的概念之後,我們馬上來建立 Store,Redux 的最佳實踐推薦我們在將 Store 儲存在 store 資料夾中,我們在 src 資料夾下面建立 store 資料夾,並在其中建立 index.js 來編寫我們的 Store:

import { createStore, applyMiddleware } from 'redux'
import { createLogger } from 'redux-logger'
import rootReducer from '../reducers'

const middlewares = [createLogger()]

export default function configStore() {
  const store = createStore(rootReducer, applyMiddleware(...middlewares))
  return store
}

可以看到,我們匯出了一個 configureStore 函式,並在其中建立並返回 Store,這裡我們用到了 redux-logger 中介軟體,用於在發起 Action 時,在控制檯列印 Action 及其前後 Store 中的儲存的狀態資訊。

這裡我們的 createstore 接收兩個引數:rootReducerapplyMiddleware(...middlewares)

rootReducer 是響應 actionreducer,這裡我們匯出了一個 rootReducer,代表組合了所有的 reducer ,我們將在後面 “組合 User 和 Post Reducer“ 中講到它。

createStore 函式的第二個引數我們使用了 redux 為我們提供的工具函式 applyMiddleware 來在 Redux 中注入需要使用的中介軟體,因為它接收的引數是 (args1, args2, args3, ..., argsn) 的形式,所以這裡我們用了陣列展開運算子 ... 來展開 middlewares 陣列。

編寫 User Reducer

建立完 Store 之後,我們接在來編寫 Reducer。回到我們的頁面邏輯,我們在底部有兩個 Tab 欄,一個為 “首頁”,一個為 “我的”,在 ”首頁“ 裡面主要是展示一列文章和允許新增文章等,在 ”我的“ 裡面主要是允許使用者進行登入並展示登入資訊,所以整體上我們的邏輯有兩類,我們分別將其命名為 postuser,接下來我們將建立處理這兩類邏輯的 reducers。

Reducer 的邏輯形如 (state, action) => newState,即接收上一步 state 以及修改 state 的動作 action,然後返回修改後的新的 state,它是一個純函式,意味著我們不能突變的修改 state。

推薦:

newState = { ...state, prop: newValue }

不推薦:

state.prop = newValue

Redux 推薦的最佳實踐是建立獨立的 reducers 資料夾,在裡面儲存我們的一個個 reducer 檔案。我們在 src 資料夾下建立 reducers 資料夾,在裡面建立 user.js 檔案,並加入我們的 User Reducer 相應的內容如下:

import { SET_LOGIN_INFO, SET_IS_OPENED } from '../constants/'

const INITIAL_STATE = {
  avatar: '',
  nickName: '',
  isOpened: false,
}

export default function user(state = INITIAL_STATE, action) {
  switch (action.type) {
    case SET_IS_OPENED: {
      const { isOpened } = action.payload

      return { ...state, isOpened }
    }

    case SET_LOGIN_INFO: {
      const { avatar, nickName } = action.payload

      return { ...state, nickName, avatar }
    }

    default:
      return state
  }
}

我們在 user.js 中申明瞭 User Reducer 的初始狀態 INITIAL_STATE,並將它賦值給 user 函式 state 的預設值,它接收待響應的 action,在 user 函式內部就是一個 switch 語句根據 action.type 進行判斷,然後執行相應的邏輯,這裡我們主要有兩個型別:SET_IS_OPENED 用於修改 isOpened 屬性,SET_LOGIN_INFO 用於修改 avatarnickName 屬性,當 switch 語句中沒有匹配到任何 action.type 值時,它返回原 state。

提示

根據 Redux 最近實踐,這裡的 SET_IS_OPENEDSET_LOGIN_INFO 常量一般儲存到 constants 資料夾中,我們將馬上建立它。這裡使用常量而不是直接硬編碼字串的目的是為了程式碼的可維護性。

接下來我們來建立 src/reducer/user.js 中會用到的常量,我們在 src 資料夾下建立 constants 資料夾,並在其中建立 user.js 檔案,在其中新增內容如下:

export const SET_IS_OPENED = 'MODIFY_IS_OPENED'
export const SET_LOGIN_INFO = 'SET_LOGIN_INFO'

編寫 Post Reducer

為了響應 post 邏輯的狀態修改,我們建立在 src/reducers 下建立 post.js,並在其中編寫相應的內容如下:

import { SET_POSTS, SET_POST_FORM_IS_OPENED } from '../constants/'

import avatar from '../images/avatar.png'

const INITIAL_STATE = {
  posts: [
    {
      title: '泰羅奧特曼',
      content: '泰羅是奧特之父和奧特之母唯一的親生兒子',
      user: {
        nickName: '圖雀醬',
        avatar,
      },
    },
  ],
  isOpened: false,
}

export default function post(state = INITIAL_STATE, action) {
  switch (action.type) {
    case SET_POSTS: {
      const { post } = action.payload
      return { ...state, posts: state.posts.concat(post) }
    }

    case SET_POST_FORM_IS_OPENED: {
      const { isOpened } = action.payload

      return { ...state, isOpened }
    }

    default:
      return state
  }
}

可以看到,Post Reducer 的形式和 User Reducer 類似,我們將之前需要多元件中共享的狀態 postsisOpened 提取出來儲存在 post 的狀態裡,這裡的 post 函式主要響應 SET_POSTS 邏輯,用於新增新的 postposts 狀態種,以及 SET_POST_FORM_IS_OPENED 邏輯,使用者設定 isOpened 狀態。

接下來我們來建立 src/reducer/post.js 中會用到的常量,我們在 src/constants 資料夾下建立 user.js 檔案,在其中新增內容如下:

export const SET_POSTS = 'SET_POSTS'
export const SET_POST_FORM_IS_OPENED = 'SET_POST_FORM_IS_OPENED'

眼尖的同學可能注意到了,我們在 src/reducers/user.jssrc/reducers/post.js 中匯入需要使用的常量時都是從 ../constants 的形式,那是因為我們在 src/constants 資料夾下建立了一個 index.js 檔案,用於統一匯出所有的常量,這也是程式碼可維護性的一種嘗試。

export * from './user'
export * from './post'

組合 User 和 Post Reducer

我們在之前將整個全域性的響應邏輯分別拆分到了 src/reducers/user.jssrc/reducers/post.js 中,這使得我們可以把響應邏輯拆分到很多個很小的函式單元,極大增加了程式碼的可讀性和可維護性。

但最終我們還是要將這些拆分的邏輯組合成一個邏輯樹,並將其作為引數傳給 createStore 函式來使用。

Redux 為我們提供了 combineReducers 來組合這些拆分的邏輯,我們在 src/reducers 資料夾下建立 index.js 檔案,並在其中編寫如下內容:

import { combineReducers } from 'redux'

import user from './user'
import post from './post'

export default combineReducers({
  user,
  post,
})

可以看到,我們匯入了 user.jspost.js,並使用物件簡介寫法傳給 combineReducers 函式並匯出,通過 combineReducers 將邏輯進行組合並匯出為 rootReducer 作為引數在我們的 src/store/index.jscreateStore 函式中使用。

這裡的 combineReducers 函式主要完成兩件事:

  • 組合 user Reducer 和 post Reducer 中的狀態,並將其合併成一顆形如 { user, post } 的狀態樹,其中 user 屬性儲存這 user Reducer 的狀態,post 屬性儲存著 post Reducer 的狀態。
  • 分發 Action,當元件中 dispatch 一個 Action, combineReducers 會遍歷 user Reducer 和 post Reducer,當匹配到任一 Reducer 的 switch 語句時,就會響應這個 Action。

提示

我們將馬上在之後講解如何在元件中 dispatch Action。

整合 Redux 和 React

當我們編寫了 reducers 建立了 store 之後,下一步要考慮的就是如何將 Redux 整合進 React,我們開啟 src/app.js,對其中的內容作出如下修改:

import Taro, { Component } from '@tarojs/taro'
import { Provider } from '@tarojs/redux'

import configStore from './store'
import Index from './pages/index'
import './app.scss'

// ...

const store = configStore()

class App extends Component {
  config = {
    // ...
  }

  render() {
    return (
      <Provider store={store}>
        <Index />
      </Provider>
    )
  }
}

Taro.render(<App />, document.getElementById('app'))

可以看到,上面的內容主要修改了三部分:

  • 我們匯入了 configureStore,並呼叫它獲取 store
  • 接著我們從 Redux 對應的 Taro 繫結庫 @tarojs/redux 中匯出 Provider,它架設起 Redux 和 React 交流的橋樑。
  • 最後我們用 Provider 包裹我們之前的根元件,並將 store 作為其屬性傳入,這樣後續的元件就可以通過獲取到 store 裡面儲存的狀態。

Hooks 版的 Action 初嚐鮮

準備好了 Store 和 Reducer,又整合了 Redux 和 React,是時候來體驗一下 Redux 狀態管理容器的先進性了,不過為了使用 Hooks 版本的 Action,這裡我們先來講一講會用到的 Hooks。

useDispatch Hooks

這個 Hooks 返回 Redux store 的 dispatch 引用。你可以使用它來 dispatch actions。

講完 useDispatch Hooks,我們馬上來實踐一波,首先搞定我們 ”普通登入“ 的 Redux 化問題,讓我們開啟 src/components/LoginButton/index.js,對其中內容作出相應的修改如下:

import Taro from '@tarojs/taro'
import { AtButton } from 'taro-ui'
import { useDispatch } from '@tarojs/redux'

import { SET_IS_OPENED } from '../../constants'

export default function LoginButton(props) {
  const dispatch = useDispatch()

  return (
    <AtButton
      type="primary"
      onClick={() =>
        dispatch({ type: SET_IS_OPENED, payload: { isOpened: true } })
      }
    >
      普通登入
    </AtButton>
  )
}

可以看到,上面的內容主要有四塊改動:

  • 首先我們從 @tarojs/redux 中匯出 useDispatch API。
  • 接著我們從之前定義的常量檔案中匯出 SET_IS_OPENED 常量。
  • 然後,我們在 LoginButton 函式式元件中呼叫 useDispatch Hooks 來返回我們的 dispatch 函式,我們可以用它來 dispatch action 來修改 Redux store 的狀態
  • 最後我們將 AtButtononClick 接收的回撥函式進行替換,當按鈕點選時,我們發起一個 typeSET_IS_OPENED 的 action,並傳遞了一個 payload 引數,用於將 Redux store 裡面對應的 user 屬性中的 isOpened 修改為 true

搞定完 ”普通登入“,我們接著來收拾一下 ”微信登入“ 的邏輯,開啟 src/components/WeappLoginButton/index.js 檔案,對檔案的內容作出如下修改:

import Taro, { useState } from '@tarojs/taro'
import { Button } from '@tarojs/components'
import { useDispatch } from '@tarojs/redux'

import './index.scss'
import { SET_LOGIN_INFO } from '../../constants'

export default function WeappLoginButton(props) {
  const [isLogin, setIsLogin] = useState(false)

  const dispatch = useDispatch()

  async function onGetUserInfo(e) {
    setIsLogin(true)

    const { avatarUrl, nickName } = e.detail.userInfo

    await Taro.setStorage({
      key: 'userInfo',
      data: { avatar: avatarUrl, nickName },
    })

    dispatch({
      type: SET_LOGIN_INFO,
      payload: {
        avatar: avatarUrl,
        nickName,
      },
    })

    setIsLogin(false)
  }

  // return ...
}

可以看到,上面的改動和之前在 ”普通登入“ 裡面的改動類似:

  • 我們匯出了 useDispatch 鉤子
  • 匯出了 SET_LOGIN_INFO 常量
  • 然後我們將之前呼叫父元件傳下的 setLoginInfo 方法改成了 dispatch typeSET_LOGIN_INFO 的 action,因為我們的 avatarnickName 狀態已經在 store 中的 user 屬性中定義了,所以我們修改也是需要通過 dispatch action 來修改,最後我們將之前定義在父元件中的 Taro.setStorage 設定快取的方法移動到了子元件中,以保證相關資訊的改動具有一致性。

最後我們來搞定 ”支付寶登入“ 的 Redux 邏輯,開啟 src/components/AlipayLoginButton/index.js 對檔案內容作出對應的修改如下:

import Taro, { useState } from '@tarojs/taro'
import { Button } from '@tarojs/components'
import { useDispatch } from '@tarojs/redux'

import './index.scss'
import { SET_LOGIN_INFO } from '../../constants'

export default function AlipayLoginButton(props) {
  const [isLogin, setIsLogin] = useState(false)
  const dispatch = useDispatch()

  async function onGetAuthorize(res) {
    setIsLogin(true)
    try {
      let userInfo = await Taro.getOpenUserInfo()

      userInfo = JSON.parse(userInfo.response).response
      const { avatar, nickName } = userInfo

      await Taro.setStorage({
        key: 'userInfo',
        data: { avatar, nickName },
      })

      dispatch({
        type: SET_LOGIN_INFO,
        payload: {
          avatar,
          nickName,
        },
      })
    } catch (err) {
      console.log('onGetAuthorize ERR: ', err)
    }

    setIsLogin(false)
  }

  // return ...
}

可以看到,上面的改動和之前在 ”微信登入“ 裡面的改動幾乎一樣,所以這裡我們就不在重複講解啦 :)

useSelector Hooks 來捧場

一路跟下來的同學可能有點明白我們正在使用 Redux 我們之前的程式碼,而我們重構的思路也是先從 src/pages/mine/mine.jsx 中的 src/components/Header/index.jsx 開始,搞定完 Header.jsx 裡面的所有登入按鈕之後,接下來應該就輪到 Header.jsx 內的最後一個元件 src/components/LoggedMine/index.jsx 了。

因為在 LoggedMine 元件中我們要用到 useSelector Hooks,所以這裡我們先來講一下這個 Hooks。

useSelector Hooks

useSelector 允許你使用 selector 函式從一個 Redux Store 中獲取資料。

Selector 函式大致相當於 connect 函式的 mapStateToProps 引數。Selector 會在元件每次渲染時呼叫。useSelector 同樣會訂閱 Redux store,在 Redux action 被 dispatch 時呼叫。

useSelector 還是和 mapStateToProps 有一些不同:

  • 不像 mapStateToProps 只返回物件一樣,Selector 可能會返回任何值。
  • 當一個 action dispatch 時,useSelector 會把 selector 的前後返回值做一次淺對比,如果不同,元件會強制更新。
  • Selector 函式不接受 ownProps 引數。但 selector 可以通過閉包訪問函式式元件傳遞下來的 props。

好的,瞭解了 useSelector 的概念之後,我們馬上來實操一下,開啟 src/components/LoggedMine/index.jsx 檔案,對其中的內容作出如下的修改:

import Taro from '@tarojs/taro'
import { View, Image } from '@tarojs/components'
import { useSelector } from '@tarojs/redux'
import { AtAvatar } from 'taro-ui'

import './index.scss'

export default function LoggedMine(props) {
  const nickName = useSelector(state => state.user.nickName)
  const avatar = useSelector(state => state.user.avatar)

  function onImageClick() {
    Taro.previewImage({
      urls: [avatar],
    })
  }

  return (
    <View className="logged-mine">
      {avatar ? (
        <Image src={avatar} className="mine-avatar" onClick={onImageClick} />
      ) : (
        <AtAvatar size="large" circle text="雀" />
      )}
      <View className="mine-nickName">{nickName}</View>
    </View>
  )
}

可以看到,我們上面的程式碼主要有四處改動:

  • 首先我們從 @tarojs/redux 中匯出了 useSelector Hooks。
  • 接著我們使用了兩次 useSelector 分別從 Redux Store 裡面獲取了 nickNameavatar,它們位於 state.user 屬性下。
  • 接著我們將之前從 props 裡面獲取到的 nickNameavatar 替換成我們從 Redux store 裡面獲取到狀態,這裡我們為了使用者體驗,從 taro-ui 中匯出了一個 AtAvatar 元件用於展示在沒有 avatar 時的預設頭像。
  • 最後,在點選頭像進行預覽的 onImageClick 方法裡面,我們使用從 Redux store 裡面獲取到的 avatar

是時候收割最後一波 ”韭菜“ 了,讓我們徹底完成 Header/index.js 的 Redux 化,開啟 src/components/Header/index.js ,對其中的內容做出相應的修改如下:

// ...
import { useSelector } from '@tarojs/redux'

// import 各種元件 ...

export default function Header(props) {
  const nickName = useSelector(state => state.user.nickName)

  // 雙取反來構造字串對應的布林值,用於標誌此時是否使用者已經登入
  const isLogged = !!nickName

  const isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP
  const isAlipay = Taro.getEnv() === Taro.ENV_TYPE.ALIPAY

  return (
    <View className="user-box">
      <AtMessage />
      <LoggedMine />
      {!isLogged && (
        <View className="login-button-box">
          <LoginButton />
          {isWeapp && <WeappLoginButton />}
          {isAlipay && <AlipayLoginButton />}
        </View>
      )}
    </View>
  )
}

可以看到,上面的程式碼主要有五處主要的變動:

  • 首先我們匯出了 useSelector Hooks。
  • 接著我們使用 useSelector 中取到我們需要的 nickName 屬性,用於進行雙取反轉換成布林值 isLogged,表示是否登入。
  • 接著我們將之前從父元件獲取的 props.isLogged 屬性替換成新的從 isLogged
  • 接著,我們去掉 ”普通登入” 按鈕上不再需要的 handleClick 屬性和 “微信登入”、“支付寶登入” 上面不再需要的 setLoginInfo 屬性。
  • 最後,我們去掉 LoggedMine 元件上不再需要的 userInfo 屬性,因為我們已經在元件內部從使用 useSelector Hooks 從元件內部獲取了。

小結

在這一篇文章中,我們講解了 user 邏輯的狀態管理的重構,受限於篇幅,我們的 user 邏輯還剩下 Footer 部分沒有講解,在下一篇中,我們將首先講解使用 Hooks 版的 Redux 來重構 Footer 元件的狀態管理,接著,我們再來講解重構 post 部分的狀態管理。

想要學習更多精彩的實戰技術教程?來圖雀社群逛逛吧。

本文所涉及的原始碼都放在了 Github 上,如果您覺得我們寫得還不錯,希望您能給❤️這篇文章點贊+Github倉庫加星❤️哦~

本作品採用《CC 協議》,轉載必須註明作者和本文連結

圖雀社群

相關文章