主题
第 15 章 · 路由 面试题集
Q1:SPA 路由的两种实现模式(hash vs history)有什么区别?
考察点:路由原理、浏览器 API
标准答案:
| 维度 | hash 模式 | history 模式 |
|---|---|---|
| URL 形态 | /#/about | /about |
| 监听事件 | hashchange | popstate(仅前进/后退) |
| 切换 API | location.hash = ... | history.pushState/replaceState |
| 服务器配置 | 无需 | 必须配置 fallback 到 index.html |
| 刷新后 | 永远 OK | 服务器没配置就 404 |
| 兼容性 | 几乎所有浏览器 | IE10+ |
| SEO | 较差(搜索引擎对 # 不友好) | 好 |
| 美观 | 带 # 较丑 | 干净 |
通俗理解:
- hash 像"杂志的目录"——翻到不同页(#about)但杂志没换
- history 像"博物馆走动"——门牌(URL)变了但其实还在同一栋楼,要管理员(服务器)配合允许"任何门进来都引到大厅"
代码示例:
js
// hash
window.addEventListener('hashchange', () => render(location.hash));
location.hash = '#/about';
// history
history.pushState(null, '', '/about');
window.addEventListener('popstate', () => render(location.pathname));加分回答:
pushState/replaceState不会触发popstate,框架要手动派发更新- history 模式刷新 404 解决:Nginx
try_files $uri /index.html; - hash 部分(
#xxx)不会发到服务器,是天然的"前端专属区"
追问:
- 服务器怎么配置才能支持 history 模式?
- pushState 之后页面 URL 变了,但
popstate没触发,说明什么?
Q2:React Router 6 相比 v5 有哪些重要变化?
考察点:版本演进
标准答案:
- Switch → Routes:自动按"最特异匹配"选择,无需 exact
- component / render prop → element prop:直接传 JSX
- 嵌套路由用
<Outlet />:子路由的渲染口 - useHistory → useNavigate:API 更简洁
- Hooks 全面取代 HOC:
useNavigate、useParams、useLocation、useSearchParams - 支持相对路径:嵌套路由的 path 默认相对父级
- v6.4+ 数据路由:loader / action / errorElement,与服务端框架接近
代码示例:
jsx
// v5
<Switch>
<Route exact path="/" component={Home} />
<Route path="/user/:id" render={(props) => <User {...props} />} />
</Switch>
// v6
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user/:id" element={<User />} />
</Routes>通俗理解: v5 像"老式收音机"——很多旋钮可调(exact、render、children),新人选错一个就出 bug。v6 像"智能音箱"——默认就是最合理的,少几个按钮反而更易用。
加分回答:
- v6 包体积比 v5 小约 60%
- v6.4 引入数据路由(createBrowserRouter)后类似 Remix 的体验
- v7 即将合并 Remix 项目
追问:
- v6 的 loader 和 useEffect 里发请求有什么区别?
- v6 怎么实现"全局路由守卫"?
Q3:动态参数路由怎么写?怎么读取参数?
考察点:路由参数
标准答案:
React Router 6:
jsx
<Route path="/user/:id" element={<User />} />
const { id } = useParams(); // 路径参数
const [params] = useSearchParams(); // 查询参数 ?tab=info
params.get('tab');Vue Router 4:
js
{ path: '/user/:id', component: User }
const route = useRoute();
route.params.id; // 路径参数
route.query.tab; // 查询参数通俗理解:
- 路径参数 = 路牌上的门牌号(
/user/123) - 查询参数 = 路牌后面的"备注便条"(
?tab=info)
加分回答:
- 多参数:
/user/:id/post/:postId - 可选参数(v6 用
?):/user/:id? - 通配符:
/files/*,用params['*']取剩余路径 - 参数变化但路由不变(如
/user/1→/user/2),组件不会重建,要在 useEffect 依赖中加 id
追问:
- 如何让"参数变化"时强制重建组件?
- 路径参数和查询参数怎么选?
Q4:React Router 6 嵌套路由怎么实现?Outlet 是什么?
考察点:嵌套路由、布局复用
标准答案: 嵌套路由用于"共用布局"。父路由渲染布局组件,子路由通过 <Outlet /> 注入到布局内的指定位置。
代码示例:
jsx
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<Home />} /> {/* 默认子路由 */}
<Route path="settings" element={<Settings />} />
<Route path="profile" element={<Profile />} />
</Route>
</Routes>
function DashboardLayout() {
return (
<div>
<Sidebar />
<main><Outlet /></main> {/* 子路由在这里渲染 */}
</div>
);
}URL /dashboard/settings → 渲染 <DashboardLayout><Settings/></DashboardLayout>。
通俗理解: 父路由像"楼栋"(包含侧边栏、顶栏),子路由像"房间"。<Outlet /> 是楼栋里"留给房间的位置"。
加分回答:
<Route index>表示"父路径完全匹配时显示这个"<Outlet context={...} />可向子路由传递数据,子用useOutletContext()- Vue Router 等价物是
<router-view />
追问:
- 如果嵌套 3 层,每层都需要 Outlet 吗?
- index 路由和 path="" 有什么区别?
Q5:什么是路由守卫?React Router 6 怎么实现"未登录跳转登录页"?
考察点:路由守卫、权限控制
标准答案: 路由守卫 = 在路由跳转前/后做权限校验、数据预取等。Vue Router 有 beforeEach,React Router 6 没有全局守卫,需要用"包装组件"实现。
React 实现:
jsx
function RequireAuth({ children }) {
const isLogin = useAuth();
const location = useLocation();
if (!isLogin) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
<Route path="/admin" element={<RequireAuth><Admin /></RequireAuth>} />Vue 实现:
js
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLogin()) return '/login';
});
{ path: '/admin', component: Admin, meta: { requiresAuth: true } }通俗理解: 路由守卫像"前台门卫"——客人进门前查身份,没通行证就请去办理(跳登录页)。
加分回答:
- 登录后要跳回原页面:
navigate(location.state?.from || '/') - 多种权限校验可组合:
<RequireAuth><RequireRole role="admin"><Page /></RequireRole></RequireAuth> - React Router 6.4 的 loader 也可以做权限:返回
redirect('/login')
追问:
- 守卫里的异步校验怎么处理?
- 怎么避免守卫死循环(A→B→A)?
Q6:React Router 的 loader 是什么?跟 useEffect 发请求有何区别?
考察点:数据路由、SSR 兼容
标准答案: loader 是 路由级数据加载函数,在路由匹配后、组件渲染前执行。组件用 useLoaderData() 取数据。
jsx
const router = createBrowserRouter([{
path: '/user/:id',
element: <UserPage />,
loader: async ({ params }) => {
return fetch(`/api/user/${params.id}`).then(r => r.json());
}
}]);
function UserPage() {
const data = useLoaderData(); // 已经有数据,无需 loading 状态
return <div>{data.name}</div>;
}对比 useEffect:
| 维度 | loader | useEffect 发请求 |
|---|---|---|
| 时机 | 渲染前 | 挂载后 |
| 用户体验 | 等数据再渲染(无闪烁) | 先渲染骨架/loading 再切换 |
| 并行加载 | 路由库自动并行 | 自己手动控制 |
| 错误处理 | errorElement 统一处理 | 组件内 try/catch |
| 与 SSR | 兼容 | 需要在服务端再写一遍 |
| 取消/竞态 | 路由库处理 | 自己写 cancelled 标志 |
通俗理解:
- useEffect = "进了餐厅再点菜,菜上之前先看着空盘子"
- loader = "进餐厅前已经预订好了,菜和你一起到桌"
加分回答:
- loader 来自 Remix 的设计哲学,把数据加载从组件解耦
- defer + Await 可让"快数据先返回,慢数据流式"
- 类似的还有 Vue Router 的
beforeEnter里 await + Pinia 预填
追问:
- loader 抛错会怎样?怎么自定义错误页?
- loader 和 React Query 配合怎么用?
Q7:手写一个极简 hash 路由
考察点:路由原理掌握度
标准答案:
js
class Router {
constructor() {
this.routes = {};
window.addEventListener('hashchange', () => this.fire());
window.addEventListener('load', () => this.fire());
}
on(path, handler) {
this.routes[path] = handler;
return this;
}
push(path) {
location.hash = path; // 触发 hashchange
}
fire() {
const path = location.hash.slice(1) || '/';
const handler = this.routes[path] || this.routes['*'];
handler && handler(path);
}
}
const r = new Router();
r.on('/', () => render('首页'))
.on('/about', () => render('关于'))
.on('*', () => render('404'));
function render(text) {
document.getElementById('app').textContent = text;
}通俗理解: 本质就三件事:
- 注册一张表:"这个 URL 显示啥"
- 监听 URL 变化(hashchange / popstate)
- 命中时调用对应的渲染函数
加分回答:
- 升级方向:参数路由(
/user/:id)→ 用正则替换:xxx为([^/]+) - 嵌套路由 → 维护父子关系
- 守卫 → 在 fire 前执行 hook chain
- 历史栈 → 用数组记录访问顺序
追问:
- history 模式 50 行怎么写?要注意什么?
- 怎么实现动态路由匹配?
Q8:history 模式上线后页面刷新 404,原因和解法?
考察点:部署、服务器配置
标准答案: 原因:history 模式的 URL(如 /about)在浏览器看来是真实路径。用户直接访问或刷新时,浏览器向服务器发起请求 GET /about,但服务器上没有这个路径的资源 → 404。
解决方案:服务器把所有未匹配到静态资源的路径都返回 index.html,让前端路由接管。
Nginx:
nginx
location / {
try_files $uri $uri/ /index.html;
}Apache:
apacheconf
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]Express:
js
app.get('*', (req, res) => res.sendFile(path.resolve('dist/index.html')));Vercel / Netlify:自带 SPA 模式,无需配置。
通俗理解: 你给朋友地址 /about(但博物馆只有 /),朋友按门牌找过来发现门牌不存在。解法:让博物馆所有未知地址都引到大厅(index.html),由大厅的指示牌(前端路由)告诉客人去 about 展厅。
加分回答:
- 必须把 fallback 放在静态资源匹配之后,否则
/static/app.js也会被吞掉 - API 路径要排除:
location /api { proxy_pass ... }单独写 - 出现刷新 404 时先排查:是不是 history 模式 + 没配 fallback
追问:
- 为什么 hash 模式不会有这个问题?
- 静态资源(图片)找不到时也要 fallback 吗?
Q9:React Router 6 怎么实现"代码分割 + 按需加载"?
考察点:性能优化
标准答案: 用 React.lazy + <Suspense>:
jsx
import { lazy, Suspense } from 'react';
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<User />} />
</Routes>
</Suspense>构建工具(Vite/Webpack)会把每个 import() 切成独立 chunk,访问到才下载。
v6.4+ 数据路由:
js
{
path: '/about',
lazy: () => import('./pages/About').then(m => ({ Component: m.default }))
}通俗理解: 不分割像"开机就把整个公司的文件夹搬上桌"。分割像"按需打开抽屉",要哪个开哪个。
加分回答:
- 加 prefetch:用户 hover 链接时预加载(如 react-router 的
prefetch、Next.js 自动 prefetch) - 别把"很可能立即用到的页面"懒加载,反而拖慢首屏
- Webpack 魔法注释
/* webpackChunkName: "about" */可控制 chunk 名
追问:
- 懒加载 chunk 加载失败怎么处理?
- 如何 preload 路由的相关资源?
Q10:Vue Router 4 的导航守卫有哪些?执行顺序?
考察点:Vue Router 守卫体系
标准答案:
完整执行顺序:
router.beforeEach(全局前置)- 路由独享的
beforeEnter - 组件内
beforeRouteEnter(注意此时拿不到 this) router.beforeResolve(全局解析守卫)- DOM 更新
- 组件内
beforeRouteEnter的next(vm => {})回调 router.afterEach(全局后置,无返回值)
js
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLogin()) return '/login';
return true;
});
router.afterEach((to, from) => {
document.title = to.meta.title || '默认标题';
});通俗理解: 就像一场出差审批:
- beforeEach = 部门 leader 审批
- beforeEnter = 项目组长审批
- 组件内 = 自己确认
- afterEach = 出差完报销(已经进入新页面,不能取消)
加分回答:
- Composition API:
onBeforeRouteUpdate、onBeforeRouteLeave用于组件内守卫 - 守卫返回值可以是:
false(中止)、字符串/对象(重定向)、true/undefined(放行) - 滚动行为通过
scrollBehavior配置而非守卫
追问:
- 为什么 beforeRouteEnter 不能用 this?怎么用
next拿到组件实例? - 异步守卫里 await 慢请求会卡住路由跳转吗?
Q11:keep-alive 是什么?路由切换怎么保持组件状态?
考察点:组件缓存、路由优化
标准答案:
Vue:内置 <KeepAlive> 组件包裹 router-view,保留被切换走的组件实例:
vue
<router-view v-slot="{ Component }">
<keep-alive include="UserList,Settings">
<component :is="Component" />
</keep-alive>
</router-view>被缓存的组件触发 activated/deactivated 而不是 mount/unmount。
React:没有官方 keep-alive,常用方案:
- 把状态提升到 Redux/Zustand
- 用
react-activation第三方库 - 隐藏而非卸载(display: none)
- React Router 6 + tabs 模式自定义
通俗理解: keep-alive 像"暂时把人锁在屋里不离场"——切到别的页面后,原页面的 input 输入、滚动位置都还在,回来时一切如旧。
代码示例:
vue
<KeepAlive :max="10">
<component :is="Component" />
</KeepAlive>加分回答:
include/exclude用组件 name 控制;max限制缓存数量(LRU)activated/deactivated钩子区别于mounted/unmounted- React 中可以用
useRef保存"上次的滚动位置/输入值",重新挂载后恢复
追问:
- 缓存的组件 watch 还工作吗?
- 怎么强制清除某个 keep-alive 缓存?
Q12:单页应用(SPA)和多页应用(MPA)的区别?什么时候选哪个?
考察点:技术选型、架构理解
标准答案:
| 维度 | SPA | MPA |
|---|---|---|
| 页面切换 | 不刷新,前端路由 | 服务器返回新 HTML |
| 首屏速度 | 慢(要加载完整 JS bundle) | 快 |
| 后续切换 | 快(无白屏) | 慢(每次刷新) |
| SEO | 差(除非 SSR) | 好 |
| 开发体验 | 一致的状态管理、复杂交互友好 | 简单页面间独立 |
| 服务器压力 | 低(只返 JSON) | 高(每次返 HTML) |
| 适合场景 | 后台系统、复杂交互 App | 内容型网站、博客 |
SSR/SSG 弥合差距:Next.js / Nuxt 既有 SPA 的"切换不刷新",又有 MPA 的"首屏 HTML 完整"。
通俗理解:
- SPA = "翻杂志":进门时杂志全本搬来,之后翻很快
- MPA = "看电视换台":每个频道独立加载,但每次都是新画面
- SSR = "餐厅外卖":菜(HTML)已经做好送到,吃的体验和堂食差不多
加分回答:
- SPA 首屏可用 SSR + Hydration 解决
- 巨型应用可用 micro-frontend 拆分
- 静态内容可用 SSG(Static Site Generation)极致快
追问:
- SSR 中前端路由还有用吗?
- 微前端怎么实现路由协调?