ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

React应用框架umi初探:约定式路由与微前端实战

React应用框架umi初探:约定式路由与微前端实战

1. 从一个真实的React项目痛点说起:为什么需要umi

1.1 脚手架能解决"从零创建",解决不了"持续演进"

如果你是从React生态开始接触前端的,大概率经历过这样的阶段:用Vite或Create React App把项目初始化好了,本地能跑起来,页面也能出,但接下来每走一步都要自己拿主意。加路由,要选react-router还是别的方案;做全局状态,要在Redux、Zustand、Recoil之间纠结;到了接口联调,又要去配proxy;等到部署,还得处理路由history模式和静态资源路径,防止刷新404。每一步单独看都不难,但它们叠加起来,就变成了一堆没有统一标准的工程决策。

我最初带的一个模拟项目X就是这样起步的。第一次提交代码很顺利,项目骨架在半小时内就跑了起来。结果一个月之后,问题开始集中暴露:团队里有人用Hash路由,有人用Browser路由;页面之间跳转有的直接window.location.href,有的用react-router的useNavigate;接口地址在不同开发者的本地机器上各写各的。这些问题的根子不在代码水平,而在于脚手架只给了你一个"能运行"的起点,没给你一套"持续演进"的约束。脚手架解决的是初始化问题,不负责项目长大之后的秩序问题。

1.2 umi给自己的定位是"应用框架",不是"脚手架"

umi是某一线互联网团队开源的一套React应用框架。它和脚手架最本质的区别在于:脚手架帮你生成一个项目,umi帮你定义这个项目接下来怎么发展。它把路由、构建、环境管理、插件扩展、微前端等工程能力都内置成了框架的一部分,而不是把这些事情留给每个团队自己拼装。你拿到的是一个自带主见的框架,同时这个主见可以通过配置文件去调整。

为什么"主见"重要?真实业务里,工程方案最怕的是"每个人有自己的一套"。umi的做法是把大多数项目都需要的共性能力做成默认开启、约定优先:页面目录结构天然就是路由,配置集中在config文件,环境变量按约定文件加载。团队里的新同学进入项目时,不需要先读懂一份几百行的webpack配置才能干活。框架帮你守住了下限,团队可以把精力放在业务上。

"初探"这两个字我是认真的:这篇文章不打算面面俱到讲umi的每个API,而是把我自己从零接触umi、把一个真实小项目逐步落到umi上的完整过程梳理出来,包括它解决了我什么问题、哪些地方一开始觉得别扭但后来觉得合理、哪些坑是我真实踩过的。如果你是个React开发者,正犹豫要不要把项目迁到umi上,或者已经初始化了一个umi项目但还在摸索它的工作方式,这篇文章应该能帮你省一些时间。

2. 初始化一个umi项目:从命令到第一个页面

2.1 先把Node环境对齐,省得后面全部卡住

umi对Node版本有明确要求,目前的版本建议使用Node 18及以上。我个人用的是20的LTS版本,跑umi的构建和开发命令都很顺。如果你机器上的Node是16以下,装依赖时可能遇到node-gyp编译报错,或者启动时直接SyntaxError,这类问题往往不是umi本身的问题,而是环境和框架版本不匹配。

安装依赖我推荐用pnpm,原因很实际:umi这类大型框架的依赖树非常庞大,npm安装时容易慢,还会产生大量重复的包副本。pnpm通过硬链接机制共享依赖文件,安装速度和磁盘占用都有明显优势。我第一次用npm安装umi相关依赖等了差不多十分钟,换成pnpm之后两三分钟就结束了。当然这不是强制要求,如果你所在团队已经统一用npm,也不用勉强切换,只是理解区别有助于将来排障。

创建项目之前,先确认环境:

node -v pnpm -v

版本正常后,在你要放项目的目录下执行:

pnpm create umi

也可以写成:

npx create-umi@latest

2.2 初始化时模板怎么选:别一上来就选最重的

执行创建命令后,会有交互式提问,让你选项目模板和语言。我的建议是:第一次接触umi,选基础的Simple App模板,语言选TypeScript。注意这里的选项会直接影响后续目录结构,比如带Ant Design的模板会额外安装组件库,带Max能力的模板会引入权限、模型、微前端等扩展。这些能力都很强,但如果你还不了解umi的基本工作方式,一上来就选最复杂的模板,反而会把"框架本身的能力"和"模板自带的能力"搅在一起,出问题时很难定位。

