Skip to content

第 15 章 · 路由 面试题集

Q1:SPA 路由的两种实现模式(hash vs history)有什么区别?

考察点:路由原理、浏览器 API

标准答案

维度hash 模式history 模式
URL 形态/#/about/about
监听事件hashchangepopstate(仅前进/后退)
切换 APIlocation.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 有哪些重要变化?

考察点:版本演进

标准答案

  1. Switch → Routes:自动按"最特异匹配"选择,无需 exact
  2. component / render prop → element prop:直接传 JSX
  3. 嵌套路由用 <Outlet />:子路由的渲染口
  4. useHistory → useNavigate:API 更简洁
  5. Hooks 全面取代 HOCuseNavigateuseParamsuseLocationuseSearchParams
  6. 支持相对路径:嵌套路由的 path 默认相对父级
  7. 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 有 beforeEachReact 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

维度loaderuseEffect 发请求
时机渲染前挂载后
用户体验等数据再渲染(无闪烁)先渲染骨架/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;
}

通俗理解: 本质就三件事:

  1. 注册一张表:"这个 URL 显示啥"
  2. 监听 URL 变化(hashchange / popstate)
  3. 命中时调用对应的渲染函数

加分回答

  • 升级方向:参数路由(/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 守卫体系

标准答案

完整执行顺序

  1. router.beforeEach(全局前置)
  2. 路由独享的 beforeEnter
  3. 组件内 beforeRouteEnter(注意此时拿不到 this
  4. router.beforeResolve(全局解析守卫)
  5. DOM 更新
  6. 组件内 beforeRouteEnternext(vm => {}) 回调
  7. 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:onBeforeRouteUpdateonBeforeRouteLeave 用于组件内守卫
  • 守卫返回值可以是: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)的区别?什么时候选哪个?

考察点:技术选型、架构理解

标准答案

维度SPAMPA
页面切换不刷新,前端路由服务器返回新 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 中前端路由还有用吗?
  • 微前端怎么实现路由协调?