Vue3 + Axios 适配多节点后端服务:最小侵入式解决方案

Vue3 + Axios 适配多节点后端服务:最小侵入式解决方案

在实际项目开发中,后端服务常从「单节点部署」演进为「多节点部署」,且节点间数据隔离 ------ 此时前端需精准路由请求到指定节点,同时要最小化修改原有代码(避免重构风险)。本文将分享基于 Vue3 的两种适配方案,完美解决该问题。

一、问题背景

1. 原始架构(单节点)

前端通过 Axios 实例配置固定 baseURL,所有请求直接指向唯一节点:

TypeScript 复制代码
// 原有 Axios 配置(单节点)
const service = axios.create({
    baseURL: import.meta.env.VITE_VECTOR_TILE_BASE_API, // 固定节点地址
    timeout: 50000
});

2. 新架构(多节点)

  • 后端新增多个服务节点,节点间数据隔离(如节点 1 存储北京数据,节点 2 存储上海数据);
  • 前端需支持「用户手动选择节点」或「自动路由节点」,请求需动态指向选中节点;
  • 核心约束:最小化破坏原代码------ 原有 API 调用、业务逻辑不改动,仅扩展节点路由能力。

二、核心设计思路

要满足「最小侵入」和「动态路由」,核心思路如下:

  1. 保留原有 Axios 实例:不修改现有请求逻辑,避免全局替换导致的风险;
  1. 动态替换 baseURL:通过 Axios 请求拦截器,在请求发送前注入当前选中节点的 baseURL;
  1. 全局管理节点状态:统一维护节点列表(支持接口动态获取)和当前选中节点,确保全局状态一致;
  1. 支持状态持久化:缓存用户选中的节点,页面刷新后自动恢复,提升体验。

三、方案一:Pinia 状态管理方案(推荐)

Pinia 是 Vue3 官方状态管理库,支持全局访问、异步逻辑、调试工具,适合中大型项目或已使用 Pinia 的项目。

1. 环境准备

安装 Pinia(若未安装):

bash 复制代码
npm install pinia
# 或 yarn add pinia

2. 注册 Pinia 实例(入口文件)

TypeScript 复制代码
// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';

const app = createApp(App);
app.use(createPinia()); // 注册 Pinia
app.use(router);
app.mount('#app');

3. 实现节点管理 Store

定义节点状态、接口请求、切换逻辑,支持动态获取节点列表:

TypeScript 复制代码
// src/store/nodeStore.ts

import { defineStore } from 'pinia';

import axios from 'axios';

// 节点类型接口(与后端返回字段对齐)

export interface ServiceNode {
  id: string; // 节点唯一标识(如 node1、node2)

  name: string; // 节点名称(用于UI显示,如「北京节点」)

  baseUrl: string; // 节点基础URL(如 http://node1.example.com/api)

  [key: string]: any; // 兼容后端额外字段(如节点状态、描述)
}

interface NodeState {
  nodeList: ServiceNode[]; // 所有服务节点列表

  currentNode: ServiceNode | null; // 当前选中节点

  loading: boolean; // 节点列表加载状态

  error: string | null; // 加载错误信息
}

export const useNodeStore = defineStore('node', {
  state: (): NodeState => ({
    nodeList: [],

    currentNode: null,

    loading: false,

    error: null
  }),

  getters: {
    // 快捷获取当前节点的baseURL(供Axios拦截器使用)

    currentBaseUrl: (state) => state.currentNode?.baseUrl || ''
  },

  actions: {
    /** 从后端接口动态获取节点列表 */

    async fetchNodeList() {
      this.loading = true;

      this.error = null;

      try {
        // 替换为你的后端节点列表接口

        const res = await axios.get('/api/service/nodes');

        // 假设后端返回格式:{ code: 200, data: ServiceNode[] }

        if (res.data.code === 200 && Array.isArray(res.data.data)) {
          this.nodeList = res.data.data;

          // 默认选中第一个节点(可根据业务调整)

          this.nodeList.length && this.setCurrentNode(this.nodeList[0].id);
        } else {
          this.error = '节点列表数据格式错误';
        }
      } catch (err) {
        this.error = '获取服务节点失败,请刷新重试';

        console.error('节点请求失败:', err);
      } finally {
        this.loading = false;
      }
    },

    /** 切换当前节点(供UI组件调用) */

    setCurrentNode(nodeId: string) {
      const targetNode = this.nodeList.find((node) => node.id === nodeId);

      if (targetNode) {
        this.currentNode = targetNode;

        // 持久化到本地存储,页面刷新后恢复

        localStorage.setItem('selectedNode', JSON.stringify(targetNode));
      }
    },

    /** 初始化节点(页面刷新后恢复选中状态) */

    initNode() {
      const cachedNode = localStorage.getItem('selectedNode');

      if (cachedNode) {
        try {
          const parsedNode = JSON.parse(cachedNode);

          // 验证缓存节点是否仍在节点列表中

          const isValid = this.nodeList.some((node) => node.id === parsedNode.id);

          this.currentNode = isValid ? parsedNode : this.nodeList[0] || null;
        } catch (err) {
          this.currentNode = this.nodeList[0] || null;
        }
      } else {
        this.currentNode = this.nodeList[0] || null;
      }
    }
  }
});

4. 改造 Axios 实例(动态注入 baseURL)

保留原有 Axios 配置,仅添加请求拦截器:

TypeScript 复制代码
// src/utils/request.ts(原有文件)

import axios from 'axios';

import { useNodeStore } from '@/store/nodeStore';

// 保留原有创建逻辑,初始baseURL可留空

const service = axios.create({
  timeout: 50000 // 保留原有配置
});

// 新增请求拦截器:动态替换baseURL

service.interceptors.request.use(
  (config) => {
    const nodeStore = useNodeStore();

    // 用当前选中节点的baseURL覆盖默认配置

    if (nodeStore.currentBaseUrl) {
      config.baseURL = nodeStore.currentBaseUrl;
    }

    // 可选:添加节点ID到请求头(供后端识别)

    // config.headers['X-Node-Id'] = nodeStore.currentNode?.id;

    return config;
  },

  (error) => Promise.reject(error)
);

export default service;

5. 全局初始化节点列表

在路由守卫中初始化节点(确保应用启动时获取节点列表):

TypeScript 复制代码
// src/router/index.ts

import { useNodeStore } from '@/store/nodeStore';

import { createRouter, createWebHistory } from 'vue-router';

import routes from './routes';

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),

  routes
});

// 仅初始化一次节点列表

let isNodeInited = false;

router.beforeEach(async (to, from, next) => {
  if (!isNodeInited) {
    const nodeStore = useNodeStore();

    await nodeStore.fetchNodeList(); // 动态获取节点

    nodeStore.initNode(); // 恢复缓存节点

    isNodeInited = true;
  }

  next();
});

export default router;

6. 节点切换 UI 组件

实现全局节点选择下拉框,用户可手动切换节点:

html 复制代码
<!-- src/components/NodeSelector.vue -->

<template>

    <el-select v-model="selectedNodeId" placeholder="选择服务节点" class="node-selector" @change="handleNodeChange"
        :disabled="nodeStore.loading || !nodeStore.nodeList.length">

        <el-option v-for="node in nodeStore.nodeList" :key="node.id" :label="node.name" :value="node.id" />

    </el-select>

</template>

<script setup lang="ts">

import { useNodeStore } from '@/store/nodeStore';

import { ref, watch } from 'vue';

const nodeStore = useNodeStore();

const selectedNodeId = ref(nodeStore.currentNode?.id || '');

// 同步节点状态到下拉框

watch(

    () => nodeStore.currentNode,

    (newNode) => {

        selectedNodeId.value = newNode?.id || '';

    },

    { immediate: true }

);

// 切换节点

const handleNodeChange = (nodeId: string) => {

    nodeStore.setCurrentNode(nodeId);

};

</script>

