前端路由:这次怎么落地的

去年年底做后台管理系统重构,遇到一个让我整了两天的问题:列表页翻到第三页,点击详情页,再返回列表页时,页码重置回了第一页。

URL 只是入口,状态才是真相。

那些年用过的路由方式

最早做前端时,我用的是 hash 路由。

// hash 路由的样子
window.location.hash = '#/user/detail/123'
// 实际 URL: http://example.com/#/user/detail/123

这玩意儿简单粗暴,兼容性极好,连 IE 都能用。但有个问题:路由变化会在 URL 里带个 #,看着有点丑。更严重的是,服务端路由会变成这样:

// 你得配置 nginx 把所有请求都指向 index.html
location / {
    try_files $uri $uri/ /index.html;
}

但这还只是表面问题。真正让我头疼的是状态同步。

从 hash 到 history

后来项目升级,换成了 history 路由。

// history 路由
history.pushState({ page: 3 }, '', '/users?page=3')
// URL: http://example.com/users?page=3

URL 看起来正常了,但坑也多了。

第一个坑是刷新就 404。解决方法跟 hash 一样,还是得配置服务端把所有请求指向 index.html。这还好说,改个 nginx 配置就行。

第二个坑更恶心:用 history.pushState 改 URL 时,浏览器不会触发路由变化事件。你改了 URL,但路由系统不知道,导致组件不刷新。

// React Router v6 里踩过的坑
import { useNavigate } from 'react-router-dom';

const navigate = useNavigate();

// ❌ 这样改 URL,但路由系统不会响应
window.history.pushState({ page: 3 }, '', '/users?page=3');

// ✅ 应该用 navigate
navigate('/users?page=3');

第三个坑是浏览器前进后退按钮。用户点了后退,URL 变了,但页面状态没回来。你得手动监听 popstate 事件:

window.addEventListener('popstate', (event) => {
  // event.state 里可能有你之前 pushState 时存的数据
  if (event.state?.page) {
    setCurrentPage(event.state.page);
  }
});

但这个事件触发时机很诡异——它只在浏览器前进后退时触发,你手动调用 history.pushStatehistory.replaceState 时不会触发。

状态路由是干嘛的

折腾了一段时间后,我发现一个事实:路由和状态其实是一回事。

路由本身就是在表达应用状态。用户在哪个页面、当前第几页、选了哪些过滤条件、某个详情页打开了哪条记录,这些都是状态。

传统路由系统的问题是:它只管理 URL,不管状态。你需要手动把 URL 解析成状态,再手动把状态同步到 URL。做两次同步,就多两倍出错的概率。

状态路由的思想是把状态当成唯一真理来源(source of truth)。URL 只是状态的一种表现形式。

// 不再是 URL → 状态
// 而是 状态 → URL

// 当状态变化时
const state = {
  route: 'users',
  params: { page: 3, filter: 'active' }
};

// 自动同步到 URL
history.pushState(null, '', '/users?page=3&filter=active');

在 React 项目里的实践

去年用 React Router v6 做后台系统时,我试了几种方案。

最开始是直接用 useSearchParams

import { useSearchParams } from 'react-router-dom';

function UserList() {
  const [searchParams, setSearchParams] = useSearchParams();

  const page = parseInt(searchParams.get('page') || '1');
  const filter = searchParams.get('filter') || 'all';

  const handlePageChange = (newPage) => {
    setSearchParams({ page: newPage, filter });
  };

  // ...
}

这方案能用,但有个问题:每次 setSearchParams 调用,组件都会重新渲染。如果你有一个复杂的列表组件,里面有分页、过滤、排序、批量操作,每次改 URL 都会导致整个组件重渲染。

后来我换成了状态路由:

import { useLocation } from 'react-router-dom';
import { useSyncExternalStore } from 'react';

// 创建一个简单的状态管理器
function createRouteState(initialState) {
  let state = initialState;
  const listeners = new Set();

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const getState = () => state;

  const setState = (newState) => {
    state = { ...state, ...newState };

    // 同步到 URL
    const params = new URLSearchParams();
    if (state.page !== 1) params.set('page', state.page);
    if (state.filter !== 'all') params.set('filter', state.filter);

    const url = state.page === 1 && state.filter === 'all'
      ? '/users'
      : `/users?${params.toString()}`;

    history.pushState(state, '', url);

    listeners.forEach(listener => listener());
  };

  return { subscribe, getState, setState };
}

