0%

记录-react + ts + egg 全栈开发账单H5

项目背景

过年前学完了 reactts,在找面试项目 idea 的时候找到了这个掘金手册,Node + React 实战:从 0 到 1 实现记账本,总体来说,符合我的要求,所以我的项目在这个项目的基础上加上了改进。 那时我并不知道这个想法会让我吃个大亏。不过幸运的是,这个项目最终完成了。
具体的改进如下:

  1. 使用了最新的 react18react routerv6
  2. 开始使用 TypeScript 进行类型约束
  3. 手册使用了 zarm UI 库, 而我则使用 antd-mobile UI 库
  4. 手册使用了 less, 而我则使用了 sass
  5. 手册并没有规范代码格式了,而我使用了 eslint + prettier 来规范美化代码

项目的开发

需求分析

  1. 后端需要实现用户登录注册接口,账单数据的增删改查接口,返回月度账单的统计接口,账单类型的接口
  2. 前端需要实现用户登录页注册页,账单页,账单详情页,月度账单展示页,用户信息页

前端项目初始化

项目新建和安装依赖

pnpm create vite my-vue-app --template react-ts, 使用 vite 创建 react ts 项目, pnpm i, 安装依赖

格式校验和美化代码

pnpm add --save-dev --save-exact prettier,
pnpm add eslint-config-prettier eslint-plugin-prettier -D,配置.prettierignore.prettierrc,.eslintrc.cjs,package.json,执行命令pnpm run lint, pnpm run lint:fix

1
2
3
4
5
// .prettierignore
node_modules
# Ignore artifacts:
build
coverage
1
2
3
4
5
6
7
8
9
10
11
// .prettierrc
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"semi": false,
"trailingComma": "none",
"bracketSpacing": true,
"endOfLine": "auto"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// .eslintrc.cjs
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier',
'plugin:prettier/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
'@typescript-eslint/no-explicit-any': ['warn'],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'react/jsx-uses-react': 'off',
'react/react-in-jsx-scope': 'off'
}
}
1
2
3
4
5
// package.json
"scripts": {
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint --fix . --ext ts,tsx --report-unused-disable-directives",
},

后端项目初始化

使用 Egg.js 脚手架进行初始化

npm init egg --type=simple # Optionally pnpm create egg --type=simple, 参考egg.js github 仓库

代码格式美化类似,规则略有不同。
安装依赖。推荐使用 pnpm, npm 不知道是我的问题还是什么问题,有点慢。

Egg.js 基础知识

只是做个大概介绍,可以直接看完类似内容,直接上手,遇到问题再查文档。

Egg.js 框架基于 koa,思想是约定大于配置,举个例子是controllerservice,需要在固定文件夹下,配置需要到这两个文件下去配置config/config.default.jsconfig/plugin.js,好处是排查问题,开发起来快,缺点是上手门槛还是比较高的。
入门的话,建议直接看完官方入门,直接上手,遇到问题解决问题。
认识项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
egg-example
├── app
│ ├── controller (controller 层)
│ │ └── home.js
│ ├── middleware (中间件)
│ │ └── tokenVerify.js
│ ├── public (静态文件)
│ ├── service (service 层)
│ │ └── home.js
│ └── router.js
├── config (配置)
│ └── config.default.js (项目常规配置)
│ └── plugin.js (项目插件配置)
├── log (日志)
├── run (框架运行产生的附加数据,不会有影响)
├── typings (框架生成的 ts 类型提示,当你新建 controller/home.js 一个方法时,就会有提示,不过又是需要重启)
├── test (单元测试)
└── package.json

有几个概念,地方需要理解,注意一下,

  1. Controller
  2. Router 配置
  3. Service 配置
  4. Middleware中间件
  5. 配置文件
  6. 插件可以先放过。
  7. 框架使用了 ts 进行类型提醒,所以当你书写了 controller 和 service ,可以重启下项目,会有相应的代码提示
  8. 框架内置对象
  9. 调试

写代码 遇到的问题

模块化 css

