> 目前因?qū)W業(yè)任務(wù)比較重,沒有好好的完善,現(xiàn)在比較完善的只有題庫管理,新增題庫,修改題庫以及登錄的功能,但搭配小程序使用,主體功能已經(jīng)實現(xiàn)了
### 此后臺系統(tǒng)是為了搭配我的另一個項目 `School-Partners學習伴侶`微信小程序而開發(fā)的。是一個采用`Taro`多端框架開發(fā)的跨平臺的小程序。感興趣的可以看一下之前的文章?
這篇文章主要是分享一下在開發(fā)這個東東的時候,遇到的一些問題,以及一些技術(shù)的巧妙的方法分享給大家,如果對大家有幫助的話,請給我點贊一下給個star鼓勵一下~無比感謝嘿嘿

希望大佬們走過路過可以給個star鼓勵一下~感激不盡~這個是小程序還有后臺都整合在一起的倉庫,`client`是小程序端的前端代碼,`server`是小程序端和管理端的后臺,`admin`是管理端的前端代碼
> https://github.com/zhcxk1998/School-Partners
這個是小程序的介紹文章?
[這是配套的小程序介紹文章,使勁戳!](https://juejin.im/post/5dd161675188254efb3bceea)
無圖無真相!先上幾個圖~
## 運行截圖
### 1. 登錄界面

### 2. 題庫管理

### 3. 修改題庫

## 技術(shù)分析
就來說一下項目中自己推敲做出來的幾個算是亮點的東西吧
### 1. 使用Hook封裝API訪問工具
本項目采用的UI框架是Ant-Design框架?
因為這個項目的后臺對于表格有著比較大的需求,而表格加載就需要使用到`Loading`的狀態(tài),所以就特地封裝一下便于之后使用
首先我們先新建一個文件`useService.ts`
然后我們先引入`axios`來作為我們的api訪問工具
```
import axios from 'axios'
const instance = axios.create({
? baseURL: '/api',
? timeout: 10000,
? headers: {
? ? 'Content-Type': "application/json;charset=utf-8",
? },
})
instance.interceptors.request.use(
? config => {
? ? const token = localStorage.getItem('token');
? ? if (token) {
? ? ? config.headers.common['Authorization'] = token;
? ? }
? ? return config
? },
? error => {
? ? return Promise.reject(error)
? }
)
instance.interceptors.response.use(
? res => {
? ? let { data, status } = res
? ? if (status === 200) {
? ? ? return data
? ? }
? ? return Promise.reject(data)
? },
? error => {
? ? const { response: { status } } = error
? ? switch (status) {
? ? ? case 401:
? ? ? ? localStorage.removeItem('token')
? ? ? ? window.location.href = './#/login'
? ? ? ? break;
? ? ? case 504:
? ? ? ? message.error('代理請求失敗')
? ? }
? ? return Promise.reject(error)
? }
)
```
先將`axios`的攔截器,基本配置這些寫好先
接著我們實現(xiàn)一個獲取接口信息的方法`useServiceCallback`
```typescript
const useServiceCallback = (fetchConfig: FetchConfig) => {
? // 定義狀態(tài),包括返回信息,錯誤信息,加載狀態(tài)等
? const [isLoading, setIsLoading] = useState<boolean>(false)
? const [response, setResponse] = useState<any>(null)
? const [error, setError] = useState<any>(null)
? const { url, method, params = {}, config = {} } = fetchConfig
? const callback = useCallback(
? ? () => {
? ? ? setIsLoading(true)
? ? ? setError(null)
? ? ? // 調(diào)用axios來進行接口訪問,并且將傳來的參數(shù)傳進去
? ? ? instance(url, {
? ? ? ? method,
? ? ? ? data: params,
? ? ? ? ...config
? ? ? })
? ? ? ? .then((response: any) => {
? ? ? ? ? // 獲取成功后,則將loading狀態(tài)恢復(fù),并且設(shè)置返回信息
? ? ? ? ? setIsLoading(false)
? ? ? ? ? setResponse(Object.assign({}, response))
? ? ? ? })
? ? ? ? .catch((error: any) => {
? ? ? ? ? const { response: { data } } = error
? ? ? ? ? const { data: { msg } } = data
? ? ? ? ? message.error(msg)
? ? ? ? ? setIsLoading(false)
? ? ? ? ? setError(Object.assign({}, error))
? ? ? ? })
? ? }, [fetchConfig]
? )
? return [callback, { isLoading, error, response }] as const
}
```
這樣就完成了主體部分了,可以利用這個hook來進行接口訪問,接下來我們再做一點小工作
```
const useService = (fetchConfig: FetchConfig) => {
? const preParams = useRef({})
? const [callback, { isLoading, error, response }] = useServiceCallback(fetchConfig)
? useEffect(() => {
? ? if (preParams.current !== fetchConfig && fetchConfig.url !== '') {
? ? ? preParams.current = fetchConfig
? ? ? callback()
? ? }
? })
? return { isLoading, error, response }
}
export default useService
```
我們定義一個useService的方法,我們通過定義一個`useRef`來判斷前后傳過來的參數(shù)是否一致,如果不一樣且接口訪問配置信息的`url`不為空就可以開始調(diào)用`useServiceCallback`方法來進行接口訪問了
具體使用如下:?
我們先在組件內(nèi)render外使用這個鉤子,并且定義好返回的信息?
接口返回體如下

```
const { isLoading = false, response } = useService(fetchConfig)
const { data = {} } = response || {}
const { exerciseList = [], total: totalPage = 0 } = data
```
因為我們這個hook是依賴`fetchConfig`這個對象的,這里是他的類型
```
export interface FetchConfig {
? url: string,
? method: 'GET' | 'POST' | 'PUT' | 'DELETE',
? params?: object,
? config?: object
}
```
所以我們只需要再頁面加載時候調(diào)用`useEffect`來進行更新這個`fetchConfig`就可以觸發(fā)這個獲取數(shù)據(jù)的hook啦
```
? const [fetchConfig, setFetchConfig] = useState<FetchConfig>({
? ? url: '', method: 'GET', params: {}, config: {}
? })
? ...
? useEffect(() => {
? ? const fetchConfig: FetchConfig = {
? ? ? url: '/exercises',
? ? ? method: 'GET',
? ? ? params: {},
? ? ? config: {}
? ? }
? ? setFetchConfig(Object.assign({}, fetchConfig))
? }, [fetchFlag])
```
這樣就大功告成啦!然后我們再到表格組件內(nèi)傳入相關(guān)數(shù)據(jù)就可以啦
```
<Table
? ? ? ? ? rowSelection={rowSelection}
? ? ? ? ? dataSource={exerciseList}
? ? ? ? ? columns={columns}
? ? ? ? ? rowKey="exerciseId"
? ? ? ? ? scroll={{
? ? ? ? ? ? y: "calc(100vh - 300px)"
? ? ? ? ? }}
? ? ? ? ? loading={{
? ? ? ? ? ? spinning: isLoading,
? ? ? ? ? ? tip: "加載中...",
? ? ? ? ? ? size: "large"
? ? ? ? ? }}
? ? ? ? ? pagination={{
? ? ? ? ? ? pageSize: 10,
? ? ? ? ? ? total: totalPage,
? ? ? ? ? ? current: currentPage,
? ? ? ? ? ? onChange: (pageNo) => setCurrentPage(pageNo)
? ? ? ? ? }}
? ? ? ? ? locale={{
? ? ? ? ? ? emptyText: <Empty
? ? ? ? ? ? ? image={Empty.PRESENTED_IMAGE_SIMPLE}
? ? ? ? ? ? ? description="暫無數(shù)據(jù)" />
? ? ? ? ? }}
? ? ? ? />
```
大功告成!!
### 2. 實現(xiàn)懶加載通用組件
我們這里使用的是`react-loadable`這個組件,挺好用的嘿嘿,搭配`nprogress`來進行過渡處理,具體效果參照`github`網(wǎng)站上的加載效果?
我們先封裝好一個組件,在`components/LoadableComponent`內(nèi)定義如下內(nèi)容
```
import React, { useEffect, FC } from 'react'
import Loadable from 'react-loadable'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
const LoadingPage: FC = () => {
? useEffect(() => {
? ? NProgress.start()
? ? return () => {
? ? ? NProgress.done()
? ? }
? }, [])
? return (
? ? <div className="load-component" />
? )
}
const LoadableComponent = (component: () => Promise<any>) => Loadable({
? loader: component,
? loading: () => <LoadingPage />,
})
export default LoadableComponent
```
我們先定義好一個組件`LoadingPage`這個是我們再加載中的時候需要展示的頁面,在`useEffect`中使用`nprogress`的加載條進行顯示,組件卸載時候則結(jié)束,而下面的`div`則可以由用戶自己定義需要展示的樣式效果
下面的`LoadableCompoennt`就是我們這個的主體,我們需要獲取到一個組件,賦值給`loader`,具體的賦值方法如下,我們可以在項目內(nèi)的`pages`部分將所有需要展示的頁面引入進來,再導(dǎo)出,這樣就可以方便的實現(xiàn)所有頁面的懶加載了
```
// 引入剛剛定義的懶加載組件
import { LoadableComponent } from '@/admin/components'
// 定義組件,傳給LoadableCompoennt組件需要的組件信息
const Login = LoadableComponent(() => import('./Login'))
const Register = LoadableComponent(() => import('./Register'))
const Index = LoadableComponent(() => import('./Index/index'))
const ExerciseList = LoadableComponent(() => import('./ExerciseList'))
const ExercisePublish = LoadableComponent(() => import('./ExercisePublish'))
const ExerciseModify = LoadableComponent(() => import('./ExerciseModify'))
// 導(dǎo)出,到時候再從這個pages/index.ts中引入,即可擁有懶加載效果了
export {
? Login,
? Register,
? Index,
? ExerciseList,
? ExercisePublish,
? ExerciseModify
}
```
大功告成?。。?/p>
### 3. 使用嵌套路由
項目因為涉及到后臺信息的管理,所以個人認為導(dǎo)航欄與主題信息欄應(yīng)該一同顯示,如同下圖?

這樣可以清晰的展示出信息以及給用戶提供導(dǎo)航效果?
我們現(xiàn)在項目的`routes/index.tsx`定義一個全局通用的路由組件
```
import React from 'react'
import {
? Switch, Redirect, Route,
} from 'react-router-dom'
// 這個是私有路由,下面會提到
import PrivateRoute from '../components/PrivateRoute'
import { Login, Register } from '../pages'
import Main from '../components/Main/index'
const Routes = () => (
? <Switch>
? ? <Route exact path="/login" component={Login} />
? ? <Route exact path="/register" component={Register} />
? ? <PrivateRoute component={Main} path="/admin" />
? ? <Redirect exact from="/" to="/admin" />
? </Switch>
)
export default Routes
```
這里的意思就是,登錄以及注冊頁面是獨立開來的,而Main這個組件就是負責包裹導(dǎo)航條以及內(nèi)容部分的組件啦
接下來看看`components/Main`中的內(nèi)容吧
```
import React, { ComponentType } from 'react'
import { Layout } from 'antd';
import HeaderNav from '../HeaderNav'
import ContentMain from '../ContentMain'
import SiderNav from '../SiderNav'
import './index.scss'
const Main = () => (
? <Layout className="index__container">
? ? // 頭部導(dǎo)航欄
? ? <HeaderNav />
? ? <Layout>
? ? ? // 側(cè)邊欄
? ? ? <SiderNav />
? ? ? <Layout>
? ? ? ? // 主體內(nèi)容
? ? ? ? <ContentMain />
? ? ? </Layout>
? ? </Layout>
? </Layout>
)
export default Main as ComponentType
```
接下來重點就是這個`ContentMain`組件啦
```
import React, { FC } from 'react'
import { withRouter, Switch, Redirect, RouteComponentProps, Route } from 'react-router-dom'
import { Index, ExerciseList, ExercisePublish, ExerciseModify } from '@/admin/pages'
import './index.scss'
const ContentMain: FC<RouteComponentProps> = () => {
? return (
? ? <div className="main__container">
? ? ? <Switch>
? ? ? ? <Route exact path="/admin" component={Index} />
? ? ? ? <Route exact path="/admin/content/exercise-list" component={ExerciseList} />
? ? ? ? <Route exact path="/admin/content/exercise-publish" component={ExercisePublish} />
? ? ? ? <Route exact path="/admin/content/exercise-modify/:id" component={ExerciseModify} />
? ? ? ? <Redirect exact from="/" to="/admin" />
? ? ? </Switch>
? ? </div>
? )
}
export default withRouter(ContentMain)
```
這個就是一個嵌套路由啦,在這里面使用withRouter來包裹一下,然后在這里再次定義路由信息,這樣就可以只切換主體部分的內(nèi)容而不改變導(dǎo)航欄啦
大功告成?。。?/p>
### 4. 側(cè)邊欄的選中條目動態(tài)變化

通過圖片我們可以看出,側(cè)邊導(dǎo)航欄有一個選中的內(nèi)容,那么我們該如何判斷不同的url頁面對應(yīng)哪一個選中部分呢?
```
? const [selectedKeys, setSelectedKeys] = useState(['index'])
? const [openedKeys, setOpenedKeys] = useState([''])
? const { location: { pathname } } = props
? const rank = pathname.split('/')
? useEffect(() => {
? ? switch (rank.length) {
? ? ? case 2: // 一級目錄
? ? ? ? setSelectedKeys([pathname])
? ? ? ? setOpenedKeys([''])
? ? ? ? break
? ? ? case 4: // 二級目錄
? ? ? ? setSelectedKeys([pathname])
? ? ? ? setOpenedKeys([rank.slice(0, 3).join('/')])
? ? ? ? break
? ? }
? }, [pathname])
```
如果是用React的沒有使用到hook,則這里可以使用`componentWillReceiveProps()` 還有 `componentDidMount()`搭配使用,意思就是頁面加載好之后設(shè)置一下這個選中,然后有更新也設(shè)置一下
這就是最重要的部分啦,我們通過定義幾個狀態(tài)`selectedKeys`選中的條目,`openedKeys`打開的多級導(dǎo)航欄
我們通過在頁面加載時候,判斷頁面url路徑,如果是一級目錄,例如首頁,就直接設(shè)置選中的條目即可,如果是二級目錄,例如導(dǎo)航欄中`內(nèi)容管理/題庫管理`這個功能,他的url鏈接是`/admin/content/exercise-list`,所以我們的`case 4`就可以捕獲到啦,然后設(shè)置當前選中的條目以及打開的多級導(dǎo)航,具體的導(dǎo)航信息請看下面
```
<Menu
? ? ? ? mode="inline"
? ? ? ? defaultSelectedKeys={['/admin']}
? ? ? ? selectedKeys={selectedKeys}
? ? ? ? openKeys={openedKeys}
? ? ? ? onOpenChange={handleMenuChange}
? ? ? >
? ? ? ? <Menu.Item key="/admin">
? ? ? ? ? <Link to="/admin">
? ? ? ? ? ? <Icon type="home" />
? ? ? ? ? ? 首頁
? ? ? ? </Link>
? ? ? ? </Menu.Item>
? ? ? ? <SubMenu
? ? ? ? ? key="/admin/content"
? ? ? ? ? title={
? ? ? ? ? ? <span>
? ? ? ? ? ? ? <Icon type="profile" />
? ? ? ? ? ? ? 內(nèi)容管理
? ? ? ? ? ? </span>
? ? ? ? ? }
? ? ? ? >
? ? ? ? ? <Menu.Item key="/admin/content/exercise-list">
? ? ? ? ? ? <Link to="/admin/content/exercise-list">題庫管理</Link>
? ? ? ? ? </Menu.Item>
? ? ? ? </SubMenu>
? ? </Menu>
```
這樣我們無論是通過點擊側(cè)邊導(dǎo)航欄,或者是直接輸入url訪問頁面,這個導(dǎo)航欄選中的條目就會與我們訪問的頁面對應(yīng)的啦~
大功告成!??!
### 5. 巧妙利用Antd表單來構(gòu)造特殊的數(shù)據(jù)結(jié)構(gòu)
使用過Antd表單的胖友們一定知道`this.props.form.validateFields()`這個方法吧嘿嘿,他是如果驗證成功就返回表單的值給你,不用自己去綁定輸入組件的值,很方便,來看看官方的例子

可以看到,最簡單的一個登錄框,然后我們就可以得到一組數(shù)據(jù)啦,不過我們可以發(fā)現(xiàn),這些數(shù)據(jù)就是一個對象中的幾個值。?
假如我們有很多數(shù)據(jù),想用多個對象來構(gòu)造數(shù)據(jù)結(jié)構(gòu),這應(yīng)該怎么辦呢,就例如這樣子的數(shù)據(jù)結(jié)構(gòu),我們還是舉上面這個例子

假如吼,我們提交后臺的數(shù)據(jù)需要是這樣子的數(shù)據(jù)結(jié)構(gòu),用戶名和密碼在`userInfo`這個對象內(nèi),然后是否記住密碼是在`other`對象里面,自己得到數(shù)據(jù)之后再構(gòu)造又十分麻煩,這可怎么辦呢。?
在此之前,我們不如看看官方給的另一個例子,一個動態(tài)添加表單項的例子,于此我們就可以發(fā)揮想象力,然后就可以解決我們上面的問題啦

可以看到這個動態(tài)添加表單項的,是以數(shù)組形式來存儲數(shù)據(jù)的,他的代碼是這樣的
```
{getFieldDecorator(`names[${k}]`, {
? validateTrigger: ['onChange', 'onBlur'],
? rules: [
? ? {
? ? ? required: true,
? ? ? whitespace: true,
? ? ? message: "Please input passenger's name or delete this field.",
? ? },
? ],
})(<Input placeholder="passenger name" style={{ width: '60%', marginRight: 8 }} />)}
```
Antd表單的構(gòu)造數(shù)據(jù)關(guān)鍵就在于里面的`getFieldDecorator`內(nèi)的第一個參數(shù),也就是我們的`propName`用來指定數(shù)據(jù)叫啥,跟之后驗證表單傳回的值是對應(yīng)的了。這就給了我們一個很大的提示啦??!?
> 這個`propName`叫什么,之后生成的數(shù)據(jù)結(jié)構(gòu)里面就是什么,是`a`,之后數(shù)據(jù)就對應(yīng)`a`,是`b`,就對應(yīng)`b`
這里通過一個`names[$k]`,就可以讓之后得到的數(shù)據(jù)變成一個數(shù)組`names:Array(2): ['1', '2']`這樣子的形式,那么我們稍加改造一下,就可以變成對象的形式啦!下面看看代碼,其實也很簡單!
```
<Form.Item label="題目內(nèi)容" >
{getFieldDecorator(`topicList[${index}].topicContent`, {
? rules: TopicContentRules,
? initialValue: topicList[index].topicContent
})(<Input.TextArea />)}
</Form.Item>
```
這里我就直接舉項目中題庫提交的例子啦,`topicList`是一個列表,里面存的是每一個題目對應(yīng)的數(shù)據(jù)對象

這里的`propName`,我指定成了`topicList[$(index)]`就代表,這個屬于這個列表里面的第幾個對象,然后后面的`.topicContent`就代表這個對象里面的值是什么,最后我們的出的結(jié)構(gòu)就是這樣子的啦!

我們?nèi)缭傅玫搅讼胍臄?shù)據(jù)結(jié)構(gòu)了,這里面有對象,有數(shù)組,十分方便,可以靈活根據(jù)實際情況進行使用,關(guān)鍵就在于`getFieldDecorator()`里面的`propName`,直接以對象的形式命名,就可以啦!就按照下面這種形式就好啦!
```
<Form.Item label="itemName" >
? ? {getFieldDecorator(`object.itemName`, {
? ? ? initialValue: 'BB小天使'
? ? })(<Input />)}
</Form.Item>
```
之后就可以得到對象類型的表單值啦!
大功告成?。?!
### 6. 后臺接口獲取信息后填充Antd表單
因為有一個題庫修改的功能,所以打算獲取完接口信息之后,直接將內(nèi)容通過Antd表單的`setFields`的方法來直接填充表格中的信息,結(jié)果控制臺報錯了