const routeState = createRouteState({
  page: 1,
  filter: 'all'
});

// 监听浏览器前进后退
window.addEventListener('popstate', (event) => {
  if (event.state) {
    routeState.setState(event.state);
  }
});

function UserList() {
  const { getState, setState } = routeState;
  const state = useSyncExternalStore(routeState.subscribe, getState);

  // state.page, state.filter 现在是稳定的
  // setState 时只会触发订阅了它的组件更新

  const handlePageChange = (newPage) => {
    setState({ page: newPage });
  };

  // ...
}

这样改完后,列表组件不会因为 URL 变化而全量重渲染,只有真正需要响应状态变化的组件才会更新。

在 Vue 项目里的踩坑

今年用 Vue 3 做一个新项目时,我尝试用类似的方式。

Vue Router v4 的 useRouteuseRouter 用起来很顺手:

import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();

const page = computed(() => parseInt(route.query.page || '1'));
const filter = computed(() => route.query.filter || 'all');

const handlePageChange = (newPage) => {
  router.push({
    query: {
      ...route.query,
      page: newPage
    }
  });
};

但 Vue 的响应式系统跟路由的交互有些微妙的地方。

第一个坑是 route.query 的引用变化。当你调用 router.push 改变 query 参数时,route.query 对象本身会变,但如果你在 setup 里解构了它:

// ❌ 这样写会有问题
const { page, filter } = toRefs(route.query);

// route.query 变化后,page 和 filter 不会自动更新

正确的写法是用 computed

// ✅ 正确写法
const page = computed(() => parseInt(route.query.page || '1'));
const filter = computed(() => route.query.filter || 'all');

第二个坑是导航守卫。如果你在路由守卫里修改 query 参数,会触发导航守卫递归调用:

router.beforeEach((to, from, next) => {
  // ❌ 在守卫里直接修改路由会导致无限循环
  if (!to.query.page) {
    next({ ...to, query: { ...to.query, page: 1 } });
  } else {
    next();
  }
});

// ✅ 正确做法是检查来源
router.beforeEach((to, from, next) => {
  if (!to.query.page && from.path !== to.path) {
    next({ ...to, query: { ...to.query, page: 1 } });
  } else {
    next();
  }
});

状态管理跟路由的集成

状态管理工具(如 Redux、Pinia)跟路由系统的集成也是个常见需求。

我在 Redux 项目里用过一种简单的方案:把路由状态也放进 Redux store 里。

// Redux reducer
const initialState = {
  page: 1,
  filter: 'all'
};

function routeReducer(state = initialState, action) {
  switch (action.type) {
    case 'ROUTE_CHANGE':
      return action.payload;
    default:
      return state;
  }
}

// 监听路由变化,同步到 Redux
history.listen(({ location, action }) => {
  const params = new URLSearchParams(location.search);
  const page = parseInt(params.get('page') || '1');
  const filter = params.get('filter') || 'all';

  store.dispatch({
    type: 'ROUTE_CHANGE',
    payload: { page, filter }
  });
});

但这样有两个问题:一是 Redux store 会多出路由状态这个分支,二是每次路由变化都会触发 store 更新,可能影响性能。

后来我换了个思路:路由状态由路由系统管,Redux 只管业务状态。需要同步时,在组件层做:

function UserList() {
  const dispatch = useDispatch();
  const { page, filter } = useRouteParams();
  const users = useSelector(state => selectUsers(state, page, filter));

  useEffect(() => {
    dispatch(fetchUsers({ page, filter }));
  }, [dispatch, page, filter]);

  // ...
}

这样拆分后,每个系统的职责更清晰:路由系统管 URL 和导航,Redux 管业务数据。

一些实际踩过的坑

1. 浏览器前进后退后状态不一致

// 用户从列表第3页点击详情
history.pushState({ userId: 123 }, '', '/users/123');

// 用户点浏览器后退,返回列表页
// URL 变回 /users?page=3
// 但组件没收到通知,还停留在详情页状态

解决方法是在 popstate 事件里处理:

