likes
comments
collection
share

React开发记账应用:经验记录分享

作者站长头像
站长
· 阅读数 45

最近,我使用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,原理如下: React开发记账应用:经验记录分享 主要原因是错误的使用了自动跳转导致的,其次是因为所有路由都使用了根路由的errorElement。所以我们在welcome的路由上单独配置errorElement,并且将根路由的errorElemt页面的自动跳转删除,换成普通的错误页面。

React Router 动画实践

react-spring 是一个基于 React 的动画库,它提供了多种动画效果和动画控制方式,可以方便地实现各种复杂的交互效果和动画效果。 官网:  react-spring.dev/ 安装: npm i @react-spring/web

路由过渡动画状态示意图:

React开发记账应用:经验记录分享 即当页面切换时,当前页面由稳定状态变为退出状态,下一个页面由进入状态变成稳定状态。

我们使用react-spring库中的useTransition钩子来实现页面左滑时的过渡动画。

  1. 定义三个状态,即进入动画的起始状态、进入动画的结束(稳定)状态和离开状态,并设置动画切换时间。

    js
    const transitions = useTransition(location.pathname, {
        from: { transform: 'translateX(100%)' },
        enter: { transform: 'translateX(0)' },
        leave: { transform: 'translateX(-100%)' },
        config: { duration: 1000 },
    })
    

    location.pathname是当前路由,我们使用useLocation()钩子来获取。

  2. 展示动画

    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>
      })
    }
    
  3. 如果只需要某一个地方有动画,如只需要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>

实现效果: React开发记账应用:经验记录分享

React开发记账应用:经验记录分享 如上图所示,当点击下一页的时候,另一个页面是从下面过来的。这是因为定位导致的,我们使用绝对定位来解决。

  1. 设置额外样式相对定位: const [extraStyle, setExtraStyle] = useState<{ position: 'relative' | 'absolute' }>({ position: 'relative' }) <main shrink-1 grow-1 relative m-16px>
  2. 然后给过渡动画加上额外样式: <animated.div key={pathname} style={{ ...style, ...extraStyle }}
  3. 在动画开始和结束的时候改变样式: onStart:()=>{setExtraStyle({position:'absolute'})} onRest:()=>{setExtraStyle({position:'relative'})}

此时我们又发现,动画切换时中间是没有空隙的。

  1. 我们可以先让动的部分宽度占满,然后将里面白色部分缩小即可。
  2. 首先把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

  1. 在同一层级新建一个Welcome1.module.scss文件。

    js
    .wrapper{
        border: 1px solid red;
    }
    .blue{
        color: blue;
    }
    
  2. 然后在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>
    
  3. 这样生成的类名后面都会自动加上随机数,如果我们需要没有随机数的类名如"demo",需要使用字符串,代码如下:

    js
    // Welcome1.module.scss
    :global(.demo){
       border:1px solid black;
    }
    // Welcome1.tsx
    <div className={c(s.wrapper, s.blue, 'demo')}>Welcome1</div>
    
  4. 只能使用class,不能使用id。

方案二 styled-components

  1. 安装:npm i styled-components 使用ts还需安装ts依赖:npm i --save-dev @types/styled-components

  2. 导入 import styled from 'styled-components'

  3. 使用示例

    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>
      )
    }
    
  4. 如何复用

    js
    const Bordered = styled.div`
        border: 1px solid red;
        &:hover{
            background: red;
        }
    `
    const Bordered2 = styled(Bordered)`
        color:blue
    `
    
  5. 这种方案类名完全随机的。

方案三 Unocss

  1. 安装npm install -D unocss

  2. 安装插件

    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(),
        ],
    }
    
  3. 添加virtual:uno.css

    js
    // main.ts
    import 'virtual:uno.css'
    
  4. 创建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()
      ],
    })
    
  5. 但是部分属性不能识别。如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
      }
    }
    
  6. 使用,如设置border为1px <div border-1></div>

  7. 语法查询网址:Getting Started | Windi CSS

升级bug记录

为了解决TypeScript类型报错,我升级了unocss。但是,升级后我发现页面上出现了90个错误,经过反推,我认为是unocss的一个插件导致的。于是我决定修改源码,首先定位了bug,发现是正则表达式写错了,导致一些变量被错误地识别为unocss,然后就发生了错误。我对正则表达式进行了修正,但发现页面中的border-style又失效了。查看提交记录后,我发现在某个版本中取消了通过添加b-solid来解决这个问题。

使用zustand.js做全局状态管理

官网:  github.com/pmndrs/zust… 安装:  npm install zustand 示例用法:

  1. 创建一个src/stores目录,用来存放状态钩子(hooks)文件。

  2. 创建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 })),
    }))
    
  3. 使用

    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

React开发记账应用:经验记录分享

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

如何判断用户是否已登录?

  1. 用JS读取cookies,但是cookies可能被更改。
  2. 用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?

  1. 接口没做完,但是需要接口,使用mock就可以。
  2. 篡改接口的返回值,包括状态码和返回值等等。

mock的主流方案

  1. 使用第三方服务 APIFox、apipost
  2. 自己写本地mock服务器

自己搭建Mock服务器

前提:

  1. vite有服务器功能。
  2. 使用插件github.com/vbenjs/vite…

步骤:

  1. 安装npm i mockjs@1.1.0 -S npm i vite-plugin-mock@2.9.6 -D
  2. 配置vite.config.ts
js
import { viteMockServe } from 'vite-plugin-mock'
export default defineConfig(({ command }) => ({
  plugins: [
    viteMockServe({
      mockPath: 'mock',
      localEnabled: command === 'serve',
    })
  ]
}))
  1. 创建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,
    },
  }
},

源码链接:guozhq/mangoledger (github.com)

转载自:https://juejin.cn/post/7243748145884528677
评论
请登录