先从最基础的模板跑通核心机制,后面需要什么能力再按需补,路径更清晰。初始化完成后的目录大致长这样:

├── .umirc.ts // 项目配置文件 ├── package.json ├── src │ ├── pages // 约定式路由页面目录 │ │ └── index.tsx │ ├── layouts // 布局目录 │ ├── app.ts // 运行时配置入口 │ └── global.css // 全局样式 └── typings.d.ts

这时你只需要记住最关键的两个位置:pages目录(放页面)和配置文件(.umirc.ts或config/config.ts)。其他目录等你用到对应的功能时自然就会明白,不用一开始就死记。

2.3 跑起来之后,先看一眼构建产物再继续

在项目根目录执行:

pnpm dev

默认启动地址是http://localhost:8000,能看到默认的说明页面。这一步跑通,说明你的环境、依赖、配置都是正常的,可以放心往下学。

然后再跑一次构建命令:

pnpm build

umi会在dist目录生成构建结果。我第一次看它的构建产物时还挺意外的:每个路由页面被单独拆成了一个文件,首页只需要加载首屏对应的JS,其他页面在访问时才按需加载。这个行为默认就是开启的,不需要你在构建配置里写splitChunks之类的东西。这也侧面解释了umi强调"框架在替你做事"的含义——你写页面,它处理工程细节。

到这里,基础流程已经打通了。接下来的内容会围绕路由、配置、插件三个核心模块展开,这三个模块也是umi最值得深入了解的地方。

3. 约定式路由:把文件结构变成页面路径的那套规则

3.1 pages目录里的文件,就是访问路径本身

约定式路由是umi里最核心的一个理念,简单说就是:你往pages目录放一个文件,umi就自动帮你生成一个对应路径,不用在配置文件里手动注册路由。具体规则是:

  • src/pages/index.tsx 对应路径 /
  • src/pages/about.tsx 对应路径 /about
  • src/pages/user/list.tsx 对应路径 /user/list

也就是说,你的目录结构本身就是站点的信息架构。这个约定对团队的约束力很强:新同学接到需求时,不需要问"路由配置在哪里",把文件放进对应位置就能跑通。这解决了一个在传统React项目里很容易出现的问题——路由表和页面文件长期不一致,代码合并时经常冲突。

有读者可能会问:那动态参数页面怎么办?嵌套布局怎么办?umi同样用目录语法做了约定。

3.2 动态路由、可选参数和嵌套布局的约定

动态路由使用的是文件夹加中括号的语法,比如:

src/pages/users/[id].tsx

对应的访问路径是 /users/:id,组件内部可以用useParams拿到id参数,非常适用于详情页。还有一种可选参数语法,用$符号标记,比如:

src/pages/users/index$.tsx

它可以同时匹配 /users 和 /users/xxx,适合做列表页和详情页共用一套逻辑的场景。

嵌套布局方面,src/layouts/index.tsx是全局布局,所有页面默认都会套用。如果你想在某些目录下使用自己的布局,就在该目录下创建一个_layout.tsx文件,它会自动包裹这个目录下的所有子页面。布局文件本身就是一个React组件,内部通过Outlet组件来渲染子页面内容。

这里有一个我实际踩过的细节:全局布局如果包含一个Sidebar侧边栏,Sidebar的选中状态不能简单依赖组件内部的state来记忆。因为每次切换路由,页面组件重新渲染时,布局组件也会重新挂载,Sidebar的state就丢了。正确做法是根据当前路由信息去计算哪个菜单项应该高亮,而不是靠组件卸载前的状态。这个问题在传统React项目里也会遇到,但在约定式路由的体系下特别容易被触发,因为大家会默认"布局组件只在首次加载时执行一次"。

3.3 页面跳转:记忆两个来自umi的导出

约定式路由下的页面跳转,使用umi提供的Link组件和useNavigate方法:

import { Link, useNavigate } from 'umi'; function Home() { const navigate = useNavigate(); return ( <div> <Link to="/about">关于页面</Link> <button onClick={() => navigate('/users/42')}> 跳转到用户详情 </button> </div> ); }

