歡迎繼續閱讀《Taro 小程式開發大型實戰》系列,前情回顧:
- 熟悉的 React,熟悉的 Hooks:我們用 React 和 Hooks 實現了一個非常簡單的新增帖子的原型
- 多頁面跳轉和 Taro UI 元件庫:我們用 Taro 自帶的路由功能實現了多頁面跳轉,並用 Taro UI 元件庫升級了應用介面
- 實現微信和支付寶多端登入:實現了微信、支付寶以及普通登入和退出登入
如果你跟著敲到了這裡,你一定會發現現在 的狀態管理和資料流越來越臃腫,元件狀態的更新非常複雜。在這一篇中,我們將開始用 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
接收兩個引數:rootReducer
和 applyMiddleware(...middlewares)
。
rootReducer
是響應 action
的 reducer
,這裡我們匯出了一個 rootReducer
,代表組合了所有的 reducer
,我們將在後面 “組合 User 和 Post Reducer“ 中講到它。
createStore
函式的第二個引數我們使用了 redux
為我們提供的工具函式 applyMiddleware
來在 Redux 中注入需要使用的中介軟體,因為它接收的引數是 (args1, args2, args3, ..., argsn)
的形式,所以這裡我們用了陣列展開運算子 ...
來展開 middlewares
陣列。
編寫 User Reducer
建立完 Store 之後,我們接在來編寫 Reducer。回到我們的頁面邏輯,我們在底部有兩個 Tab 欄,一個為 “首頁”,一個為 “我的”,在 ”首頁“ 裡面主要是展示一列文章和允許新增文章等,在 ”我的“ 裡面主要是允許使用者進行登入並展示登入資訊,所以整體上我們的邏輯有兩類,我們分別將其命名為 post
和 user
,接下來我們將建立處理這兩類邏輯的 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
用於修改 avatar
和 nickName
屬性,當 switch
語句中沒有匹配到任何 action.type
值時,它返回原 state。
提示
根據 Redux 最近實踐,這裡的
SET_IS_OPENED
和SET_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 類似,我們將之前需要多元件中共享的狀態 posts
和 isOpened
提取出來儲存在 post
的狀態裡,這裡的 post
函式主要響應 SET_POSTS
邏輯,用於新增新的 post
到 posts
狀態種,以及 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.js
和 src/reducers/post.js
中匯入需要使用的常量時都是從 ../constants
的形式,那是因為我們在 src/constants
資料夾下建立了一個 index.js
檔案,用於統一匯出所有的常量,這也是程式碼可維護性的一種嘗試。
export * from './user'
export * from './post'
組合 User 和 Post Reducer
我們在之前將整個全域性的響應邏輯分別拆分到了 src/reducers/user.js
和 src/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.js
和 post.js
,並使用物件簡介寫法傳給 combineReducers
函式並匯出,通過 combineReducers
將邏輯進行組合並匯出為 rootReducer
作為引數在我們的 src/store/index.js
的 createStore
函式中使用。
這裡的 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 的狀態 - 最後我們將
AtButton
的onClick
接收的回撥函式進行替換,當按鈕點選時,我們發起一個type
為SET_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
方法改成了 dispatchtype
為SET_LOGIN_INFO
的 action,因為我們的avatar
和nickName
狀態已經在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 裡面獲取了nickName
和avatar
,它們位於state.user
屬性下。 - 接著我們將之前從
props
裡面獲取到的nickName
和avatar
替換成我們從 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 協議》,轉載必須註明作者和本文連結