看了看大致意思就是說emmmm不可以在渲染之前就設(shè)置表單的值,嘶~這可難受了,這時候想到他的表單內(nèi)有一個`initialValue`的屬性,是表單項的默認值,這可好辦啦,這樣我們先拉取信息,存入對象中,然后再通過這個屬性給表單傳值,果然不出所料,真的ok了沒有報錯了哈哈哈,具體看下面
```
? // 定義選項列表來存儲題庫的題目列表信息
? const [topicList, setTopicList] = useState<TopicList[]>([{
? ? topicType: 1,
? ? topicAnswer: [],
? ? topicContent: '',
? ? topicOptions: []
? }])
? // 定義題庫基本信息對象
? const [exerciseInfo, setExerciseInfo] = useState<ExerciseInfo>({
? ? exerciseName: '',
? ? exerciseContent: '',
? ? exerciseDifficulty: 1,
? ? exerciseType: 1,
? ? isHot: false
? })
? // 首先先拉取信息,這就是題庫的信息啦
? const { data } = await http.get(`/exercises/${id}`)
? const {
? ? exerciseName,
? ? exerciseContent,
? ? exerciseDifficulty,
? ? exerciseType,
? ? isHot,
? ? topicList } = data
? topicList.forEach((_: any, index: number) => {
? ? topicList[index].topicOptions = topicList[index].topicOptions.map((item: any) => item.option)
? })
? // 獲取信息后,設(shè)置狀態(tài)
? setTopicList([...topicList])
? setExerciseInfo({
? ? exerciseName,
? ? exerciseContent,
? ? exerciseDifficulty,
? ? exerciseType,
? ? isHot,
? })
```
這樣我們就得到了題庫信息的對象啦,待會我們就可以用來傳默認值給表單啦!
```
// 這里就通過題庫名稱來做例子,就從剛才設(shè)置的信息對象中取值然后設(shè)置默認值就可以啦
<Form.Item label="題庫名稱">
? {getFieldDecorator('exerciseName', {
? ? rules: ExerciseNameRules,
? ? initialValue: exerciseInfo.exerciseName
? })(<Input />)}
</Form.Item>
```
因為題庫的題目是有挺多,所以是一個列表,類似下圖