有一个新手很容易掉进去的坑:umi底层虽然依赖react-router,但它有自己的增强和重导出,所以Link和useNavigate都应该从'umi'这个包里导入,而不是直接从'react-router-dom'导入。我自己初期直接引了react-router-dom的useNavigate,跳转功能倒是正常,但类型推断偶尔和umi的路由类型对不上,排查了很久才明白是导入来源的问题。

3.4 约定式路由的边界:什么时候转配置式

约定式路由不是银弹。页面数量少、结构简单时它非常高效,但一旦你的项目开始涉及权限控制、多级菜单、面包屑、动态页面标题这些信息时,把路由元数据全塞在页面组件里,就会造成代码分散。这时候,umi的配置式路由就更有优势。两者其实可以混合使用,你也可以在config里设置路由模式,完全按自己的方式组织。这个从约定到配置的切换过程,我建议每个初学者都亲手做一遍,只有体会过两者的差别,你才会真正理解umi对路由的设计意图。这一节先说到这里,下一章我会专门展开配置式路由的写法。

4. 配置化开发:一个config文件解决大多数工程问题

4.1 为什么要从"组件里写路由"转向"配置文件里定义路由"

传统React项目里,路由通常是组件树的一部分。你在App组件里写<Route path="/about" element={ }>,当前页面需要知道什么信息,就往组件里传props。这么做在小项目里很自然,但在大项目里你会慢慢发现一个问题:菜单项、面包屑、页面标题、权限标识这些和页面强相关但又不能算组件渲染逻辑的数据,散落在各个组件定义里,团队想统一调整导航结构时,必须一个个文件去翻。

umi配置式路由的思路是把这份"页面地图"从组件树里抽离出来,变成一份独立的路由配置文件。每个路由对象除了path和component,还可以携带name(菜单名称)、icon(图标)、wrappers(权限包裹组件)、routes(子路由)等元数据。页面组件只负责接收参数,路由行为和页面结构在配置层定义。本质上,你是在用一份集中式的配置去描述整个应用的信息架构,而不是靠组件嵌套去隐式表达。

4.2 一份最小配置式路由示例

在config/config.ts里,可以这样写:

export default { routes: [ { path: '/', redirect: '/home' }, { path: '/home', component: '@/pages/home' }, { path: '/user', component: '@/layouts/BasicLayout', routes: [ { path: '/user', redirect: '/user/list' }, { path: '/user/list', component: '@/pages/user/list' }, { path: '/user/detail/:id', component: '@/pages/user/detail' }, ], }, ], };

这里有三个细节值得说明。

第一,component里的@符号指向src目录。这是umi内置的别名,不需要额外配置,属于约定的一部分。第二,redirect字段表示重定向,使用它可以让父级路径访问时自动跳到默认子页面,避免访问/user时因为没有匹配页面而显示空白。第三,嵌套路由通过routes字段实现,匹配顺序按数组顺序执行,所以要注意把具体的静态路径放在动态路径之前,避免路由被提前截胡。

4.3 多环境变量:用dotenv约定代替if判断

一个后台项目至少有三个环境:本地开发、测试环境、生产环境。传统做法是在代码里写死接口地址,然后通过注释切换,这种方案在多人协作时几乎一定会出错。umi的约定是读取.env系列文件,优先级从高到低分别是.env.local、.env.development、.env.production,最终覆盖到.env。

具体用法是:在.env里定义所有环境都一样的公共配置,在.env.local里放本机调试参数且不提交到git,在.env.development和.env.production里分别放对应环境的地址。只有以UMI_APP_开头的变量才会被自动注入到前端代码中,这是为了阻止把服务端机密信息意外暴露到浏览器脚本里。

# .env UMI_APP_TITLE=演示后台 # .env.production UMI_APP_API_BASE=https://api.example.com

代码里通过process.env.UMI_APP_API_BASE访问时,构建工具会在编译阶段直接把变量替换为对应值。这样切环境就变成了切换构建命令,而不是手动改代码。

4.4 代理配置:解决联调阶段最头疼的跨域

前后端分离开发时,本地前端和远端开发环境的后端接口之间必然存在跨域问题。umi内置了devServer.proxy配置,只需要在config里声明:

