我们研发开源了一款基于 Git 进行技术实战教程写作的工具,我们图雀社区的所有教程都是用这款工具写作而成,欢迎 Star 哦
如果你想快速了解如何使用,欢迎阅读我们的 教程文档哦
欢迎继续阅读《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仓库加星❤️哦~
组件化和逻辑复用能帮助写出简洁易懂的代码,随着应用越写越复杂,我们有必要把视图层中重复的逻辑抽成组件,以求在多个页面中复用;同时对于 Vuex 端,Store 中的逻辑也会越来越臃肿,我们有必要使用 Vuex 提供的 Getters 来复用本地数据获取逻辑。在这篇教程中,我们将带领你抽出 Vue 组件简化页面逻辑,使用 Vuex Getters 复用本地数据获取逻辑。
欢迎阅读《从零到部署:用 Vue 和 Express 实现迷你全栈电商应用》系列:
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(一)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(二)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(三)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(四)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(五)》(也就是这篇)
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(六)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(七)》
- 《 从零到部署:用 Vue 和 Express 实现迷你全栈电商应用(终篇)》
如果您觉得我们写得还不错,记得 点赞 + 关注 + 评论 三连,鼓励我们写出更好的教程💪
使用 Vue 组件简化页面逻辑
在前面的教程中,我们已经学习了如何使用 Vuex 进行状态管理,如何使用 Action 获取远程数据以及如何使用 Mutation 修改本地状态,实现了用户修改客户端数据的同时,同步更新后端数据,然后更新本地数据,最后进行重新渲染。
这一节我们将进一步通过 Vue 组件化的思想简化复杂的页面逻辑。
实现 ProductButton 组件
我们打开 src/components/products/ProductButton.vue
文件,它是用于操作商品在购物车中状态的按钮组件,代码如下:
<template>
<div>
<button v-if="isAdding" class="button" @click="addToCart">加入购物车</button>
<button v-else class="button" @click="removeFromCart(product._id)">从购物车移除</button>
</div>
</template>
<script>
export default {
props: ['product'],
computed: {
isAdding() {
let isAdding = true;
this.cart.map(product => {
if (product._id === this.product._id) {
isAdding = false;
}
});
return isAdding;
},
cart() {
return this.$store.state.cart;
}
},
methods: {
addToCart() {
this.$store.commit('ADD_TO_CART', {
product: this.product,
})
},
removeFromCart(productId) {
this.$store.commit('REMOVE_FROM_CART', {
productId,
})
}
}
}
</script>
该组件通过 v-if
判断 isAdding
是否为 true
来决定创建加入购物车按钮还是从购物车移除按钮。cart
数组是通过 this.$store.state.cart
从本地获取的。在 isAdding
中我们先令其为 true
,然后通过 cart
数组的 map
方法遍历数组,判断当前商品是否在购物车中,如果不在则 isAdding
为 true
,创建加入购物车按钮;如果在则 isAdding
为 false
,创建从购物车移除按钮。
对应的两个按钮添加了两个点击事件:addToCart
和removeFromCart
- 当点击加入购物车按钮时触发
addToCart
,我们通过this.$store.commit
的方式将包含当前商品的对象作为载荷直接提交到类型为ADD_TO_CART
的mutation
中,将该商品添加到本地购物车中。 - 当点击从购物车移除按钮时触发
removeFromCart
,我们也是通过this.$store.commit
的方式将包含当前商品id的对象作为载荷直接提交到类型为REMOVE_FROM_CART
的mutation
中,将该商品从本地购物车中移除。
实现 ProductItem 组件
src/components/products/ProductItem.vue
文件为商品信息组件,用来展示商品详细信息,并且注册了上面讲的按钮组件,改变商品在购物车中的状态,除此之外我们还使用了之前创建好的ProductButton
组件,实现对商品在购物车中的状态进行修改。
- 首先通过
import ProductButton from './ProductButton'
导入创建好的ProductButton
组件。 - 然后在
components
中注册组件。 - 最后在模板中使用该组件。
代码如下:
<template>
<div>
<div class="product">
<p class="product__name">产品名称:{{product.name}}</p>
<p class="product__description">介绍:{{product.description}}</p>
<p class="product__price">价格:{{product.price}}</p>
<p class="product.manufacturer">生产厂商:{{product.manufacturer.name}}</p>
<img :src="product.image" alt="" class="product__image">
<product-button :product="product"></product-button>
</div>
</div>
</template>
<script>
import ProductButton from './ProductButton';
export default {
name: 'product-item',
props: ['product'],
components: {
'product-button': ProductButton,
}
}
</script>
可以看到,我们将父组件传入的product
对象展示到模板中,并将该product
对象传到子组件ProductButton
中。
重构 ProductList 组件
有了 ProductButton 和 ProductItem,我们便可以来重构之前略显臃肿的 ProductList 组件了,修改 src/components/products/ProductList.vue
,代码如下:
// ...
This is ProductList
</div>
<template v-for="product in products">
<product-item :product="product" :key="product._id"></product-item>
</template>
</div>
</div>
// ...
</style>
<script>
import ProductItem from './ProductItem.vue';
export default {
name: 'product-list',
created() {
// ...
return this.$store.state.products;
}
},
components: {
'product-item': ProductItem
}
}
</script>
这部分代码是将之前展示商品信息的逻辑代码封装到了子组件ProductItem
中,然后导入并注册子组件ProductItem
,再将子组件挂载到模板中。
可以看到,我们通过this.$store.state.products
从本地获取products
数组,并返回给计算属性products
。然后在模板中利用v-for
遍历products
数组,并将每个product
对象传给每个子组件ProductItem
,在每个子组件中展示对应的商品信息。
重构 Cart 组件
最后,我们重构一波购物车组件 src/pages/Cart.vue
,也使用了子组件ProductItem
简化了页面逻辑,修改代码如下:
// ...
<h1>{{msg}}</h1>
</div>
<template v-for="product in cart">
<product-item :product="product" :key="product._id"></product-item>
</template>
</div>
</template>
// ...
</style>
<script>
import ProductItem from '@/components/products/ProductItem.vue';
export default {
name: 'home',
data () {
// ...
return this.$store.state.cart;
}
},
components: {
'product-item': ProductItem
}
}
</script>
这里也是首先导入并注册子组件ProductItem
,然后在模板中挂载子组件。通过this.$store.state.cart
的方式从本地获取购物车数组,并返回给计算属性cart
。在模板中通过v-for
遍历购物车数组,并将购物车中每个商品对象传给对应的子组件ProductItem
,通过子组件来展示对应的商品信息。
把项目开起来,查看商品列表,可以看到每个商品下面都增加了“添加到购物车”按钮:
购物车中,也有了“移出购物车”按钮:
尽情地买买买吧!
小结
这一节我们学习了如何使用 Vue 组件来简化页面逻辑:
- 首先我们需要通过
import
的方式导入子组件。 - 然后在
components
中注册子组件。 - 最后将子组件挂载到模板中,并将需要子组件展示的数据传给子组件。
使用 Vuex Getters 复用本地数据获取逻辑
在这一节中,我们将实现这个电商应用的商品详情页面。商品详情和之前商品列表在数据获取上的逻辑是非常一致的,能不能不写重复的代码呢?答案是肯定的。之前我们使用 Vuex 进行状态管理是通过 this.$store.state
的方式获取本地数据,而在这一节我们使用 Vuex Getters
来复用本地数据的获取逻辑。
Vuex
允许我们在 store
中定义“getter”(可以认为是 store
的计算属性)。就像计算属性一样,getter
的返回值会根据它的依赖被缓存起来,且只有当它的依赖值发生了改变才会被重新计算。
Getter
也是定义在 Vuex Store 的 getter
属性中的一系列方法,用于获取本地状态中的数据。我们可以通过两种方式访问 getter
,一个是通过属性访问,另一个是通过方法访问:
- 属性访问的方式为
this.$store.getter.allProducts
,对应的getter
如下:
allProducts(state) {
// 返回本地中的数据
return state.products;
}
- 方法访问的方式为
this.$store.getter.productById(id)
,对应的getter
如下:
productById: (state, getters) => id => {
//通过传入的id参数进行一系列操作并返回本地数据
return state.product;
}
我们可以看到Getter
可以接受两个参数:state
和getters
,state
就表示本地数据源;我们可以通过第二个参数getters
获取到不同的getter
属性。
定义 Vuex Getters
光说不练假把式,我们来手撸几个 getters。打开 src/store/index.js
文件,我们添加了一些需要用到的 action
属性、mutation
属性以及这一节的主角—— getters
。代码如下:
// ...
state.showLoader = false;
state.products = products;
},
PRODUCT_BY_ID(state) {
state.showLoader = true;
},
PRODUCT_BY_ID_SUCCESS(state, payload) {
state.showLoader = false;
const { product } = payload;
state.product = product;
}
},
getters: {
allProducts(state) {
return state.products;
},
productById: (state, getters) => id => {
if (getters.allProducts.length > 0) {
return getters.allProducts.filter(p => p._id == id)[0];
} else {
return state.product;
}
}
},
actions: {
// ...
commit('ALL_PRODUCTS')
axios.get(`${API_BASE}/products`).then(response => {
commit('ALL_PRODUCTS_SUCCESS', {
products: response.data,
});
})
},
productById({ commit }, payload) {
commit('PRODUCT_BY_ID');
const { productId } = payload;
axios.get(`${API_BASE}/products/${productId}`).then(response => {
commit('PRODUCT_BY_ID_SUCCESS', {
product: response.data,
});
})
}
}
});
这里主要添加了三部分内容:
- 在
actions
中添加了productById
属性,当视图层通过指定id分发到类型为PRODUCT_BY_ID
的action
中,这里会进行异步操作从后端获取指定商品,并将该商品提交到对应类型的mutation
中,就来到了下一步。 - 在
mutations
中添加了PRODUCT_BY_ID
和PRODUCT_BY_ID_SUCCESS
属性,响应指定类型提交的事件,将提交过来的商品保存到本地。 - 添加了
getters
并在getters
中添加了allProducts
属性和productById
方法,用于获取本地数据。在allProducts
中获取本地中所有的商品;在productById
通过传入的id查找本地商品中是否存在该商品,如果存在则返回该商品,如果不存在则返回空对象。
在后台 Products 组件中使用 Getters
我们先通过一个简单的例子演示如果使用 Vuex Getters。打开后台商品组件,src/pages/admin/Products.vue
,我们通过属性访问的方式调用对应的 getter
属性,从而获取本地商品,代码如下:
// ...
export default {
computed: {
product() {
return this.$store.getters.allProducts[0];
}
}
}
// ...
我们通过this.$store.getters.allProducts
属性访问的方式调用对应getter
中的allProducts
属性,并返回本地商品数组中的第一个商品。
创建 ProductDetail 组件
接着开始实现商品详情组件 src/components/products/ProductDetail.vue
,代码如下:
<template>
<div class="product-details">
<div class="product-details__image">
<img :src="product.image" alt="" class="image">
</div>
<div class="product-details__info">
<div class="product-details__description">
<small>{{product.manufacturer.name}}</small>
<h3>{{product.name}}</h3>
<p>
{{product.description}}
</p>
</div>
<div class="product-details__price-cart">
<p>{{product.price}}</p>
<product-button :product="product"></product-button>
</div>
</div>
</div>
</template>
<style>
.product-details__image .image {
width: 100px;
height: 100px;
}
</style>
<script>
import ProductButton from './ProductButton';
export default {
props: ['product'],
components: {
'product-button': ProductButton
}
}
</script>
该组件将父组件传入的product
对象展示在了模板中,并复用了ProductButton
组件。
在 ProductItem 组件中添加链接
有了商品详情,我们还需要进入详情的链接。再次进入 src/components/products/ProductItem.vue
文件中,我们对其进行了修改,将模板中的商品信息用 Vue 原生组件 router-link
包裹起来,实现商品信息可点击查看详情。代码如下:
<template>
<div>
<div class="product">
<router-link :to="'/detail/' + product._id" class="product-link">
<p class="product__name">产品名称:{{product.name}}</p>
<p class="product__description">介绍:{{product.description}}</p>
<p class="product__price">价格:{{product.price}}</p>
<p class="product.manufacturer">生产厂商:{{product.manufacturer.name}}</p>
<img :src="product.image" alt="" class="product__image">
</router-link>
<product-button :product="product"></product-button>
</div>
</div>
</template>
<style>
.product {
border-bottom: 1px solid black;
}
.product__image {
width: 100px;
height: 100px;
}
</style>
<script>
import ProductButton from './ProductButton';
export default {
// ...
该组件经过修改之后实现了点击商品的任何一条信息,都会触发路由跳转到商品详情页,并将该商品id通过动态路由的方式传递到详情页。
在 ProductList 中使用 Getters
修改商品列表组件 src/components/products/ProductList.vue
文件,使用了 Vuex Getters 复用了本地数据获取逻辑,代码如下:
// ...
</div>
</template>
<script>
import ProductItem from './ProductItem.vue';
export default {
// ...
computed: {
// a computed getter
products() {
return this.$store.getters.allProducts;
}
},
components: {
// ...
我们在计算属性products
中使用this.$store.getters.allProducts
属性访问的方式调用getters
中的allProducts
属性,我们也知道在对应的getter
中获取到了本地中的products
数组。
创建 Detail 页面组件
实现了 ProductDetail 子组件之后,我们便可以搭建商品详情我页面组件 src/pages/Detail.vue
,代码如下:
<template>
<div>
<product-detail :product="product"></product-detail>
</div>
</template>
<script>
import ProductDetail from '@/components/products/ProductDetail.vue';
export default {
created() {
// 跳转到详情时,如果本地状态里面不存在此商品,从后端获取此商品详情
const { name } = this.product;
if (!name) {
this.$store.dispatch('productById', {
productId: this.$route.params['id']
});
}
},
computed: {
product() {
return this.$store.getters.productById(this.$route.params['id']);
}
},
components: {
'product-detail': ProductDetail,
}
}
</script>
该组件中定义了一个计算属性product
,用于返回本地状态中指定的商品。这里我们使用了this.$store.getters.productById(id)
方法访问的方式获取本地中指定的商品,这里的id参数通过this.$route.params['id']
从当前处于激活状态的路由对象中获取,并传入对应的getter
中,进而从本地中获取指定商品。
在该组件刚被创建时判断当前本地中是否有该商品,如果没有则通过this.$store.dispatch
的方式将包含当前商品id的对象作为载荷分发到类型为productById
的action
中,在action
中进行异步操作从后端获取指定商品,然后提交到对应的mutation
中进行本地状态修改,这已经使我们习惯的思路了。
配置 Detail 页面的路由
最后我们打开路由配置 src/router/index.js
文件,导入了 Detail
组件,并添加了对应的路由参数,代码如下:
// ...
import Home from '@/pages/Home';
import Cart from '@/pages/Cart';
import Detail from '@/pages/Detail';
// Admin Components
import Index from '@/pages/admin/Index';
// ...
name: 'Cart',
component: Cart,
},
{
path: '/detail/:id',
name: 'Detail',
component: Detail,
}
],
});
又到了验收的环节,运行项目,点击单个商品,可以进入到商品详情页面,并且数据是完全一致的:
小结
这一节中我们学会了如何使用Vuex Getters
来复用本地数据的获取逻辑:
- 我们需要先在
store
实例中添加getters
属性,并在getters
属性中定义不同的属性或者方法。 - 在这些不同类型的
getter
中,我们可以获取本地数据。 - 我们可以通过属性访问和方法访问的方式来调用我们的
getter
。
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。