所以我們實現(xiàn)設(shè)置好`topicList`這個數(shù)組來存儲題目的信息,然后我們通過遍歷這個列表來實現(xiàn)多題目編輯
```
<Form.Item label="新增題目">
? ? {topicList && topicList.map((_: any, index: number) => {
? ? ? return (
? ? ? ? <Fragment key={index}>
? ? ? ? ? <div className="form__subtitle">
? ? ? ? ? ? 第{index + 1}題
? ? ? ? ? ? <Tooltip title="刪除該題目">
? ? ? ? ? ? ? <Icon
? ? ? ? ? ? ? ? type="delete"
? ? ? ? ? ? ? ? theme="twoTone"
? ? ? ? ? ? ? ? twoToneColor="#fa4b2a"
? ? ? ? ? ? ? ? style={{ marginLeft: 16, display: topicList.length > 1 ? 'inline' : 'none' }}
? ? ? ? ? ? ? ? onClick={() => handleTopicDeleteClick(index)} />
? ? ? ? ? ? </Tooltip>
? ? ? ? ? </div>
? ? ? ? ? <Form.Item label="題目內(nèi)容" >
? ? ? ? ? ? {getFieldDecorator(`topicList[${index}].topicContent`, {
? ? ? ? ? ? ? rules: TopicContentRules,
? ? ? ? ? ? ? initialValue: topicList[index].topicContent
? ? ? ? ? ? })(<Input.TextArea />)}
? ? ? ? ? </Form.Item>
? ? ? ? ? ...... 省略一堆~
? ? ? ? </Fragment>
? ? ? )
? ? })}
? ? <Form.Item>
? ? ? <Button onClick={handleTopicAddClick}>新增題目</Button>
? ? </Form.Item>
? </Form.Item>
```
例如**題目內(nèi)容**的話,我們就設(shè)置他的`initialValue`為`topicList[index].topicContent`即可,別的屬性同理,然后點擊新增題目按鈕,就直接往topicList內(nèi)添加對象信息即可完成題目列表的增加,點擊刪除圖標,就刪除列表中某一項,是不是十分方便??!哈哈哈
大功告成?。?!
### 7. 使用JWTToken來驗證用戶登錄狀態(tài)以及返回信息
要想使用登錄注冊功能,還有用戶權(quán)限的問題,我們就需要使用到這個token啦!為什么我們要使用token呢?而不是用傳統(tǒng)的cookies呢,因為使用token可以避免跨域啊還有更多的復(fù)雜問題,大大簡化我們的開發(fā)效率
> 本項目后臺采用nodeJs來進行開發(fā)?
我們先在后臺定義一個工具`utils/token.js`
```
// token的秘鑰,可以存在數(shù)據(jù)庫中,我偷懶就卸載這里面啦hhh
const secret = "zhcxk1998"
const jwt = require('jsonwebtoken')
// 生成token的方法,注意前面一定要有Bearer ,注意后面有一個空格,我們設(shè)置的時間是1天過期
const generateToken = (payload = {}) => (
? 'Bearer ' + jwt.sign(payload, secret, { expiresIn: '1d' })
)
// 這里是獲取token信息的方法
const getJWTPayload = (token) => (
? jwt.verify(token.split(' ')[1], secret)
)
module.exports = {
? generateToken,
? getJWTPayload
}
```
這里采用的是`jsonwebtoken`這個庫,來進行token的生成以及驗證。
有了這個token啦,我們就可以再登錄或者注冊的時候給用戶返回一個token信息啦
```
router.post('/login', async (ctx) => {
? const responseBody = {
? ? code: 0,
? ? data: {}
? }
? try {
? ? if (登錄成功) {
? ? ? responseBody.data.msg = '登陸成功'
? ? ? // 在這里就可以返回token信息給前端啦
? ? ? responseBody.data.token = generateToken({ username })
? ? ? responseBody.code = 200
? ? } else {
? ? ? responseBody.data.msg = '用戶名或密碼錯誤'
? ? ? responseBody.code = 401
? ? }
? } catch (e) {
? ? responseBody.data.msg = '用戶名不存在'
? ? responseBody.code = 404
? } finally {
? ? ctx.response.status = responseBody.code
? ? ctx.response.body = responseBody
? }
})
```
這樣前端就可以獲取這個token啦,前端部分只需要將token存入`localStorage`中即可,不用擔心`localStorage`是永久保存,因為我們的token有個過期時間,所以不用擔心
```
? /* 登錄成功 */
? if (code === 200) {
? ? const { msg, token } = data
? ? // 登錄成功后,將token存入localStorage中
? ? localStorage.setItem('token', token)
? ? message.success(msg)
? ? props.history.push('/admin')
? }
```
好嘞,現(xiàn)在前端獲取token也搞定啦,接下來我們就需要在訪問接口的時候帶上這個token啦,這樣才可以讓后端知道這個用戶的權(quán)限如何,是否過期等
需要傳tokne給后端,我們可以通過每次接口都傳一個字段`token`,但是這樣十分浪費成本,所以我們再封裝好的`axios`中,我們設(shè)置請求頭信息即可
```
import axios from 'axios'
const instance = axios.create({
? baseURL: '/api',
? timeout: 10000,
? headers: {
? ? 'Content-Type': "application/json;charset=utf-8",
? },
})
instance.interceptors.request.use(
? config => {
? ? // 請求頭帶上token信息
? ? const token = localStorage.getItem('token');
? ? if (token) {
? ? ? config.headers.common['Authorization'] = token;
? ? }
? ? return config
? },
? error => {
? ? return Promise.reject(error)
? }
)
...
export default instance
```

