0%

Taro 小程序开发大型实战(四):使用 Hooks 版的 Redux 实现应用状态管理

@tuture-dev

查看代码

双剑合璧:Hooks + Redux

欢迎继续阅读《Taro 小程序开发大型实战》系列,前情回顾:

如果你跟着敲到了这里,你一定会发现现在的状态管理和数据流越来越臃肿,组件状态的更新非常复杂。在这一篇中,我们将开始用 Redux 重构。

如果你不熟悉 Redux,推荐阅读我们的《Redux 包教包会》系列教程:

如果你希望直接从这一步开始,请运行以下命令:

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

提示

这一篇的内容很长,要敲的代码很多,建议先收藏,在有空的时候抽出一大块时间去实践哦。相信经过这一篇的洗礼,不仅能对 Redux 的理解更上一层楼,Taro 的开发功力也能大增!

写到这一步,我们发现状态已经有点多了,而且 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
package.json查看完整代码
  "dependencies": {
"@tarojs/components": "2.0.0-beta.10",
"@tarojs/components-qa": "2.0.0-beta.10",
[tuture-add] "@tarojs/redux": "^2.0.0-beta.12",
[tuture-add] "@tarojs/redux-h5": "^2.0.0-beta.12",
"@tarojs/router": "2.0.0-beta.10",
"@tarojs/taro": "2.0.0-beta.10",
"@tarojs/taro-alipay": "2.0.0-beta.10",
[tuture-omit]
"nerv-devtools": "^1.5.5",
"nervjs": "^1.5.5",
"prop-types": "^15.7.2",
[tuture-add] "redux": "^4.0.5",
[tuture-add] "redux-logger": "^3.0.6",
"taro-ui": "^2.2.4"
},
"devDependencies": {

可以看到,除了我们熟悉的 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:

src/store/index.js查看完整代码
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 相应的内容如下:

src/reducers/user.js查看完整代码
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 文件,在其中添加内容如下:

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,并在其中编写相应的内容如下:

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 文件,在其中添加内容如下:

src/constants/post.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 文件,用于统一导出所有的常量,这也是代码可维护性的一种尝试。

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 文件,并在其中编写如下内容:

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,对其中的内容作出如下修改:

src/app.jsx查看完整代码
import Taro, { Component } from '@tarojs/taro'
[tuture-del]import Index from './pages/index'
[tuture-add]import { Provider } from '@tarojs/redux'

[tuture-add]import configStore from './store'
[tuture-add]import Index from './pages/index'
import './app.scss'

// 如果需要在 h5 环境中开启 React Devtools
[tuture-omit]
// require('nerv-devtools')
// }

[tuture-add]const store = configStore()
[tuture-add]
class App extends Component {
config = {
pages: ['pages/index/index', 'pages/mine/mine', 'pages/post/post'],
[tuture-omit]
// 在 App 类中的 render() 函数没有实际作用
// 请勿修改此函数
render() {
[tuture-del] return <Index />
[tuture-add] return (
[tuture-add] <Provider store={store}>
[tuture-add] <Index />
[tuture-add] </Provider>
[tuture-add] )
}
}

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

  • 我们导入了 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,对其中内容作出相应的修改如下:

src/components/LoginButton/index.js查看完整代码
import Taro from '@tarojs/taro'
import { AtButton } from 'taro-ui'
[tuture-add]import { useDispatch } from '@tarojs/redux'
[tuture-add]
[tuture-add]import { SET_IS_OPENED } from '../../constants'

export default function LoginButton(props) {
[tuture-add] const dispatch = useDispatch()
[tuture-add]
return (
[tuture-del] <AtButton type="primary" onClick={props.handleClick}>
[tuture-add] <AtButton
[tuture-add] type="primary"
[tuture-add] onClick={() =>
[tuture-add] dispatch({ type: SET_IS_OPENED, payload: { isOpened: true } })
[tuture-add] }
[tuture-add] >
普通登录
</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 文件,对文件的内容作出如下修改:

src/components/WeappLoginButton/index.js查看完整代码
import Taro, { useState } from '@tarojs/taro'
import { Button } from '@tarojs/components'
[tuture-add]import { useDispatch } from '@tarojs/redux'

import './index.scss'
[tuture-add]import { SET_LOGIN_INFO } from '../../constants'

[tuture-del]export default function LoginButton(props) {
[tuture-add]export default function WeappLoginButton(props) {
const [isLogin, setIsLogin] = useState(false)

[tuture-add] const dispatch = useDispatch()
[tuture-add]
async function onGetUserInfo(e) {
setIsLogin(true)

const { avatarUrl, nickName } = e.detail.userInfo
[tuture-del] await props.setLoginInfo(avatarUrl, nickName)
[tuture-add]
[tuture-add] await Taro.setStorage({
[tuture-add] key: 'userInfo',
[tuture-add] data: { avatar: avatarUrl, nickName },
[tuture-add] })
[tuture-add]
[tuture-add] dispatch({
[tuture-add] type: SET_LOGIN_INFO,
[tuture-add] payload: {
[tuture-add] avatar: avatarUrl,
[tuture-add] nickName,
[tuture-add] },
[tuture-add] })

setIsLogin(false)
}

可以看到,上面的改动和之前在 ”普通登录“ 里面的改动类似:

  • 我们导出了 useDispatch 钩子
  • 导出了 SET_LOGIN_INFO 常量
  • 然后我们将之前调用父组件传下的 setLoginInfo 方法改成了 dispatch typeSET_LOGIN_INFO 的 action,因为我们的 avatarnickName 状态已经在 store 中的 user 属性中定义了,所以我们修改也是需要通过 dispatch action 来修改,最后我们将之前定义在父组件中的 Taro.setStorage 设置缓存的方法移动到了子组件中,以保证相关信息的改动具有一致性。

最后我们来搞定 ”支付宝登录“ 的 Redux 逻辑,打开 src/components/AlipayLoginButton/index.js 对文件内容作出对应的修改如下:

src/components/AlipayLoginButton/index.js查看完整代码
import Taro, { useState } from '@tarojs/taro'
import { Button } from '@tarojs/components'
[tuture-add]import { useDispatch } from '@tarojs/redux'

import './index.scss'
[tuture-add]import { SET_LOGIN_INFO } from '../../constants'

[tuture-del]export default function LoginButton(props) {
[tuture-add]export default function AlipayLoginButton(props) {
const [isLogin, setIsLogin] = useState(false)
[tuture-add] const dispatch = useDispatch()

async function onGetAuthorize(res) {
setIsLogin(true)
[tuture-omit]
userInfo = JSON.parse(userInfo.response).response
const { avatar, nickName } = userInfo

[tuture-del] await props.setLoginInfo(avatar, nickName)
[tuture-add] await Taro.setStorage({
[tuture-add] key: 'userInfo',
[tuture-add] data: { avatar, nickName },
[tuture-add] })
[tuture-add]
[tuture-add] dispatch({
[tuture-add] type: SET_LOGIN_INFO,
[tuture-add] payload: {
[tuture-add] avatar,
[tuture-add] nickName,
[tuture-add] },
[tuture-add] })
} catch (err) {
console.log('onGetAuthorize ERR: ', err)
}

可以看到,上面的改动和之前在 ”微信登录“ 里面的改动几乎一样,所以这里我们就不在重复讲解啦 :)

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 文件,对其中的内容作出如下的修改:

src/components/LoggedMine/index.jsx查看完整代码
import Taro from '@tarojs/taro'
import { View, Image } from '@tarojs/components'
[tuture-del]import PropTypes from 'prop-types'
[tuture-add]import { useSelector } from '@tarojs/redux'
[tuture-add]import { AtAvatar } from 'taro-ui'

import './index.scss'
[tuture-del]import avatar from '../../images/avatar.png'

export default function LoggedMine(props) {
[tuture-del] const { userInfo = {} } = props
[tuture-add] const nickName = useSelector(state => state.user.nickName)
[tuture-add] const avatar = useSelector(state => state.user.avatar)
[tuture-add]
function onImageClick() {
Taro.previewImage({
[tuture-del] urls: [userInfo.avatar],
[tuture-add] urls: [avatar],
})
}

return (
<View className="logged-mine">
[tuture-del] <Image
[tuture-del] src={userInfo.avatar ? userInfo.avatar : avatar}
[tuture-del] className="mine-avatar"
[tuture-del] onClick={onImageClick}
[tuture-del] />
[tuture-del] <View className="mine-nickName">
[tuture-del] {userInfo.nickName ? userInfo.nickName : '图雀酱'}
[tuture-del] </View>
[tuture-del] <View className="mine-username">{userInfo.username}</View>
[tuture-add] {avatar ? (
[tuture-add] <Image src={avatar} className="mine-avatar" onClick={onImageClick} />
[tuture-add] ) : (
[tuture-add] <AtAvatar size="large" circle text="雀" />
[tuture-add] )}
[tuture-add] <View className="mine-nickName">{nickName}</View>
</View>
)
}
[tuture-del]
[tuture-del]LoggedMine.propTypes = {
[tuture-del] avatar: PropTypes.string,
[tuture-del] nickName: PropTypes.string,
[tuture-del] username: PropTypes.string,
[tuture-del]}

可以看到,我们上面的代码主要有四处改动:

  • 首先我们从 @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 ,对其中的内容做出相应的修改如下:

src/components/Header/index.js查看完整代码
import Taro from '@tarojs/taro'
import { View } from '@tarojs/components'
import { AtMessage } from 'taro-ui'
[tuture-add]import { useSelector } from '@tarojs/redux'

import LoggedMine from '../LoggedMine'
import LoginButton from '../LoginButton'
[tuture-omit]
import './index.scss'

export default function Header(props) {
[tuture-add] const nickName = useSelector(state => state.user.nickName)
[tuture-add]
[tuture-add] // 双取反来构造字符串对应的布尔值,用于标志此时是否用户已经登录
[tuture-add] const isLogged = !!nickName
[tuture-add]
const isWeapp = Taro.getEnv() === Taro.ENV_TYPE.WEAPP
const isAlipay = Taro.getEnv() === Taro.ENV_TYPE.ALIPAY

return (
<View className="user-box">
<AtMessage />
[tuture-del] <LoggedMine userInfo={props.userInfo} />
[tuture-del] {!props.isLogged && (
[tuture-add] <LoggedMine />
[tuture-add] {!isLogged && (
<View className="login-button-box">
[tuture-del] <LoginButton handleClick={props.handleClick} />
[tuture-del] {isWeapp && <WeappLoginButton setLoginInfo={props.setLoginInfo} />}
[tuture-del] {isAlipay && <AlipayLoginButton setLoginInfo={props.setLoginInfo} />}
[tuture-add] <LoginButton />
[tuture-add] {isWeapp && <WeappLoginButton />}
[tuture-add] {isAlipay && <AlipayLoginButton />}
</View>
)}
</View>

可以看到,上面的代码主要有五处主要的变动:

  • 首先我们导出了 useSelector Hooks。
  • 接着我们使用 useSelector 中取到我们需要的 nickName 属性,用于进行双取反转换成布尔值 isLogged,表示是否登录。
  • 接着我们将之前从父组件获取的 props.isLogged 属性替换成新的从 isLogged
  • 接着,我们去掉 ”普通登录” 按钮上不再需要的 handleClick 属性和 “微信登录”、“支付宝登录” 上面不再需要的 setLoginInfo 属性。
  • 最后,我们去掉 LoggedMine 组件上不再需要的 userInfo 属性,因为我们已经在组件内部从使用 useSelector Hooks 从组件内部获取了。

本来这个小标题我是不想起的,但是因为,是吧,大家上面在没有小标题的情况下看了这么久,可能已经废(累)了,所以我就贴心的加上一个小标题,帮助你定位接下来讲解的重心。

是的接下来,我们要重构 “我的” tab 页面中的下半部分组件 src/components/Footer/index.js 我们遵循自顶向下的方式来重构,首先是 src/components/Logout/index.js 文件,我们打开这个文件,对其中内容作出如下修改:

src/components/Logout/index.js查看完整代码
[tuture-del]import Taro from '@tarojs/taro'
[tuture-add]import Taro, { useState } from '@tarojs/taro'
import { AtButton } from 'taro-ui'
[tuture-add]import { useDispatch } from '@tarojs/redux'
[tuture-add]
[tuture-add]import { SET_LOGIN_INFO } from '../../constants'

export default function LoginButton(props) {
[tuture-add] const [isLogout, setIsLogout] = useState(false)
[tuture-add] const dispatch = useDispatch()
[tuture-add]
[tuture-add] async function handleLogout() {
[tuture-add] setIsLogout(true)
[tuture-add]
[tuture-add] try {
[tuture-add] await Taro.removeStorage({ key: 'userInfo' })
[tuture-add]
[tuture-add] dispatch({
[tuture-add] type: SET_LOGIN_INFO,
[tuture-add] payload: {
[tuture-add] avatar: '',
[tuture-add] nickName: '',
[tuture-add] },
[tuture-add] })
[tuture-add] } catch (err) {
[tuture-add] console.log('removeStorage ERR: ', err)
[tuture-add] }
[tuture-add]
[tuture-add] setIsLogout(false)
[tuture-add] }
[tuture-add]
return (
[tuture-del] <AtButton
[tuture-del] type="secondary"
[tuture-del] full
[tuture-del] loading={props.loading}
[tuture-del] onClick={props.handleLogout}
[tuture-del] >
[tuture-add] <AtButton type="secondary" full loading={isLogout} onClick={handleLogout}>
退出登录
</AtButton>
)

这一步可能是最能体现引入 Redux 进行状态管理带来好处的一步了 – 我们将之前至上而下的 React 状态管理逻辑压平,使得底层组件可以在自身中就解决响应的状态和逻辑问题。

可以看到,我们上面的文件中主要有五处改动:

  • 首先我们从 @tarojs/taro 里面导出 useState Hooks。
  • 接着我们将之前在 src/pages/mine/mine.js 中定义的 isLogout 状态移动到组件 Logout 组件内部来,因为它只和此组件有关系。
  • 接着我们用 isLogout 替换在 AtButton 里面用到的 props.loading 属性。
  • 然后,我们考虑将之前按钮点击调用 props.handleLogout Redux 化,我们将这个点击之后的回调函数 handleLogout 在组件内部定义。
  • 最后,我们从 @tarojs/redux 中导入 useDispatch Hooks,并在组件中调用成我们需要的 dispatch 函数,接着我们在 handleLogout 函数中去 dispatch 一个 SET_LOGIN_INFO action 来重置 Store 中的 nickNameavatar 属性。

提示

这里我们在组件内定义的 handleLogout 函数和我们之前在 src/pages/mine/mine.js 中定义的类似,只是使用 dispatch action 的方式替换了重置 nickNameavatar 的部分。

搞定完 Logout 组件,接着就是 LoginForm 组件的重构了,让我们快马加鞭,让它也接受 Redux 光环的洗礼吧!

打开 src/components/LoginForm/index.jsx ,对其中的内容作出相应的修改如下:

src/components/LoginForm/index.jsx查看完整代码
import Taro, { useState } from '@tarojs/taro'
import { View, Form } from '@tarojs/components'
import { AtButton, AtImagePicker } from 'taro-ui'
[tuture-add]import { useDispatch } from '@tarojs/redux'

[tuture-add]import { SET_LOGIN_INFO, SET_IS_OPENED } from '../../constants'
import './index.scss'

export default function LoginForm(props) {
[tuture-add] // Login Form 登录数据
[tuture-add] const [formNickName, setFormNickName] = useState('')
[tuture-add] const [files, setFiles] = useState([])
const [showAddBtn, setShowAddBtn] = useState(true)

[tuture-add] const dispatch = useDispatch()
[tuture-add]
function onChange(files) {
if (files.length > 0) {
setShowAddBtn(false)
[tuture-add] } else {
[tuture-add] setShowAddBtn(true)
}

[tuture-del] props.handleFilesSelect(files)
[tuture-add] setFiles(files)
}

function onImageClick() {
[tuture-omit]
})
}

[tuture-add] async function handleSubmit(e) {
[tuture-add] e.preventDefault()
[tuture-add]
[tuture-add] // 鉴权数据
[tuture-add] if (!formNickName || !files.length) {
[tuture-add] Taro.atMessage({
[tuture-add] type: 'error',
[tuture-add] message: '您还有内容没有填写!',
[tuture-add] })
[tuture-add]
[tuture-add] return
[tuture-add] }
[tuture-add]
[tuture-add] setShowAddBtn(true)
[tuture-add]
[tuture-add] // 提示登录成功
[tuture-add] Taro.atMessage({
[tuture-add] type: 'success',
[tuture-add] message: '恭喜您,登录成功!',
[tuture-add] })
[tuture-add]
[tuture-add] // 缓存在 storage 里面
[tuture-add] const userInfo = { avatar: files[0].url, nickName: formNickName }
[tuture-add]
[tuture-add] // 清空表单状态
[tuture-add] setFiles([])
[tuture-add] setFormNickName('')
[tuture-add]
[tuture-add] // 缓存在 storage 里面
[tuture-add] await Taro.setStorage({ key: 'userInfo', data: userInfo })
[tuture-add]
[tuture-add] dispatch({ type: SET_LOGIN_INFO, payload: userInfo })
[tuture-add]
[tuture-add] // 关闭弹出层
[tuture-add] dispatch({ type: SET_IS_OPENED, payload: { isOpened: false } })
[tuture-add] }
[tuture-add]
return (
<View className="post-form">
[tuture-del] <Form onSubmit={props.handleSubmit}>
[tuture-add] <Form onSubmit={handleSubmit}>
<View className="login-box">
<View className="avatar-selector">
<AtImagePicker
length={1}
mode="scaleToFill"
count={1}
[tuture-del] files={props.files}
[tuture-add] files={files}
showAddBtn={showAddBtn}
onImageClick={onImageClick}
onChange={onChange}
[tuture-omit]
className="input-nickName"
type="text"
placeholder="点击输入昵称"
[tuture-del] value={props.formNickName}
[tuture-del] onInput={props.handleNickNameInput}
[tuture-add] value={formNickName}
[tuture-add] onInput={e => setFormNickName(e.target.value)}
/>
<AtButton formType="submit" type="primary">
登录

这一步和上一步类似,可能也是最能体现引入 Redux 进行状态管理带来好处的一步了,我们同样将之前在顶层组件中提供的状态压平到了底层组件内部。

可以看到,我们上面的文件中主要有四处改动:

  • 首先我们将 formNickNamefiles 等状态放置到 LoginForm 组件内部,并使用 useState Hooks 管理起来,因为它们只和此组件有关系。
  • 接着,我们将 AtImagePicker 里面的 props.files 替换成 files,将它的 onChange 回调函数内部的设置改变状态的 props.handleFilesSelect(files) 替换成 setFiles(files)。可以看到这里我们还对 files.length = 0 的形式做了一个判断,当没有选择图片时,要把我们选择图片的按钮显示出来。
  • 接着,我们将 Input 组件的 props.formNickName 替换成 formNickName,将之前 onInput 接收的回调函数换成了 setFormNickName 的形式来设置 formNickName 的变化。
  • 接着,我们将之前提交表单需要调用的父组件方法 props.handleSubmit 移动到组件内部来定义,可以看到,这个 hanldeSubmit 组合了之前在 src/components/Footer/index.jsxsrc/pages/mine/mine.js 组件里的 handleSubmit 逻辑:
    • 首先使用 e.preventDefault 禁止浏览器默认行为。
    • 接着进行数据验证,不合要求的数据就会被驳回并显示错误(其实这里应该显示警告 warning,当时写代码时石乐志😅)。
    • 接着因为 LoginForm 表单数据要被清除,所以我们将选中图片的按钮又设置为可显示状态。
    • 接着提示登录成功。
    • 清空表单状态。
    • 将登录数据缓存在 storage 里面,在 Taro 里面使用 Taro.setStorage({ key, data }) 的形式来缓存,其中 key 是字符串,data 是字符串或者对象。
      • 最后我们导出了 useDispatch Hooks,使用 useDispatch Hooks 生成的 dispatch 函数的引用来发起更新 Redux store 的 action 来更新本地数据,typeSET_LOGIN_INFO 的 action 用来更新用户登录信息,typeSET_IS_OPENED 的 action 用来更新 isOpened 属性,它将关闭展示登录框的弹出层 FloatLayout 组件。

讲到这里,我们的 Footer 部分的重构大业还剩下临门一脚了。让我们打开 src/components/Footer/index.js 文件,立马来重构它:

src/components/Footer/index.js查看完整代码
[tuture-del]import Taro, { useState } from '@tarojs/taro'
[tuture-add]import Taro from '@tarojs/taro'
import { View } from '@tarojs/components'
import { AtFloatLayout } from 'taro-ui'
[tuture-add]import { useSelector, useDispatch } from '@tarojs/redux'

import Logout from '../Logout'
import LoginForm from '../LoginForm'
import './index.scss'
[tuture-add]import { SET_IS_OPENED } from '../../constants'

export default function Footer(props) {
[tuture-del] // Login Form 登录数据
[tuture-del] const [formNickName, setFormNickName] = useState('')
[tuture-del] const [files, setFiles] = useState([])
[tuture-add] const nickName = useSelector(state => state.user.nickName)

[tuture-del] async function handleSubmit(e) {
[tuture-del] e.preventDefault()
[tuture-add] const dispatch = useDispatch()

[tuture-del] // 鉴权数据
[tuture-del] if (!formNickName || !files.length) {
[tuture-del] Taro.atMessage({
[tuture-del] type: 'error',
[tuture-del] message: '您还有内容没有填写!',
[tuture-del] })
[tuture-add] // 双取反来构造字符串对应的布尔值,用于标志此时是否用户已经登录
[tuture-add] const isLogged = !!nickName

[tuture-del] return
[tuture-del] }
[tuture-del]
[tuture-del] // 提示登录成功
[tuture-del] Taro.atMessage({
[tuture-del] type: 'success',
[tuture-del] message: '恭喜您,登录成功!',
[tuture-del] })
[tuture-del]
[tuture-del] // 缓存在 storage 里面
[tuture-del] const userInfo = { avatar: files[0].url, nickName: formNickName }
[tuture-del] await props.handleSubmit(userInfo)
[tuture-del]
[tuture-del] // 清空表单状态
[tuture-del] setFiles([])
[tuture-del] setFormNickName('')
[tuture-del] }
[tuture-add] // 使用 useSelector Hooks 获取 Redux Store 数据
[tuture-add] const isOpened = useSelector(state => state.user.isOpened)

return (
<View className="mine-footer">
[tuture-del] {props.isLogged && (
[tuture-del] <Logout loading={props.isLogout} handleLogout={props.handleLogout} />
[tuture-del] )}
[tuture-add] {isLogged && <Logout />}
<View className="tuture-motto">
[tuture-del] {props.isLogged ? 'From 图雀社区 with Love ❤' : '您还未登录'}
[tuture-add] {isLogged ? 'From 图雀社区 with Love ❤' : '您还未登录'}
</View>
<AtFloatLayout
[tuture-del] isOpened={props.isOpened}
[tuture-add] isOpened={isOpened}
title="登录"
[tuture-del] onClose={() => props.handleSetIsOpened(false)}
[tuture-add] onClose={() =>
[tuture-add] dispatch({ type: SET_IS_OPENED, payload: { isOpened: false } })
[tuture-add] }
>
[tuture-del] <LoginForm
[tuture-del] formNickName={formNickName}
[tuture-del] files={files}
[tuture-del] handleSubmit={e => handleSubmit(e)}
[tuture-del] handleNickNameInput={e => setFormNickName(e.target.value)}
[tuture-del] handleFilesSelect={files => setFiles(files)}
[tuture-del] />
[tuture-add] <LoginForm />
</AtFloatLayout>
</View>
)

可以看到上面的代码主要有五处改动:

  • 首先我们已经将 nickName 抽取到 Redux store 保存的状态中,所以之前从父组件获取的 props.isLogged 判断是否登录的信息,我们移动到组件内部来,使用 useSelector Hooks 从 Redux store 从获取 nickName 属性,进行双取反操作成布尔值来表示是否已经登录的 isLogged 属性,并使用它来替换之前的 props.isLogged 属性。
  • 接着,就是取代之前从父组件获取的 props.isOpened 属性,我们使用 useSelector Hooks 从 Redux store 中获取对应的 isOpened 属性,然后替换之前的 props.isOpened,用户控制登录框窗口的弹出层 AtFloatLayout 的打开和关闭。
  • 接着,我们将之前 AtFloatLayout 关闭时(onClose)的回调函数替换成 dispatch 一个 typeSET_IS_OPENED 的 action 来设置 isOpened 属性将 AtFloatLayout 关闭。
  • 接着,我们开始移除 LogoutLoginForm 组件上不再需要传递的属性,因为在对应的组件中我们已经声明了对应的属性了。
  • 最后,我们删掉之前定义在 Footer 组件内的 formNickNamefiles 等状态,以及不再需要的 handleSubmit 函数,因为它已经在 LoginForm 里面定义了。

完成 “我的” 页面重构

熟悉套路的同学可能都知道起这个标题的含义了吧 😏。

我们一路打怪重构到这里,相比眼尖的人已经摸清楚 Redux 的套路了,结合 Redux 来写 React 代码,就好比 “千里之堤,始于垒土” 一般,我们先把所有细小的分支组件搞定,进而一步一步向顶层组件进发,以完成所有组件的编写。

而这个 src/pages/mine/mine.jsx 组件就是 “我的” 这一 tab 页面的顶层组件了,也是我们在 “我的” 页面需要重构的最后一个页面了,是的,我们马上就要达到第一阶段性胜利了✌️。现在就打开这个文件,对其中的内容作出如下的修改:

src/pages/mine/mine.jsx查看完整代码
[tuture-del]import Taro, { useState, useEffect } from '@tarojs/taro'
[tuture-add]import Taro, { useEffect } from '@tarojs/taro'
import { View } from '@tarojs/components'
[tuture-add]import { useDispatch } from '@tarojs/redux'

import { Header, Footer } from '../../components'
import './mine.scss'
[tuture-add]import { SET_LOGIN_INFO } from '../../constants'

export default function Mine() {
[tuture-del] const [nickName, setNickName] = useState('')
[tuture-del] const [avatar, setAvatar] = useState('')
[tuture-del] const [isOpened, setIsOpened] = useState(false)
[tuture-del] const [isLogout, setIsLogout] = useState(false)
[tuture-del]
[tuture-del] // 双取反来构造字符串对应的布尔值,用于标志此时是否用户已经登录
[tuture-del] const isLogged = !!nickName
[tuture-add] const dispatch = useDispatch()

useEffect(() => {
async function getStorage() {
[tuture-omit]
const { data } = await Taro.getStorage({ key: 'userInfo' })

const { nickName, avatar } = data
[tuture-del] setAvatar(avatar)
[tuture-del] setNickName(nickName)
[tuture-add]
[tuture-add] // 更新 Redux Store 数据
[tuture-add] dispatch({ type: SET_LOGIN_INFO, payload: { nickName, avatar } })
} catch (err) {
console.log('getStorage ERR: ', err)
}
[tuture-omit]
getStorage()
})

[tuture-del] async function setLoginInfo(avatar, nickName) {
[tuture-del] setAvatar(avatar)
[tuture-del] setNickName(nickName)
[tuture-del]
[tuture-del] try {
[tuture-del] await Taro.setStorage({
[tuture-del] key: 'userInfo',
[tuture-del] data: { avatar, nickName },
[tuture-del] })
[tuture-del] } catch (err) {
[tuture-del] console.log('setStorage ERR: ', err)
[tuture-del] }
[tuture-del] }
[tuture-del]
[tuture-del] async function handleLogout() {
[tuture-del] setIsLogout(true)
[tuture-del]
[tuture-del] try {
[tuture-del] await Taro.removeStorage({ key: 'userInfo' })
[tuture-del]
[tuture-del] setAvatar('')
[tuture-del] setNickName('')
[tuture-del] } catch (err) {
[tuture-del] console.log('removeStorage ERR: ', err)
[tuture-del] }
[tuture-del]
[tuture-del] setIsLogout(false)
[tuture-del] }
[tuture-del]
[tuture-del] function handleSetIsOpened(isOpened) {
[tuture-del] setIsOpened(isOpened)
[tuture-del] }
[tuture-del]
[tuture-del] function handleClick() {
[tuture-del] handleSetIsOpened(true)
[tuture-del] }
[tuture-del]
[tuture-del] async function handleSubmit(userInfo) {
[tuture-del] // 缓存在 storage 里面
[tuture-del] await Taro.setStorage({ key: 'userInfo', data: userInfo })
[tuture-del]
[tuture-del] // 设置本地信息
[tuture-del] setAvatar(userInfo.avatar)
[tuture-del] setNickName(userInfo.nickName)
[tuture-del]
[tuture-del] // 关闭弹出层
[tuture-del] setIsOpened(false)
[tuture-del] }
[tuture-del]
return (
<View className="mine">
[tuture-del] <Header
[tuture-del] isLogged={isLogged}
[tuture-del] userInfo={{ avatar, nickName }}
[tuture-del] handleClick={handleClick}
[tuture-del] setLoginInfo={setLoginInfo}
[tuture-del] />
[tuture-del] <Footer
[tuture-del] isLogged={isLogged}
[tuture-del] isOpened={isOpened}
[tuture-del] isLogout={isLogout}
[tuture-del] handleLogout={handleLogout}
[tuture-del] handleSetIsOpened={handleSetIsOpened}
[tuture-del] handleSubmit={handleSubmit}
[tuture-del] />
[tuture-add] <Header />
[tuture-add] <Footer />
</View>
)
}

可以看到,上面的代码做了一下五处改动:

  • 我们导入了 useDispatch Hooks 和 SET_LOGIN_INFO 常量,并把之前在 getStorage 方法里面设置 nickNameavatar 的操作替换成了 dispatch 一个 typeSET_LOGIN_INFO 的 action。
  • 接着我们删除不再需要的 formNickNamefilesisLogoutisOpened 状态,以及 setLoginInfohandleLogouthandleSetIsOpenedhandleClickhandleSubmit 方法。
  • 最后我们删除 HeaderFooter 组件上不再不需要的属性。

大功告成🥈!这里给你颁发一个银牌,以奖励你能一直坚持阅读并跟到这里,我们这一篇教程很长很长,能跟下来的都不容易,希望你能在心里或用实际行动给自己鼓鼓掌👏。

小憩一下,恢复精力,整装待发!很多同学可能很好奇了,为什么还只能拿一个银牌呢?那是因为我们的重构进程才走了一半呀✌️,但是不要担心,我们所有新的东西都已经讲完了,接下来就只是一些收尾工作了,当你能坚持到终点的时候,会有惊喜等着你哦!加油吧骚年💪。

开始重构 “首页” 之旅

我们依然按照之前的套路,从最底层的组件开始重构,首先是我们的登录框弹出层 LoginForm 组件,让我们打开 src/components/PostForm/index.jsx 文件,对其中的内容作出相应的修改如下:

src/components/PostForm/index.jsx查看完整代码
[tuture-del]import Taro from '@tarojs/taro'
[tuture-del]import { View, Form, Input, Textarea, Button } from '@tarojs/components'
[tuture-add]import Taro, { useState } from '@tarojs/taro'
[tuture-add]import { View, Form, Input, Textarea } from '@tarojs/components'
import { AtButton } from 'taro-ui'
[tuture-add]import { useDispatch, useSelector } from '@tarojs/redux'

import './index.scss'
[tuture-add]import { SET_POSTS, SET_POST_FORM_IS_OPENED } from '../../constants'

export default function PostForm(props) {
[tuture-add] const [formTitle, setFormTitle] = useState('')
[tuture-add] const [formContent, setFormContent] = useState('')
[tuture-add]
[tuture-add] const nickName = useSelector(state => state.user.nickName)
[tuture-add] const avatar = useSelector(state => state.user.avatar)
[tuture-add]
[tuture-add] const dispatch = useDispatch()
[tuture-add]
[tuture-add] async function handleSubmit(e) {
[tuture-add] e.preventDefault()
[tuture-add]
[tuture-add] if (!formTitle || !formContent) {
[tuture-add] Taro.atMessage({
[tuture-add] message: '您还有内容没有填写完哦',
[tuture-add] type: 'warning',
[tuture-add] })
[tuture-add]
[tuture-add] return
[tuture-add] }
[tuture-add]
[tuture-add] dispatch({
[tuture-add] type: SET_POSTS,
[tuture-add] payload: {
[tuture-add] post: {
[tuture-add] title: formTitle,
[tuture-add] content: formContent,
[tuture-add] user: { nickName, avatar },
[tuture-add] },
[tuture-add] },
[tuture-add] })
[tuture-add]
[tuture-add] setFormTitle('')
[tuture-add] setFormContent('')
[tuture-add]
[tuture-add] dispatch({
[tuture-add] type: SET_POST_FORM_IS_OPENED,
[tuture-add] payload: { isOpened: false },
[tuture-add] })
[tuture-add]
[tuture-add] Taro.atMessage({
[tuture-add] message: '发表文章成功',
[tuture-add] type: 'success',
[tuture-add] })
[tuture-add] }
[tuture-add]
return (
<View className="post-form">
[tuture-del] <Form onSubmit={props.handleSubmit}>
[tuture-add] <Form onSubmit={handleSubmit}>
<View>
<View className="form-hint">标题</View>
<Input
className="input-title"
type="text"
placeholder="点击输入标题"
[tuture-del] value={props.formTitle}
[tuture-del] onInput={props.handleTitleInput}
[tuture-add] value={formTitle}
[tuture-add] onInput={e => setFormTitle(e.target.value)}
/>
<View className="form-hint">正文</View>
<Textarea
placeholder="点击输入正文"
className="input-content"
[tuture-del] value={props.formContent}
[tuture-del] onInput={props.handleContentInput}
[tuture-add] value={formContent}
[tuture-add] onInput={e => setFormContent(e.target.value)}
/>
<AtButton formType="submit" type="primary">
提交

这个文件的形式和我们之前的 src/components/LoginForm/index.jsx 文件类似,可以看到,我们上面的文件中主要有四处改动:

  • 首先我们将 formTitleformContent 等状态放置到 PostForm 组件内部,并使用 useState Hooks 管理起来,因为它们只和此组件有关系。
  • 接着,我们将 Input 里面的 props.formTitle 替换成 formTitle,将它的 onInput 回调函数内部的设置改变状态的 props. handleTitleInput 替换成 setFormTitle(e.target.value) 的回调函数。
  • 接着,我们将 Textarea 组件的 props. formContent 替换成 formContent,将之前 onInput 接收的回调函数换成了 setFormContent 的形式来设置 formContent 的变化。
  • 最后,我们将之前提交表单需要调用的父组件方法 props.handleSubmit 移动到组件内部来定义,可以看到,这个 hanldeSubmit 和我们之前定义在 src/pages/index/index.js 组件里的 handleSubmit 逻辑类似:
    • 首先使用 e.preventDefault 禁止浏览器默认行为。
    • 接着进行数据验证,不合要求的数据就会被驳回并显示警告(这里我们又显示对了😅)。
    • 接着 dispatch 一个 typeSET_POSTS 的 action,将新发表的 post 添加到 Redux store 对应的 posts 数组中。我们注意到这里我们使用 useSelector Hooks 从 Redux store 里面获取了 nickNameavatar 属性,并把它们组合到 post.user 属性里,随着 action 的 payload 一起被 dispatch,我们用这个 user 属性标志发帖的用户属性。
    • 清空表单状态。
    • 接着我们 dispatch 一个 typeSET_POST_FORM_IS_OPENED 的 action 用来更新 isOpened 属性,它将关闭展示发表帖子的表单弹出层 FloatLayout 组件。
    • 最后提示发帖成功。

接着是我们 “首页” 页面组件另外一个底层子组件 PostCard,它主要用于展示一个帖子,让我们 src/components/PostCard/index.jsx 文件,对其中的内容作出对应的修改如下:

src/components/PostCard/index.jsx查看完整代码
import Taro from '@tarojs/taro'
import { View } from '@tarojs/components'
import classNames from 'classnames'
[tuture-add]import { AtAvatar } from 'taro-ui'

import './index.scss'

export default function PostCard(props) {
[tuture-add] // 注意:
[tuture-add] const { title = '', content = '', user } = props.post
[tuture-add] const { avatar, nickName } = user || {}
[tuture-add]
const handleClick = () => {
// 如果是列表,那么就响应点击事件,跳转到帖子详情
if (props.isList) {
[tuture-del] const { title, content } = this.props
Taro.navigateTo({
[tuture-del] url: `/pages/post/post?title=${title}&content=${content}`,
[tuture-add] url: `/pages/post/post?postId=${props.postId}`,
})
}
}

[tuture-add] const slicedContent =
[tuture-add] props.isList && content.length > 66
[tuture-add] ? `${content.slice(0, 66)} ...`
[tuture-add] : content
[tuture-add]
return (
<View
[tuture-del] className={classNames('postcard', { postcard__isList: props.isList })}
[tuture-add] className={classNames('at-article', { postcard__isList: props.isList })}
onClick={handleClick}
>
[tuture-del] <View className="post-title">{props.title}</View>
[tuture-del] <View className="post-content">{props.content}</View>
[tuture-add] <View className="post-header">
[tuture-add] <View className="at-article__h1">{title}</View>
[tuture-add] <View className="profile-box">
[tuture-add] <AtAvatar circle size="small" image={avatar} />
[tuture-add] <View className="at-article__info post-nickName">{nickName}</View>
[tuture-add] </View>
[tuture-add] </View>
[tuture-add] <View className="at-article__content">
[tuture-add] <View className="at-article__section">
[tuture-add] <View className="at-article__p">{slicedContent}</View>
[tuture-add] </View>
[tuture-add] </View>
</View>
)
}

PostCard.defaultProps = {
isList: '',
[tuture-add] post: [],
}

可以看到这个组件基本不保有自己的状态,它接收来自父组件的状态,我们对它的修改主要有下面五个部分:

  • 将之前的直接获取 props.titleprops.content 放到了 props.post 属性中,我们从 props.post 属性中导出我们需要展示的 titlecontent,还要一个额外的 user 属性,它应该是一个对象,保存着发帖人的用户属性,我们使用解构的方法获取 user.avataruser.nickName 的值。
  • 接着我们看到 return 的组件结构发生了很大的变化,这里我们为了方便,使用了 taro-ui 提供给我们的 Article 文章样式组件,用于展示类似微信公众号文章页的一些样式,可供用户快速呈现文章内容,可以详情可以查看 taro-ui 链接,有了 taro-ui 加持,我们就额外的展示了发表此文章的用户头像(avatar)和昵称(nickName)。
  • 我们还可以看到,这里我们对原 content 做了一点修改,当 PostCard 组件在文章列表中被引用的时候,我们对内容长度进行截断,当超过 66 字符时,我们就截断它,并加上省略号 ...
  • 最后,我们改动了 handleClick 方法,之前是在跳转路由的页面路径里直接带上查询参数 titlecontent ,当我们要传递的内容多了,这个路径就会显得很臃肿,所以这里我们传递此文章对应的 id,这样可以通过此 id 取到完整的 post 数据,使路径保持简洁,这也是最佳实践的推荐做法。

接着我们补充一下在 PostCard 组件里面会用到的样式,打开 src/components/PostCard/index.scss 文件,补充和改进对应的样式如下:

src/components/PostCard/index.scss查看完整代码
[tuture-add]@import '~taro-ui/dist/style/components/article.scss';
[tuture-add]
.postcard {
margin: 30px;
padding: 20px;
}

.postcard__isList {
[tuture-del] border: 1px solid #ddd;
[tuture-add] border-bottom: 1px solid #ddd;
[tuture-add] padding-bottom: 20px;
[tuture-add]}
[tuture-add]
[tuture-add].post-header {
[tuture-add] display: flex;
[tuture-add] flex-direction: column;
[tuture-add] align-items: center;
}

[tuture-del].post-title {
[tuture-del] font-weight: bolder;
[tuture-del] margin-bottom: 10px;
[tuture-add].profile-box {
[tuture-add] display: flex;
[tuture-add] flex-direction: row;
[tuture-add] align-items: center;
}

[tuture-del].post-content {
[tuture-del] font-size: medium;
[tuture-del] color: #666;
[tuture-add].post-nickName {
[tuture-add] color: #777;
}

可以看到我们更新了一些样式,然后引入了 taro-ui 提供给我们的 article 文章样式。

重构完 “首页” 页面组件的所有底层组件,我们开始完成最终的顶层组件,打开 src/pages/index/index.jsx 文件,对相应的内容修改如下:

src/pages/index/index.jsx查看完整代码
[tuture-del]import Taro, { useState } from '@tarojs/taro'
[tuture-del]import { View } from '@tarojs/components'
[tuture-add]import Taro, { useEffect } from '@tarojs/taro'
[tuture-add]import { View, Text } from '@tarojs/components'
import { AtFab, AtFloatLayout, AtMessage } from 'taro-ui'
[tuture-add]import { useSelector, useDispatch } from '@tarojs/redux'

import { PostCard, PostForm } from '../../components'
import './index.scss'
[tuture-add]import { SET_POST_FORM_IS_OPENED, SET_LOGIN_INFO } from '../../constants'

export default function Index() {
[tuture-del] const [posts, setPosts] = useState([
[tuture-del] {
[tuture-del] title: '泰罗奥特曼',
[tuture-del] content: '泰罗是奥特之父和奥特之母唯一的亲生儿子。',
[tuture-del] },
[tuture-del] ])
[tuture-del] const [formTitle, setFormTitle] = useState('')
[tuture-del] const [formContent, setFormContent] = useState('')
[tuture-del] const [isOpened, setIsOpened] = useState(false)
[tuture-add] const posts = useSelector(state => state.post.posts) || []
[tuture-add] const isOpened = useSelector(state => state.post.isOpened)
[tuture-add] const nickName = useSelector(state => state.user.nickName)

[tuture-del] function handleSubmit(e) {
[tuture-del] e.preventDefault()
[tuture-add] const isLogged = !!nickName

[tuture-del] const newPosts = posts.concat({ title: formTitle, content: formContent })
[tuture-del] setPosts(newPosts)
[tuture-del] setFormTitle('')
[tuture-del] setFormContent('')
[tuture-del] setIsOpened(false)
[tuture-add] const dispatch = useDispatch()

[tuture-del] Taro.atMessage({
[tuture-del] message: '发表文章成功',
[tuture-del] type: 'success',
[tuture-del] })
[tuture-add] useEffect(() => {
[tuture-add] async function getStorage() {
[tuture-add] try {
[tuture-add] const { data } = await Taro.getStorage({ key: 'userInfo' })
[tuture-add]
[tuture-add] const { nickName, avatar } = data
[tuture-add]
[tuture-add] // 更新 Redux Store 数据
[tuture-add] dispatch({ type: SET_LOGIN_INFO, payload: { nickName, avatar } })
[tuture-add] } catch (err) {
[tuture-add] console.log('getStorage ERR: ', err)
[tuture-add] }
[tuture-add] }
[tuture-add]
[tuture-add] getStorage()
[tuture-add] })
[tuture-add]
[tuture-add] function setIsOpened(isOpened) {
[tuture-add] dispatch({ type: SET_POST_FORM_IS_OPENED, payload: { isOpened } })
}

[tuture-add] function handleClickEdit() {
[tuture-add] if (!isLogged) {
[tuture-add] Taro.atMessage({
[tuture-add] type: 'warning',
[tuture-add] message: '您还未登录哦!',
[tuture-add] })
[tuture-add] } else {
[tuture-add] setIsOpened(true)
[tuture-add] }
[tuture-add] }
[tuture-add]
[tuture-add] console.log('posts', posts)
[tuture-add]
return (
<View className="index">
<AtMessage />
{posts.map((post, index) => (
[tuture-del] <PostCard
[tuture-del] key={index}
[tuture-del] title={post.title}
[tuture-del] content={post.content}
[tuture-del] isList
[tuture-del] />
[tuture-add] <PostCard key={index} postId={index} post={post} isList />
))}
<AtFloatLayout
isOpened={isOpened}
title="发表新文章"
onClose={() => setIsOpened(false)}
>
[tuture-del] <PostForm
[tuture-del] formTitle={formTitle}
[tuture-del] formContent={formContent}
[tuture-del] handleSubmit={e => handleSubmit(e)}
[tuture-del] handleTitleInput={e => setFormTitle(e.target.value)}
[tuture-del] handleContentInput={e => setFormContent(e.target.value)}
[tuture-del] />
[tuture-add] <PostForm />
</AtFloatLayout>
<View className="post-button">
[tuture-del] <AtFab onClick={() => setIsOpened(true)}>
[tuture-add] <AtFab onClick={handleClickEdit}>
<Text className="at-fab__icon at-icon at-icon-edit"></Text>
</AtFab>
</View>

可以看到我们上面的内容有以下五处改动:

  • 首先我们导出了 useSelector 钩子,然后从 Redux store 中获取了 postsisOpenednickName 等属性。
  • 接着,我们将之前定义在 PostCard 组件上的属性进行了一次换血,之前是直接传递 titlecontent 属性,现在我们传递整个 post 属性,并且额外传递了一个 postId 属性,用于在 PostCard 里面点击跳转路由时进行标注。
  • 接着,我们去掉 PostForm 组件上面的所有属性,因为我们已经在组件内部定义了它们。
  • 接着,我们使用 useEffect Hooks,在里面定义并调用了 getStorage 方法,获取了我们保存在 storage 里面的用户登录信息,如果用户登录了,我们 dispatch 一个 typeSET_LOGIN_INFO 的 action,将这份登录信息保存在 Redux store 里面以供后续使用。
  • 最后,我们将 AtFabonClick 回调函数替换成 handleClickEdit,在其中对用户点击进行判断,如果用户未登录,那么弹出警告,告知用户,如果用户已经登录,那么就 dispatch 一个 typeSET_POST_FORM_IS_OPENED 的 action 去设置 isOpened 属性,打开发帖的弹出层,允许用户进行发帖操作。

以重构 “文章详情” 页结束

最后,让我们坚持一下,跑赢重构工作的最后一公里💪!完成 “文章详情” 页的重构。

让我们打开 src/pages/post/post.jsx 文件,对其中的内容作出相应的修改如下:

src/pages/post/post.jsx查看完整代码
import Taro, { useRouter } from '@tarojs/taro'
import { View } from '@tarojs/components'
[tuture-del]import { PostCard } from '../../components'
[tuture-add]import { useSelector } from '@tarojs/redux'

[tuture-add]import { PostCard } from '../../components'
import './post.scss'

export default function Post() {
const router = useRouter()
[tuture-del] const { params } = router
[tuture-add] const { postId } = router.params
[tuture-add]
[tuture-add] const posts = useSelector(state => state.post.posts)
[tuture-add] const post = posts[postId]
[tuture-add]
[tuture-add] console.log('posts', posts, postId)

return (
<View className="post">
[tuture-del] <PostCard title={params.title} content={params.content} />
[tuture-add] <PostCard post={post} />
</View>
)
}

可以看到,上面的文件做了以下四处修改:

  • 我们从 router.params 中导出了 postId,因为之前我们在 PostCard 里面点击跳转的路径参数使用了 postId
  • 接着我们导入并使用 useSelector Hooks 获取了保存在 Redux store 中的 posts 属性,然后使用上一步获取到的 postId,来获取我们最终要渲染的 post 属性。
  • 最后,我们将传给 PostCard 的属性改成上一步获取到的 post

注意

这里的 console.log 是调试时使用的,生产环境中建议删掉。

查看效果

可以看到,在未登录状态下,会提示请登录:

在已登录的情况下,发帖子会显示当前登录用户的头像和昵称:

小结

有幸!到这里,我们 Redux 重构之旅的万里长征就跑完了!让我们来回顾一下我们在这一小节中学到了那些东西。

  • 首先我们讲解了使用 Redux 的初衷,接着我们安装了相关依赖,然后引出了 Redux 三大核心概念:Store、Action、Reducers,接着我们创建了应用需要的两个 Reducer:postuser;接着我们将将 Redux 和 React 整合起来;因为 Action 是从组件中 dispatch 出来了,所以我们接下来就开始了组件的重构之旅。

  • 在重构 “我的” 页面组件时,我们按照 Redux 的思想,从它的底层组件三个登录按钮重构开始,接着重构了 LoggedMine 组件,再往上就是 Header 组件;重构完 Header 组件之后,我们接着从 Footer 组件的底层组件 Logout 组件开始重构,然后重构了 LoginForm 组件,最后是 Footer 组件,重构完 HeaderFooter 组件,我们开始重构其上层组件 mine 页面组件,自此我们就完成了 “我的” 页面的重构。

  • 在重构 “首页” 页面组件时,我们同样按照 Redux 的思想,从它的底层组件 PostForm 组件开始,接着是 PostCard 组件,最后再回到顶层组件 index 首页页面组件。

在重构 “帖子详情” 页面组件时,因为其底层组件 PostCard 已经重构过了,所以我们就直接重构了 post 帖子详情页面组件。

能跟着这么长的文章坚持到这里,我想给你鼓个掌,也希望你能给自己鼓个掌,我想,我可以非常肯定且自豪的颁布给你第一名的奖章了🥇。

终于,这漫长的第四篇结束了。在接下来的文章中,我们将接触小程序云后台开发,并在前端接入后台数据。

图雀社区 微信公众号

扫一扫关注上方公众号,拉学习群和答疑解惑

  • 本文作者: 图雀社区
  • 本文链接: https://tuture.co/2019/12/25/5e100f7/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!