0802api设计和实战-网络ajax请求1-react-仿低代码平台项目

发布于:2025-05-01 ⋅ 阅读:(52) ⋅ 点赞:(0)

1 API设计

1.1 用户功能

1.1.1 获取用户信息
  • method: get
  • path: /api/user/info
  • response:
    • 成功:{errno: 0, data{...}}
    • 失败:{errno: 10001, mes: 'xxx'}
1.1.2 注册
  • method: post

  • path: /api/user/register

  • request body: {username, password, nickname}

  • response: {errno: 0}

1.1.3 登录
  • method: post
  • path: /api/user/login
  • request body: {username, password}
  • response: {errno: 0, token}
    • Token令牌:使用jwt

1.2 问卷功能

1.2.1 获取单个问卷
  • method: get
  • path: /api/question/:id
  • response: {errno: 0, data: {id, title,...}}
1.2.2 获取问卷列表
  • method: get
  • path: /api/question/
  • response: {errno: 0, data:{list:[{...}}
1.2.3 创建问卷
  • method: post
  • path: /api/question
  • request body: 暂无
  • response: {errno: 0, data: {id}}
1.2.4 更新问卷
  • method: patch
  • path: /api/question/:id
  • response: {errno: 0}

tips:删除是“假删除”,实际 是更新isDeleted属性

1.2.5 批量彻底删除问卷
  • method: delete
  • path: /api/question
  • request body: {ids: [...]}
  • response: {errno: 0}
1.2.6 复制问卷
  • method: post
  • path: `/api/question/duplicate/:id``
  • response: {errno: 0, data: {id}}

1.3 小结

  • 使用Restful API
  • 用户验证使用JWT
  • 统一返回格式:{errno, data, msg}

2 实战

2.1配置axios

src/services/request.ts基础代码如下:

import axios from "axios";

const request = axios.create({
  timeout: 5000,
});

export default request;

添加返回类型和统一错误出来,request.ts代码如下:

import axios from "axios";
import { message } from "antd";

const request = axios.create({
  timeout: 5000,
});

// response 拦截:统一处理errno和msg
request.interceptors.response.use(
  res => {
    const resData = (res.data || {}) as ResType
    const {errno, data, msg} = resData

    if (errno !== 0) {
      // 错误提示
      if (msg) {
        message.error(msg)
      }
      throw new Error(msg);
    }

    

    return data as any
  }
)
export default request;

export type ResDataType = {
  [key: string]: any;
};

export type ResType = {
  errno: number;
  data?: ResDataType;
  msg?: string;
};

2.2 封装API和测试

获取问卷信息API,src/api/question.ts代码如下:

import request, { ResDataType } from "../services/request";

export async function getQuestionApi(id: string): Promise<ResDataType> {
  const url = `/api/question/${id}`;
  const data = (await request.get(url)) as ResDataType;
  return data;
}

src/pages/quesiton/Edit/index.tsx代码如下:

import { FC, useEffect} from "react";
import { useParams } from "react-router-dom";
import { getQuestionApi } from "@/api/question";

const Edit: FC = () => {
  const { id = "" } = useParams();
  // 获取问卷信息

  useEffect(()=> {
    async function fn() {
      const resData = await getQuestionApi(id)
      console.log(resData);
      
    }

    fn()
    
  }, [])

  return <div>Edit {id}</div>;
};

export default Edit;

测试结果如下图所示:在这里插入图片描述

2.3 新建问卷

封装新建问卷API,question.ts代码如下:

import request, { ResDataType } from "../services/request";

...

/**
 * 新建问卷
 * @returns 问卷id
 */
export async function createQuestionApi() {
  const url = `/api/question`;
  const data = (await request.post(url)) as ResDataType;
  return data;
}

ManageLayout.tsx中调用新建问卷,src/layouts/MangeLayout.tsx代码如下:

import { FC, useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
  PlusOutlined,
  BarsOutlined,
  StarOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { Button, Space, Divider, message } from "antd";

import { createQuestionApi } from "@/api/question";
import styles from "./ManageLayout.module.scss";

const ManageLayout: FC = () => {
  const nav = useNavigate();
  const { pathname } = useLocation();

  const [loading, setLoading] = useState(false)

  // 点击创建问卷
  async function handleCreateClick() {
    setLoading(true)
    const data = await  createQuestionApi()
    const {id} = data || {}
    if (id) {
      nav(`/question/edit/${id}`)
      message.success("创建成功")
    }
    setLoading(false)
  }

  return (
    <div className={styles.container}>
      <div className={styles.left}>
        <Space direction="vertical">
          <Button
            type="primary"
            size="large"
            icon={<PlusOutlined />}
            onClick={handleCreateClick}
            disabled={loading}
          >
            新建问卷
          </Button>
			...
  );
};

export default ManageLayout;

tips:接口请求有延迟,防止用户连续点击按钮,触发重复操作,这里添加loading控制按钮,后续可以用封装好的“防抖”操作替换。

2.4 自定义hooks封装获取问卷信息

在编辑页和统计页都需要获取带加载状态的问卷信息,这里我们通过自定义hooks抽取公共部分,src/hooks/useLoadQuestionData.ts代码如下:

import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";

import { getQuestionApi } from "@/api/question";

/**
 * 获取带加载状态的问卷信息
 * @returns loading状态,问卷信息
 */
function useLoadQuestionData() {
  const { id = "" } = useParams();
  const [loading, setLoading] = useState(true);
  const [questionData, setQuestionData] = useState({});

  useEffect(() => {
    async function fn() {
      const data = await getQuestionApi(id);
      setQuestionData(data);
      setLoading(false);
    }
    fn();
  }, []);

  return { loading, questionData };
}

export default useLoadQuestionData;

编辑页src/pages/question/Edit/index.tsx代码改造如下:

import { FC } from "react";
import useLoadQuestionData from "@/hooks/useLoadQuestionData";

const Edit: FC = () => {
  // 获取问卷信息
  const { loading, questionData } = useLoadQuestionData();
  return (
    <div>
      <p>Edit page</p>
      <div>
        {loading ? <p>loading</p> : <p>{JSON.stringify(questionData)}</p>}
      </div>
    </div>
  );
};

export default Edit;

2.5 使用useRequest重构Ajax请求-统一处理

第三方ahooks-useRequst 当前功能有:

  • 自动/手动请求
  • 轮询
  • 防抖
  • 节流
  • 窗口聚焦刷新
  • 错误重试
  • 加载延迟
  • SWR(stale-while-revalidate)
  • 缓存

由于接口请求,每次都需要定义loading,data,使用useEffect执行函数,很繁琐,这里我们使用第三方ahooks提供的useRequest简化。

改造自定义hook-useLoadQuestionData,useLoadQuestionData.ts代码如下:

import { useParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionApi } from "@/api/question";

/**
 * 获取带加载状态的问卷信息
 * @returns loading状态,问卷信息
 */
function useLoadQuestionData() {
  const { id = "" } = useParams();

  async function load() {
    const data = await getQuestionApi(id);
    return data;
  }
  const { loading, data, error } = useRequest(load);
  return { loading, data, error };
}

export default useLoadQuestionData;

改造创建问卷,ManageLayout.tsx代码如下:

import { FC, useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import {
  PlusOutlined,
  BarsOutlined,
  StarOutlined,
  DeleteOutlined,
} from "@ant-design/icons";
import { Button, Space, Divider, message } from "antd";

import { createQuestionApi } from "@/api/question";
import styles from "./ManageLayout.module.scss";
import { useRequest } from "ahooks";

const ManageLayout: FC = () => {
  const nav = useNavigate();
  const { pathname } = useLocation();

  // 点击创建问卷
  const {
    loading,
    // error,
    run: handleCreateClick,
  } = useRequest(createQuestionApi, {
    manual: true,
    onSuccess: (res) => {
      nav(`/question/edit/${res.id}`);
      message.success("创建成功");
    },
  });

  return (
    <div className={styles.container}>
      <div className={styles.left}>
        <Space direction="vertical">
          <Button
            type="primary"
            size="large"
            icon={<PlusOutlined />}
            onClick={handleCreateClick}
            disabled={loading}
          >
            新建问卷
          </Button>
...
};

export default ManageLayout;

2.6 开发问卷列表

获取问卷列表mock接口 getQuestionList.js代码如下:

const Mock = require('mockjs');
const Random = Mock.Random;

function getQuestionList(len = 10, isDeleted = false) {
  const list = [];
  for (let i = 0; i < len; i++) {
    list.push({
      _id: Random.id(),
      title: Random.ctitle(),
      isPublished: Random.boolean(),
      isStar: Random.boolean(),
      answerCount: Random.natural(50, 100),
      createdAt: Random.datetime(),
      isDeleted
    })
  }

  return list;
}

module.exports = getQuestionList;

question.js代码如下:

const Mock = require('mockjs')

const getQuestionList = require("./data/getQuestionList")

const Random = Mock.Random


module.exports = [
...
  {
    // 获取问卷列表
    url: '/api/question',
    method: 'get',
    response() {
      return {
        errno: 0,
        data: {
          list: getQuestionList(),
          total: 100
        }
      }
    }
  },
]

question.ts api代码如下所示:

/**
 * 获取问卷列表
 * @returns 问卷列表数据
 */
export async function getQuestionListApi() {
  const url = `/api/question`;
  const data = (await request.get(url)) as ResDataType;
  return data;
}

List.tsx调用接口代码如下所示:

import { FC } from "react";
// import { useSearchParams } from "react-router-dom";
import { useRequest, useTitle } from "ahooks";
import { Typography, Spin, Divider } from "antd";

import QuestionCard from "@/components/QuestionCard";
import ListSearch from "@/components/ListSearch";
import { getQuestionListApi } from "@/api/question";
import styles from "./common.module.scss";

const { Title } = Typography;

const List: FC = () => {
  useTitle("调查问卷-我的问卷");

  // const [searchParams] = useSearchParams();
  // console.log("keyword", searchParams.get("keyword"));

  //问卷列表数据
  const { data = {}, loading } = useRequest(getQuestionListApi);
  const { list = [], total = 0 } = data;

  return (
    <>
      <div className={styles.header}>
        <div className={styles.left}>
          <Title level={3}>我的问卷</Title>
        </div>
        <div className={styles.right}>
          <ListSearch />
        </div>
      </div>
      <div className={styles.content}>
        {loading && (
          <div style={{ textAlign: "center" }}>
            <Spin />
          </div>
        )}
        {!loading &&
          list.length > 0 &&
          list.map((q: any) => {
            const { _id } = q;
            return <QuestionCard key={_id} {...q} />;
          })}
      </div>
      <div className={styles.footer}>loadingMore 上划加载更多</div>
    </>
  );
};

export default List;

2.7 第三方hook抽离搜索功能

在我的问卷、星标问卷和回收站都有搜索功能,有相同的逻辑,这里我们通过自定义hook抽离公共逻辑。自定义搜索hook-useLoadQuestionListData.ts代码如下所示:

import { useSearchParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionListApi } from "@/api/question";

import { LIST_SEARCH_PARAM_KEY } from "@/constant";

/**
 * 获取问卷列表
 * @returns 问卷列表
 */
function useLoadQuestionListData() {
  const [searchParams] = useSearchParams();
  const keyword = searchParams.get(LIST_SEARCH_PARAM_KEY) || "";

  async function load() {
    const data = await getQuestionListApi({ keyword });
    return data;
  }
  const { loading, data, error } = useRequest(load, {
    refreshDeps: [searchParams],
  });
  return { loading, data, error };
}

export default useLoadQuestionListData;

getQuestionListApi.ts如下所示:

import request, { ResDataType } from "../services/request";

type SearchOption = {
  keyword: string;
};

...

/**
 * 获取问卷列表
 * @param opt 请求参数
 * @returns 问卷列表数据
 */
export async function getQuestionListApi(
  opt: Partial<SearchOption>
): Promise<ResDataType> {
  const url = `/api/question`;
  const data = (await request.get(url, {params: opt})) as ResDataType;
  return data;
}

说明

  • refreshDeps: useRequest 刷新依赖项,根据数组里面的变量重新执行useRequest
  • request.get(url, {params: opt})),params axios get请求传递参数,形式:url?a=b&b=1…

2.8 开发星标和回收站

星标问卷查询的数据 isStar=true;回收站查询的问卷列表isDeleted=true,我们需要扩展api接口参数。

useLoadQuestionListData.ts代码改造如下:

import { useSearchParams } from "react-router-dom";
import { useRequest } from "ahooks";
import { getQuestionListApi } from "@/api/question";

import { LIST_SEARCH_PARAM_KEY } from "@/constant";

type OptionType = {
  isStar: boolean;
  isDeleted: boolean;
};

/**
 * 获取问卷列表
 * @returns 问卷列表
 */
function useLoadQuestionListData(opt: Partial<OptionType>) {
  const { isStar, isDeleted } = opt;
  const [searchParams] = useSearchParams();
  const keyword = searchParams.get(LIST_SEARCH_PARAM_KEY) || "";

  async function load() {
    const data = await getQuestionListApi({ keyword, isStar, isDeleted });
    return data;
  }
  const { loading, data, error } = useRequest(load, {
    refreshDeps: [searchParams],
  });
  return { loading, data, error };
}

export default useLoadQuestionListData;

星标问卷-Star.tsx代码如下所示:

import { FC } from "react";
import { useTitle } from "ahooks";
import { Typography, Empty, Spin } from "antd";

import QuestionCard from "@/components/QuestionCard";
import ListSearch from "@/components/ListSearch";
import styles from "./common.module.scss";
import useLoadQuestionListData from "@/hooks/useLoadQuestionListData";

const { Title } = Typography;

const List: FC = () => {
  useTitle("调查问卷-星标问卷");

  //问卷列表数据
  const { data = {}, loading } = useLoadQuestionListData({ isStar: true });
  const { list = [], total = 0 } = data;

  return (
    <>
      <div className={styles.header}>
        <div className={styles.left}>
          <Title level={3}>星标问卷</Title>
        </div>
        <div className={styles.right}>
          <ListSearch />
        </div>
      </div>
      <div className={styles.content}>
        {loading && (
          <div style={{ textAlign: "center" }}>
            <Spin />
          </div>
        )}
        {!loading && list.length === 0 && <Empty description="暂无数据" />}
        {!loading &&
          list.length > 0 &&
          list.map((q: any) => {
            const { _id } = q;
            return <QuestionCard key={_id} {...q} />;
          })}
      </div>
      <div className={styles.footer}>分页</div>
    </>
  );
};

export default List;

回收站-Trash.tsx代码如下所示;

import { FC, useState } from "react";
import { useTitle } from "ahooks";
import {
  Typography,
  Empty,
  Table,
  Tag,
  Space,
  Button,
  Modal,
  message,
  Spin,
} from "antd";

import { ExclamationCircleOutlined } from "@ant-design/icons";
import ListSearch from "../../components/ListSearch";
import styles from "./common.module.scss";
import useLoadQuestionListData from "@/hooks/useLoadQuestionListData";

const { Title } = Typography;
const { confirm } = Modal;

const List: FC = () => {
  useTitle("调查问卷-回收站");

  //问卷列表数据
  const { data = {}, loading } = useLoadQuestionListData({ isDeleted: true });
  const { list = [], total = 0 } = data;

  const columns = [
    {
      title: "标题",
      dataIndex: "title",
    },
    {
      title: "是否发布",
      dataIndex: "isPublished",
      render: (isPublished: boolean) =>
        isPublished ? <Tag color="processing">已发布</Tag> : <Tag>未发布</Tag>,
    },
    {
      title: "答卷",
      dataIndex: "answerCount",
    },
    {
      title: "创建时间",
      dataIndex: "createdAt",
    },
  ];

  // 选择ids集合
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  function del() {
    confirm({
      title: "您确定要删除吗?",
      okText: "确定",
      cancelText: "取消",
      content: "删除以后不可找回!",
      icon: <ExclamationCircleOutlined />,
      onOk: () => message.success("删除成功"),
    });
  }

  const TableEle = (
    <>
      <div style={{ marginBottom: "10px" }}>
        <Space>
          <Button type="primary" disabled={selectedIds.length === 0}>
            恢复
          </Button>
          <Button danger disabled={selectedIds.length === 0} onClick={del}>
            彻底删除
          </Button>
        </Space>
      </div>
      <Table
        dataSource={list}
        columns={columns}
        pagination={false}
        rowKey={(q: any) => q._id}
        rowSelection={{
          type: "checkbox",
          onChange: (selectRowKeys) => {
            setSelectedIds(selectRowKeys as string[]);
          },
        }}
      ></Table>
    </>
  );

  return (
    <>
      <div className={styles.header}>
        <div className={styles.left}>
          <Title level={3}>回收站</Title>
        </div>
        <div className={styles.right}>
          <ListSearch />
        </div>
      </div>
      <div className={styles.content}>
        {loading && (
          <div style={{ textAlign: "center" }}>
            <Spin />
          </div>
        )}
        {!loading && list.length === 0 && <Empty description="暂无数据" />}
        {TableEle}
      </div>
      <div className={styles.footer}>分页</div>
    </>
  );
};

export default List;

服务端星标问卷查询返回的isStar=true;回收站返回的isDeleted=true

index.js代码如下所示:

...

async function getRes(fn, ctx) {
  return new Promise(resolve => {
    setTimeout(() => {
      const res = fn(ctx)
      resolve(res)
    }, 500);
  })
}

// 注册mock路由
mockList.forEach(item => {
  const {url, method, response} = item
  router[method](url, async ctx => {
    const res = await getRes(response, ctx)
    ctx.body = res
  })
})

...

question.js代码如下:

...
  {
    // 获取问卷列表
    url: '/api/question',
    method: 'get',
    response(ctx) {
      const { url = '' } = ctx
      const isStar = url.indexOf('isStar=true') >= 0
      const isDeleted = url.indexOf('isDeleted=true') >= 0
      return {
        errno: 0,
        data: {
          list: getQuestionList({isStar, isDeleted}),
          total: 100
        }
      }
    }
  },
]

getQuestionList.js如下所示:

const Mock = require('mockjs');
const Random = Mock.Random;

function getQuestionList(opt = {}) {
  const { len = 10, isStar, isDeleted } = opt
  const list = [];
  for (let i = 0; i < len; i++) {
    list.push({
      _id: Random.id(),
      title: Random.ctitle(),
      isPublished: Random.boolean(),
      isStar: isStar || Random.boolean(),
      answerCount: Random.natural(50, 100),
      createdAt: Random.datetime(),
      isDeleted: isDeleted || Random.boolean()
    })
  }

  return list;
}

module.exports = getQuestionList;

说明

  • 数据展示有3种状态:根据不同的状态有不同展示
    • 加载中:
    • 加载完成,数据为空
    • 加载完成,有数据
  • rowKey={(q: any) => q._id}:ts不设置any类型,程序不报错,但是ts检查错误

结语

❓QQ:806797785

⭐️仓库地址:https://gitee.com/gaogzhen

⭐️仓库地址:https://github.com/gaogzhen

[1]ahook官网[CP/OL].

[2]mock文档[CP/OL].

[3]Ant Design官网[CP/OL].