1
2
3
4
5
6
7
8
9
10
11
文件命令
- style.css
+ style.module.scss

导入文件
- import './style.css'
+ import s from './style.module.scss'

使用
- <div className="test"></div>
+ <div className={s.test}></div>

Localstorage ts 简单封装

参考 https://juejin.cn/post/7048976403349536776

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 参考 https://juejin.cn/post/7048976403349536776
// 枚举
enum StorageType {
l = 'localStorage',
s = 'sessionStorage'
}

class MyStorage {
storage: Storage

constructor(type: StorageType) {
this.storage =
type === StorageType.l ? window.localStorage : window.sessionStorage
}

set(key: string, value: any) {
const data = JSON.stringify(value)
this.storage.setItem(key, data)
}

get(key: string) {
const value = this.storage.getItem(key)
if (value) {
return JSON.parse(value)
}
}

delete(key: string) {
this.storage.removeItem(key)
}

clear() {
this.storage.clear()
}
}

const LStorage = new MyStorage(StorageType.l)
const SStorage = new MyStorage(StorageType.s)

export { LStorage, SStorage }

// 使用
LStorage.set('token', data?.token)
const token: string = LStorage.get('token')

axios ts 简单封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import axios from 'axios'
// AxiosInstance,使用axios实例对象类型。
// AxiosRequestConfig,使用axios发送请求传递参数的类型
// AxiosResponse, axios请求返回值类型都是AxiosResponse类型
import type {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios'
import { baseURL } from '.'
import { LStorage } from './localStorage'
import { Toast } from 'antd-mobile'
import type { Result } from '@/types'
import { basename } from '@/utils'

// 导出
export class Request {
// navigate = useNavigate()

// axios 实例
instance: AxiosInstance
// 基础配置, url 和超时时间
baseConfig: AxiosRequestConfig = {
baseURL,
timeout: 5000,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json'
}
}

constructor(config: AxiosRequestConfig) {
this.instance = axios.create(Object.assign(this.baseConfig, config))
// 请求拦截器
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 一般会请求拦截里面加token,用于后端的验证
const token: string = LStorage.get('token')
if (token) {
config.headers!.Authorization = token
}

return config
},
(err: AxiosError) => {
return Promise.reject(err)
}
)
// 响应拦截器
this.instance.interceptors.response.use(
(res: AxiosResponse) => {
if (res.data.code !== 200) {
if (res.data.message) {
Toast.show({
content: res.data.message,
icon: 'fail'
})
}

if (res.data.code === 401) {
if (basename) {
window.location.href = `${basename}/login`
} else {
window.location.href = `/login`
}
// this.navigate('/login')
}
return Promise.reject(res.data)
}
return res.data
},
(err: any) => {
Toast.show({
content: '系统错误',
icon: 'fail'
})
// 这里是AxiosError类型,所以一般我们只reject我们需要的响应即可
return Promise.reject(err.response)
}
)
}

// 封装 get
get<T>(url: string, params?: object, config = {}): Promise<Result<T>> {
return this.instance.get(url, { params, ...config })
}

// 封装 post
post<T>(
url: string,
params?: object | string,
config: object = {}
): Promise<Result<T>> {
return this.instance.post(url, params, config)
}
}

export default new Request({})

返回的数据结构一般是

1
2
3
4
5
{
"message":"", // 返回的信息提示
"code":"", // 返回的代码提示
"data":"" // 返回的数据,可能是对象, 也可能是单纯的字符串
}

那么如何使用呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 类型
// 分页类型
// /types/index.d.ts
export interface IPage {
page?: number
pageSize: number
}
export interface IRequst {
date?: string
type_id: string
}
// 响应数据中 data 类型
export interface IRes {
token?: string
list?: IDayBill[] | BillType[] | undefined
total_data?: ITotalDataItem[]
totalExpense: number
totalIncome: number
totalPage: number
}
// api 定义
// api/bill.tsx
// 获取账单列表
export function GetBillList(params: IPage & IRequst) {
return request.get<IRes>('/bill/list', params)
}

// 添加账单
export function AddBill(params: IBillItem) {
return request.post('/bill/add', params)
}

