我用 50 行代码重写了 React Router 核心,终于搞懂了前端路由原理

用了很多年的 React Router,一直是"会用但不懂原理"的状态。<Route>useNavigatehashhistory 模式的区别------背过,但说不清底层到底怎么实现的。上周末我花了两个小时,从零手写了一个 50 行的迷你路由,写完之后有一种"原来就这么回事"的顿悟感。这篇文章把完整代码和思考过程分享出来,帮你也达到这个"顿悟时刻"。

在动手写之前,先想清楚 4 个问题

很多人看源码或手写的方式是:上来就写代码。这样写完也记不住。

正确的做法是先想清楚设计,再写代码。我给自己提了 4 个问题:

  1. 前端路由的本质是什么? --- 监听 URL 变化 → 匹配对应处理函数 → 渲染内容。本质就是一个事件驱动的映射表。
  2. hash 和 history 的区别到底在哪? --- hash 用 hashchange 事件,history 用 popstate 事件,但 API 应该统一,调用方不关心底层。
  3. 哪些场景容易遗漏? --- 直接访问 URL(首次加载)、浏览器前进/后退按钮、动态路由参数(/user/:id)。
  4. 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 什么时候触发?"

答:浏览器前进/后退按钮时触发。pushStatereplaceState 不会触发 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 行代码做了三件事:

  1. 精确匹配/about 精确命中 /about
  2. 动态参数/user/:id 匹配 /user/123
  3. 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 一句话就能给你一个完整的路由实现。但有两个东西它给不了你:

  1. 理解力 :你让 AI 生成的路由代码出了 bug,你能 debug 吗?如果你不理解 popstate 不响应 pushState,你可能查半天都找不到问题。

  2. 设计判断力:新项目要选 hash 还是 history?什么时候该用嵌套路由?路由守卫放在哪层合适?这些决策需要你理解底层原理才能做对。

用 AI 写代码,用手写学原理。两者不矛盾。


写在最后

React Router 的源码有几千行,但核心原理就是这 50 行。那些多出来的代码是在处理嵌套路由、懒加载、滚动恢复、SSR 等工程化问题。

搞懂了核心,再去看源码就不会一脸懵了。

如果你也有类似"会用但不懂原理"的框架 API,试试花两个小时手写一个最小版本。写完之后的顿悟感,比看十篇文章都管用。

你还有哪些"用了很久但说不清原理"的前端知识?评论区说说,下次可以继续写。

相关推荐
WebInfra2 小时前
Rspack 2.1 发布:React Compiler 提速 10 倍!
前端
李明卫杭州3 小时前
CSS 媒体查询详解:一文掌握响应式设计的核心技术
前端
lichenyang4533 小时前
从 H5 按钮到 OpenHarmony 能力调用:我如何理解 ASCF 的运行链路
前端
下家4 小时前
我放弃了 Vue/React,选择自研框架
前端·前端框架
Asize4 小时前
HTML5 Canvas 基础:从按帧动画到 ECharts 数据可视化
前端·javascript·canvas
默_笙4 小时前
🎄 后端给我一堆扁平数据,我 10 行代码把它变成了树
前端·javascript
Mahut4 小时前
我用 Electron + FFmpeg 做了一个本地视频处理工作站 ClipForge
前端·ffmpeg·electron
前端Hardy4 小时前
又一个 AI 神器火了!
前端·javascript·后端
锋行天下4 小时前
我试图优化 Vite 的拆包,结果首屏慢了 10 倍
前端·vue.js·架构