Umi 运行时配置 app.tsx 详解

Umi 运行时配置 app.tsx 详解


一、什么是运行时配置?

Umi 的配置分为两种:

类型 文件 执行时机 用途
编译时配置 .umirc.tsconfig/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 应用的运行时配置入口,核心能力:

  1. 请求拦截:统一处理认证、错误、日志
  2. 路由钩子:权限控制、页面标题、访问统计
  3. 渲染控制:初始化、根组件包装

掌握 app.tsx 的配置,是开发企业级 Umi 应用的必备技能。


相关推荐
提子拌饭1331 小时前
个人月事记录表应用 - 鸿蒙PC Electron框架完整实现指南
前端·javascript·华为·electron·前端框架·开源·鸿蒙系统
YHL1 小时前
📚 JS执行机制(执行上下文 + 调用栈 + 编译流程)
前端·javascript
不简说1 小时前
这次真香!sv-print 可视化打印设计器更新:插件脚手架、Excel 导出、弹窗 API 三连发
前端·javascript·前端框架
无聊的老谢1 小时前
Web GIS 最佳实践:Vue 集成 Leaflet/OpenLayers 实现基站海量点位渲染
前端·javascript·vue.js
yingyima1 小时前
GCP Cloud Scheduler 核心语法与实战示例速查手册
前端
用户57350107252061 小时前
Elpis 项目阶段性总结 - 基于 vue3 完成领域模型架构建设
前端
假如让我当三天老蒯2 小时前
为什么 setData 能获取到 prev 参数?(自学用)
前端·react.js
AskHarries2 小时前
Workspace:文件系统、项目上下文和执行边界
java·服务器·前端
Aphasia3112 小时前
从内存模型看深浅拷贝
前端·javascript·面试