Umi 运行时配置 app.tsx 详解
一、什么是运行时配置?
Umi 的配置分为两种:
| 类型 | 文件 | 执行时机 | 用途 |
|---|---|---|---|
| 编译时配置 | .umirc.ts 或 config/config.ts |
构建时执行 | 配置构建选项、插件、路由等 |
| 运行时配置 | src/app.tsx |
浏览器运行时执行 | 请求拦截、路由钩子、全局状态等 |
运行时配置的核心特点:
┌─────────────────────────────────────────────────────────────┐
│ 编译时配置 (.umirc.ts) │
│ ├── 在 Node.js 环境执行 │
│ ├── 构建时一次性读取 │
│ └── 用于配置 Webpack、插件、路由生成等 │
│ │
│ 运行时配置 (app.tsx) │
│ ├── 在浏览器环境执行 │
│ ├── 每次页面加载都会执行 │
│ └── 用于请求拦截、路由守卫、全局初始化等 │
└─────────────────────────────────────────────────────────────┘
二、app.tsx 的位置与基础结构
2.1 文件位置
src/
├── app.tsx ← 运行时配置文件(固定位置,固定名称)
├── pages/
├── layouts/
└── ...
2.2 基础结构
tsx
// src/app.tsx
// 运行时配置导出的是一个个函数
// Umi 会在特定时机调用这些函数
export const request = {
// 请求配置
};
export function render(oldRender: () => void) {
// 渲染前钩子
}
export function onRouteChange({ routes, location, action }) {
// 路由变化钩子
}
export function rootContainer(container) {
// 包装根组件
}
export function modifyClientRenderOpts(memo) {
// 修改渲染配置
}
三、请求拦截配置(最常用)
3.1 为什么需要请求拦截?
┌─────────────────────────────────────────────────────────────┐
│ 前端应用与后端 API 交互时的常见需求: │
│ │
│ 1. 统一添加 Token 认证 │
│ 2. 统一处理错误响应(401、500 等) │
│ 3. 统一添加请求头 │
│ 4. 统一处理响应数据格式 │
│ 5. 请求日志记录 │
│ 6. 请求超时配置 │
└─────────────────────────────────────────────────────────────┘
3.2 基础请求配置
tsx
// src/app.tsx
export const request = {
// 请求超时时间(毫秒)
timeout: 10000,
// 基础 URL,会自动拼接到请求路径前
baseURL: '/api',
// 错误配置
errorConfig: {
// 错误抛出
errorThrower: (res) => {
const { success, data, errorCode, errorMessage } = res;
if (!success) {
const error: any = new Error(errorMessage);
error.name = 'BizError';
error.info = { errorCode, errorMessage, data };
throw error; // 抛出自定义错误
}
},
// 错误接收及处理
errorAdaptor: (resData, ctx) => {
return {
success: resData.success,
errorMessage: resData.message,
errorCode: resData.code,
};
},
},
// 请求拦截器
requestInterceptors: [
(config: any) => {
// 在请求发送前做一些事情
// 例如:添加 token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
],
// 响应拦截器
responseInterceptors: [
(response: any) => {
// 对响应数据做处理
const { data } = response;
return response;
},
],
};
3.3 完整的请求拦截示例
tsx
// src/app.tsx
import { message } from 'antd';
import type { RequestConfig } from 'umi';
export const request: RequestConfig = {
timeout: 30000,
// 请求拦截器
requestInterceptors: [
// 请求前处理
(url: string, options: any) => {
// 获取 token
const token = localStorage.getItem('token');
// 添加请求头
const headers = {
...options.headers,
'Content-Type': 'application/json',
};
// 如果有 token,添加认证头
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return {
url,
options: { ...options, headers },
};
},
],
// 响应拦截器
responseInterceptors: [
// 响应成功处理
(response: any) => {
const { data } = response;
// 业务逻辑错误处理
if (data.code !== 0 && data.code !== 200) {
message.error(data.message || '请求失败');
return Promise.reject(data);
}
return response;
},
// 响应错误处理
(error: any) => {
const { response } = error;
if (response) {
switch (response.status) {
case 401:
// 未授权,清除 token 并跳转登录
localStorage.removeItem('token');
window.location.href = '/login';
message.error('登录已过期,请重新登录');
break;
case 403:
message.error('没有权限访问');
break;
case 404:
message.error('请求的资源不存在');
break;
case 500:
message.error('服务器错误');
break;
default:
message.error(`请求失败:${response.status}`);
}
} else {
// 网络错误或请求被取消
if (error.message.includes('timeout')) {
message.error('请求超时');
} else if (error.message.includes('Network Error')) {
message.error('网络错误');
}
}
return Promise.reject(error);
},
],
};
3.4 请求拦截流程图
┌─────────────────────────────────────────────────────────────┐
│ 前端发起请求 │
│ ↓ │
│ requestInterceptors(请求拦截器) │
│ ├── 添加 token │
│ ├── 添加请求头 │
│ └── 修改请求参数 │
│ ↓ │
│ 发送到服务器 │
│ ↓ │
│ 收到响应 │
│ ↓ │
│ responseInterceptors(响应拦截器) │
│ ├── 检查业务状态码 │
│ ├── 处理错误响应 │
│ └── 统一数据格式 │
│ ↓ │
│ 返回给调用方 │
└─────────────────────────────────────────────────────────────┘
四、路由钩子
4.1 onRouteChange - 路由变化监听
当路由发生变化时触发,常用于:
- 页面访问统计
- 权限校验
- 设置页面标题
- 滚动到顶部
tsx
// src/app.tsx
import type { RuntimeConfig } from 'umi';
export const onRouteChange: RuntimeConfig['onRouteChange'] = ({
routes, // 当前路由配置
location, // 当前 location 对象
action, // 路由动作:PUSH、POP、REPLACE
matchedRoutes, // 匹配的路由数组
}) => {
console.log('路由变化了:', location.pathname);
// 1. 设置页面标题
const route = matchedRoutes[matchedRoutes.length - 1];
if (route?.route?.title) {
document.title = route.route.title + ' - 我的应用';
}
// 2. 滚动到页面顶部
window.scrollTo(0, 0);
// 3. 页面访问统计(如 Google Analytics)
if (window.gtag) {
window.gtag('event', 'page_view', {
page_path: location.pathname,
});
}
};
4.2 路由权限控制示例
tsx
// src/app.tsx
import { Navigate } from 'umi';
import type { RuntimeConfig } from 'umi';
// 定义不需要登录的页面
const whiteList = ['/login', '/register', '/forgot-password'];
export const onRouteChange: RuntimeConfig['onRouteChange'] = ({
location,
matchedRoutes,
}) => {
// 检查是否在白名单中
if (whiteList.includes(location.pathname)) {
return;
}
// 检查登录状态
const token = localStorage.getItem('token');
if (!token) {
// 未登录,跳转到登录页
window.location.href = `/login?redirect=${encodeURIComponent(location.pathname)}`;
return;
}
// 检查页面权限
const route = matchedRoutes[matchedRoutes.length - 1]?.route;
const requiredPermission = route?.permission;
if (requiredPermission) {
const userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]');
if (!userPermissions.includes(requiredPermission)) {
// 无权限,跳转到 403 页面
window.location.href = '/403';
}
}
};
4.3 render - 渲染前钩子
在应用渲染前执行,可以做一些初始化工作:
tsx
// src/app.tsx
export function render(oldRender: () => void) {
console.log('应用即将渲染');
// 1. 初始化第三方库
// initAnalytics();
// 2. 检查登录状态
const token = localStorage.getItem('token');
if (!token) {
console.log('未登录');
}
// 3. 加载全局配置
fetch('/api/config')
.then(res => res.json())
.then(config => {
// 保存全局配置
window.APP_CONFIG = config;
// 完成初始化,继续渲染
oldRender();
})
.catch(() => {
// 即使配置加载失败,也要渲染应用
oldRender();
});
}
4.4 rootContainer - 包装根组件
用于在根组件外层添加 Provider(如 Redux、主题 Provider 等):
tsx
// src/app.tsx
import { Provider } from 'react-redux';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { store } from './store';
export function rootContainer(container: React.ReactNode) {
return (
<Provider store={store}>
<ConfigProvider locale={zhCN}>
{container}
</ConfigProvider>
</Provider>
);
}
4.5 路由钩子执行流程
┌─────────────────────────────────────────────────────────────┐
│ 应用启动 │
│ ↓ │
│ render() 执行 │
│ ├── 初始化工作 │
│ └── 调用 oldRender() 继续渲染 │
│ ↓ │
│ rootContainer() 执行 │
│ └── 包装根组件(添加 Provider 等) │
│ ↓ │
│ 应用渲染完成 │
│ ↓ │
│ 用户访问页面 │
│ ↓ │
│ onRouteChange() 触发 │
│ ├── 路由变化时 │
│ ├── 设置标题 │
│ ├── 权限检查 │
│ └── 页面统计 │
└─────────────────────────────────────────────────────────────┘
五、完整的 app.tsx 示例
以下是一个生产环境可用的完整配置:
tsx
// src/app.tsx
import { message, notification } from 'antd';
import type { RequestConfig, RuntimeConfig } from 'umi';
import { history } from 'umi';
// ============================================
// 类型定义
// ============================================
interface ResponseStructure {
code: number;
data: any;
message: string;
success: boolean;
}
// ============================================
// 全局状态(简单示例,实际项目建议用 useModel)
// ============================================
let globalConfig: any = null;
// ============================================
// 请求配置
// ============================================
export const request: RequestConfig = {
timeout: 30000,
// 错误配置
errorConfig: {
// 错误抛出
errorThrower: (res) => {
const { success, data, code, message: msg } = res as ResponseStructure;
if (!success) {
const error: any = new Error(msg);
error.name = 'BizError';
error.info = { code, msg, data };
throw error;
}
},
},
// 请求拦截器
requestInterceptors: [
(url: string, options: any) => {
// 获取 token
const token = localStorage.getItem('token');
// 请求头
const headers: Record<string, string> = {
...options.headers,
};
// 添加 token
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 添加租户 ID(如果有多租户需求)
const tenantId = localStorage.getItem('tenantId');
if (tenantId) {
headers['X-Tenant-Id'] = tenantId;
}
return {
url: `${url}`,
options: { ...options, headers },
};
},
],
// 响应拦截器
responseInterceptors: [
// 成功响应处理
(response: any) => {
const { data } = response;
// 如果是文件下载,直接返回
if (response.headers?.get('content-disposition')) {
return response;
}
// 业务错误处理
if (data.code !== 0 && data.code !== 200) {
message.error(data.message || '请求失败');
return Promise.reject(new Error(data.message));
}
return {
...response,
data: data.data,
};
},
// 错误响应处理
(error: any) => {
const { response } = error;
if (response) {
const { status } = response;
switch (status) {
case 401:
localStorage.removeItem('token');
localStorage.removeItem('userInfo');
message.error('登录已过期,请重新登录');
history.push('/login');
break;
case 403:
notification.error({
message: '无权限',
description: '您没有权限访问该资源',
});
break;
case 404:
notification.error({
message: '资源不存在',
description: '请求的资源不存在',
});
break;
case 500:
notification.error({
message: '服务器错误',
description: '服务器开小差了,请稍后再试',
});
break;
case 502:
case 503:
case 504:
notification.error({
message: '服务不可用',
description: '服务暂时不可用,请稍后再试',
});
break;
default:
message.error(`请求失败:${status}`);
}
} else if (error.message) {
// 处理网络错误等
if (error.message.includes('timeout')) {
message.error('请求超时,请检查网络');
} else if (error.message.includes('Network Error')) {
message.error('网络错误,请检查网络连接');
} else {
message.error(error.message);
}
}
return Promise.reject(error);
},
],
};
// ============================================
// 路由配置
// ============================================
// 白名单路由(不需要登录)
const whiteList = [
'/login',
'/register',
'/forgot-password',
'/403',
'/404',
];
// 从路由配置中获取标题
function getPageTitle(matchedRoutes: any[]): string {
const route = matchedRoutes[matchedRoutes.length - 1];
return route?.route?.title || '';
}
export const onRouteChange: RuntimeConfig['onRouteChange'] = ({
location,
matchedRoutes,
action,
}) => {
// 1. 设置页面标题
const title = getPageTitle(matchedRoutes);
document.title = title ? `${title} - DGP` : 'DGP';
// 2. 滚动到顶部
if (action !== 'POP') {
window.scrollTo(0, 0);
}
// 3. 白名单路由直接放行
if (whiteList.includes(location.pathname)) {
return;
}
// 4. 登录检查
const token = localStorage.getItem('token');
if (!token) {
history.push({
pathname: '/login',
query: {
redirect: location.pathname,
},
});
return;
}
// 5. 权限检查(如果有配置路由权限)
const route = matchedRoutes[matchedRoutes.length - 1]?.route;
const permissions = route?.permissions || [];
if (permissions.length > 0) {
const userPermissions = JSON.parse(
localStorage.getItem('permissions') || '[]'
);
const hasPermission = permissions.some((p: string) =>
userPermissions.includes(p)
);
if (!hasPermission) {
history.push('/403');
}
}
// 6. 页面访问日志(开发环境)
if (process.env.NODE_ENV === 'development') {
console.log('[Route]', location.pathname, action);
}
};
// ============================================
// 渲染前钩子
// ============================================
export function render(oldRender: () => void) {
// 1. 初始化全局配置
fetch('/api/config')
.then((res) => res.json())
.then((config) => {
globalConfig = config;
window.APP_CONFIG = config;
})
.catch((err) => {
console.warn('加载全局配置失败:', err);
})
.finally(() => {
// 2. 继续渲染
oldRender();
});
}
// ============================================
// 根组件包装
// ============================================
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
export function rootContainer(container: React.ReactNode) {
return (
<ConfigProvider
locale={zhCN}
prefixCls="ant"
getPopupContainer={(node) => {
if (node) {
return node.parentNode as HTMLElement;
}
return document.body;
}}
>
{container}
</ConfigProvider>
);
}
// ============================================
// 修改客户端渲染配置
// ============================================
export function modifyClientRenderOpts(memo: any) {
return {
...memo,
// rootElement 默认是 document.getElementById('root')
// 可以修改挂载点
};
}
六、常见问题与解决方案
6.1 请求拦截器中获取不到 token
原因:页面刷新后,localStorage 中的数据可能有延迟读取。
解决方案:
tsx
// 方案 1:在 render 钩子中预加载
export function render(oldRender: () => void) {
// 确保 token 已加载
const token = localStorage.getItem('token');
console.log('token loaded:', !!token);
oldRender();
}
// 方案 2:使用持久化存储(如 useModel + localStorage)
6.2 路由拦截导致循环跳转
原因:拦截逻辑中跳转的路由也会触发 onRouteChange,导致无限循环。
解决方案:
tsx
const whiteList = ['/login', '/403', '/404'];
export const onRouteChange: RuntimeConfig['onRouteChange'] = ({
location,
}) => {
// 白名单路由不做拦截
if (whiteList.includes(location.pathname)) {
return; // 重要!
}
// 拦截逻辑
if (!localStorage.getItem('token')) {
history.push('/login'); // /login 在白名单中,不会再次触发拦截
}
};
6.3 请求错误处理重复弹窗
原因:全局拦截器和业务代码都在处理错误。
解决方案:
tsx
// 在拦截器中统一处理,业务代码不再单独处理
// 或者:业务代码捕获错误后不再弹窗
// service 层
export async function getUser(id: string) {
return request(`/api/user/${id}`);
// 不需要 .catch(),拦截器已统一处理
}
// 页面中
const fetchUser = async () => {
try {
const user = await getUser('1');
setUser(user);
} catch (error) {
// 拦截器已处理错误提示
// 这里只做业务逻辑处理(如重置状态)
setLoading(false);
}
};
6.4 请求拦截器修改 URL 导致问题
问题:直接修改 url 可能导致 baseURL 不生效。
解决方案:
tsx
// 错误写法
(config) => {
config.url = '/api' + config.url; // 可能导致重复前缀
return config;
}
// 正确写法 1:在 requestInterceptors 返回对象
(url, options) => {
return {
url: `/api${url}`,
options,
};
}
// 正确写法 2:配置 baseURL
export const request = {
baseURL: '/api', // 自动添加前缀
requestInterceptors: [
(config) => config,
],
};
七、最佳实践总结
7.1 请求拦截最佳实践
| 场景 | 推荐做法 |
|---|---|
| 添加 Token | 在 requestInterceptors 中统一添加 |
| 处理 401 | 在 responseInterceptors 中跳转登录页 |
| 业务错误 | 在 responseInterceptors 中统一弹窗 |
| 错误上报 | 在 responseInterceptors 中调用埋点接口 |
| 请求日志 | 开发环境在拦截器中打印日志 |
7.2 路由钩子最佳实践
| 场景 | 推荐做法 |
|---|---|
| 登录检查 | 在 onRouteChange 中统一检查 |
| 权限控制 | 路由配置中声明 permissions,在 onRouteChange 中校验 |
| 页面标题 | 路由配置中声明 title,在 onRouteChange 中设置 |
| 访问统计 | 在 onRouteChange 中上报 PV |
| 滚动行为 | 在 onRouteChange 中滚动到顶部 |
7.3 目录结构建议
src/
├── app.tsx # 运行时配置
├── app/ # 运行时配置拆分(配置复杂时)
│ ├── request.ts # 请求配置
│ ├── route.ts # 路由配置
│ └── render.ts # 渲染配置
├── utils/
│ ├── auth.ts # 认证工具函数
│ └── permission.ts # 权限工具函数
└── store/
└── user.ts # 用户状态(useModel)
拆分示例:
tsx
// src/app.tsx
import { request } from './app/request';
import { onRouteChange } from './app/route';
import { render, rootContainer } from './app/render';
export { request, onRouteChange, render, rootContainer };
八、与项目规范结合
根据 DGP-FrontEnd 项目规范,app.tsx 应遵循以下原则:
8.1 文件拆分
当 app.tsx 超过 600 行时,应拆分:
src/app/
├── index.tsx # 导出配置
├── request.ts # 请求配置
├── route.ts # 路由钩子
├── render.ts # 渲染钩子
└── types.ts # 类型定义
8.2 不在 service 函数中处理 UI 反馈
tsx
// ❌ 错误:在 service 中弹窗
export async function getUser(id: string) {
const res = await request(`/api/user/${id}`);
if (!res.success) {
message.error(res.message); // 禁止!
}
return res;
}
// ✅ 正确:service 只发请求
export async function getUser(id: string) {
return request(`/api/user/${id}`);
// 错误处理在 app.tsx 拦截器中统一完成
}
8.3 全局状态不放在 app.tsx
tsx
// ❌ 错误:在 app.tsx 中定义全局状态
export const globalState = {
user: null,
permissions: [],
};
// ✅ 正确:使用 useModel 管理状态
// src/store/user.ts
import { useState, useCallback } from 'umi';
export default function useUser() {
const [user, setUser] = useState(null);
const [permissions, setPermissions] = useState([]);
const login = useCallback(async (credentials) => {
const res = await request('/api/login', { data: credentials });
setUser(res.user);
setPermissions(res.permissions);
localStorage.setItem('token', res.token);
}, []);
return { user, permissions, login };
}
九、总结
app.tsx 是 Umi 应用的运行时配置入口,核心能力:
- 请求拦截:统一处理认证、错误、日志
- 路由钩子:权限控制、页面标题、访问统计
- 渲染控制:初始化、根组件包装
掌握 app.tsx 的配置,是开发企业级 Umi 应用的必备技能。