【山竹记账前端-react】9.搭建 Mock、封装 Axios、使用 SWR


大纲链接 §

[toc]


实现首页,需要分以下几种情况

  • 如果没有登录:可选显示首页,重定向到登录
  • 如果登录但没有记账:展示首页
  • 如果登录且记过账:记账列表页面

为什么要 Mock

  1. 后台接口还没完成,但前端要用到接口数据
  2. 我想篡改后台接口的结果来测试

1. 使用 swr 发送关联请求 ⇧

实现 Mock

  • 需要篡改接口的返回值,包括状态码和消息体
  • 需要mock /api/v1/me
  • 需要mock /api/v1/items
  • 判断记账账目数据长度大于零,需要跳转
  • 在页面挂载之后才发请求,使用 useEffect 模拟,多个前后依赖条件的接口会嵌套
  • 使用 useSWR 解决请求嵌套问题,内部会使用 useEffect,可以直接替代 useEffect

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    // ...
    export const Home: React.FC = () => {
    const {data: meData, error: meError} = useSWR('/api/v1/me', (path) => {
    return axios.get(`https://xx.xxx.xxx.xx:xxxx${path}`)
    })
    const {data: itemsData, error: itemsError} = useSWR(data1 ? '/api/v1/items' : null, (path) => {
    return axios.get(`https://xx.xxx.xxx.xx:xxxx${path}`)
    })
    // return ...
    }
    
  • 前后依赖的关联请求可以使用技巧:const {data, error} = useSWR(data1 ? '/api/v1/items' : null, () => {})

  • 判断前一个数据是否为空来设置请求路径 data1 ? '/api/v1/items' : null

  • 当第一个请求的 data1 还未成功时,值为空,则第二个请求路径为空,不发请求

  • 等第一个请求数据有内容后,UI 重新渲染,才会去请求第二个 api


2. 使用第三方 Mock 工具 ⇧

  • apiFox
  • apiPost
  • 第三方商业服务可能面临收费、宕机或者供应链投毒等情况

参考


3. 自己搭建 Mock 服务器 ⇧

搜索 vite plugin mock

安装

  • pnpm add vite-plugin-mock@3.0.2 -D
  • 无需另外安装 mockjs

配置 vite.config.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
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, UserConfigExport, ConfigEnv } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import jsxScoped from '@10coding/vite-plugin-jsx-scoped'
import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig(({ command }: ConfigEnv) => ({
  // base: '/mangosteen-react-1-preview/',
  plugins: [
    UnoCSS(),
    jsxScoped({ scopedIdAttributeName: 'scopedid' }),
    react(),
    viteMockServe({
      mockPath: './src/mock',
      enable: command === 'serve',
    }),
  ],
  server: {
    host: true
  },
  resolve: {
    // 开启 Vite 8 的原生 tsconfig 路径解析支持
    tsconfigPaths: true,
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    }
  }
})) satisfies UserConfigExport
  • 仅在开发环境使用,判断:enable: command === 'serve'
  • 也可以配置在 .env.production 和 .env.development 中配置
  • 注意配置路径 mockPath: './src/mock',默认在 src 同级目录

添加文件 src/mock/test.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import type { MockMethod } from 'vite-plugin-mock'

export default [
  {
    url: '/api/v1/me',
    method: 'get',
    response: () => {
      return {
        code: 0,
        data: {
          id: 1,
          email: 'frank@frank.com',
        },
      }
    },
  },
] as MockMethod[]

重启 vite 服务,利用 vite 本身服务器的功能模拟接口数据

vite-mock

get请求直接访问浏览器地址 http://localhost:5173/api/v1/me

vite-mock-me

再添加 /api/v1/items 接口模拟数据

 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
import type { MockMethod } from 'vite-plugin-mock'

