React开发记账应用:经验记录分享
最近,我使用React开发了一款记账应用。在这个过程中,我遇到了许多挑战,学到了很多东西。今天,我想分享一下我的经验,希望能够帮助到其他初学者和开发者。 这个记账应用是我第一次使用React实现的项目。我将分享我在开发这个记账应用中遇到的部分问题,以及我是如何克服它们的。希望这些经验对你有所帮助!
React Router
安装:npm i react-router-dom@6.4.2
初始化:Tutorial v6.10.0 | React Router
errorPage页面初始化:教程 v6.10.0 |反应路由器 (reactrouter.com)
嵌套路由用法示例
js
{
path: '/',
element: <div>Hello world!<Outlet /></div>,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <div>请选择 1 2 3</div> },
{ path: '1', element: <div>1</div> },
{ path: '2', element: <div>2</div> }
]
}
设置错误页面自动跳转到Welcome/1
js
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
export const ErrorPage: React.FC = () => {
const nav = useNavigate()
useEffect(() => {
nav('welcome/1')
}, [])
return null
}
useNavigate()
是 React Router v6 中提供的一个 hooks,它用于在 React 组件中进行页面导航。使用useNavigate
可以获得一个navigate
函数,该函数可以在组件中进行页面导航,而无需使用<Link>
或<NavLink>
等 React Router 提供的组件。useEffect
钩子函数用于在 React 组件渲染后执行副作用操作。在这个例子中,useEffect
用于在组件渲染后立即执行导航操作。由于useEffect
的第二个参数是一个空数组,它只会在组件渲染后执行一次。
bug:errorElement造成无限循环
我们errorElement设置为errorPage页面,并设置了自动跳转,导致了无限循环的bug,原理如下: 主要原因是错误的使用了自动跳转导致的,其次是因为所有路由都使用了根路由的errorElement。所以我们在welcome的路由上单独配置errorElement,并且将根路由的errorElemt页面的自动跳转删除,换成普通的错误页面。
React Router 动画实践
react-spring 是一个基于 React 的动画库,它提供了多种动画效果和动画控制方式,可以方便地实现各种复杂的交互效果和动画效果。
官网: react-spring.dev/
安装: npm i @react-spring/web
路由过渡动画状态示意图:
即当页面切换时,当前页面由稳定状态变为退出状态,下一个页面由进入状态变成稳定状态。
我们使用react-spring库中的useTransition钩子来实现页面左滑时的过渡动画。
-
定义三个状态,即进入动画的起始状态、进入动画的结束(稳定)状态和离开状态,并设置动画切换时间。
js const transitions = useTransition(location.pathname, { from: { transform: 'translateX(100%)' }, enter: { transform: 'translateX(0)' }, leave: { transform: 'translateX(-100%)' }, config: { duration: 1000 }, })
location.pathname是当前路由,我们使用useLocation()钩子来获取。
-
展示动画
js return transitions((style, item) => ( <animated.div style={style}>{item}</animated.div> ))
item表示当前路由的内容:我们可以使用useOutlet()钩子获取子路由出口的内容,并将其保存在map中。
以下是完整的代码示例:
js import { animated, useTransition } from '@react-spring/web' import type { ReactNode } from 'react' import { useLocation, useOutlet } from 'react-router-dom' const map: Record<string, ReactNode> = {} export const WelcomeLayout: React.FC = () => { const location = useLocation() const outlet = useOutlet() map[location.pathname] = outlet const transitions = useTransition(location.pathname, { from: { transform: 'translateX(100%)' }, enter: { transform: 'translateX(0%)' }, leave: { transform: 'translateX(-100%)' }, config: { duration: 1000 }, }) return transitions((style, pathname) => { return <animated.div key={pathname} style={style}> {map[pathname]} </animated.div> }) }
-
如果只需要某一个地方有动画,如只需要main有动画,代码如下:
js return ( <div> <header></header> <main> {transitions((style, pathname) => <animated.div key={pathname} style={style}> {map.current[pathname]} </animated.div>)} </main> <footer></footer> </div> )
bug1:首页也会执行动画
我们需要给动画的起始状态加一个判断,如下:
js
from: {
transform: location.pathname === '/welcome/1'
? 'translateX(0%)'
: 'translateX(100%)',
},
bug2:map内存泄漏的问题
内存泄漏是指在应用程序中,当不再需要使用的内存空间没有被正确释放时发生的现象。这些未释放的内存空间会一直占用系统的内存资源,最终可能导致系统运行缓慢或崩溃。
如上示例代码,我们将map定义在了WelcomeLayout组件的外面,这样就会导致当WelcomeLayout组件不存在的时候,map还是会存在,导致内存泄漏。
map与组件的生命周期不同,它初始化时就会存在于js内存中,而WelcomeLayout组件只有当路由为/welcome
时才存在。
所以需要让map和组件的生命周期同步,如果直接将map放在组件里面也不行,因为每次调用组件都会重新初始化map。所以我们需要使用useRef
钩子来避免map重置。
useRef
钩子会返回一个ref对象,该对象可以在组件的整个生命周期中保持不变,不像普通变量在组件重新渲染时会被重新初始化。注意后续得通过map.current
来访问 map
变量。
完成动画样式时遇到的问题记录
js
<main shrink-1 grow-1 relative m-16px>
{transitions((style, pathname) =>
<animated.div key={pathname} style={style} w-full h-full rounded-8px flex justify-center items-center bg-white>
<div>
{map.current[pathname]}
</div>
</animated.div>)}
</main>
实现效果:
如上图所示,当点击下一页的时候,另一个页面是从下面过来的。这是因为定位导致的,我们使用绝对定位来解决。
- 设置额外样式相对定位:
const [extraStyle, setExtraStyle] = useState<{ position: 'relative' | 'absolute' }>({ position: 'relative' })
<main shrink-1 grow-1 relative m-16px>
- 然后给过渡动画加上额外样式:
<animated.div key={pathname} style={{ ...style, ...extraStyle }}
- 在动画开始和结束的时候改变样式:
onStart:()=>{setExtraStyle({position:'absolute'})}
onRest:()=>{setExtraStyle({position:'relative'})}
此时我们又发现,动画切换时中间是没有空隙的。
- 我们可以先让动的部分宽度占满,然后将里面白色部分缩小即可。
- 首先把main的margin去掉。然后在animated里面再加一个div为白色部分,代码如下:
js
<main shrink-1 grow-1 relative m-16px>
{transitions((style, pathname) =>
<animated.div key={pathname} style={{ ...style, ...extraStyle }} w-full h-full p-16px flex>
<div bg-white grow-1 flex justify-center items-center rounded-8px>
{map.current[pathname]}
</div>
</animated.div>)}
</main>
CSS in JS
方案一 CSS Modules
-
在同一层级新建一个Welcome1.module.scss文件。
js .wrapper{ border: 1px solid red; } .blue{ color: blue; }
-
然后在Welcome1.tsx引入并使用。
js import s from './Welcome1.module.scss' export const Welcome1: React.FC = () => { return ( <div className={s.wrapper}>Welcome1</div> ) } // 如果多个类 <div className={[s.wrapper, s.blue].join('')}>Welcome1</div> // 或者使用classnames库 安装:nmp i classnames 导入:import c from classnames 使用:<div className={c(s.wrapper, s.blue)}>Welcome1</div>
-
这样生成的类名后面都会自动加上随机数,如果我们需要没有随机数的类名如"demo",需要使用字符串,代码如下:
js // Welcome1.module.scss :global(.demo){ border:1px solid black; } // Welcome1.tsx <div className={c(s.wrapper, s.blue, 'demo')}>Welcome1</div>
-
只能使用class,不能使用id。
方案二 styled-components
-
安装:
npm i styled-components
使用ts还需安装ts依赖:npm i --save-dev @types/styled-components
-
导入
import styled from 'styled-components'
-
使用示例
js import styled from 'styled-components' const Bordered = styled.div` border: 1px solid red; &:hover{ background: red; } ` export const Welcome1: React.FC = () => { return ( <div> <Bordered> 123 </Bordered> </div> ) }
-
如何复用
js const Bordered = styled.div` border: 1px solid red; &:hover{ background: red; } ` const Bordered2 = styled(Bordered)` color:blue `
-
这种方案类名完全随机的。
方案三 Unocss
-
安装
npm install -D unocss
-
安装插件
js // vite.config.ts import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [ UnoCSS(), ], })
按照官网提示,如果using @vitejs/plugin-react with @unocss/preset-attributify,则代码如下:
js import react from '@vitejs/plugin-react' import UnoCSS from 'unocss/vite' export default { // 注意UnoCSS和react的顺序。 plugins: [ UnoCSS({ /* options */ }), react(), ], }
-
添加
virtual:uno.css
js // main.ts import 'virtual:uno.css'
-
创建
uno.config.ts
js import { defineConfig, presetAttributify, presetIcons, presetTypography, presetUno, transformerAttributifyJsx } from 'unocss' export default defineConfig({ theme: { }, shortcuts: { }, safelist: [], presets: [ presetUno(), presetAttributify(), presetIcons({ extraProperties: { 'display': 'inline-block', 'vertical-align': 'middle' }, }), presetTypography(), ], transformers: [ transformerAttributifyJsx() ], })
-
但是部分属性不能识别。如flex。所以我们需要告诉React,所有的HTML都要有flex属性。
解决方法:src目录下新建shims.d.ts,添加如下代码。此文件的作用就是告诉React,所有的HTML都要有flex属性。后续如果有其他不能识别的属性,也可以加进来。
js import * as React from 'react' declare module 'react' { interface HTMLAttributes<T> extends AriaAttributes, DOMAttributes<T> { flex?: boolean } }
-
使用,如设置border为1px
<div border-1></div>
-
语法查询网址:Getting Started | Windi CSS
升级bug记录
为了解决TypeScript类型报错,我升级了unocss。但是,升级后我发现页面上出现了90个错误,经过反推,我认为是unocss的一个插件导致的。于是我决定修改源码,首先定位了bug,发现是正则表达式写错了,导致一些变量被错误地识别为unocss,然后就发生了错误。我对正则表达式进行了修正,但发现页面中的border-style又失效了。查看提交记录后,我发现在某个版本中取消了通过添加b-solid来解决这个问题。
使用zustand.js做全局状态管理
官网: github.com/pmndrs/zust…
安装: npm install zustand
示例用法:
-
创建一个src/stores目录,用来存放状态钩子(hooks)文件。
-
创建useDemoStore.tsx文件
js import create from 'zustand' interface Demo { count: number add: () => void } export const useDemoStore = create<Demo>(set => ({ count: 0, add: () => set(state => ({ count: state.count + 1 })), }))
-
使用
js import { useDemoStore } from '../stores/useDemoStore' export const Home: React.FC = () => { const { count, add } = useDemoStore() return <div text-6xl> <div>{count}</div> <div><button onClick={add}>+1</button></div> </div> }
用zustand封装localStorage
js
// src/stores/useLocalStore.tsx
import create from 'zustand'
interface Local {
hasReadWelcomes:boolean
sethasReadWelcomes:(read: boolean)=>void
}
const init = localStorage.getItem('hasReadWelcomes')
export const useLocalStore = create<Local>(set =>({
hasReadWelcomes: init === 'yes',
sethasReadWelcomes:(read:boolean)=>{
const result = read? 'yes' : 'no'
localStorage.setItem('hasReadWelcomes', result)
set({hasReadWelcomes: result === 'yes'})
}
}))
写入数据
js
const { sethasReadWelcomes } = useLocalStore()
const onSkip = () =>{
sethasReadWelcomes(true)
}
读取数据
js
const { hasReadWelcomes }= useLocalStore()
if(hasReadWelcomes){}
SWR
如何判断用户是否已登录?
- 用JS读取cookies,但是cookies可能被更改。
- 用JS发请求问服务器用户是否已登录。
那React如何发请求呢?下面是React发送请求出错最少的一种方法:
js
const { loading, errors, data } = useAjax('get', '/api/v1/currentUser')
if (loading) {}
if (errors) {}
return <div>{data.user.name}</div>
swr是用于数据请求的React Hook库,我们可以用axios做请求,用swr做缓存。下面是swr的示例用法。
js
import useSWR from 'swr'
const fetcher = (path: string) => {
return axios.get('/')
}
function Profile() {
const { data, error, isLoading } = useSWR('/api/user', fetcher)
if (error) return <div>failed to load</div>
if (isLoading) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
使用swr发送关联请求
js
useEffect(()=>{
axios.get('/api/v1/me')
.then(() => {
axios.get('/api/v1/items')
.then((response) => {
if(response.data.resources.length > 0) {
console.log('需要跳转')
}
}, () => {})
}, () => {})
}, [])
// 上面代码使用swr
const { data:meData, error:meError } = useSWR('/api/v1/me', (path) => {
return axios.get(path)
})
const {data:itemData, error:itemError } = usrSWR(meData ? '/api/v1/item' : null, (path) =>{
return axios.get(path)
})
加载更多按钮实现
通过useSWRInfinite来实现无限加载
js
const getKey = (pageIndex: number, prev: Resources<Item>) => {
if (prev) {
const sendCount = (prev.pager.page - 1) * prev.pager.per_page + prev.resources.length
const count = prev.pager.count
if (sendCount >= count) { return null }
}
return `/api/v1/items?page=${pageIndex + 1}`
}
const { data, error, size, setSize } = useSWRInfinite(
getKey,
async path => (await ajax.get<Resources<Item>>(path)).data,
{ revalidateFirstPage: false }
)
const onLoadMore = () => {
setSize(size + 1)
}
使用mock
为什么要用mock?
- 接口没做完,但是需要接口,使用mock就可以。
- 篡改接口的返回值,包括状态码和返回值等等。
mock的主流方案
- 使用第三方服务 APIFox、apipost
- 自己写本地mock服务器
自己搭建Mock服务器
前提:
- vite有服务器功能。
- 使用插件github.com/vbenjs/vite…
步骤:
- 安装
npm i mockjs@1.1.0 -S
npm i vite-plugin-mock@2.9.6 -D
- 配置vite.config.ts
js
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig(({ command }) => ({
plugins: [
viteMockServe({
mockPath: 'mock',
localEnabled: command === 'serve',
})
]
}))
- 创建mock/test.ts文件
js
import type { MockMethod } from 'vite-plugin-mock'
export default [
{
url: '/api/v1/me',
method: 'get',
response: () => {
return {
id: 1,
email: '123@123.com'
}
},
},
{
url: '/api/v1/items',
method: 'get',
response: () => {
return {
resources: [{
id: 1,
user_id: 1,
amount: 1000,
}],
pager: {
page: 1,
per_page: 25,
count: 100
}
}
},
}
] as MockMethod[]
Router Loader与路由守卫
当用户点击登录后,会跳转到列表页,如果列表页没有数据,会跳转到home页,即
const response = await get('/api/v1/items?page=1')
if(response.data.resources.length === 0){
nav('/home')
}
但是这样对用户的体验特别不好,用户先看到一个空白的item页,然后又跳转到home页。
能不能直接跳转到home页?
// loader的作用是给element加载一个数据,跟据数据决定要不要渲染这个页面。
// 如果有数据,就渲染 element
// 如果没有数据,就跳转页面到 /home
{ path: '/items',
element: <ItemsPages />,
errorElement:<Navigate to="/home" />,
loader: async () =>{
const response = await axios.get<Resources<Item>>('/api/v1/items?page=1')
if(response.data.resources.length > 0) {
return response.data
} else {
throw new Error('没数据')
}
}},
// 但是如果没有登录,应该跳转到登录页面,而不是home页面。
{ path: '/items',
element: <ItemsPages />,
errorElement:<Navigate to="/home" />,
loader: async () =>{
const onError = (error: AxiosError) =>{
if(error.response?.status === 401) { throw new Error('没登录')}
throw error
}
const response = await axios.get<Resources<Item>>('/api/v1/items?page=1').catch(onError)
if(response.data.resources.length > 0) {
return response.data
} else {
throw new Error('没数据')
}
}},
// 现在我们的errorElement只能跳转到一个页面,不能根据错误跳转到另一个页面。
// 所以我们单独写一个errorelement页面控制跳转。
import { Navigate, useRouteError } from "react-router-dom";
export const ItesmPageError: React.FC =()=>{
const error = useRouteError()
const e = error as Error
if (e.message === 'unauthorized'){
return <Navigate to="/sign_in" />
}else if(e.message === 'data_empty'){
return <Navigate to="/home" />
}else {
return <div>出错了</div>
}
};
// 我们的error名称有的叫unauthorized,有的是data_empty,那万一写错怎么办?两个地方不一致
// 我们新将一个error.ts,将所有的error放进去。
const errors = {
empty_data: 'empty_data',
unauthorized: 'unauthorized',
}
// 然后将之前的错误替换,如errors.empty_data
// 也可以写成下面这样的
export class ErrorUnauthorized extends Error{}
export class ErrorEmptyData extends Error{}
// 使用 throw new ErrorUnauthorized (e instanceof ErrorUnauthorized)
有了errorElement,React也可以实现路由守卫。loader就是守卫,loader只要发现数据不对,就会走errorElement
Vite Proxy处理跨域
在访问后端接口的时候,我们可以使用Vite Proxy篡改请求和响应,使得后端可以兼容跨域请求。配置示例:
// vite.config.js
server: {
proxy: {
'/api/': {
target: '目标url',
changeOrigin: true,
},
}
},
转载自:https://juejin.cn/post/7243748145884528677