如上圖所示,我們每次請求接口的時候就會帶上這個請求頭啦!那么接下來我們就談?wù)労蠖巳绾潍@取這個token并且驗證吧
有獲取token,以及驗證部分,那么就需要出動我們的中間件啦!
我們驗證token的話,要是用戶是訪問的登錄或者注冊接口,那么這個時候token其實是沒有作用噠,所以我們需要將它隔離一下,所以我們定義一個中間件,用來跳過某些路由,我們再`middleware/verifyToken.js`中定義(這里我們采用`koa-jwt`來驗證token)
```
const koaJwt = require('koa-jwt')
const verifyToken = () => {
? return koaJwt({ secret: 'zhcxk1998' }).unless({
? ? path: [
? ? ? /login/,
? ? ? /register/
? ? ]
? })
}
module.exports = verifyToken
```
這樣就可以忽略這登錄注冊路由啦,別的路由就驗證token
攔截已經(jīng)成功啦,那么我們該如何捕獲,然后進行處理呢?我們再`middleware/interceptToken`定義一個中間件,來處理捕獲的token信息
```
const interceptToken = async (ctx, next) => {
? return await next().catch((err) => {
? ? const { status } = err
? ? if (status === 401) {
? ? ? ctx.response.status = 401
? ? ? ctx.response.body = {
? ? ? ? code: 401,
? ? ? ? data: {
? ? ? ? ? msg: '請登錄后重試'
? ? ? ? }
? ? ? }
? ? } else {
? ? ? throw err
? ? }
? })
}
module.exports = () => (
? interceptToken
)
```
由于`koa-jwt`攔截的token,如果過期,他會自動拋出一個401的異常以表示該token已經(jīng)過期,所以我們只需要判斷這個狀態(tài)`status`然后進行處理即可
好嘞,中間件也定義好了,我們就在后端服務(wù)中使用起來吧!
```
const Koa = require('koa')
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser')
const cors = require('koa2-cors');
const routes = require('../routes/routes')
const router = new Router()
const admin = new Koa();
const {
? verifyToken,
? interceptToken
} = require('../middleware')
const {
? login,
? info,
? register,
? exercises
} = require('../routes/admin')
admin.use(cors())
admin.use(bodyParser())
/* 攔截token */
admin.use(interceptToken())
admin.use(verifyToken())
/* 管理端 */
admin.use(routes(router, { login, info, register, exercises }))
module.exports = admin
```
我們直接使用`router.use()`的方法就可以使用中間件啦,這里要記??!驗證攔截token一定要在路由信息之前,否則是攔截不到的喲(如果在后面,路由都先執(zhí)行了,還攔截啥嘛?。?/p>
大功告成?。?!
### 8. 密碼使用加密加鹽的方式存儲
我們在處理用戶的信息的時候,需要存儲密碼,但是直接存儲肯定不安全啦!所以我們需要加密以及加鹽的處理,在這里我用到的是`crypto`這個庫
首先我們再`utils/encrypt.js`中定義一個工具函數(shù)用來生成鹽值以及獲取加密信息
```
const crypto = require('crypto')
// 獲取隨機鹽值,例如 c6ab1 這樣子的字符串
const getRandomSalt = () => {
? const start = Math.floor(Math.random() * 5)
? const count = start + Math.ceil(Math.random() * 5)
? return crypto.randomBytes(10).toString('hex').slice(start, count)
}
// 獲取密碼轉(zhuǎn)換成md5之后的加密信息
const getEncrypt = (password) => {
? return crypto.createHash('md5').update(password).digest('hex')
}
module.exports = {
? getRandomSalt,
? getEncrypt
}
```
這樣我們就可以通過驗證密碼與數(shù)據(jù)庫中加密的信息對不對得上,來判斷是否登錄成功等等
我們現(xiàn)在注冊中使用上,當然我們需要兩個表進行數(shù)據(jù)存儲,一個是用戶信息,一個是用戶密碼表,這樣分開更加安全,例如這樣

這樣就可以將用戶信息還有密碼分開存放,更加安全,這里就不重點敘述啦
```
const { getRandomSalt, getEncrypt } = require('../../utils/encrypt')
// 注冊部分
router.post('/register', async (ctx) => {
? const { username, password, phone, email } = ctx.request.body
? // 獲取鹽值以及加密后的信息
? const salt = getRandomSalt()
? // 數(shù)據(jù)庫存放的密碼是由用戶輸入的密碼加上隨機鹽值,然后再進行加密所得到的的炒雞加密密碼
? const encryptPassword = getEncrypt(password + salt)
? // 插入用戶信息,以及獲取這個的id
? const { insertId: user_id } = await query(INSERT_TABLE('user_info'), { username, phone, email });
? // 插入用戶密碼信息,user_id與上面對應(yīng)
? await query(INSERT_TABLE('user_password'), {
? ? user_id,
? ? password: encryptPassword,
? ? salt
? })
? ...
})
```
接下來再來看登錄部分,登錄的話,就需要從用戶密碼表中取出加密密碼,以及鹽值,然后進行對比
```
// 通過用戶名,先獲取加密密碼以及鹽值
const { password: verifySign, salt } = await query(`select password, salt from user_password where user_id = '${userId}'`)[0]
// 這個就是用戶輸入的密碼加上鹽值一起加密后的密碼
const sign = getEncrypt(password + salt)
// 這個加密的密碼與數(shù)據(jù)庫中加密的密碼對比,如果一樣則登陸成功
if (sign === verifySign) {
? responseBody.data.msg = '登陸成功'
? responseBody.data.token = generateToken({ username })
? responseBody.code = 200
} else {
? responseBody.data.msg = '用戶名或密碼錯誤'
? responseBody.code = 401
}
```
大功告成?。?!
## 結(jié)語
大部分的內(nèi)容就大概這樣子,這是自己開發(fā)中遇到的小問題還有解決方法,希望對大家有所幫助,大家一起成長!現(xiàn)在得看看面試題準備一波春招了,不然大學畢業(yè)了都找不到工作啦!有時間再繼續(xù)更新這個文章!
## 最后還是順便求一波star還有點贊?。。?/p>
何時才能上100點贊,100star啊嗚嗚嗚
[github項目猛戳進來star一下嘿嘿](https://github.com/zhcxk1998/School-Partners)?
[小程序介紹文章,使勁戳!](https://juejin.im/post/5dd161675188254efb3bceea)