export default [
  {
    url: '/api/v1/me',
    method: 'get',
    response: () => {
      return {
        code: 0,
        data: {
          id: 1,
          email: 'frank@frank.com',
        },
      }
    },
  },
  {
    url: '/api/v1/items',
    method: 'get',
    response: () => {
      return {
        code: 0,
        data: {
          resources: [
            {
              id: 1,
              user_id: 1,
              amount: 100,
            },
          ],
          pager: {
            page: 1,
            per_page: 25,
            count: 1,
          },
        },
      }
    },
  },
] as MockMethod[]

目前已经做到

  • 在本地 5173 端口模拟api响应数据

在页面中模拟访问当前地址接口 @/pages/Home.tsx

 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
import pig from '@/assets/icons/pig.svg'
import add from '@/assets/icons/add.svg'
import { cn } from 'cn'
import useSWR from 'swr'
import axios from 'axios'

export const Home: React.FC = () => {
  const { data: meData, error: meError } = useSWR('/api/v1/me', (path) => {
    return axios.get(path)
  })
  const { data: itemsData, error: itemsError } = useSWR(meData ? '/api/v1/items' : null, (path) => {
    return axios.get(path)
  })

  window.console.log('Home_______________________')
  window.console.log('meData', meData?.data?.data)
  window.console.log('meError', meError)
  window.console.log('itemsData', itemsData?.data?.data)
  window.console.log('itemsError', itemsError)
  window.console.log('_______________________Home')

  return (
    <main flex flex-col justify-center items-center>
      {/* ... */}
    </main>
  )
}

4. 封装 Axios ⇧

使用 vite 配置区分开发与生产环境 ⇧

需要根据环境切换地址

  • pnpm dev 当处于开发环境访问地址前缀 /api/v1/xx
  • pnpm build 当处于生产环境访问地址前缀为真实服务器地址 https://xx.xxx.xxx.xx:x0x0/api/v1/xx

配置 vite.config.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
import { fileURLToPath, URL } from 'node:url'
import { defineConfig, UserConfigExport, ConfigEnv } from 'vite'
import react from '@vitejs/plugin-react'
import UnoCSS from 'unocss/vite'
import jsxScoped from '@10coding/vite-plugin-jsx-scoped'
import { viteMockServe } from 'vite-plugin-mock'

// https://vitejs.dev/config/
export default defineConfig(({ command }: ConfigEnv) => ({
  // base: '/mangosteen-react-1-preview/',
  define: {
    __IS_DEV__: command === 'serve',
  },
  plugins: [
    UnoCSS(),
    jsxScoped({ scopedIdAttributeName: 'scopedid' }),
    react(),
    viteMockServe({
      mockPath: './src/mock',
      enable: command === 'serve',
    }),
  ],
  server: {
    host: true
  },
  resolve: {
    // 开启 Vite 8 的原生 tsconfig 路径解析支持
    tsconfigPaths: true,
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    }
  }
})) satisfies UserConfigExport
  • 添加配置项 define: { __IS_DEV__: command === 'serve' },表示是否在开发环境,否则表示在部署生产环境
  • define 配置的属性会被添加全局变量中,即可访问 globalThis.IS_DEV 或 window.__IS_DEV__

参考


封装请求库 ⇧

解耦页面请求,最小知识原则

  • 使用时,不需要知道是在使用 axios 库
  • 封装成自己使用的api,即使请求库换成原生 fetch 或者其他请求库,对使用者无感
  • 目前仅提供四种方法 get|post|patch|delete

@/lib/request.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import axios from 'axios'

axios.defaults.baseURL = __IS_DEV__ ? '/' : 'https://xx.xxx.xxx.xx:x0x0/api/v1'
export const request = {
  get: (path: string) => {
    return axios.get(path)
  },
  post: () => { },
  patch: () => { },
  delete: () => { },
}

ts 不认识 __IS_DEV__,添加全局环境类型声明 @/vite-env.d.ts

1
2
3
4
/// <reference types="vite/client" />

// vite-env.d.ts
declare const __IS_DEV__: boolean

参考


测试开发环境

  • 运行 pnpm dev
  • 查看接口是否直接访问当前网址 http://localhost:5173/api/v1/xxx
  • 打开浏览器网络面板查看 Request URL

