用了很多年的 React Router,一直是"会用但不懂原理"的状态。
<Route>、useNavigate、hash和history模式的区别------背过,但说不清底层到底怎么实现的。上周末我花了两个小时,从零手写了一个 50 行的迷你路由,写完之后有一种"原来就这么回事"的顿悟感。这篇文章把完整代码和思考过程分享出来,帮你也达到这个"顿悟时刻"。
在动手写之前,先想清楚 4 个问题
很多人看源码或手写的方式是:上来就写代码。这样写完也记不住。
正确的做法是先想清楚设计,再写代码。我给自己提了 4 个问题:
- 前端路由的本质是什么? --- 监听 URL 变化 → 匹配对应处理函数 → 渲染内容。本质就是一个事件驱动的映射表。
- hash 和 history 的区别到底在哪? --- hash 用
hashchange事件,history 用popstate事件,但 API 应该统一,调用方不关心底层。 - 哪些场景容易遗漏? --- 直接访问 URL(首次加载)、浏览器前进/后退按钮、动态路由参数(
/user/:id)。 - history 模式刷新 404 是前端还是后端的问题? --- 后端的。前端路由只管 JS 运行时的 URL 变化,刷新是浏览器发了一个真实请求,需要服务端 fallback。
想清楚这 4 个问题,代码基本就能写出来了。
50 行代码实现
先看完整代码,然后逐块讲解。
javascript
class Router {
constructor(mode = 'hash') {
this.mode = mode;
this.routes = new Map();
this.currentPath = '';
this._bindEvents();
}
_bindEvents() {
if (this.mode === 'hash') {
window.addEventListener('hashchange', () => {
this._handleRoute(location.hash.slice(1) || '/');
});
} else {
window.addEventListener('popstate', () => {
this._handleRoute(location.pathname);
});
}
}
route(path, handler) {
this.routes.set(path, handler);
return this;
}
_handleRoute(path) {
this.currentPath = path;
const handler = this._matchRoute(path);
if (handler) handler(path);
}
_matchRoute(path) {
if (this.routes.has(path)) return this.routes.get(path);
for (const [pattern, handler] of this.routes) {
const regex = new RegExp('^' + pattern.replace(/:(\w+)/g, '([^/]+)') + '$');
if (regex.test(path)) return handler;
}
return this.routes.get('*');
}
push(path) {
if (this.mode === 'hash') {
location.hash = path;
} else {
history.pushState(null, '', path);
this._handleRoute(path);
}
}
start() {
const path = this.mode === 'hash'
? (location.hash.slice(1) || '/')
: location.pathname;
this._handleRoute(path);
}
}
正好 50 行(去掉空行)。下面逐块拆解。
第一块:构造函数和事件绑定
javascript
constructor(mode = 'hash') {
this.mode = mode;
this.routes = new Map();
this.currentPath = '';
this._bindEvents();
}
用 Map 存路由映射表,而不是普通对象。面试加分点:Map 的 key 可以保持插入顺序,遍历时有序,普通对象不保证。
javascript
_bindEvents() {
if (this.mode === 'hash') {
window.addEventListener('hashchange', () => {
this._handleRoute(location.hash.slice(1) || '/');
});
} else {
window.addEventListener('popstate', () => {
this._handleRoute(location.pathname);
});
}
}
两种模式的核心区别就在这里:
| 模式 | 监听事件 | URL 格式 | 需要服务端配合 |
|---|---|---|---|
| hash | hashchange |
example.com/#/about |
不需要 |
| history | popstate |
example.com/about |
需要 |
面试高频追问:"popstate 什么时候触发?"
答:浏览器前进/后退按钮时触发。pushState 和 replaceState 不会触发 popstate ,所以在 push 方法里需要手动调用 _handleRoute。
第二块:路由匹配
javascript
_matchRoute(path) {
// 精确匹配
if (this.routes.has(path)) return this.routes.get(path);
// 动态路由匹配(:id 这种参数)
for (const [pattern, handler] of this.routes) {
const regex = new RegExp('^' + pattern.replace(/:(\w+)/g, '([^/]+)') + '$');
if (regex.test(path)) return handler;
}
// 兜底:404
return this.routes.get('*');
}
这 10 行代码做了三件事:
- 精确匹配 :
/about精确命中/about - 动态参数 :
/user/:id匹配/user/123 - 404 兜底 :没命中的路径走
*通配符
面试加分点:这里用正则 /:(\w+)/g 把 :id 转成 ([^/]+)。面试官可能会问你"这个正则的性能怎么样"------答:路由数量通常在 20-50 条,正则匹配的性能开销可以忽略。如果路由上千条(不太现实),可以用 Trie 树优化。
第三块:导航和启动
javascript
push(path) {
if (this.mode === 'hash') {
location.hash = path; // 自动触发 hashchange
} else {
history.pushState(null, '', path);
this._handleRoute(path); // pushState 不触发 popstate,手动调用
}
}
start() {
const path = this.mode === 'hash'
? (location.hash.slice(1) || '/')
: location.pathname;
this._handleRoute(path);
}
push 是编程式导航,对应 Vue Router 的 router.push() 和 React Router 的 navigate()。
start 处理初始加载------用户直接通过 URL 访问页面时,需要立即匹配当前路由。
使用方式
javascript
const router = new Router('history');
router
.route('/', () => {
document.getElementById('app').innerHTML = '<h1>首页</h1>';
})
.route('/about', () => {
document.getElementById('app').innerHTML = '<h1>关于我们</h1>';
})
.route('/user/:id', (path) => {
const id = path.split('/').pop();
document.getElementById('app').innerHTML = `<h1>用户 ${id}</h1>`;
})
.route('*', () => {
document.getElementById('app').innerHTML = '<h1>404 页面不存在</h1>';
});
router.start();
// 编程式导航
document.getElementById('btn').addEventListener('click', () => {
router.push('/about');
});
链式调用,API 干净,和 Vue Router / React Router 的使用体验基本一致。
面试中会被追问的 6 个问题
理解了原理之后,这些常见面试题就不用背了------你能从原理推导出答案。
Q1:"hash 模式和 history 模式怎么选?"
bash
hash 模式:
✅ 不需要服务端配置
✅ 兼容性好(IE9+)
❌ URL 里有 #,不美观
❌ 对 SEO 不友好
history 模式:
✅ URL 干净
✅ SEO 友好
❌ 刷新页面会 404(需要服务端配置 fallback)
❌ 需要 IE10+
一句话答: 后台管理系统用 hash,面向用户的产品用 history。
Q2:"history 模式刷新 404 怎么解决?"
Nginx 加一行配置:
nginx
location / {
try_files $uri $uri/ /index.html;
}
所有未匹配的路径都 fallback 到 index.html,由前端路由接管。
Q3:"如果路由很多,匹配性能会不会有问题?"
实际项目中路由通常不超过 50 条,线性遍历没有性能问题。如果极端场景需要优化,可以用 Trie 树(前缀树)做路由匹配,时间复杂度从 O(n) 降到 O(m),m 是路径深度。
Q4:"你这个路由怎么支持路由守卫?"
加一个 beforeEach 钩子:
javascript
beforeEach(guard) {
this._guard = guard;
return this;
}
_handleRoute(path) {
if (this._guard && !this._guard(this.currentPath, path)) return;
this.currentPath = path;
const handler = this._matchRoute(path);
if (handler) handler(path);
}
用法:
javascript
router.beforeEach((from, to) => {
if (to === '/admin' && !isLoggedIn()) {
router.push('/login');
return false;
}
return true;
});
Q5:"怎么提取动态路由的参数?"
当前实现里参数要手动从 path 里解析。优化版:
javascript
_matchRoute(path) {
for (const [pattern, handler] of this.routes) {
const keys = [];
const regex = new RegExp(
'^' + pattern.replace(/:(\w+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
}) + '$'
);
const match = path.match(regex);
if (match) {
const params = Object.fromEntries(keys.map((k, i) => [k, match[i + 1]]));
return () => handler(params);
}
}
return this.routes.get('*');
}
这样 handler 收到的就是 { id: '123' } 这种结构化参数了。
Q6:"Vue Router 和 React Router 的实现原理和你这个一样吗?"
核心原理一样,都是基于 hashchange / popstate 监听 URL 变化。但它们多了很多工程化的东西:
- 嵌套路由:支持路由树结构
- 懒加载 :
React.lazy+Suspense/ Vue 的() => import() - 过渡动画:路由切换时的动画钩子
- 滚动恢复:前进后退时恢复滚动位置
但底层原理,就是你面前这 50 行代码。
为什么要手写,AI 不是一秒就能生成吗
确实,Claude Code 一句话就能给你一个完整的路由实现。但有两个东西它给不了你:
-
理解力 :你让 AI 生成的路由代码出了 bug,你能 debug 吗?如果你不理解
popstate不响应pushState,你可能查半天都找不到问题。 -
设计判断力:新项目要选 hash 还是 history?什么时候该用嵌套路由?路由守卫放在哪层合适?这些决策需要你理解底层原理才能做对。
用 AI 写代码,用手写学原理。两者不矛盾。
写在最后
React Router 的源码有几千行,但核心原理就是这 50 行。那些多出来的代码是在处理嵌套路由、懒加载、滚动恢复、SSR 等工程化问题。
搞懂了核心,再去看源码就不会一脸懵了。
如果你也有类似"会用但不懂原理"的框架 API,试试花两个小时手写一个最小版本。写完之后的顿悟感,比看十篇文章都管用。
你还有哪些"用了很久但说不清原理"的前端知识?评论区说说,下次可以继续写。