<style scoped>
.node-selector {

    width: 180px;

    margin-right: 16px;

}
</style>

四、方案二:全局 Hooks 方案(轻量无依赖)

若项目无需 Pinia 等状态管理库,可使用 Vue3 原生 provide/inject 实现轻量级全局状态管理,无额外依赖。

1. 实现全局节点管理 Hooks

TypeScript 复制代码
// src/hooks/useServiceNodes.ts

import { ref, provide, inject, App, Ref } from 'vue';

import axios from 'axios';

// 节点类型接口(与方案一一致)

export interface ServiceNode {
  id: string;

  name: string;

  baseUrl: string;

  [key: string]: any;
}

// 注入密钥(避免命名冲突)

const ServiceNodeKey = Symbol('service-node');

// Hooks 返回类型定义

interface UseServiceNodesReturn {
  nodeList: Ref<ServiceNode[]>;

  currentNode: Ref<ServiceNode | null>;

  loading: Ref<boolean>;

  error: Ref<string | null>;

  fetchNodeList: () => Promise<void>;

  setCurrentNode: (nodeId: string) => void;

  initNode: () => void;
}

/** 全局注册节点管理 Hooks(在main.ts调用) */

export const provideServiceNodes = (app: App) => {
  // 状态定义

  const nodeList = ref<ServiceNode[]>([]);

  const currentNode = ref<ServiceNode | null>(null);

  const loading = ref(false);

  const error = ref<string | null>(null);

  /** 从接口获取节点列表 */

  const fetchNodeList = async () => {
    loading.value = true;

    error.value = null;

    try {
      const res = await axios.get('/api/service/nodes'); // 后端节点接口

      if (res.data.code === 200 && Array.isArray(res.data.data)) {
        nodeList.value = res.data.data;

        nodeList.value.length && setCurrentNode(nodeList.value[0].id);
      } else {
        error.value = '节点列表数据错误';
      }
    } catch (err) {
      error.value = '获取节点失败';

      console.error(err);
    } finally {
      loading.value = false;
    }
  };

  /** 切换当前节点 */

  const setCurrentNode = (nodeId: string) => {
    const targetNode = nodeList.value.find((node) => node.id === nodeId);

    if (targetNode) {
      currentNode.value = targetNode;

      localStorage.setItem('selectedNode', JSON.stringify(targetNode));
    }
  };

  /** 初始化节点(恢复缓存) */

  const initNode = () => {
    const cachedNode = localStorage.getItem('selectedNode');

    if (cachedNode) {
      try {
        const parsedNode = JSON.parse(cachedNode);

        const isValid = nodeList.value.some((node) => node.id === parsedNode.id);

        currentNode.value = isValid ? parsedNode : nodeList.value[0] || null;
      } catch (err) {
        currentNode.value = nodeList.value[0] || null;
      }
    } else {
      currentNode.value = nodeList.value[0] || null;
    }
  };

  // 提供全局状态

  app.provide(ServiceNodeKey, {
    nodeList,
    currentNode,
    loading,
    error,

    fetchNodeList,
    setCurrentNode,
    initNode
  });
};

/** 全局使用 Hooks(组件/axios中注入) */

export const useServiceNodes = (): UseServiceNodesReturn => {
  const instance = inject<UseServiceNodesReturn>(ServiceNodeKey);

  if (!instance) {
    throw new Error('请先在main.ts中调用 provideServiceNodes 注册');
  }

  return instance;
};

2. 入口文件注册 Hooks

TypeScript 复制代码
// src/main.ts

import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import { provideServiceNodes } from '@/hooks/useServiceNodes';
const app = createApp(App);

provideServiceNodes(app); // 注册全局 Hooks

app.use(router);

app.mount('#app');

3. Axios 集成(与方案一类似)

TypeScript 复制代码
// src/utils/request.ts

import axios from 'axios';

import { useServiceNodes } from '@/hooks/useServiceNodes';

const service = axios.create({
  timeout: 50000
});

