简介:路由是单页应用的骨架,页面跳转、参数传递、嵌套布局与按需加载是日常开发中最高频的四大场景。本文逐一拆解四大核心用法的实现思路与适用边界,深入剖析跳转后状态丢失、传参方式选错、子路由不渲染、懒加载白屏等典型问题的成因,并配套示意代码与上线自查清单,帮助你建立完整、可直接落地的路由知识体系。
路由是单页应用的骨架。与传统多页站点不同,单页应用的视图切换完全由前端接管:URL 的变化不再触发整页刷新,而是由路由系统完成“地址到视图”的映射与局部渲染。因此路由承担的职责远不止“跳页面”——它同时决定了状态如何持久、布局如何复用、代码何时到达用户手中。
日常开发中与路由打交道最密集的是四件事:怎么跳转页面、怎么把数据从 A 页带到 B 页、怎么组织“公共布局加局部切换”的结构、怎么让首屏更快。这四件事看似基础,却是线上问题的高发区:跳转后状态丢失、传参方式选错导致刷新后数据全无、子路由配置了却不渲染、懒加载页面长时间白屏。下面逐一拆解。(文中代码均为示意代码,用于说明结构与思路,具体 API 命名以你所使用的框架与路由方案为准。)
一、页面跳转:声明式导航与编程式导航
声明式导航以“路由链接组件”的形式出现在界面中,适合菜单、面包屑、按钮等可预见的静态入口:
// 示意代码:声明式导航<AppLink to="/orders/42">查看订单 42</AppLink>
编程式导航发生在逻辑代码里,用于“满足条件才跳”的动态场景:表单提交成功后、权限校验不通过时、倒计时结束自动跳转等:
// 示意代码:编程式导航function onLoginSuccess() {navigate('/dashboard', { replace: true });}
工程经验:凡能被 UI 直接表达的入口,用声明式;凡依赖运行时逻辑的跳转,用编程式。两者混用不是不行,但会让“哪些地方会发生跳转”变得难以追踪,排查问题时成本很高。
push 向浏览器历史栈压入一条新记录,用户按后退会回到跳转前页面,适合常规浏览路径;replace 替换当前记录,后退不会回到上一页,适合“流程终点型”跳转。
典型翻车场景:登录成功后用 push 跳到首页,用户按后退键又回到登录页,甚至触发重复提交。凡是登录页、支付中间页、表单提交后的结果页,都应使用 replace,让流程“有去无回”。
很多开发者把跳转后的数据丢失当成路由缺陷,实际成因通常是两条:
其一,路由切换意味着旧页面组件被卸载、新页面组件重新挂载,组件内部的受控输入、滚动位置等本地状态自然清零——这是组件生命周期的正常结果。需要跨页面保留的数据,应当放进 URL、全局状态管理或本地存储,而不是寄希望于“组件别被销毁”。
其二,滚动行为需要显式管理。从长列表进入详情再返回,期望恢复列表滚动位置;跳到全新页面,期望回到顶部。两种诉求方向相反,多数路由方案不会默认全部做对,需在导航发生时按场景处理。
二、传参:三种方式的边界与取舍
// 示意代码:动态段匹配// 路由声明:/orders/:id// 实际地址:/orders/42 → 目标页面中取到 id = "42"
路径参数是资源地址的一部分,语义强、可收藏、可分享,适合表达“唯一标识”:详情页 ID、文章别名等。两个注意点:参数取出来是字符串,参与运算前要显式转换;对外部输入的参数务必校验,避免非法值直接进入渲染逻辑。
// 示意代码:查询参数navigate('/list?page=2&status=paid');// 目标页面解析:new URLSearchParams(location.search).get('page') → "2"
查询参数不改变资源主体,只描述同一资源的不同视图:分页、排序、筛选、搜索词。它天然与 URL 绑定,刷新、分享、后退都能恢复现场,是列表页状态管理的首选载体,但不适合承载大量或敏感数据。
// 示意代码:携带内存状态跳转navigate('/result', { state: { from: 'checkout', amount: 199 } });// 目标页面读取该状态;刷新后为空
这种方式不体现在 URL 上,适合一次性、临时性的上下文,比如“从哪个步骤过来”。刷新即失效是它的天性——使用前必须先回答:“这个页面被用户直接刷新后,还能正常工作吗?”若答案是否定的,就要换方案。
典型反模式:把整个对象序列化后塞进查询参数,导致 URL 超长、转义混乱、内部结构外泄。URL 只放“索引类”信息,内容在目标页按需获取,才是干净的分界。
三、嵌套路由:布局复用的正确姿势
中后台与内容型站点的结构高度一致:顶栏与侧边栏恒定,只有中间内容区随 URL 切换。如果每个页面各自渲染一遍公共布局,会出现两个恶果:公共代码重复渲染、切换时整页闪动;公共区域自身的状态(例如侧边栏展开项)被无谓重置。
嵌套路由的解法是“父子分工”:父路由对应布局壳,子路由对应内容区;父组件中放置一个“路由出口”占位,当前命中的子路由视图就渲染在出口处。
// 示意代码:嵌套路由结构{path: '/',element: <AppLayout />, // 布局壳:顶栏 + 侧边栏children: [{ index: true, element: <Home /> }, // 访问 / 时的默认子视图{ path: 'orders', element: <Orders /> }, // 匹配 /orders{ path: 'orders/:id', element: <OrderDetail /> } // 匹配 /orders/42]}// AppLayout 内部<main>{/* 路由出口:命中的子路由视图渲染在这里 */}<RouteOutlet /></main>
这是嵌套路由翻车率最高的问题,按顺序检查四点:
布局层级控制在三层以内,过深往往说明信息架构需要重新梳理;面包屑、页签类需求直接基于路由层级与当前路径推导,不要另行维护一套“当前页面”状态,否则两套状态迟早不同步。
四、懒加载:把首屏时间还给用户
现代打包工具普遍支持代码分割,路由级分割是最自然的切分单位:每个页面单独打成一个异步分包,访问到该路由时才下载执行,核心依赖的是语言标准的动态导入能力:
// 示意代码:路由组件按需引入const OrderDetail = lazyPage(() => import('./pages/OrderDetail'));// 挂到路由上时,包一层“异步加载边界”提供占位// <AsyncBoundary fallback={<Skeleton />}> <OrderDetail /> </AsyncBoundary>
懒加载引入了新的等待:弱网下分包迟迟不到,若没有任何处理,用户面对的就是一片空白——这正是“懒加载白屏”的由来。对策分三层:必须有加载占位,骨架屏的感知等待时间显著短于转圈动画,优先使用;设置最小展示时长,占位至少停留两三百毫秒,避免“闪一下就消失”的跳变感;提供失败兜底,动态包加载失败时给出重试入口,而不是让页面永远停在占位态,弱网与版本发布窗口期尤其容易触发。
预加载:用户把指针悬停到导航项的瞬间就提前拉取对应分包,真正点击时资源已就绪,体感近乎零延迟。合理分包:高频互跳的页面可合到一个包里,避免一次跳转连续请求多个碎包。此外还可利用浏览器空闲时机预取低频页面。
懒加载不是银弹:分包过碎会增加请求数与缓存碎片;首屏必达的页面(登录页、首页)不应该懒加载。正确姿势是“首屏直出、非首屏按需”。
五、四件合一:一段典型的路由设计
// 示意代码:综合结构(概念演示)const routes = [{ path: '/login', element: <Login /> }, // 首屏直出,不懒加载{path: '/',element: <AppLayout />, // 公共布局 + 路由出口children: [{ index: true, element: <Home /> },{ path: 'orders', element: lazyPage(<Orders />) },{ path: 'orders/:id', element: lazyPage(<OrderDetail />) },{ path: 'search', element: lazyPage(<Search />) },],},];
配套的行为约定:登录成功后用 replace 进入首页,避免后退回登录页;列表跳详情只在 URL 携带 id,其余数据由详情页自行拉取;筛选与分页全部落在查询参数,刷新、分享、后退都能恢复现场;从详情返回列表时恢复滚动位置。
六、总结
四件事背后是同一条主线:URL 是单页应用唯一可靠的外部状态。跳转决定 URL 如何变化,传参决定什么进 URL,嵌套决定 URL 与布局的映射结构,懒加载决定 URL 对应的代码何时到达。把 URL 当作应用的状态机来设计,绝大多数路由问题都会消解于无形。
上线前自查清单: