路由就是URL 的翻译器
路由题看着细碎,其实都能归到三个问题:URL 怎么变、变完怎么匹配、匹配完在哪渲染。这份按这三个问题组织,把 hash 与 history 的分工、守卫的顺序、动态路由的坑讲透。
01先分清模式hash 和 history 的差别不在写法,在"服务端能不能看见路径"。
02记顺序不记名字守卫按"先离开、再全局、再局部"排,比背七个名字好记。
03认准复用还是重建参数变化时组件默认复用,这是大半路由 bug 的来源。
1
路由的本质:URL 到组件树的映射★
先想清楚"状态存在哪"
前端路由要解决一件事:URL 变了但不刷新页面。做法是接管 URL 变化,把 URL 解析成一条路由记录,再渲染对应组件。所以核心是三块——监听 URL、匹配规则、渲染出口。所有框架都是这三块的不同实现。
◐ 可交互:输一个路径,看它命中哪条路由、服务端收到什么
试试 /user/42、/user/42/edit、/nope,再切一下模式,看服务端各收到什么。
一个最小的前端路由,不含任何框架// 1) 路由表
const routes = [
{ path: '/', render: () => '首页' },
{ path: '/about', render: () => '关于' },
{ path: '/user/:id',render: (p) => '用户 ' + p.id },
];
// 2) 匹配:把 /user/42 变成 { path:'/user/:id', params:{ id:'42' } }
function match(pathname) {
for (const r of routes) {
const keys = [];
const pattern = r.path.replace(/:([^/]+)/g, (_, k) => { keys.push(k); return '([^/]+)'; });
const m = pathname.match(new RegExp('^' + pattern + '$'));
if (m) {
const params = {};
keys.forEach((k, i) => params[k] = decodeURIComponent(m[i + 1]));
return { route: r, params };
}
}
return null;
}
// 3) 渲染
function render() {
const hit = match(location.pathname);
document.getElementById('app').textContent = hit ? hit.route.render(hit.params) : '404';
}
// 4) 监听:两种模式在这里分叉
window.addEventListener('popstate', render); // 前进后退
render();
// 5) 跳转:不能改 location.pathname(会刷新),要用 pushState
function push(path) {
history.pushState({}, '', path);
render(); // pushState 不触发事件,要手动调
}
pushState不会触发popstate,这是最常见的困惑。只有用户点前进/后退才会触发。所以"跳转"要自己再调用一次渲染。- 路由状态存在 URL 里还是内存里:URL 里的是 pathname、search、hash,内存里的是组件状态。刷新会丢掉内存里的,所以筛选条件这类需要保留的东西要放 URL。
- 为什么不用
location.hash = x而用 pushState:hash 会触发 hashchange 事件(省事),但 URL 里会多一个 #,且服务端拿不到 hash 后面的内容。
面试怎么答前端路由本质是把 URL 映射到组件树。三块职责:监听 URL 变化、按规则匹配出路由记录和参数、在出口处渲染。hash 模式靠监听 hashchange,改 hash 不请求服务端;history 模式靠 pushState 改 URL 加 popstate 监听前进后退,但它需要服务端把所有路径都回退到 index.html,否则刷新会 404。
2
hash 模式:为什么它天然不会 404★
# 后面的东西服务端看不见
URL 里
# 之后的部分叫 fragment,规范里它不参与 HTTP 请求——浏览器发请求时会把它去掉。所以 /a#/user/1 和 /a#/user/2 请求的都是 /a,服务端不用做任何配置。hash 的优势
- 服务端零配置,刷新、直接访问都正常
- 兼容到很老的浏览器
- 不需要后端配合,静态托管就能用
hash 的代价
- URL 里有个 #,不好看,也不符合"一个 URL 一个资源"
- 服务端拿不到 # 后面的内容,做不了根据路径的 SSR 或埋点
- 部分第三方回调、分享卡片对 # 处理不一致
hashchange触发条件:hash 真的变了才触发。设成一样的值不会触发。要用代码触发可以location.hash =再设回去。- hash 也能存状态:常见的
#/list?page=2这种把 query 塞进 hash 的写法,需要自己解析?后面的部分。 - 什么时候还值得用 hash:纯静态托管、没权限改服务端配置、嵌入到别人页面里的场景。否则一律 history。
面试怎么答hash 模式利用 URL 里 # 后面的部分不参与 HTTP 请求这个特性,所以服务端永远只收到 # 前面的路径,不需要任何配置,刷新和直接访问都不会 404。缺点一是 URL 不好看,二是服务端拿不到路由信息,做不了基于路径的 SSR 和统计。适合纯静态托管、不能改服务端配置的场景。
3
history 模式:pushState 与 popstate★
干净 URL 的代价在服务端
history 模式用
pushState / replaceState 改 URL(不刷新),用 popstate 感知前进后退。代价是服务端必须把未知路径回退到 index.html,否则用户刷新 /user/42 时服务端会去找这个真实文件,返回 404。服务端必须配合:把未命中的路径回退到入口文件【Nginx】
location / {
try_files $uri $uri/ /index.html;
}
【Apache】
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
【Node / Express】
app.get('*', (req, res) => res.sendFile(path.join(dist, 'index.html')));
注意要放在所有 API 路由之后,否则会吃掉 API 请求
【静态托管】
大部分平台支持配置 rewrite / fallback,
没有这个选项就只能退回 hash 模式。
- 为什么回退到 index.html 就能工作:路由表在 JS 里,服务端不需要知道有哪些页面。它只负责把任何路径都返回同一个 HTML,剩下的交给前端匹配。
- 回退的副作用:真 404 也没了:用户访问
/not-exist会拿到首页 HTML 然后前端渲染 404 页面。这对用户是好事,但对搜索引擎来说状态码是 200,需要额外处理。 - base 路径:部署在子目录时(比如
/admin/),Vite 要设base: "/admin/",路由要设createWebHistory("/admin/"),两边不一致就会出现资源 404 或路由匹配不到。 - 不能跨域 pushState:
pushState的 URL 必须同源,否则抛 SecurityError。这是安全限制,不是 bug。
面试怎么答history 模式用 pushState 和 replaceState 改变 URL 而不刷新,用 popstate 监听前进后退。要注意 pushState 本身不触发 popstate,所以封装跳转时要手动调一次渲染。最大的代价在服务端:因为 /user/42 这种路径在磁盘上不存在,刷新会 404,必须配置 try_files 把所有未命中的请求回退到 index.html,并且 API 路由要放在前面,否则会被吃掉。
4
动态路由与参数
params 和 query 不是一回事
params 是路径的一部分(
/user/:id),query 是 ? 后面的(?page=2)。两者最大的区别是:params 变化会复用同一个组件实例,query 变化也是——但很多人误以为组件会重建,于是在 onMounted 里取参数,结果拿到旧值。- 为什么复用而不重建:同一个路由记录对应同一个组件,框架认为"还是这个页面,只是数据变了",所以保留实例。这样有性能优势,但也意味着组件内部状态不会被重置。
key="route.fullPath"是一把双刃剑:能强制重建,但会丢掉所有组件状态(滚动位置、输入内容),而且每次切换都有完整生命周期开销。只在确实需要时用。- 可选参数:Vue Router 的
/user/:id?,React Router 的/user/:id?(v6.5+)。老版本 React Router 要用两条路由。 - query 的类型永远是字符串:
?page=2拿到的page是"2"不是 2。?tags=a&tags=b在 Vue Router 里是数组,在 React Router 里是URLSearchParams。
面试怎么答params 是路径的一部分,query 是问号后面的。这两个变化时,框架默认复用同一个组件实例而不是重建,所以只在 mounted 里读参数会拿到旧值,必须用 watch 或 useEffect 监听参数变化。如果确实需要重建,可以给 router-view 加 key 等于 fullPath,但代价是会丢掉所有组件内部状态。
5
嵌套路由:router-view 的递归
父子出口怎么对上
嵌套路由的本质是父组件里放一个出口,子路由渲染到那个出口里。所以父组件必须包含
<router-view />(React 里是 <Outlet />),否则子路由匹配成功但没地方渲染——这是最常见的"路由配了但页面空白"。React Router 的同样结构<Routes>
<Route path="/settings" element={<SettingsLayout />}>
<Route index element={<Profile />} /> {/* /settings */}
<Route path="account" element={<Account />} /> {/* /settings/account */}
<Route path="theme" element={<Theme />} /> {/* /settings/theme */}
</Route>
</Routes>
// SettingsLayout 里必须渲染 <Outlet />,否则子路由白屏
- 空路径子路由:Vue Router 用
path: '',React Router 用index。都表示"父路径本身"要渲染什么。 - 多层嵌套要有对应数量的出口:三层嵌套就需要三个 router-view 层层套着,少一个就断在某层。
- 命名视图:Vue Router 支持一个页面多个出口(
<router-view name="sidebar" />),适合布局里放侧栏、主区两个独立路由。React Router 没有等价能力,要用嵌套布局组合。 - 布局路由:常见做法是顶层一个 Layout 路由包住所有页面,登录页单独拿出来。这样切换页面时导航栏不重新渲染。
面试怎么答嵌套路由是父组件里放一个出口,子路由渲染到那个出口里。所以父组件必须渲染 router-view 或 Outlet,否则会匹配成功但页面空白,这是最常见的排查点。子路由的 path 不要写开头的斜杠,写了会被当成绝对路径。空路径的子路由用来指定父路径本身渲染什么。
6
路由守卫:完整执行顺序★
背不下来就记"先全局后局部"
Vue Router 的守卫分五类,执行顺序固定:组件内离开 → 全局 beforeEach → 路由独享 beforeEnter → 组件内 beforeRouteEnter → 全局 beforeResolve → 全局 afterEach → 组件挂载。记法:离开的、全局的、路由的、组件的、解析后的、收尾的。
Vue Router 4 的完整顺序导航从 /a 到 /b
1. beforeRouteLeave (/a 组件内) ← 用户离开当前页
2. beforeEach (全局) ← 最常用的鉴权位置
3. beforeRouteUpdate (/b 组件复用时才触发)
4. beforeEnter (/b 路由独享)
5. beforeRouteEnter (/b 组件内,此时拿不到 this)
6. beforeResolve (全局) ← 所有异步守卫完成后
7. afterEach (全局) ← 导航已确认,收尾用
8. DOM 更新
9. beforeRouteEnter 的 next 回调(此时才有 this)
被取消或重定向时,走不到 7 之后的步骤。
beforeRouteEnter里为什么拿不到 this:组件还没创建。要访问实例只能在next(vm => { ... })回调里。Vue Router 4 里更推荐直接在 setup 里用onBeforeRouteEnter。- 多个全局 beforeEach 的顺序:按注册顺序执行。所以"权限校验"要先注册,"埋点"可以后注册。
- 守卫里做异步请求要小心死循环:
beforeEach里发现没权限就return '/login',但/login本身也经过beforeEach,如果没判断目标路径就会无限重定向。 - React Router 没有守卫这个概念:v6 的做法是包一层组件(
<RequireAuth>里判断后return <Navigate to="/login" />),或者用 v6.4+ 的 loader 里throw redirect()。理念是"用组件组合代替钩子"。
面试怎么答Vue Router 的守卫顺序是:先组件内的 beforeRouteLeave,再全局 beforeEach,然后路由独享的 beforeEnter、组件内的 beforeRouteEnter、全局的 beforeResolve,最后 afterEach 收尾。记住先离开再进入、全局包着局部。鉴权一般放在全局 beforeEach,返回路由地址做重定向,返回 false 取消导航。React Router 没有守卫,是用包装组件加 Navigate 或者在 loader 里 throw redirect 来实现。
7
React Router v6:从组件路由到数据路由
loader 把取数挪出了组件
v6 之前是"渲染时才取数"(useEffect 里 fetch),于是必然出现"渲染空态 → 请求 → 再渲染"。v6.4 引入 loader:导航发生前先取数,数据准备好再渲染,等效于把取数提前到了路由层。
useEffect 取数
- 组件先渲染一次空态,再请求,再渲染
- 请求瀑布:父子组件串行发请求
- 错误和 loading 都要手写状态
- 导航过程中无法取消
loader 取数
- 导航确认后先取数,数据到了才渲染
- 同级路由的 loader 并行执行
- 错误交给 errorElement,不用手写
- 导航被取消时请求也会中断
- loader 跑在渲染之前,所以组件里拿不到 loading 状态:需要 loading 时用
useNavigation()的state === "loading"判断。 - loader 里不能调用 Hook:它是普通函数,不是组件。要用 store 得从外部导入实例,不能 useContext。
- loader 只在导航时执行,不随 state 变化:这是和 useEffect 最大的思维差异。数据要随组件状态变化,还是得用 useEffect 或 SWR 这类库。
- v5 到 v6 的主要变化:
Switch变Routes、component变element、useHistory变useNavigate、嵌套路由用Outlet、不再支持正则路径。
面试怎么答React Router v6.4 之后引入了 loader,把数据获取提到路由层:导航发生时先执行 loader,数据准备好再渲染组件,所以不会出现空态闪烁,同级路由的 loader 还会并行执行。错误统一交给 errorElement,导航取消时请求也会中断。要注意 loader 是普通函数不能用 Hook,而且它只在导航时跑,不响应组件状态变化。
8
路由懒加载:分包点选在哪
一个 import() 就是一个 chunk
路由懒加载就是把
import Component from './X' 换成 () => import('./X')。每个动态 import 就是一个独立 chunk,所以拆得越细首屏越小,但请求数越多。经验值是按"页面"切,不按"组件"切。拆太细的代价按页面拆(推荐)
index.js 180KB
home.js 40KB
detail.js 55KB
首屏 220KB,切页面再拉 40-55KB
按组件拆(常见错误)
index.js 60KB
30 个 chunk 各 3-8KB
首屏 60KB 但要发 12 个请求(首页用到的组件)
结论:HTTP/2 下多请求不算致命,但每个 chunk 都有
解析和执行开销,还有依赖被重复打进多个 chunk 的风险。
切分粒度应该是"会一起出现的代码"。
- 预加载比懒加载更值钱:用户停留 3 秒后,用
<link rel="prefetch">或import()提前把下一页拉下来,点击时就能秒开。Vite 的modulepreload自动处理依赖。 - Loading chunk failed 是什么:发版后旧 chunk 文件被删,用户还停在旧页面,点击懒加载路由时 404。解法是监听
vite:preloadError或 webpack 的error事件,然后location.reload()。 - 共享依赖要抽出来:两个页面都 import 了 lodash,如果没配
splitChunks/manualChunks,lodash 会被打进两个 chunk,体积翻倍。 - 首屏路由不要懒加载:首页那条路由再包一层 import() 只是多一次往返,没有收益。
面试怎么答路由懒加载就是把静态 import 换成动态 import,每个动态 import 是一个独立 chunk。切分粒度按页面而不是按组件,因为会一起出现的代码拆开只会增加请求数和重复依赖。真正提升体验的是预加载:用户停留一会就提前把下一页拉下来。还要处理发版后旧 chunk 被删导致的 Loading chunk failed,监听加载错误后强制刷新。
9
权限路由:前端过滤还是动态注册★
两种做法差别在"什么时候知道有权限"
两种主流方案:静态全量路由 + 前端过滤(简单,但路由表打包进了产物,能被人翻出来)、后端返回菜单 + 动态 addRoute(安全,但要注意刷新后重建和 404 兜底顺序)。企业后台基本用后者。
- 为什么 404 要最后加:路由是按注册顺序匹配的(Vue Router 4 按 score 排序,但通配路由优先级最低)。如果 404 先注册,动态加进来的路由永远匹配不到,刷新会直接跳 404。这是这类方案最经典的坑。
- 刷新会丢掉动态路由:addRoute 只在内存里,刷新页面后路由表回到初始状态。所以必须在应用启动时(守卫第一次执行前)重新拉菜单重建,通常用
router.isReady()之前包一层。 - 重新登录要清掉旧路由:
addRoute会返回一个移除函数,切换账号时要逐个调用,否则上一个人的路由还留着。 - 按钮级权限是另一件事:路由级控制"能不能进页面",按钮级要指令或
v-if配合权限码,两者不要混在一起做。
面试怎么答权限路由有两种做法。一是路由全量注册,在全局守卫里判断 meta 上的角色,不满足就跳 403,优点是简单,缺点是完整路由表在产物里能被看到。二是登录后从后端拉菜单,转成路由记录用 addRoute 动态注册,更安全。动态注册要注意三个坑:404 兜底路由必须最后注册、刷新会丢动态路由所以在启动时要重建、切换账号要清掉旧路由。
10
滚动行为与页面缓存
返回列表页位置丢了,怎么办
两件事要分开:滚动位置用框架的滚动行为配置恢复;组件状态(筛选条件、已加载的数据)要靠缓存组件,Vue 是 keep-alive,React 需要把状态提到上层或者自建缓存。
Vue keep-alive 的两个坑<keep-alive :include="['ListPage']">
<router-view />
</keep-alive>
坑 1:include 匹配的是组件的 name
<script setup> 里没有 name,要用 defineOptions({ name: 'ListPage' })
或者文件名推导(Vue 3.3+ 的 defineOptions)
坑 2:从详情页返回时数据不刷新
因为组件没销毁,onMounted 不再执行
解法:onActivated 里判断是否需要重新拉数据
坑 3:同一个组件不同参数被复用
/list?page=1 和 /list?page=2 会命中同一个缓存实例
需要 key 区分,或者 onBeforeRouteUpdate 里手动处理
面试怎么答返回列表页要恢复两样东西:滚动位置和组件状态。滚动位置用路由的 scrollBehavior,有 savedPosition 时返回它,新页面回顶部。组件状态在 Vue 里用 keep-alive 缓存,要注意 include 匹配的是组件 name,script setup 里需要用 defineOptions 声明;缓存之后 onMounted 不再执行,取数要挪到 onActivated。React 没有内置的缓存方案,通常把状态提到上层或者用状态管理保存。
11
路由设计的几个判断题
面试常问的"你会怎么组织"
路由设计有三组常见取舍:状态放 URL 还是放内存、路由层级按页面还是按布局、路由参数放路径还是放 query。答案不是固定的,判断标准是这个状态需不需要被分享、被刷新保留、被埋点统计。
状态放哪:三个判断标准能被分享 / 刷新要保留 / 要埋点统计
→ 放 URL(query 或 params)
举个具体的:
列表筛选条件(状态、日期、关键词) → 放 query,可分享
列表页码 / 每页条数 → 放 query
弹窗打开与否 → 放内存(除非要深链)
表格列宽、展开行 → 放 localStorage
当前 tab → 放 query(能深链到某个 tab)
反面例子:把筛选条件放 store 里,
用户复制链接发给同事,打开是默认列表。
- 路径参数 vs query 怎么选:路径参数表达"资源标识"(
/user/42),query 表达"视图修饰"(?tab=order&page=2)。/user/42?tab=order读起来是对的,/user?tab=order&id=42就别扭。 - 路由层级别超过三层:再深用户就迷失了,面包屑也会很长。深层关系用 query 展开,比如
/order?status=paid而不是/order/paid/list。 - 路由要能承载"回到原样":用户从列表点进详情,按返回应该回到"第 3 页、筛选已付款"。这要求列表状态在 URL 里,而不能只在内存。
- 路由命名要有规范:全小写、中划线分隔、名词复数表示列表(
/users)、单数加 id 表示详情(/users/42)、动作用子路径(/users/42/edit)。
面试怎么答路由设计的核心判断是"这个状态该不该放进 URL"。能被分享、刷新要保留、要埋点统计的,都放 URL,比如列表的筛选条件和页码;纯交互的临时状态放内存。路径参数用来标识资源,query 用来表达视图修饰。层级不超过三层,再深就用 query 展开。这样用户按返回、复制链接都能回到原样。
12
速答清单
考前十分钟只看这一页
01前端路由的本质是什么?
把 URL 映射到组件树。三块职责:监听 URL 变化、按规则匹配出路由记录和参数、在出口处渲染。
02hash 模式为什么不会 404?
# 后面的 fragment 不参与 HTTP 请求,服务端永远只收到 # 前面的路径,所以不需要任何配置。
03history 模式为什么需要服务端配合?
/user/42 在磁盘上不存在,刷新时服务端找不到文件就 404。要配置 try_files 把所有未命中路径回退到 index.html。
04pushState 会触发 popstate 吗?
不会。只有用户点前进后退才触发。所以自己封装的跳转函数要手动调用一次渲染。
05从 /user/1 跳到 /user/2,组件会重建吗?
不会,复用同一个实例。所以要 watch 参数变化,不能只在 mounted 里取一次。需要重建给 router-view 加 key="fullPath"。
06嵌套路由页面空白怎么排查?
父组件里没有渲染 router-view 或 Outlet。子路由匹配成功但没地方渲染。
07Vue Router 守卫的执行顺序?
组件内 beforeRouteLeave → 全局 beforeEach → 路由独享 beforeEnter → 组件内 beforeRouteEnter → 全局 beforeResolve → 全局 afterEach → DOM 更新。
08beforeRouteEnter 里为什么拿不到 this?
组件还没创建。要在 next(vm => ...) 回调里访问,或者用 setup 里的 onBeforeRouteEnter。
09React Router v6 的 loader 解决了什么?
把取数提到路由层:导航时先执行 loader,数据准备好再渲染,避免空态闪烁;同级 loader 并行执行,错误统一交给 errorElement。
10路由懒加载怎么切才合理?
按页面切,不按组件切。切分粒度应该是"会一起出现的代码",首屏路由不要懒加载。
11发版后点菜单报 Loading chunk failed,原因?
旧 chunk 文件已被删除,用户还在旧页面。监听加载错误事件后 location.reload() 强制刷新。
12动态加路由时 404 兜底路由要注意什么?
必须最后注册。先注册的话动态路由永远匹配不到,刷新直接跳 404。
13刷新后动态路由丢失怎么办?
addRoute 只在内存里。要在应用启动时、路由就绪前重新拉菜单重建路由表。
14返回列表页怎么恢复滚动位置?
用路由的 scrollBehavior,savedPosition 存在时返回它,新页面返回 top: 0。
15keep-alive 缓存后数据不刷新怎么解?
onMounted 不再执行,把取数逻辑挪到 onActivated 里,或者在里面判断是否需要重新请求。
16筛选条件该放 URL 还是 store?
放 URL 的 query。能被分享、刷新要保留、要埋点统计的都该放 URL,否则复制链接给别人打开就是默认状态。