测试生产环境

  • 运行 pnpm build
  • 搜索产物是否包含网址 http://localhost:5173/api/v1/xxx

更多 axios 配置 ⇧

封装单独的 axios 实例 @/lib/request/axiosInstances.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import axios from 'axios'

axios.defaults.baseURL = __IS_DEV__ ? '/' : 'https://xx.xxx.xxx.xx:x0x0/api/v1'
// axios.defaults.headers.common.Authorization = AUTH_TOKEN
axios.defaults.headers.post['Content-Type'] = 'application/json'
axios.defaults.timeout = 2500

export const axiosInstance = axios.create({
  // baseURL: 'https://some-domain.com/api/',
  // timeout: 1000,
  // headers: { 'X-Custom-Header': 'fooBar' },
})

重构 @/lib/request/request.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { axiosInstance } from '@/lib/request/axiosInstances'

export const request = {
  get: (path: string) => {
    return axiosInstance.get(path)
  },
  post: () => { },
  patch: () => { },
  delete: () => { },
}

参考


5. 设置远程资源的类型 ⇧

全局类型 ⇧

即返回响应数据的类型,本项目基本包括以下几种

  • 单数类型 resource: {/* 数据 */}
  • 复数类型 resources: [{/* 数据1 */}, {/* 数据2 */}, ...], pager: {page: number, per_page: number, count: number}

声明到全局类型文件 @/global.d.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
export interface Resource<T> {
  resource: T
}

export interface Resources<T> {
  resource: T[]
  pager: {
    page: number
    per_page: number
    count: number
    page_size?: number
    total?: number
  }
}

export interface User {
  id: number
  email: string
  name: string
  created_at: ISOString
  updated_at: ISOString
}

// 账目信息
export interface Item {
  id: number
  user_id: number
  kind: 'incomes' | 'expenses'
  amount: number
  note?: string
  tag_ids: number[]
  happened_at: ISOString
  created_at: ISOString
  updated_at: ISOString
  deleted_at?: ISOString
  // tags?: []
}

表示请求方法中获取信息的类型 ⇧

修改 @/pages/Home.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import pig from '@/assets/icons/pig.svg'
import add from '@/assets/icons/add.svg'
import { cn } from 'cn'
import useSWR from 'swr'
import { request } from '@/lib/request/request'
import type { Item, Resource, Resources, User } from '@/global'

export const Home: React.FC = () => {
  const { data: meData, error: meError } = useSWR('/api/v1/me', (path) => {
    return request.get<Resource<User>>(path)
  })
  const { data: itemsData, error: itemsError } = useSWR(meData ? '/api/v1/items' : null, (path) => {
    return request.get<Resources<Item>>(path)
  })

  return (
    <main flex flex-col justify-center items-center>
      {/* ... */}
    </main>
  )
}

此时原来声明的 request 中的各个请求方法为声明类型而报错 @/lib/request/request.ts

1
2
3
4
5
6
7
import { axiosInstance } from '@/lib/request/axiosInstances'

export const request = {
  get: <T>(path: string) => {
    return axiosInstance.get<T>(path)
  },
}
  • 注意 request.ts 不能命名为 request.tsx,因为其中的泛型类型声明 <T> 括号和 TSX 语法冲突
  • 目前将鼠标移到meData上可以看到返回数据的类型提示

    1
    
    const meData: AxiosResponse<Resource<User>, any, {}, any> | undefined
    

meData_1 meData_2

进一步解构明确 meData 数据 @/pages/Home.tsx

 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
import pig from '@/assets/icons/pig.svg'
import add from '@/assets/icons/add.svg'
import { cn } from 'cn'
import useSWR from 'swr'
import { request } from '@/lib/request/request'
import type { Item, Resource, Resources, User } from '@/global'