export default { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, }, }, };

这段配置的意思是把开发服务器收到的/api开头请求,转发到我本地的8080端口。changeOrigin设为true是让转发请求头里的Host字段变成target地址,否则有些后端服务会拒绝来自非预期Host的请求。

这里有一个我初期没搞明白的点:代理路径是/api,但很多后端接口本身并不带/api前缀。比如后端真实接口是/user/list,前端希望用fetch('/api/user/list')请求,如果你只配置上面的proxy,转发过去的路径仍然是/api/user/list,后端会404。这类情况要么让后端统一加/api前缀,要么在目标地址上再加一层网关做路径重写,具体要看团队的接口规范。提前确认好这个规则,能省去联调阶段的不少沟通成本。

4.5 按需加载与构建效果的直观对比

config里有一个dynamicImport配置,开启后会自动做路由级代码分割:

export default { dynamicImport: {}, };

我实际测试的一个几十页后台系统,关闭该配置时首屏包将近1.2MB,开启后降到约200KB,体验提升非常明显。在umi里,压缩、资源指纹、懒加载这些能力默认已经开了,你不需要额外去配,真正要关心的只是路由级的分割粒度,以及某些特别大的公共依赖要不要单独拆vendor。

到这里,一个结构清晰、能联调、能按环境构建的umi项目已经可以搭出来了。但umi真正区别于普通工具链的地方,在于它的插件体系和微前端能力,这是下一章的重点。

5. 插件体系和微前端:umi真正拉开差距的地方

5.1 插件机制在做的事,不是"加个功能"那么简单

很多框架都宣称支持插件,umi的插件机制特殊在哪里?我是在自己动手写了一个最简插件之后才真正理解的。umi的插件并不是一个独立于框架的外部脚本,而是插入到框架生命周期中的逻辑片段。插件可以修改配置、注册命令、给路由增强属性、往构建产物里追加内容,甚至改变框架的默认行为。

一个最简插件示例:

import { IApi } from 'umi'; export default (api: IApi) => { api.modifyConfig((memo) => { memo.hello = 'world'; return memo; }); };

然后在config里注册这个插件。umi构建项目时,会按顺序调用插件导出的函数。在这个机制下,团队的工程能力可以被沉淀为一个个独立插件:新项目需要权限就在config里声明权限插件,需要微前端就声明微前端插件,所有扩展能力都在一个统一的框架里运转,而不是每个项目复制粘贴一堆工具函数和配置片段。

5.2 权限控制:从"跳转后判断"到"配置里声明"

如果你使用的是@umijs/max这个发行版,官方维护的绝大多数插件已经被串联好了,包括权限、数据模型、请求库、微前端、站点工具等。我体验最深的是access权限插件。传统写法是在组件挂载后判断用户角色,不符合就跳走,这样页面组件里全是权限判断代码。umi的做法很不同:在app.ts里定义权限映射函数,在路由配置里给需要收敛权限的路由加access字段,框架自己会在渲染路由前介入。

// app.ts export function access(initialState: any) { const { currentUser } = initialState || {}; return { isAdmin: currentUser?.role === 'admin', }; }
// config/config.ts export default { routes: [ { path: '/admin', component: '@/pages/admin', access: 'isAdmin' }, ], };

无权限的用户即使手动输入/admin路径也会被拦下。对于权限点较多的后台系统,这种声明式方案的价值很直观——全站所有页面的权限归属,可以在一份配置里完整审查,而不是派人去各个页面组件里翻。

5.3 微前端:把原本复杂的方案压缩成一个配置项

微前端是很多人选择umi的直接原因。umi在框架层内置了qiankun相关能力,启用方式非常直接,在max项目中配置:

export default { qiankun: { master: {}, }, };

主应用通过插件注册子应用信息,子应用按约定导出生命周期。我在模拟项目X里实践这套时,最大的感受是链路完整性:子应用独立构建、路由前缀隔离、主应用和子应用之间的数据通信,框架基本都给你理清了。相比用一堆零散工具自己拼装微前端,在umi里打通主应用和子应用的联调链路,确实更接近"配置一下就能跑"的体验。

