Skip to content

手把手撸后台-VUE3_VITE_MOCK

在 vue3 项目中引入 mock

  • 安装 Mock 与 vite-plugin-mock-dev-server
js

npm i mockjs  vite-plugin-mock-dev-server --save-dev

安装 axios 依赖

js

npm i axios

配置 vite.config.ts

  • 在 vite.config.ts 中引入 vite-plugin-mock 插件
js
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import UnoCSS from "unocss/vite";
import AutoImport from "unplugin-auto-import/vite";

import Components from "unplugin-vue-components/vite";
import { defineConfig } from "vite";
// 新增加 eslint
import eslint from "vite-plugin-eslint2";
// 新增加 mock
import { mockDevServerPlugin } from "vite-plugin-mock-dev-server";
import vueDevTools from "vite-plugin-vue-devtools";

// https://vite.dev/config
export default defineConfig({
  plugins: [
    vue(),
    mockDevServerPlugin({
      prefix: ["/api"],
      include: ["**/*.ts"],
      dir: "mock",
    }),
    UnoCSS(),
    vueDevTools(),
    eslint(),
    AutoImport({
      imports: ["vue", "vue-router"],
      dts: "src/auto-import.d.ts",
    }),
    Components({
      // 解决命名冲突
      directoryAsNamespace: true,
      dts: true, //
      dirs: ["src/components"], // 按需加载的文件夹
      //     resolvers: [   // 设置UI框架自动加载
      //       ElementPlusResolver(),  // Antd   按需加载
      //       AntDesignVueResolver()  // ElementPlus按需加载
      //  ]
    }),
  ],
  resolve: {
    alias: {
      "@": fileURLToPath(new URL("./src", import.meta.url)),
    },
    extensions: [".js", ".json", ".ts", ".vue"],
  },
  // 配置scss
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: '@import "@/scss/index.scss";',
      },
    },
  },
});

vite 的配置文档及其 mockjs 官方文档

  • vite 配置文档地址
bash

https://vitejs.dev/config/
  • mockjs 官方文档地址
bash

http://mockjs.com/examples.html

创建 mock 文件夹

  • 在根目录下创建 mock 文件夹及 login.js 文件:

配置 mock 文件夹下面的 login.js

ts
import { defineMock } from "vite-plugin-mock-dev-server";

export default defineMock([
  {
    url: "/api/login",
    method: "POST",
    body: (request) => {
      // 1. 获取 POST body 参数
      const { username, password } = request.body;

      // 2. 获取 headers
      const authHeader = request.headers.authorization;
      const contentType = request.headers["content-type"];

      // 3. 获取 query 参数(URL 上的 ?key=value)
      const { source, version } = request.query;

      console.log("用户名:", username);
      console.log("密码:", password);
      console.log("Authorization:", authHeader);
      console.log("Query 参数:", source, version);

      return {
        code: 200,
        msg: "登录成功",
        data: {
          token: `mock-token-${username}`,
          // 可以原样返回调试
          receivedHeaders: request.headers,
          receivedBody: request.body,
        },
      };
    },
  },
  {
    url: "/api/userInfo",
    method: "GET",
    body: {
      code: 200,
      msg: "获取个人信息成功",
      data: {
        username: "admin",
        name: "管理员",
        avatar: "https://jsopy.oss-cn-beijing.aliyuncs.com/IMAGES/avatar.jpg",
      },
    },
  },
]);

使用

创建config/whitelist.ts 文件

ts
/**
 * 接口白名单配置
 * 支持精确匹配和前缀通配符匹配
 * 优先级:环境变量 > 默认配置
 */

// 默认白名单(所有环境通用)
const DEFAULT_WHITELIST: string[] = [
  "/api/login",
  "/api/register",
  "/api/refresh",
  "/public/*",
  "/captcha",
];

// 从环境变量读取白名单(逗号分隔)
function getEnvWhitelist(): string[] {
  const envList = import.meta.env.VITE_API_WHITELIST;
  if (!envList) {
    return [];
  }
  return envList
    .split(",")
    .map((s: string) => s.trim())
    .filter(Boolean);
}