export const Home: React.FC = () => {
  const { data: meData, error: meError } = useSWR('/api/v1/me', async (path) => {
    const response = await request.get<Resource<User>>(path)
    return response.data
  })
  const { data: itemsData, error: itemsError } = useSWR(meData ? '/api/v1/items' : null, async (path) => {
    const response = await request.get<Resources<Item>>(path)
    return response.data
  })

  window.console.log('meData', meData?.resource)
  window.console.log('meError', meError)
  window.console.log('itemsData', itemsData)
  window.console.log('itemsError', itemsError)

  return (
    <main flex flex-col justify-center items-center>
      {/* ... */}
    </main>
  )
}
  • 更简洁:useSWR('/api/v1/me', async path => (await request.get<Resource<User>>(path))?.data)
  • 更简洁:useSWR(meData ? '/api/v1/items' : null, async path => (await request.get<Resources<Item>>(path))?.data)

完善 Mock 数据类型 ⇧

@/global.d.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
export interface Resource<T> {
  resource: T
}

export interface Resources<T> {
  resources: T[]
  pager: {
    page: number
    per_page: number
    count: number
    page_size?: number
    total?: number
  }
}

export interface User {
  id: number
  email: string
  name: string
  created_at: ISOString
  updated_at: ISOString
}

// 账目信息
export interface Item {
  id: number
  user_id: number
  kind: 'incomes' | 'expenses'
  amount: number
  note?: string
  tag_ids: number[]
  happened_at: ISOString
  created_at: ISOString
  updated_at: ISOString
  deleted_at?: ISOString
  // tags?: []
}

// 响应数据类型
export interface ResponseData<T> {
  code: number
  data: T
}

@/mock/test.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
import type { Item, Resource, Resources, ResponseData, User } from '@/global.d.ts'
import type { MockMethod } from 'vite-plugin-mock'

export default [
  {
    url: '/api/v1/me',
    method: 'get',
    timeout: 2000,
    response: () => {
      return {
        code: 0,
        data: {
          resource: {
            id: 1,
            name: 'frank',
            email: 'frank@frank.com',
            updated_at: '2027-01-01T00:00:00.000Z',
            created_at: '2027-01-01T00:00:00.000Z',
          },
        },
      } satisfies ResponseData<Partial<Resource<User>>>
    },
  },
  {
    url: '/api/v1/items',
    method: 'get',
    timeout: 1000,
    response: (): ResponseData<Partial<Resources<Item>>> => {
      return {
        code: 0,
        data: {
          resources: [
            {
              id: 1,
              user_id: 1,
              amount: 100,
            },
          ],
          pager: {
            page: 1,
            per_page: 25,
            count: 1,
          },
        },
      }
    },
  },
] as MockMethod[]
  • 可以添加延时 timeout: 2000

完善跳转逻辑 ⇧

@/pages/Home.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// import ...

export const Home: React.FC = () => {
  const { data: meData, error: meError, isLoading: isLoadingMe } = useSWR('/api/v1/me', async path =>
    (await request.get<ResponseData<Resource<User>>>(path))?.data?.data)
  const { data: itemsData, error: itemsError, isLoading: isLoadingItems } = useSWR(meData ? '/api/v1/items' : null, async path =>
    (await request.get<ResponseData<Resources<Item>>>(path))?.data?.data)

  window.console.log('meData', meData)
  window.console.log('meError', meError)
  window.console.log('itemsData', itemsData)
  window.console.log('itemsError', itemsError)

  if (isLoadingMe || isLoadingItems) { return <div text-6xl>加载中...</div> }

  if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> }

  return (
    <main flex flex-col justify-center items-center>
      {/* ... */}
    </main>
  )
}
  • 由于 if (itemsData?.resources?.length > 0) { return <Navigate to="/items" /> } 中可能为类型 undefined,不能和 0 比较,ts报错
  • 取巧可改写为 if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> } 表示用户是否记过账

新建/items空白组建占位 ⇧

@/route/routes.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Root } from '@/components/Root'
import { Home } from '@/pages/Home'
import { welcomeRouter } from '@/route/welcomeRouter'
import type { RouteObject } from 'react-router-dom'

export const routes: RouteObject[] = [
  {
    path: '/',
    element: <Root />,
  },
  {
    path: '/home',
    element: <Home />,
  },
  welcomeRouter,
  {
    path: '/items',
    element: <div>items</div>,
  },
]