// GetBillList 使用
const { data } = await GetBillList({
pageSize: 5,
page,
date: currentTime,
type_id: currentSelectType.id + ''
})
// 这样就可以有代码提示
// data.token
// AddBill 使用
await AddBill(params)

//

echarts 封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import * as echarts from 'echarts/core'
import {
TooltipComponent,
TooltipComponentOption,
LegendComponent,
LegendComponentOption
} from 'echarts/components'

import { PieChart, PieSeriesOption } from 'echarts/charts'
import { LabelLayout } from 'echarts/features'
import { CanvasRenderer } from 'echarts/renderers'
import { FC, useEffect, useRef } from 'react'
// echarts 配置
echarts.use([
TooltipComponent,
LegendComponent,
PieChart,
CanvasRenderer,
LabelLayout
])
// echarts options 类型
export type EChartsOption = echarts.ComposeOption<
TooltipComponentOption | LegendComponentOption | PieSeriesOption
>
interface MyChartProps {
option: EChartsOption | undefined
}
const MyPieChart: FC<MyChartProps> = ({ option }) => {
// dom ref
const cRef = useRef<HTMLDivElement>(null)
// echart 实例
const instance = useRef<echarts.EChartsType>()
// 饼图的收入支出控制
// const [pieType, setPieType] = useState('expense')

// 初始化组件,监听 dom 和 options 变化
// 监听 dom ,是因为刚开始初始化 dom ref 为 null
// 监听 option 是因为 为动态传入 option
useEffect(() => {
if (cRef.current) {
// 检测 dom 节点是否挂载 echarts 实例,只有未挂载时才初始化
instance.current = echarts.getInstanceByDom(cRef.current)
if (!instance.current) {
instance.current = echarts.init(cRef.current)
}
if (option) instance.current.setOption(option)
}

return () => {
instance.current?.dispose()
}
}, [cRef, option])

return <div ref={cRef} style={{ width: '300px', height: '400px' }}></div>
}
export default MyPieChart

  1. widthheight 可以抽离出,我是直接用了

公共头部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { useNavigate } from 'react-router-dom'
import { NavBar } from 'antd-mobile'
// import { CloseOutline } from 'antd-mobile-icons'
import s from './style.module.scss'
const Header = ({
title,
height = '45px',
backgroundColor
}: {
title: string
height?: string
backgroundColor?: string
}) => {
const navigate = useNavigate()

return (
<div className={s.header} style={{ height, backgroundColor }}>
<NavBar
onBack={() => navigate(-1)}
style={{
'--height': height
}}
>
{' '}
{title}
</NavBar>
</div>
)
}

export default Header

1
2
3
4
5
6
7
8
9
/* 样式 */
/* style.module.scss */
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
border-bottom: 1px solid #e9e9e9;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 样式 */
/* header 以下样式参考 */
.main {
position: fixed;
top: 45px;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
background-color: #fafbfc;
padding: 10px;
.text {
font-size: 16px;
margin-bottom: 10px;
}
}

  1. 使用 <Header title="关于" />
  2. header 有固定样式, header以下部分样式参考上面例子

自定义权限路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { getItem, TOKENKEY } from "@/utils";
import { Navigate } from "react-router-dom";

function AuthRoute({ children }) {
const token = getItem(TOKENKEY);
if (token) {
// 这是jsx
// ts 下记得加 {children}<></>
return <>{children}</>;
} else {
return <Navigate to={"/login"} replace />;
}
}
export default AuthRoute;


// 使用
const router = createBrowserRouter(
[
{
path: '/about',
element: (
<AuthRouter>
// Suspense 是路由懒加载
<Suspense>
<About />
</Suspense>
</AuthRouter>
)
}
],
// basename 是上线是操作
{ basename }
)

项目的Icon 封装

  1. 使用阿里 iconfont
  2. 如果项目是有需要用到单色,和给图标换不同颜色的需求,记得选用 单色图标,如果误选了多色图标,可以在 资源管理==> 我的项目==> 你的项目名==> 批量操作==> 批量去色 ,参考
  3. 该封装使用Symbol 引用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 使用`Symbol` 引用