// 合并后的白名单
export const API_WHITELIST: string[] = [
  ...DEFAULT_WHITELIST,
  ...getEnvWhitelist(),
];

/**
 * 判断路径是否在白名单内
 * @param url 请求地址(不含 baseURL)
 * @returns 是否放行
 */
export function isWhitelisted(url: string): boolean {
  if (!url) {
    return false;
  }

  // 提取路径部分(去掉 baseURL 和查询参数)
  const path = url.replace(/^https?:\/\/[^/]+/, "").split("?")[0];

  for (const pattern of API_WHITELIST) {
    if (pattern.endsWith("/*")) {
      // 前缀通配符匹配
      const prefix = pattern.slice(0, -2);
      if (path.startsWith(prefix)) {
        return true;
      }
    } else {
      // 精确匹配
      if (path === pattern) {
        return true;
      }
    }
  }

  return false;
}

/**
 * 动态添加白名单(运行时可用)
 */
export function addWhitelist(paths: string | string[]): void {
  const list = Array.isArray(paths) ? paths : [paths];
  list.forEach((p) => {
    if (!API_WHITELIST.includes(p)) {
      API_WHITELIST.push(p);
    }
  });
}

/**
 * 动态移除白名单
 */
export function removeWhitelist(paths: string | string[]): void {
  const list = Array.isArray(paths) ? paths : [paths];
  list.forEach((p) => {
    const idx = API_WHITELIST.indexOf(p);
    if (idx > -1) {
      API_WHITELIST.splice(idx, 1);
    }
  });
}

创建 request.js 文件

ts
/**
 * axios-request.js
 * -------------------
 * Axios 完整封装(兼容 axios v0.21+ 和 v0.22+)
 *
 * 特点:
 * 1. 单个 axios 实例,支持 baseURL / 超时 / headers
 * 2. 请求/响应拦截器(可扩展)
 * 3. 自动携带 token,401 自动刷新 token,支持并发队列
 * 4. 请求去重(相同请求返回同一 Promise)
 * 5. GET 简易缓存(可选,支持过期时间)
 * 6. 自动重试(指数退避,可配置)
 * 7. 并发请求限制(Semaphore)
 * 8. 支持取消请求(AbortController + CancelToken)
 * 9. 上传 / 下载封装
 * 10. 内置 401 / 403 / 404 / 500 全局错误处理
 */

import axios from "axios";
import { isWhitelisted } from "../config/whitelist";

// -------------------- 默认配置 --------------------
const DEFAULT_CONFIG = {
  baseURL: "", // 基础 URL
  timeout: 15000, // 请求超时
  headers: { "Content-Type": "application/json" },
  retry: 2, // 默认重试次数
  retryDelay: 300, // 重试延迟(ms)
  cacheTTL: 10 * 1000, // GET 缓存有效期(ms)
  concurrency: 10, // 并发限制
};

// -------------------- 内部状态 --------------------
interface RefreshQueueItem {
  resolve: (value: unknown) => void;
  reject: (reason?: any) => void;
  config: any;
}

const state: {
  authToken: string | null;
  refreshTokenFn: null | (() => Promise<{ token: string }>);
  isRefreshing: boolean;
  refreshQueue: RefreshQueueItem[];
  pendingRequests: Map<string, Promise<any>>;
  cache: Map<string, { ts: number; data: any }>;
  semaphoreCounter: number;
} = {
  authToken: localStorage.getItem("token") || null, // 用户 token
  refreshTokenFn: null, // 刷新 token 回调函数
  isRefreshing: false, // 是否正在刷新 token
  refreshQueue: [], // 刷新 token 队列
  pendingRequests: new Map(), // 请求去重 Map
  cache: new Map(), // GET 请求缓存 Map
  semaphoreCounter: 0, // 并发控制计数
};