window.addEventListener('popstate', (event) => {
  // 根据 URL 解析当前应该显示什么
  const path = window.location.pathname;
  const params = new URLSearchParams(window.location.search);

  if (path === '/users') {
    // 切换到列表页
    setRoute('users', {
      page: parseInt(params.get('page') || '1'),
      filter: params.get('filter') || 'all'
    });
  } else if (path.startsWith('/users/')) {
    // 切换到详情页
    const userId = parseInt(path.split('/')[2]);
    setRoute('user-detail', { userId });
  }
});

2. 深层嵌套路由的状态丢失

// 用户在 /users?page=3 时点击用户 123 的详情
history.pushState({ userId: 123 }, '', '/users/123');

// 用户在详情页点"返回列表"
// 期望回到 /users?page=3
// 实际回到了 /users?page=1

问题在于详情页没记录列表页的状态。解决方法是在进入详情页时保存来源:

const navigateToDetail = (userId) => {
  // 保存当前列表页状态
  history.replaceState({
    from: 'list',
    listState: { page: currentPage, filter: currentFilter }
  }, '', window.location.href);

  // 再跳到详情页
  history.pushState({ userId }, '', `/users/${userId}`);
};

// 返回列表页时
const goBackToList = () => {
  const historyState = window.history.state;
  if (historyState?.listState) {
    // 恢复之前的列表页状态
    const { page, filter } = historyState.listState;
    history.pushState(null, '', `/users?page=${page}&filter=${filter}`);
  } else {
    // 没有保存状态,回到第一页
    history.pushState(null, '', '/users');
  }
};

3. URL 参数太多导致浏览器地址栏爆炸

// 用户选择了复杂的过滤条件
history.pushState(null, '', '/users?page=3&filter=active&status=verified&sort=name&order=asc&keyword=search&dateFrom=2024-01-01&dateTo=2024-12-31');

URL 太长有两个问题:一是难看,二是某些浏览器和服务器对 URL 长度有限制。

解决方法是把复杂状态存到 sessionStorage 或 localStorage,URL 里只放一个 key:

const saveComplexState = (state) => {
  const key = 'filter-' + Date.now();
  sessionStorage.setItem(key, JSON.stringify(state));
  history.pushState({ filterKey: key }, '', `/users?filter=${key}`);
};

const loadComplexState = (filterKey) => {
  const stateStr = sessionStorage.getItem(filterKey);
  if (stateStr) {
    return JSON.parse(stateStr);
  }
  return null;
};

最后一些想法

路由系统做了这么多年,从 hash 到 history,从 URL 路由到状态路由,本质上都是在解决同一个问题:如何让应用状态跟 URL 同步。

最原始的方案是手动同步:解析 URL → 更新状态,状态变化 → 更新 URL。这方案能用,但容易出bug。

现代框架的路由系统做了更多封装,把同步逻辑内置了。但封装也带来了新的问题:你不知道框架内部在做什么,一出bug就不知道从哪查。

状态路由是种折中:它承认状态才是核心,URL 只是表象。你管理好状态,URL 自然就跟上了。

但状态路由也不是万能药。它多了一层抽象,多了一次序列化/反序列化,多了一处可能出错的地方。如果你的应用很简单,可能根本不需要它。

技术的演进大概就是这样:解决一个旧问题,制造一些新问题,再想办法解决新问题。

我现在做新项目时,会先问自己几个问题:

  1. 这个应用有多复杂?列表页多不多?过滤条件多不多?
  2. 用户会不会频繁前进后退?
  3. 需不需要深层嵌套的路由?
  4. 能不能接受 URL 稍微长一点?

如果答案都是"不多"、“不会”、“不需要”、“能”,那就用最简单的方案,别折腾。

但如果答案里有几个"是",那就认真考虑一下状态路由,或者用现成的状态路由方案(如 react-router-location-state、vue-router-sync-state 之类的)。

毕竟,路由管理的目标不是用上最酷的技术,而是让用户操作起来不别扭。


改完那个列表页翻页丢失的问题后,测试同学用了两天,给我发了个消息:“那个翻页保存的问题好了,现在列表页怎么刷新都不丢页码了。”

我回了个表情包。这就是做路由管理最真实的反馈:用户不骂了,问题解决了。

版权声明: 本文首发于 指尖魔法屋-前端路由:这次怎么落地的https://blog.thinkmoon.cn/post/104-frontend-routing-history-state-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!