项目背景 过年前学完了 react
和 ts
,在找面试项目 idea 的时候找到了这个掘金手册,Node + React 实战:从 0 到 1 实现记账本 ,总体来说,符合我的要求,所以我的项目在这个项目的基础上加上了改进。 那时我并不知道这个想法会让我吃个大亏。不过幸运的是,这个项目最终完成了。 具体的改进如下:
使用了最新的 react18
和 react routerv6
开始使用 TypeScript
进行类型约束
手册使用了 zarm
UI 库, 而我则使用 antd-mobile
UI 库
手册使用了 less
, 而我则使用了 sass
手册并没有规范代码格式了,而我使用了 eslint + prettier
来规范美化代码
项目的开发 需求分析
后端需要实现用户登录注册接口,账单数据的增删改查接口,返回月度账单的统计接口,账单类型的接口
前端需要实现用户登录页注册页,账单页,账单详情页,月度账单展示页,用户信息页
前端项目初始化 项目新建和安装依赖 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 node_modules # Ignore artifacts : build coverage
1 2 3 4 5 6 7 8 9 10 11 { "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 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 "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
,思想是约定大于配置
,举个例子是controller
和 service
,需要在固定文件夹下,配置需要到这两个文件下去配置config/config.default.js
和 config/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
有几个概念,地方需要理解,注意一下,
Controller
Router
配置
Service
配置
Middleware
中间件
配置文件
插件可以先放过。
框架使用了 ts 进行类型提醒,所以当你书写了 controller 和 service ,可以重启下项目,会有相应的代码提示
框架内置对象
调试
写代码 遇到的问题 模块化 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 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' 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 { instance : AxiosInstance 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 ) => { 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` } } return Promise .reject (res.data ) } return res.data }, (err: any ) => { Toast .show ({ content : '系统错误' , icon : 'fail' }) return Promise .reject (err.response ) } ) } get<T>(url : string , params?: object , config = {}): Promise <Result <T>> { return this .instance .get (url, { params, ...config }) } 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 export interface IPage { page?: number pageSize : number } export interface IRequst { date?: string type_id : string } export interface IRes { token?: string list?: IDayBill [] | BillType [] | undefined total_data?: ITotalDataItem [] totalExpense : number totalIncome : number totalPage : number } export function GetBillList (params: IPage & IRequst ) { return request.get <IRes >('/bill/list' , params) } export function AddBill (params: IBillItem ) { return request.post ('/bill/add' , params) } const { data } = await GetBillList ({ pageSize : 5 , page, date : currentTime, type_id : currentSelectType.id + '' }) 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.use ([ TooltipComponent , LegendComponent , PieChart , CanvasRenderer , LabelLayout ]) export type EChartsOption = echarts.ComposeOption < TooltipComponentOption | LegendComponentOption | PieSeriesOption > interface MyChartProps { option : EChartsOption | undefined } const MyPieChart : FC <MyChartProps > = ({ option } ) => { const cRef = useRef<HTMLDivElement >(null ) const instance = useRef<echarts.EChartsType >() useEffect (() => { if (cRef.current ) { 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
width
和 height
可以抽离出,我是直接用了
公共头部 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 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 .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 .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 ; } }
使用 <Header title="关于" />
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) { return <> {children}</> ; } else { return <Navigate to ={ "/login "} replace /> ; } } export default AuthRoute ;const router = createBrowserRouter ( [ { path : '/about' , element : ( <AuthRouter > // Suspense 是路由懒加载 <Suspense > <About /> </Suspense > </AuthRouter > ) } ], { basename } )
项目的Icon 封装
使用阿里 iconfont
如果项目是有需要用到单色,和给图标换不同颜色的需求,记得选用 单色图标 ,如果误选了多色图标,可以在 资源管理
==> 我的项目
==> 你的项目名
==> 批量操作
==> 批量去色
,参考
该封装使用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 import './iconfont' 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' import ObserveDom from '@better-scroll/observe-dom' import ObserveImage from '@better-scroll/observe-image' const [scrollObj, setScrollObj] = useState<BScrollConstructor >()const wrapperRef = useRef<HTMLDivElement >(null )BScroll .use (ObserveDom )BScroll .use (ObserveImage )const initBs = ( ) => { setScrollObj ( new BScroll (wrapperRef.current as HTMLDivElement , { observeDOM : true , observeImage : true }) ) } <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 { 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
项目部署上线 后端上线 数据库部分
ubuntu 20 安装mysql
导出本地数据库,sql文件, mysql workbench操作为, Data Export
—-> 选择你的数据库
—-> Export to Self-Contained File
—-> Include Create Schema
—-> Start Export
上传到服务器,pscp -P 22 -i 密钥 -C -r 本地地址 用户名@远程地址:远程文件夹
导入 sql
egg
上传到 github, 首先项目需要被 git init
git clone
npm install --production
npm run start
启动项目,这里遇到一个问题,Node.js MySQL - Error: connect ECONNREFUSED , 修改一下数据库配置就可以了
测试一下,接口是否能够正常访问,curl -d '接口数据' -X POST http://127.0.0.1:7001/接口地址
记得开安全组,7001
前端代码 打包上线
pnpm run build
, 打包, 打包优化后续再做
上传代码,putty的pscp
pscp -P 22 -i 密钥 -C -r 本地地址 用户名@远程地址:远程文件夹
配置 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.json
的 build
改成 "build": "tsc && vite build --base=/bill/"
虽然这一步做完了,但页面还是无法正常跳转的话,这是因为你的路由配置的有问题参考:Nginx部署React项目至服务器二级目录实践 需要在路由中添加 basename