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 调用、业务逻辑不改动,仅扩展节点路由能力。
二、核心设计思路
要满足「最小侵入」和「动态路由」,核心思路如下:
- 保留原有 Axios 实例:不修改现有请求逻辑,避免全局替换导致的风险;
- 动态替换 baseURL:通过 Axios 请求拦截器,在请求发送前注入当前选中节点的 baseURL;
- 全局管理节点状态:统一维护节点列表(支持接口动态获取)和当前选中节点,确保全局状态一致;
- 支持状态持久化:缓存用户选中的节点,页面刷新后自动恢复,提升体验。
三、方案一: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 的项目 | 轻量项目、不想引入额外依赖的项目 |
选型建议:
- 若项目已集成 Pinia 或需要复杂状态管理(如节点权限控制、多环境切换),优先选 Pinia 方案;
- 若项目轻量、追求零依赖,或仅需基础的节点切换功能,选 全局 Hooks 方案;
- 两种方案均满足「最小侵入原代码」的核心需求,原有 API 调用无需修改。
六、核心优势总结
- 最小侵入性:仅扩展节点管理逻辑,原有 Axios 实例、API 请求、业务代码无需改动;
- 动态灵活性:支持从接口动态获取节点列表,新增节点时前端无需修改代码;
- 用户体验佳:支持节点切换记忆(本地存储),页面刷新后自动恢复选中节点;