// -------------------- 工具函数 --------------------
function requestKey(config: any) {
  // 根据请求生成唯一 key,用于去重/缓存
  const { method = "get", url = "", params, data } = config;
  return [
    method.toLowerCase(),
    url,
    JSON.stringify(params || ""),
    JSON.stringify(data || ""),
  ].join("&");
}
function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}
async function retryWithBackoff(fn: any, retries: number, delay: number) {
  // 重试逻辑(指数退避)
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (err) {
      if (attempt >= retries) throw err;
      await sleep(delay * 2 ** attempt);
      attempt += 1;
    }
  }
}
async function acquireSlot(maxConcurrency: any) {
  // 并发控制(Semaphore)
  while (state.semaphoreCounter >= maxConcurrency) await sleep(50);
  state.semaphoreCounter += 1;
}
function releaseSlot() {
  state.semaphoreCounter = Math.max(0, state.semaphoreCounter - 1);
}

// -------------------- Axios 实例 --------------------
const instance = axios.create({
  baseURL: DEFAULT_CONFIG.baseURL,
  timeout: DEFAULT_CONFIG.timeout,
  headers: DEFAULT_CONFIG.headers,
});

// -------------------- 请求拦截器 --------------------
instance.interceptors.request.use(
  (config) => {
    // 白名单接口跳过 token 验证
    const url = config.url || "";
    if (isWhitelisted(url)) {
      return config;
    }

    // 自动带 token
    if (state.authToken) {
      config.headers = config.headers || {};
      config.headers.Authorization = `Bearer ${state.authToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error),
);

// -------------------- 响应拦截器 --------------------
instance.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config;
    if (!original) return Promise.reject(error);
    const status = error.response?.status;
    // -------------------- 401 未授权 --------------------
    // 白名单接口的 401 不触发刷新 token,直接返回未授权错误
    const reqUrl = original.url || "";
    if (status === 401 && isWhitelisted(reqUrl)) {
      return Promise.reject(error);
    }
    if (status === 401 && typeof state.refreshTokenFn === "function") {
      if (state.isRefreshing) {
        // 如果已经在刷新 token,将请求加入队列等待
        return new Promise((resolve, reject) =>
          state.refreshQueue.push({ resolve, reject, config: original }),
        );
      }
      state.isRefreshing = true;
      try {
        const newTokenData = await state.refreshTokenFn();
        if (!newTokenData?.token) throw new Error("刷新 token 失败");
        state.authToken = newTokenData.token;
        // 刷新后重新发起队列中的请求
        state.refreshQueue.forEach((q) => {
          q.config.headers = q.config.headers || {};
          q.config.headers.Authorization = `Bearer ${state.authToken}`;
          instance.request(q.config).then(q.resolve).catch(q.reject);
        });
        state.refreshQueue.length = 0;
        // 当前请求也重新发起
        original.headers = original.headers || {};
        original.headers.Authorization = `Bearer ${state.authToken}`;
        return instance.request(original);
      } catch (e) {
        state.refreshQueue.forEach((q) => q.reject(e));
        state.refreshQueue.length = 0;
        state.authToken = null;
        return Promise.reject(e);
      } finally {
        state.isRefreshing = false;
      }
    }
    // -------------------- 403 / 404 / 500 全局处理 --------------------
    if (status === 403) {
      console.warn("没有权限访问,请联系管理员");
    }
    if (status === 404) {
      console.warn("请求的资源不存在");
    }
    if (status === 500) {
      console.error("服务器错误,请稍后重试");
    }
    return Promise.reject(error);
  },
);

// -------------------- 取消请求兼容处理 --------------------
function attachCancelSupport(config: any, opts: { controller?: any } = {}) {
  // axios >=0.22 支持 signal,0.21 使用 CancelToken
  if (config.signal) return config;
  if (typeof axios.CancelToken !== "undefined" && opts.controller) {
    config.cancelToken = new axios.CancelToken((c) => {
      opts.controller.cancel = c;
    });
  }
  return config;
}

// -------------------- 原始请求封装 --------------------
interface RawRequestOpts {
  dedupe?: boolean;
  cache?: boolean;
  cacheTTL?: number;
  retry?: number;
  retryDelay?: number;
  concurrency?: number;
  controller?: any;
}

async function rawRequest(config: any, opts: RawRequestOpts = {}) {
  const {
    dedupe = true,
    cache = false,
    cacheTTL = DEFAULT_CONFIG.cacheTTL,
    retry = DEFAULT_CONFIG.retry,
    retryDelay = DEFAULT_CONFIG.retryDelay,
    concurrency = DEFAULT_CONFIG.concurrency,
    controller = null,
  } = opts;
  const key = requestKey(config);
  // -------------------- GET 缓存 --------------------
  if (cache && config.method?.toLowerCase() === "get") {
    const c = state.cache.get(key);
    if (c && Date.now() - c.ts < cacheTTL) return c.data;
  }
  // -------------------- 请求去重 --------------------
  if (dedupe && state.pendingRequests.has(key)) {
    return state.pendingRequests.get(key);
  }
  const promise = (async () => {
    await acquireSlot(concurrency);
    try {
      const doRequest = () =>
        instance
          .request(attachCancelSupport(config, { controller }))
          .then((r) => r.data);
      const res = await retryWithBackoff(doRequest, retry, retryDelay);
      if (cache && config.method?.toLowerCase() === "get") {
        state.cache.set(key, { ts: Date.now(), data: res });
      }
      return res;
    } finally {
      releaseSlot();
      state.pendingRequests.delete(key);
    }
  })();
  state.pendingRequests.set(key, promise);
  return promise;
}

// -------------------- 对外 API --------------------
interface CommonOpts {
  params?: Record<string, any>;
  headers?: Record<string, any>;
  signal?: AbortSignal | null;
  retry?: number | null;
  dedupe?: boolean;
  controller?: any;
  cache?: boolean;
  cacheTTL?: number | null;
}

const api = {
  // 基础设置
  setBaseURL(url: string) {
    instance.defaults.baseURL = url;
  },
  setTimeout(ms: number) {
    instance.defaults.timeout = ms;
  },
  setAuthToken(token: string) {
    localStorage.setItem("token", token);
    state.authToken = token;
  },
  getAuthToken() {
    return state.authToken || localStorage.getItem("token");
  },
  clearAuthToken() {
    localStorage.removeItem("token");
    state.authToken = null;
  },
  setRefreshHandler(fn: () => Promise<{ token: string }>) {
    state.refreshTokenFn = fn;
  },
  // 拦截器
  addRequestInterceptor(f: any, r?: any) {
    return instance.interceptors.request.use(f, r);
  },
  addResponseInterceptor(f: any, r?: any) {
    return instance.interceptors.response.use(f, r);
  },
  removeInterceptor(id: number, type: "request" | "response" = "request") {
    if (type === "request") instance.interceptors.request.eject(id);
    else instance.interceptors.response.eject(id);
  },
  // 原始请求
  request(config: any, opts: RawRequestOpts = {}) {
    return rawRequest(config, opts);
  },
  // 常用请求方法
  get(
    url: string,
    {
      params = {},
      headers = {},
      signal = null,
      cache = false,
      cacheTTL = null,
      retry = null,
      dedupe = true,
      controller = null,
    }: CommonOpts = {},
  ) {
    return rawRequest(
      { url, method: "get", params, headers, signal },
      {
        cache,
        cacheTTL: cacheTTL || DEFAULT_CONFIG.cacheTTL,
        retry: retry ?? DEFAULT_CONFIG.retry,
        dedupe,
        controller,
      },
    );
  },
  post(
    url: string,
    data: any,
    {
      params = {},
      headers = {},
      signal = null,
      retry = null,
      dedupe = false,
      controller = null,
    }: CommonOpts = {},
  ) {
    return rawRequest(
      { url, method: "post", data, params, headers, signal },
      { retry: retry ?? DEFAULT_CONFIG.retry, dedupe, controller },
    );
  },
  put(url: string, data: any, opts: CommonOpts = {}) {
    return rawRequest(
      {
        url,
        method: "put",
        data,
        headers: opts.headers || {},
        params: opts.params || {},
        signal: opts.signal || null,
      },
      {
        cache: opts.cache,
        cacheTTL: opts.cacheTTL ?? DEFAULT_CONFIG.cacheTTL,
        retry: opts.retry ?? DEFAULT_CONFIG.retry,
        dedupe: opts.dedupe,
        controller: opts.controller,
      },
    );
  },
  delete(
    url: string,
    { params = {}, headers = {}, signal = null }: CommonOpts = {},
  ) {
    return rawRequest({ url, method: "delete", params, headers, signal });
  },
  // 上传文件
  upload(
    url: string,
    files: any = {},
    {
      fields = {},
      headers = {},
      onProgress = null,
      signal = null,
      controller = null,
    }: CommonOpts & {
      fields?: Record<string, any>;
      onProgress?: ((ev: any) => void) | null;
    } = {},
  ) {
    const form = new FormData();
    Object.keys(fields || {}).forEach((k) => form.append(k, fields[k]));
    if (Array.isArray(files))
      files.forEach((f: any) => form.append(f.name || "file", f.file));
    else Object.keys(files).forEach((k) => form.append(k, files[k]));
    return rawRequest(
      {
        url,
        method: "post",
        data: form,
        headers: { ...headers, "Content-Type": "multipart/form-data" },
        onUploadProgress: onProgress ? (ev: any) => onProgress(ev) : undefined,
        signal,
      },
      { dedupe: false, controller },
    );
  },
  // 下载文件
  async download(
    url: string,
    {
      params = {},
      filename = null,
      headers = {},
      signal = null,
      controller = null,
    }: CommonOpts & { filename?: string | null } = {},
  ) {
    const res = await rawRequest(
      { url, method: "get", params, headers, responseType: "blob", signal },
      { dedupe: false, retry: 0, controller },
    );
    const blob = res instanceof Blob ? res : new Blob([res]);
    if (typeof window !== "undefined" && filename) {
      const link = document.createElement("a");
      link.href = URL.createObjectURL(blob);
      link.download = filename;
      document.body.appendChild(link);
      link.click();
      link.remove();
      URL.revokeObjectURL(link.href);
    }
    return blob;
  },
  // 缓存管理
  clearCache(key: string | null = null) {
    if (!key) state.cache.clear();
    else state.cache.delete(key);
  },
  // 暴露原始 axios 实例
  instance,
};

/*
 *
 * 这里注意三点
 * 1. 使用的时候 登陆完成后 组件里面 一定要用api.setAuthToken(token) 保存token,退出的时候也得用api.clearAuthToken() 清除token
 * 2. 封装你自己的刷新token方法
 * 3. 基础BaseURL
 */

/* 我这里就拿BaseURL,和token  举例 */

// 设置 BaseUrl

const BaseUrl = import.meta.env.VITE_BASE_URL;
api.setBaseURL(BaseUrl);

// 设置 refreshToken

// api.setRefreshHandler(async() => {})

export default api;

创建 api 文件注意路径需要对上

bash
data 就是 post get 就是 params
js
import request from "@/api/request";

export const loginApi = (data) => {
  return request({
    url: "/api/login",
    method: "post",
    data,
  });
};

export const getList = (params) => {
  return request({
    url: "/api/getlist",
    method: "get",
    params,
  });
};
  • 使用
html
<button @click="handleget">测试get接口</button>
<button @click="handlepost">测试post接口</button>
<script>
  import { loginApi, loginApi2 } from "@/api/loginapi/loginapi.js";
  const handleget = () => {
    loginApi2({
      username: "admin",
      password: "123456",
    }).then((res) => {
      console.log(res);
    });
  };
  const handlepost = () => {
    loginApi({
      username: "admin",
      password: "123456",
    }).then((res) => {
      console.log(res);
    });
  };
</script>