后续再完善加载中页面和完成记账页面


6. 封装 useTitle ⇧

路由如何在路由表中设置 title

修改 @/pages/Home.tsx

 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
// import ...

interface Props {
  title?: string
}

export const Home: React.FC<Props> = ({ title }) => {
  useEffect(() => {
    document.title = title ?? '记账首页'
  }, [title])

  const { data: meData, /* error: meError, */ isLoading: isLoadingMe } = useSWR('/api/v1/me', async path =>
    (await request.get<ResponseData<Resource<User>>>(path))?.data?.data)
  const { data: itemsData, /* error: itemsError, */ isLoading: isLoadingItems } = useSWR(meData ? '/api/v1/items' : null, async path =>
    (await request.get<ResponseData<Resources<Item>>>(path))?.data?.data)

  if (isLoadingMe || isLoadingItems) { return <div text-6xl>加载中...</div> }

  if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> }

  return (
    <main flex flex-col justify-center items-center>
      {/* ... */}
    </main>
  )
}

路由组件中传参 @/route/routes.tsx

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import { Root } from '@/components/Root'
import { Home } from '@/pages/Home'
import { welcomeRouter } from '@/route/welcomeRouter'
import type { RouteObject } from 'react-router-dom'

export const routes: RouteObject[] = [
  {
    path: '/',
    element: <Root />,
  },
  {
    path: '/home',
    element: <Home title="首页" />,
  },
  welcomeRouter,
  {
    path: '/items',
    element: <div>items</div>,
  },
]

封装 @/hooks/useTitle.ts

1
2
3
4
5
6
7
8
import { useEffect } from 'react'

export function useTitle(title: string) {
  useEffect(() => {
    if (!title) { return }
    document.title = title ?? '记账'
  }, [title])
}

修改 @/pages/Home.tsx

 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
import pig from '@/assets/icons/pig.svg'
import add from '@/assets/icons/add.svg'
import { cn } from 'cn'
import useSWR from 'swr'
import { request } from '@/lib/request/request'
import type { Item, Resource, Resources, ResponseData, User } from '@/global'
import { Navigate } from 'react-router-dom'
import { useTitle } from '@/hooks/useTitle'

interface Props {
  title?: string
}

export const Home: React.FC<Props> = ({ title }) => {
  useTitle(title ?? '记账首页')

  const { data: meData, /* error: meError, */ isLoading: isLoadingMe } = useSWR('/api/v1/me', async path =>
    (await request.get<ResponseData<Resource<User>>>(path))?.data?.data)
  const { data: itemsData, /* error: itemsError, */ isLoading: isLoadingItems } = useSWR(meData ? '/api/v1/items' : null, async path =>
    (await request.get<ResponseData<Resources<Item>>>(path))?.data?.data)

  if (isLoadingMe || isLoadingItems) { return <div text-6xl>加载中...</div> }

  if (itemsData?.resources?.[0]) { return <Navigate to="/items" /> }

  return (
    <main flex flex-col justify-center items-center>
      <div flex justify-center items-center>
        <img src={pig} alt="pig" w-128px h-130px mt-20vh mb-20vh block />
      </div>

      <div px-16px self-stretch>
        <button
          w="100%"
          className={cn(
            'h-48px bg-[var(--button-primary-color)] b-none rounded-8px',
            'text-[var(--button-primary-text-color)]',
          )}>
          开始记账
        </button>
      </div>

      <button
        className={cn(
          'w-56px h-56px',
          'bg-[var(--button-primary-color)] rounded-full',
          'text-[var(--button-primary-text-color)] text-6xl',
          'p-4px text-center flex place-content-center place-items-center-safe',
          'fixed bottom-.5em right-.5em',
        )}>
        <img src={add} alt="add" max-w="80%" max-h="80%" translate-y="-3px" />
      </button>
    </main>
  )
}

·未完待续·

参考文章

相关文章


  • 作者: Joel
  • 文章链接:
  • 版权声明
  • 非自由转载-非商用-非衍生-保持署名