// 下载资源的 iconfont.js
import './iconfont'
// 下载资源的 iconfont.css
import s from './style.module.scss'
const Icon = ({
type,
fontSize = '20px',
color = ''
}: {
type: string
fontSize?: string
color?: string
}) => {
return color === '' ? (
<svg className={s.icon} aria-hidden="true" fontSize={fontSize}>
<use xlinkHref={`${type}`}></use>
</svg>
) : (
<svg
className={s.icon}
aria-hidden="true"
fontSize={fontSize}
color={color}
>
<use xlinkHref={`${type}`}></use>
</svg>
)
}

export default Icon

// 使用
<Icon type= "#icon-caigoutong106" fontSize="24px" color= "#f59563"/>
1
2
3
4
5
6
7
.icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}

TypeScript 与 React 的结合

传递ref给子组件

参考
react在typescript下传递ref给子组件,forwardRef
创建自己的类型声明文件
函数式组件的 TS

Antd mobile

List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{bill &&
bill.bills.map((item, index) => (
<List key={index}>
<List.Item
key={item.id}
prefix={
<Icon
type={
item.type_id
? typeMap[item.type_id].iconName
: typeMap[16].iconName
}
fontSize="24px"
color={item.type_id ? typeMap[item.type_id].color : '#ffae07'}
/>
}
extra={
<span
style={{ color: item.pay_type == 2 ? 'red' : '#39be77' }}
>{`${item.pay_type === 1 ? '-' : '+'}${item.amount}`}</span>
}
description={
<div>
{dayjs(Number(item.date)).format('HH:mm')}
{item.remark ? ` ${item.remark}` : ' '}
</div>
}
onClick={() => {
goToDetail(item?.id as number)
}}
>
{item.type_name}
</List.Item>
</List>
))}

报错 Warning: Each child in a list should have a unique “key” prop.
解决方案是给 List key={index} 加上 key,这个错误其实很容易知道, 数据结构是 bill.bills是一个数组, List 遍历的 bills 单项,结果我给 listitem加了 key还是没解决,导致我都有点怀疑自己了,

Popup实现向左滑动需求,这个最诡异,我最终模糊地知道了这个问题,而且不止我一个人遇到这个问题,关键这个问题似乎无法复现。
Popup组件内定义的元素设置overflow-x: auto;后无法滑动
大概是由于无法获取的图片的具体宽度从而导致容器的宽度始终大于子容器的宽度,在我想放弃这个做法的时候,看到了一个 BetterScroll 2.0
搜了网上的实现,大家要么是太复杂要么是太简单,
我也是简单实现,以下是具体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 核心包, 当然可以全部引入,但那样太大了,事实上用到的功能只是一点,没必要引入太大的包
import BScroll from '@better-scroll/core'
// 构造函数
import { BScrollConstructor } from '@better-scroll/core/dist/types/BScroll'
// 插件
// https://better-scroll.github.io/docs/zh-CN/plugins/observe-dom.html
import ObserveDom from '@better-scroll/observe-dom'
// 图片类型的滑动,无法检测到,使用这个插件可以检测到 宽度/高度的变化
// https://better-scroll.github.io/docs/zh-CN/plugins/observe-image.html
import ObserveImage from '@better-scroll/observe-image'
// BetterScroll 实例
const [scrollObj, setScrollObj] = useState<BScrollConstructor>()
const wrapperRef = useRef<HTMLDivElement>(null)

BScroll.use(ObserveDom)
BScroll.use(ObserveImage)
// 初始化 实例函数
const initBs = () => {
setScrollObj(
// wrapperRef 父容器
new BScroll(wrapperRef.current as HTMLDivElement, {
//...
observeDOM: true, // 开启 observe-dom 插件
observeImage: true // 开启 observe-image 插件
})
)
}

// jsx
// 父容器
<div className={s.type_wrapper} ref={wrapperRef}>
<div className={s.content}>
</div>
</div>