service.interceptors.request.use(
  (config) => {
    const { currentNode } = useServiceNodes();

    if (currentNode.value?.baseUrl) {
      config.baseURL = currentNode.value.baseUrl;
    }

    return config;
  },

  (error) => Promise.reject(error)
);

export default service;

4. 初始化节点(App.vue)

html 复制代码
<!-- src/App.vue -->

<script setup lang="ts">

import { useServiceNodes } from '@/hooks/useServiceNodes';

import { onMounted } from 'vue';

const { fetchNodeList, initNode } = useServiceNodes();

// 组件挂载时初始化节点

onMounted(async () => {
    await fetchNodeList();
    initNode();
});

</script>

5. 节点切换 UI 组件

与方案一的 NodeSelector.vue 类似,仅需替换状态获取方式:

html 复制代码
// src/components/NodeSelector.vue(Hooks版本)

<script setup lang="ts">
import { useServiceNodes } from '@/hooks/useServiceNodes';
import { ref, watch } from 'vue';

const { nodeList, currentNode, loading, setCurrentNode } = useServiceNodes();
const selectedNodeId = ref(currentNode.value?.id || '');

watch(currentNode, (newNode) => {
    selectedNodeId.value = newNode?.id || '';
}, { immediate: true });

const handleNodeChange = (nodeId: string) => {

    setCurrentNode(nodeId);

};

</script>

五、方案对比与选型建议

|------|----------------------|-------------------|
| 特性 | Pinia 方案 | 全局 Hooks 方案 |
| 依赖 | 需安装 Pinia | 无额外依赖(纯 Vue3 API) |
| 全局访问 | 支持(任意组件直接调用) | 支持(通过 inject 注入) |
| 调试工具 | 支持 Pinia DevTools 调试 | 无专门调试工具 |
| 扩展性 | 强(支持模块化、复杂异步逻辑) | 中等(适合简单场景) |
| 学习成本 | 低(Pinia API 简洁直观) | 极低(仅 Vue3 基础 API) |
| 适用场景 | 中大型项目、已使用 Pinia 的项目 | 轻量项目、不想引入额外依赖的项目 |

选型建议:

  1. 若项目已集成 Pinia 或需要复杂状态管理(如节点权限控制、多环境切换),优先选 Pinia 方案
  1. 若项目轻量、追求零依赖,或仅需基础的节点切换功能,选 全局 Hooks 方案
  1. 两种方案均满足「最小侵入原代码」的核心需求,原有 API 调用无需修改。

六、核心优势总结

  1. 最小侵入性:仅扩展节点管理逻辑,原有 Axios 实例、API 请求、业务代码无需改动;
  1. 动态灵活性:支持从接口动态获取节点列表,新增节点时前端无需修改代码;
  1. 用户体验佳:支持节点切换记忆(本地存储),页面刷新后自动恢复选中节点;
相关推荐
宁波阿成2 小时前
基于Jeecgboot3.9.0的vue3版本前后端分离的flowable流程管理平台
vue3·springboot3·flowable·jeecgboot
盛夏绽放1 天前
新手入门:实现聚焦式主题切换动效(Vue3 + Pinia + View Transitions)
前端·vue3·pinia·聚焦式主题切换
我叫张小白。1 天前
Vue3 组件通信:父子组件间的数据传递
前端·javascript·vue.js·前端框架·vue3
凯小默1 天前
11-定义接口返回类型值
vue3
前端_yu小白1 天前
websocket在vue项目和nginx中的代理配置
vue.js·websocket·nginx·vue3·服务端推送
凯小默1 天前
07-封装登录接口
vue3
小晗同学1 天前
创建第一个Nuxt v4.x 应用
vue·vue3·nuxt·prettier·nuxt 4.x
Beginner x_u1 天前
从接口文档到前端调用:Axios 封装与实战全流程详解
前端·javascript·ajax·接口·axios
初遇你时动了情2 天前
vue3 ts uniapp基本组件封装、通用组件库myCompont、瀑布流组件、城市选择组件、自定义导航栏、自定义底部菜单组件等
typescript·uni-app·vue3