当然,微前端不是每个项目都需要。如果你的团队只有一个小项目,几十个页面,微前端带来的部署复杂度和通信成本可能反而超过收益。但如果你维护的是多个团队协同的中大型产品,并且页面数量已经超过单仓库能承载的规模,那umi的微前端方案绝对是值得花时间验证的方向。

5.4 玩插件之前,先想明白这三件事

动笔写插件之前,我建议你先停下来说服自己三件事。

第一,插件不是越多越好。每个插件都意味着构建链路多一层,报错排查时涉及的面多一圈。能用基础配置解决的,就不要为了追求架构优雅而去抽象一个插件。

第二,插件里只适合放工程能力,不适合放业务逻辑。如果一个插件里出现了具体页面相关的判断,大概率是抽象边界错了。插件的存续价值在于它可以被多个项目复用,而业务逻辑不满足这个前提。

第三,遇到插件报错,最先排查的是框架版本和插件版本的兼容关系。umi的版本演进速度比较快,官方插件通常会针对某个小版本范围提供兼容。不看版本直接怀疑是插件bug,很容易绕远路。

6. 初探阶段的踩坑实录与个人建议

6.1 安装阶段最容易卡住的两个问题

第一个问题还是Node版本。umi及其依赖链对Node版本有硬性要求,如果你的环境是16以下,安装过程可能出现node-gyp相关的编译错误,或者启动后直接抛SyntaxError。遇到这类报错,优先检查版本,而不是反复删node_modules重装。

第二个问题是镜像源导致的依赖安装中断。安装到一半卡住、某个包反复下载失败,大概率是网络或源的问题。可以临时切换镜像源,也可以配置pnpm的镜像地址。这里想强调一个排查思路:安装失败时先确认核心依赖是否完整下载、lock文件是否一致,再怀疑是umi本身的问题,不要把顺序搞反。

6.2 约定式路由里三个非常容易忽略的细节

这三个细节我在项目里都碰到过,写出来帮大家避坑。

第一,文件名大小写。约定式路由的路径在本地开发环境可能大小写不敏感,但构建产物里是区分大小写的。src/pages/User/list.tsx和src/pages/user/list.tsx会被当作两个不同路径,所以项目里最好统一约定目录命名,我推荐全小写加中划线,防止换平台或迁移部署时出现找不到路由的诡异问题。

第二,动态路由和静态路由的优先级。目录里同时存在src/pages/users/me.tsx和src/pages/users/[id].tsx时,访问/users/me会优先匹配静态路径me,这是框架的默认优先级。有些新手以为动态路由先声明就先生效,结果详情参数永远拿不到,或者渲染了错误的页面。理解了这个顺序,很多怪现象都能解释。

第三,404页面的处理方式。约定式路由下,src/pages/404.tsx会被自动识别为默认404页。但如果你自己定义了routes数组,就要手动补一个path为'/*'的路由指向404组件,否则访问不存在的路径时会看到非常简陋的默认错误页。不要问我怎么知道的,我第一次改配置式路由时就被这个页面丑到了。

6.3 如果让我重新学umi,我会按这个顺序走

第一周,先跑通一个带增删改查的列表页,只用Simple模板,一个页面一个接口一个表单,把基本的请求、渲染、跳转流程走顺。第二周,新增一个带id参数的详情页,体验动态路由的参数获取方式。第三周,把这两个页面迁移到配置式路由,给其中一个加上权限字段,跑通权限拦截。最后,再去看插件机制和微前端。

这套路径每一阶段都有看得见的产出,不会让你对着文档发呆。日常开发里也建议保持一个习惯:每新增一个页面,先想清楚它在整个站点的导航结构里是什么位置,再动手建目录。因为umi的约定会把目录结构直接映射成URL路径,后期调整目录结构不仅涉及代码引用,还涉及外部链接和用户收藏,迁移成本比想象中的高。

最后再分享一个体会。学习umi时最需要调整的心态,不是反复问"它和我以前用React的写法有什么不同",而是去理解"它希望你用哪一套心智模型来组织应用"。一旦你接受了约定大于配置这套思路,你会发现路由、布局、权限、构建这些在过去不同项目里风格各异的问题,在umi里都长成了一个统一且自然的模样。这篇初探如果能帮你在迁移和实践的路上少绕几个弯,那我的目的就达到了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进