后端上传图片和删除图片接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
const fs = require('fs')
const moment = require('moment')
const mkdirp = require('mkdirp')
const path = require('path')

const Controller = require('egg').Controller

class UploadController extends Controller {
async upload() {
const { ctx } = this
let file = ctx.request.files[0]
let uploadDir = ''
try {
let f = fs.readFileSync(file.filepath)
// 获取当前日期
let day = moment(new Date()).format('YYYYMMDD')
// 创建图片保存的路径
let dir = path.join(this.config.uploadDir, day)
// 毫秒数
let date = Date.now()
// 如果不存在目录
await mkdirp.mkdirp(dir)
// 图片保存的路径
uploadDir = path.join(dir, date + path.extname(file.filename))
// 写入文件
fs.writeFileSync(uploadDir, f)
} catch (err) {
console.log('上传错误', err)
ctx.body = {
code: 500,
message: '上传失败'
}
return
} finally {
await ctx.cleanupRequestFiles()
}
uploadDir = uploadDir.split(path.sep).join('/')
ctx.body = {
code: 200,
message: '上传成功',
data: uploadDir.replace(/app/, '')
}
}

async deleteFile() {
const { ctx } = this
const { avatar } = ctx.request.body

if (!avatar) {
ctx.body = {
code: 400,
message: '参数错误'
}
}

try {
// fileName 类似 'http://127.0.0.1:7001/public/upload/20240308/1709890350676.jpeg'
// 拿到 /20240308/1709890350676.jpeg
const fileName = avatar.split('/upload')[1]
let filePath = path.join('app/public/upload', fileName)
await fs.promises.unlink(filePath)
ctx.body = {
code: 200,
message: '删除成功'
}
} catch (err) {
console.log('文件删除失败', err)
ctx.body = {
code: 500,
message: '删除失败'
}
}
}
}

module.exports = UploadController

项目部署上线

后端上线

数据库部分

  1. ubuntu 20 安装mysql
  2. 导出本地数据库,sql文件, mysql workbench操作为, Data Export —-> 选择你的数据库 —-> Export to Self-Contained File —-> Include Create Schema —-> Start Export
  3. 上传到服务器,pscp -P 22 -i 密钥 -C -r 本地地址 用户名@远程地址:远程文件夹
  4. 导入 sql

egg

  1. 上传到 github, 首先项目需要被 git init
  2. git clone
  3. npm install --production
  4. npm run start 启动项目,这里遇到一个问题,Node.js MySQL - Error: connect ECONNREFUSED, 修改一下数据库配置就可以了
  5. 测试一下,接口是否能够正常访问,curl -d '接口数据' -X POST http://127.0.0.1:7001/接口地址
  6. 记得开安全组,7001

前端代码 打包上线

  1. pnpm run build, 打包, 打包优化后续再做
  2. 上传代码,putty的pscp pscp -P 22 -i 密钥 -C -r 本地地址 用户名@远程地址:远程文件夹
  3. 配置 nginx 设置转发

nginx 配置, css,js 访问不到

1
2
3
4
5
6
7
8
9
10
11
server{
location / {
root /opt/xxxxxx/dist;
index index.html;
}

location /bill/{
alias /opt/bill_server/app/public/dist/;
index index.html;
}
}

当项目部署了太多的项目时,我们的 react 项目不太可能匹配 /, 而是要匹配 /bill 这个路径
当我们要去访问这个项目的时,访问地址是 域名/bill/xxxx
当查看 /var/log/nginx/error.log 时, 发现我们的资源访问的地址却是 域名/assets/index-xxx.css/域名/assets/index-xxx.js
而我们真正需要的访问的是 域名/bill/assets/index-xxx.css/域名/bill/assets/index-xxx.js,
而在 vite 中的这个方法是 base配置
简单一点的是做法是给 package.jsonbuild 改成 "build": "tsc && vite build --base=/bill/"
虽然这一步做完了,但页面还是无法正常跳转的话,这是因为你的路由配置的有问题
参考:Nginx部署React项目至服务器二级目录实践
需要在路由中添加 basename