前端路由:这次怎么落地的
去年年底做后台管理系统重构,遇到一个让我整了两天的问题:列表页翻到第三页,点击详情页,再返回列表页时,页码重置回了第一页。
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.pushState 或 history.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 的 useRoute 和 useRouter 用起来很顺手:
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 自然就跟上了。
但状态路由也不是万能药。它多了一层抽象,多了一次序列化/反序列化,多了一处可能出错的地方。如果你的应用很简单,可能根本不需要它。
技术的演进大概就是这样:解决一个旧问题,制造一些新问题,再想办法解决新问题。
我现在做新项目时,会先问自己几个问题:
- 这个应用有多复杂?列表页多不多?过滤条件多不多?
- 用户会不会频繁前进后退?
- 需不需要深层嵌套的路由?
- 能不能接受 URL 稍微长一点?
如果答案都是"不多"、“不会”、“不需要”、“能”,那就用最简单的方案,别折腾。
但如果答案里有几个"是",那就认真考虑一下状态路由,或者用现成的状态路由方案(如 react-router-location-state、vue-router-sync-state 之类的)。
毕竟,路由管理的目标不是用上最酷的技术,而是让用户操作起来不别扭。
改完那个列表页翻页丢失的问题后,测试同学用了两天,给我发了个消息:“那个翻页保存的问题好了,现在列表页怎么刷新都不丢页码了。”
我回了个表情包。这就是做路由管理最真实的反馈:用户不骂了,问题解决了。
版权声明: 本文首发于 指尖魔法屋-前端路由:这次怎么落地的(https://blog.thinkmoon.cn/post/104-frontend-routing-history-state-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。