完成底部导航、登陆注册实现
前言
上一章节,从 0 开始搭建出一套以 React 技术栈为基础的前端开发环境,过程中肯定会遇到各奇怪怪的问题,比如 Node 版本问题,工具包的版本问题,插件下载完之后,无法得到自己想要的效果等等,这也是很正常的,所以得耐心百度啊,留言问我也行的。
倘若学会了这一套搭建流程,根据项目需求,灵活的切换组件库、PC 版、甚至是主框架。这对提升自己的知识广度很有帮助,因为相比每次都看教程,自己手动实现一遍,印象会更深刻,遇到问题也能通过自己的认知,去解决它。
话不多说,直接开肝。
知识点
- 编写底部导航栏
- 创建图标公用组件
- 路由控制底部导航栏的显隐
- 组件:
Cell、Input、Button、CheckBox。
一.编写底部导航栏
先观察今天要实现的底部导航长啥样,如下所示:
上图红框中的底部导航栏,在很多业务场景下都是需要的,三个导航栏对应着三个不同的三个页面组件,分别是「账单」、「统计」、「我的」。这三个页面组件是需要导航栏的。如果点击内页如账单详情页,则底部的导航栏会被隐藏,这就需要在导航栏的控制上,下一些功夫。
话不多说,在上一章基础上添加导航栏组件,在 src 目录下新建 components 目录,专门用于放置一些公用组件,再在 components 目录下新建 NavBar 目录,用于编写底部导航栏,代码如下所示:
Nav/index.jsx
import React, { useState } from 'react';
import PropTypes from 'prop-types'
import { TabBar } from 'zarm';
import { useHistory } from 'react-router-dom';
import s from './style.module.less';
const NavBar = ({ showNav }) => {
const [activeKey, setActiveKey] = useState('/');
const history = useHistory()
const changeTab = (path) => {
setActiveKey(path)
history.push(path)
}
return (
<TabBar visible={showNav} className={s.tab} activeKey={activeKey} onChange={changeTab}>
<TabBar.Item
itemKey="/"
title="账单"
/>
<TabBar.Item
itemKey="/data"
title="统计"
/>
<TabBar.Item
itemKey="/user"
title="我的"
/>
</TabBar>
);
};
NavBar.propTypes = {
showNav: PropTypes.bool
}
export default NavBar;
代码解析:
首先是声明 NavBar 函数组件,它接收一个外部传入的 showNav 属性,用于控制导航栏的显示隐藏。
通过 useHistory 钩子方法,拿到路由实例 history,它内部含有很多路由的方法,在上述代码中,使用到的是 history.push 进行路由跳转。
在页面中,引入 TabBar 组件,它接受几个属性:
- visible:用于控制导航栏的显示隐藏。
- activeKey:当前被点击的导航栏。
- onChange:点击导航栏之后的回调方法,
path参数为TabBar.Item的itemKey属性。
TabBar 官方文档: zarm.gitee.io/#/component…
所以当你点击导航栏的时候,changeTab 方法便会被触发,执行内部的 setActiveKey 和 history.push,他们的作用分别是设置当前点击的高亮和让页面跳转到对应的页面组件。
说到跳转到对应的组件,'/'、'/data'、'/user' 这三个路由对应的三个组件还未编写,这里在 pages 目录下新建这三个页面组件,作为占位。
// Home/index.jsx
import React from 'react'
const Home = () => {
return <div>首页</div>
}
export default Home
// Data/index.jsx
import React from 'react'
const Data = () => {
return <div>数据</div>
}
export default Data
// User/index.jsx
import React from 'react'
const User = () => {
return <div>个人中心</div>
}
export default User
别忘了,前往 router/index.js 添加路由配置,如果不添加这个配置,调用 history.push 这个方法,就无法匹配到对应的页面组件,代码如下:
// router/index.js
import Home from '@/pages/Home'
import Data from '@/pages/Data'
import User from '@/pages/User'
const routes = [
{
path: "/",
component: Home
},
{
path: "/data",
component: Data
},
{
path: "/user",
component: User
}
];
export default routes
这时,还缺少一步,将导航栏组件引入 App.jsx 入口页面,如下所示:
// App.jsx
...
import NavBar from '@/components/NavBar';
...
function App() {
return <Router>
<ConfigProvider primaryColor={'#007fff'}>
<Switch>
{
routes.map(route => <Route exact key={route.path} path={route.path}>
<route.component />
</Route>)
}
</Switch>
</ConfigProvider>
<NavBar showNav={true} />
</Router>
}
通过 npm run dev 启动项目,浏览器展示效果如下所示:
上图效果所示,注意地址栏的变化,点击相应的 Tab,调用的 history.push 方法,将地址栏的 pathname 改变,随之而来的是页面组件的改变。这个就应证了之前解释的单页面路由控制的原理。history.push 做的事情就是改变地址栏,地址栏一旦改变,就会触发地址所对应的组件渲染,如 /data,渲染的就是 Data 页面组件。
你会问为什么导航栏会一直显示在底部,来分析以下代码:
红色框是组件展示的区域,每个路径对应着一个组件,这个在 router/index.js 文件中也有所体现。
绿色框则代表导航栏的位置,也就是说,无论上面的组件怎么变化,底部的导航栏一直都是存在的。
二.添加底部导航图标
将图标写成公共组件,这样便于后面各个页面方便引入,新建 components/CustomIcon/index.jsx,添加如下代码:
import { Icon } from 'zarm';
export default Icon.createFromIconfont('//at.alicdn.com/t/font_2236655_w1mpqp7n1ni.js');
上述代码,引入 Icon,执行它的自定义图标方法 createFromIconfont,它接收一个参数,为 iconfont 生产的静态脚本路径,你可以自己去 官网 配置,也可以直接用我提供的:
里我已经为大家添加好了各个图标,地址就是上述代码的地址。
接着将其引入到代码中使用,打开 components/NavBar/index.jsx ,添加如下属性:
import CustomIcon from '../CustomIcon';
...
<TabBar.Item
itemKey="/"
title="账单"
icon={<CustomIcon type="zhangdan" />}
/>
<TabBar.Item
itemKey="/data"
title="统计"
icon={<CustomIcon type="tongji" />}
/>
<TabBar.Item
itemKey="/user"
title="我的"
icon={<CustomIcon type="wode" />}
/>
查看浏览器展示效果如下:
三.底部导航栏的显示隐藏
在之前引入 NavBar 的代码中,将 showNav 属性写死为 true。此时,需要将其盘活,打开 App.jsx,添加如下代码:
import React, { useEffect, useState } from 'react'
import {
BrowserRouter as Router,
Switch,
Route,
useLocation
} from "react-router-dom"
import NavBar from '@/components/NavBar';
import { ConfigProvider } from 'zarm'
import routes from '@/router'
function App() {
const location = useLocation() // 拿到 location 实例
const { pathname } = location // 获取当前路径
const needNav = ['/', '/data', '/user'] // 需要底部导航栏的路径
const [showNav, setShowNav] = useState(false) // 是否展示 Nav
useEffect(() => {
setShowNav(needNav.includes(pathname))
}, [pathname]) // [] 内的参数若是变化,便会执行上述回调函数=
return <Router>
<ConfigProvider primaryColor={'#007fff'}>
<Switch>
{
routes.map(route => <Route exact key={route.path} path={route.path}>
<route.component />
</Route>)
}
</Switch>
</ConfigProvider>
<NavBar showNav={true} />
</Router>
}
export default App
当你刷新浏览器,控制台应该会报下面的错误:
执行 useLocation 时,报错 location of undefined。这是因为想要在函数组件内执行 useLocation,该组件必须被 Router 高阶组件包裹,做如下改动,将 App.jsx 的 Router 组件,前移到 main.jsx 内,如下:
逻辑分析:
拿到 pathname,将其设置为 useEffect 钩子函数的第二个参数,监听它的变化,一旦 pathname 变化,便会触发回调函数执行 setShowNav(needNav.includes(pathname)),结果会传递给 NavBar 组件,从而控制组件的显示隐藏。needNav 为需要底部导航的路径值。
不妨做个测试,在 pages 目录下新建一个测试页面组件 Detail,并且添加路由配置。
别忘记把组件属性修改成动态变量:
<NavBar showNav={showNav} />
查看浏览器就没问题了。
实现了底部导航栏,并且创建了三个主页面,这三个页面是需要展示底部导航栏,现在来制作的「登录注册页面」便是不需要底部导航栏的单独页面。
四.注册页面
系统是面向多用户的,换句话说也就是一个纯正的 C 端项目,任何人都可以通过网站,注册一个新的账号。接下来开始注册页面的编写。
首先新建 Login 文件夹,在文件夹内添加两个文件 index.jsx 和 style.module.less,先把注册页面的静态页面切出来,首先给 index.jsx 添加如下代码:
import React from 'react'
import s from './style.module.less'
const Login = () => {
return <div className={s.auth}>
注册
</div>
}
export default Login
为它添加一个路由配置,打开 router/index.js 添加如下:
import Login from '@/pages/Login'
...
{
path: "/login",
component: Login
}
重启项目,如下所示代表登录注册页面创建成功了:
接下来为 Login/index.jsx 添加静态页面代码:
import React from 'react'
import { Cell, Input, Button, Checkbox } from 'zarm'
import CustomIcon from '@/components/CustomIcon'
import s from './style.module.less'
const Login = () => {
return <div className={s.auth}>
<div className={s.head} />
<div className={s.tab}>
<span>注册</span>
</div>
<div className={s.form}>
<Cell icon={<CustomIcon type="zhanghao" />}>
<Input
clearable
type="text"
placeholder="请输入账号"
/>
</Cell>
<Cell icon={<CustomIcon type="mima" />}>
<Input
clearable
type="password"
placeholder="请输入密码"
/>
</Cell>
<Cell icon={<CustomIcon type="mima" />}>
<Input
clearable
type="text"
placeholder="请输入验证码"
/>
</Cell>
</div>
<div className={s.operation}>
<div className={s.agree}>
<Checkbox />
<label className="text-light">阅读并同意<a>《掘掘手札条款》</a></label>
</div>
<Button block theme="primary">注册</Button>
</div>
</div>
}
export default Login
样式部分就不展开说明了,布局也可一根据自己的喜好去写布局,若有需要可以留言我发给你。
上述代码中,关键部分是账号输入、密码输入、验证码输入,这三个输入框是需要获取数据作为接口的参数提交上去的。
很多时候,服务端没有开发好接口的时候,前端要做的任务就是先还原 UI 稿,把该切的页面都切出来,并且预留好需要给接口提交的数据交互,比如上述三个输入框。
样式编写部分,要注意的一点是 :global 这个关键词。由于采用的是 CSS Module 的形式进行开发,也就是你在页面中声明的类名都会根据当前页面,打一个唯一的 hash 值,比如页面中声明的 className={s.form},最终在浏览器中显示的是这样的:
_form_kpur3_30 是已经被编译过的样式,这样做的目的是避免和别的页面的样式重名,这是目前样式管理的一个诟病,当多人参与项目开发的时候,很难做到不污染全局样式名称,除非很小心的命名样式名称。
所以经过编译之后,想要修改 .form 下的 .za-cell,如下写法,将无法修改成功:
.form {
.za-cell {
color: red;
}
}
原因是,上述写法,.za-cell 会被编译加上 hash,组件库 Zarm 内的 dom 类名还是叫 za-cell,如上图所示。所以为了不加 hash,就需要这样操作:
.form {
:global {
.za-cell {
color: red;
}
}
}
完成上述页面布局之后,你会看到这样一个效果:
少了一个验证码,使用插件 react-captcha-code,通过 npm 下载它:
npm i react-captcha-code -S
在代码中引入:
...
import Captcha from "react-captcha-code"
...
<Input
clearable
type="text"
placeholder="请输入验证码"
onChange={(value) => setVerify(value)}
/>
<Captcha charNum={4} />
此时已经切完注册页面需要的内容。
给页面加上相应的逻辑,首先是账号、密码、验证码:
...
const [username, setUsername] = useState(''); // 账号
const [password, setPassword] = useState(''); // 密码
const [verify, setVerify] = useState(''); // 验证码
...
<Input
clearable
type="text"
placeholder="请输入账号"
onChange={(value) => setUsername(value)}
/>
...
<Input
clearable
type="password"
placeholder="请输入密码"
onChange={(value) => setPassword(value)}
/>
...
<Input
clearable
type="text"
placeholder="请输入验证码"
onChange={(value) => setVerify(value)}
/>
当输入框内容修改的时候,onChange 会被触发,接受的回调函数参数,便是变化的输入值,此时将其保存在声明的变量中。
输入的验证码是需要和验证码图片里的验证码匹配的,所以还需要拿到图片里的验证码,作如下操作:
import React, { useCallback } from 'react'
...
const [captcha, setCaptcha] = useState(''); // 验证码变化后存储值
// 验证码变化,回调方法
const handleChange = useCallback((captcha) => {
console.log('captcha', captcha)
setCaptcha(captcha)
}, []);
...
<Captcha charNum={4} onChange={handleChange} />
当验证码变化的时候,便能获取到相应的值。修改完上述代码,不妨测试一下:
到此,注册需要的参数都有了,开始编写注册方法:
import { Cell, Input, Button, Checkbox, Toast } from 'zarm'
import { post } from '@/utils'
...
const onSubmit = async () => {
if (!username) {
Toast.show('请输入账号')
return
}
if (!password) {
Toast.show('请输入密码')
return
}
if (!verify) {
Toast.show('请输入验证码')
return
};
if (verify != captcha) {
Toast.show('验证码错误')
return
};
try {
const { data } = await post('/api/user/register', {
username,
password
});
Toast.show('注册成功');
} catch (error) {
Toast.show('系统错误');
}
};
...
<Button onClick={onSubmit} block theme="primary">注册</Button>
上述代码中,因为使用的是 async await 做异步处理,所以需要通过 try catch 来捕获异步处理过程中出现的错误,如果使用 Promise 的回调函数,则无需使用 try catch,改动如下:
post('/api/user/register', {
username,
password
}).then(res => {
// do something
})
尝试使用之前注册过的用户名,注册一个账号:
服务端给出正确的报错,再用一个未注册过的用户名:
此时大致将注册功能实现了。这里不再展开讲样式部分,因为这样会使得文章中出现过多的重复代码,不以阅读,大家尽量根据标签的类名去查找 css 样式部分。
五.登录页面
登录页面的逻辑直接做到同一个页面中,通过一个 type 参数作为判断条件,判断当前状态是登录页面或是注册页面。
话不多说添加代码如下:
...
import cx from 'classnames'
...
const Login = () => {
...
const [type, setType] = useState('login'); // 登录注册类型
return <div className={s.auth}>
...
<div className={s.tab}>
<span className={cx({ [s.avtive]: type == 'login' })} onClick={() => setType('login')}>登录</span>
<span className={cx({ [s.avtive]: type == 'register' })} onClick={() => setType('register')}>注册</span>
</div>
</div>
<div className={s.form}>
...
{
type == 'register' ? <Cell icon={<CustomIcon type="mima" />}>
<Input
clearable
type="text"
placeholder="请输入验证码"
onChange={(value) => setVerify(value)}
/>
<Captcha ref={captchaRef} charNum={4} onChange={handleChange} />
</Cell> : null
}
</div>
<div className={s.operation}>
{
type == 'register' ? <div className={s.agree}>
<Checkbox />
<label className="text-light">阅读并同意<a>《掘掘手札条款》</a></label>
</div> : null
}
<Button onClick={onSubmit} block theme="primary">{type == 'login' ? '登录' : '注册'}</Button>
</div>
}
注意,如果引入了新的工具包,请自行安装,如上述代码就需要安装 classnames。可以通过 npm i classnames -S 指令
代码分析:
上述代码中,通过 type 属性区分注册和登录。
首先是 tab 切换,通过 classname 来判断是否是当前高亮,用于样式控制。
其次,当 type == 'register' 的时候,才把验证码展示出来,因为登录这边咱们就不设置验证码,只在注册的时候显示。
最后是事件的判断,如果 type == 'login',则按钮文案显示为 登录,否则为 注册。
此时点击触发的 onSubmit 事件也很关键,同样需要通过 type 判断是登录还是注册,修改代码如下:
const onSubmit = async () => {
if (!username) {
Toast.show('请输入账号')
return
}
if (!password) {
Toast.show('请输入密码')
return
}
try {
//判断登录
if (type === 'login') {
const { data } = await post('/api/user/login', {
username,
password
});
//登录成功,将token存储到localStorage,并跳转到首页
localStorage.setItem('token', data.token);
window.location.href = '/';
} else {
//验证密码
if (!verify) {
Toast.show('请输入验证码')
return
}
//验证码
if (verify !== captcha) {
Toast.show('验证码错误')
return
}
const { data } = await post('/api/user/register', {
username,
password
});
Toast.show('注册成功');
setType('login');
}
} catch (err) {
Toast.show(msg);
}
};
由于登录注册的账号和密码是同一参数,这边就直接复用了逻辑,并通过 type 判断调用哪一个接口。
重启项目,验证登录接口是否成功,如果成功则会返回 token 信息,如下图所示:
此时,本地的 localStorage 里,已经存下了 token,如下图所示:
保存 token 的形式有很多,你可以引入状态管理插件来对这些数据进行存储,但是这里对其进行简单处理,减少项目多余的负担。
六.总结
到此,底部导航栏,登录注册页面算是完成了,拿到的 token 是有时效性的,我在后台设置的是 24 小时的时效,如果过期了,请求其他接口时,就会报错,从而通过逻辑判断重新回到登录页面。