手写前端路由:从hash到history、路由守卫与部署避坑指南

发布时间:2026/10/2 22:20:14
手写前端路由:从hash到history、路由守卫与部署避坑指南 从多页面切到单页应用SPA之后“前端路由”就成了躲不开的一个坎。早期我写静态页面菜单跳转靠 window.location.href每个页面一个 HTML 文件结果被整页刷新和状态丢失折腾得不轻。后来换框架路由调起来非常方便但遇到刷新 404、动态参数解析、守卫里判断登录态这类问题时还是得打开源码翻半天。于是我自己从头撸了一套路由把 hash 和 history 两种模式都实现了一遍这篇文章就是完整记录。这篇内容适合三类人刚接触 SPA、想知道“前端路由到底是什么”的初学者会用 React Router / Vue Router 但没深入理解原理、遇到历史模式部署问题容易懵的开发者以及想在轻量项目里自研路由、不引入大依赖的团队或个人。读完你可以得到一套可直接运行的迷你路由实现以及我在 switch 到 history 模式时踩过的服务器配置相关的坑。1. 为什么静态页面撑不住之后我决定自己写一个前端路由1.1 静态多页面的三个真实痛点我最早给公司做官网结构很简单首页、产品页、关于页、联系页四个 HTML 文件。顶部导航共享一套样式点击每个链接就整页跳转一开始觉得没什么问题。直到项目里加入了“产品列表页 详情页 筛选条件”问题就开始冒头了。第一个痛点是整页刷新带来的白屏闪烁。用户点一下菜单页面整个重新加载如果网络慢一点中间那段时间就是白屏。移动端上这个观感特别差和原生 App 的页面切换完全没法比。第二个痛点是状态丢失。我在列表页维护了一个搜索关键词、筛选条件、当前页码用户点进详情页再返回这些状态全没了——因为 location.href 跳转就是销毁整个文档再重新加载一个新文档。想让浏览器返回时保持状态只能靠 localStorage 或者把参数全部拼在 URL query 上非常别扭而且 query 一长非常丑。第三个痛点是脚本重复加载。每个页面都要重新执行一遍第三方统计脚本、公共初始化逻辑、权限判断逻辑。如果项目里有一个全局登录态的“用户信息”对象每个页面都要重新请求接口拿一遍代码里到处是重复的初始化逻辑。这些问题的根子都在于“每次路由变化就重新请求一个 HTML 文档”这个模型。前端路由要解决的就是让 URL 变化不再触发文档重新加载只切换页面上某个区域的内容。1.2 SPA 和前端路由的边界SPASingle Page Application的核心是整个应用只有一个 HTML 页面应用启动时一次性加载好所需的 JS/CSS 资源后续路由变化时由 JavaScript 根据 URL 变化动态切换视图组件不再向服务器请求新页面。所以在前端路由里“路由跳转”这个说法其实不准确。更准确的说法是“URL 状态切换 视图渲染”。地址栏变了但浏览器没有发起页面请求只是程序监听到了变化然后决定渲染哪个组件。这里还要澄清一个常见的混淆很多人把“后端路由”和“前端路由”混在一起。后端路由是服务器根据路径返回不同的 HTML 页面前端路由是浏览器端根据路径决定渲染什么视图。两者可以共存——比如 SPA 里也有弹窗路由、Tab 路由、页面路由它们都是纯前端状态切换但即使做了前端路由发布后的静态资源本身还是需要服务器来服务。1.3 我动手之前想清楚的三件事动手写路由之前我列了一个清单路由表怎么组织视图怎么渲染浏览器前进后退怎么处理后来发现这三个问题对应的就是三条主线。第一路由表。任何路由系统都需要一个“路径 → 渲染逻辑”的映射表。最简单的形式是一个数组每项包含 path、component、meta。第二视图挂载。切换路由时要把某个容器元素里的旧内容清掉然后把新路由对应的组件渲染进去。可以用 innerHTML也可以用 createElement 挂载本质一样。第三浏览器的前进后退。这是最容易忽略的部分。用户点浏览器返回按钮时URL 变了但我们的页面不能重新加载所以必须监听 URL 变化事件重新执行视图渲染逻辑。把这三件事串起来就是我手搓路由的第一版雏形。2. hash 与 history手搓之前先把两个路线的账算清楚前端路由有两大流派hash 路由和 history 路由。这两者的区别不仅仅是 URL 里有没有#更关键的是浏览器对 URL 变化的行为不同以及部署时的服务器配置要求完全不同。2.1 hash 路由利用浏览器最稳的“锚点”机制hash 路由利用的是 URL 中#之后的部分。https://example.com/index.html#/home中的#/home就是 hash。它的特殊之处在于改变 hash 不会让浏览器重新向服务器发起请求但会触发hashchange事件。我第一次实现时只用了这么几行代码window.addEventListener(hashchange, function () { const path window.location.hash.slice(1) || /; console.log(当前路由, path); renderView(path); });这就能工作了因为浏览器把 hash 变化当作“同页内锚点跳转”天然不会刷新页面。这也是为什么很多老的 MVVM 框架比如早期 Backbone都默认采用 hash 路由。hash 路由最大的优点是部署成本极低。静态文件往 Nginx 或任意静态服务器一丢不管用户访问/#/home还是/#/about服务器始终只返回index.html路由逻辑全在浏览器端处理。这对内网工具、纯前端 Demo、没有服务器配置权限的场景非常友好。它的缺点是明显的URL 里带#不够美观且搜索引擎对带 hash 的 URL 的收录和分享都不友好。另外 hash 本身被设计用来做页面内锚点定位比如#section-2跳到页面的某个区块如果在同一个页面既有路由 hash 又有锚点定位就会冲突这一点我在后面专门踩过坑。2.2 history 路由更清爽的 URL 和必须接受的服务器约束history 路由基于 HTML5 提供的 History API核心是pushState和replaceState两个方法。它们可以改变地址栏 URL并且不会触发页面刷新也不会主动发起页面请求。注意这里的用词“不会主动发起页面请求”和“不会触发页面刷新”是两回事。用户点击浏览器返回按钮时触发的popstate事件会改变 URL但同样不通过服务器拉取新文档浏览器只是把 URL 从历史记录里恢复。但此时前端代码不用重新执行所以必须监听popstate事件来重新渲染当前路由对应的视图。history 路由的 URL 长这样https://example.com/home干净、语义化利于分享和 SEO前提是服务端能正确返回页面。但它有一个核心代价服务器必须对任意未匹配到静态文件的路径都返回同一个入口 HTML否则刷新就会 404。为什么因为用户访问/home时浏览器会请求https://example.com/home而服务器上根本没有名为home的文件。如果不做 fallback默认服务器就会返回 404。而 SPA 期望的是服务器把index.html返回给浏览器由浏览器端路由接管渲染。这里我来梳理一个容易混淆的顺序问题首次直接访问/home时其实是服务器返回了index.html然后路由器浏览器读取当前位置/home来渲染对应视图刷新也是同样过程。所以 history 路由的成败一多半取决于服务器配置是否正确。2.3 真实项目里我怎么选我自己的经验是一个简单标准是否需要对外分享美观的 URL、能否控制服务器配置。我把两种模式做了个对照维度hash 路由history 路由URL 美观度带#不够优雅干净语义化部署难度任意静态服务器即可需要配置 history fallback浏览器兼容性几乎全兼容需支持 History APIIE10SEO 友好度较差相对较好仍需服务端配合刷新页面行为始终请求入口页可能 404需配置开发调试无特殊要求本地也需要 dev server fallback如果只是做一个内部工具、学习 Demo、或者原型直接用 hash 就够了。但如果做的是对外产品、需要分享出去、URL 要好看那就应该考虑 history并提前想好服务器配置方案。我在自己的项目里做了一件事把路由模式做成一个可配置项核心代码里封装了相同接口内部根据模式分别调用 hash 或 history 的逻辑。这样既不用在两种模式之间绑死又能在切换模式时验证两套实现的正确性。3. 第一版hash 路由的实现与封装思路理论讲完了现在进入实际的代码。我先从 hash 版写起同时把路由表、参数解析、默认路由、404 兜底这些基础能力一块做进去。这一版虽然简单但已经能支撑一个正常的纯前端项目。3.1 先理解 hashchange 与视图渲染的最小闭环最简单的闭环需要三件事定义路由表、监听 hash 变化、根据路由渲染视图。我定义的路由表是这个样子const routes [ { path: /, component: () h1首页/h1p欢迎访问/p }, { path: /about, component: () h1关于/h1p这个项目用自研路由/p }, { path: /products, component: () h1产品列表/h1 }, ];然后监听 hash 变化window.addEventListener(hashchange, renderRoute); renderRoute(); // 首次加载也要渲染一次renderRoute做的事很简单读location.hash去掉#在路由表里找到匹配的项调用它的 component 渲染函数把结果塞进挂载点。function renderRoute() { const path window.location.hash.slice(1) || /; const route routes.find(item item.path path); const app document.getElementById(app); if (route) { app.innerHTML route.component(); } else { app.innerHTML h1404/h1p页面不存在/p; } }这套代码已经可以跑起来但还很粗糙不支持动态参数比如/products/10086没有做导航封装页面内跳转还得靠a href#/about这种裸写法。接下来我逐步补上。3.2 路由匹配支持动态路径参数真实项目里纯静态路径匹配几乎不够用。商品详情页的路径应该是/products/10086而不是穷举所有商品 id。所以路由表需要支持动态参数匹配。我把匹配逻辑升级为先用一个解析函数把路由表中的动态路径转成正则再拿当前路径去匹配。function pathToRegexp(path) { const keys []; const source path.replace(/:([^/])/g, (_, key) { keys.push(key); return ([^/]); }); return { regex: new RegExp(^ source $), keys, }; } function matchRoute(currentPath) { for (const route of routes) { const { regex, keys } pathToRegexp(route.path); const result regex.exec(currentPath); if (result) { const params {}; keys.forEach((key, index) { params[key] decodeURIComponent(result[index 1]); }); return { route, params }; } } return null; }这样路由表里写const routes [{ path: /products/:id, component: ... }]访问/products/10086时就能得到{ id: 10086 }并把参数传给组件的渲染函数。参数解析需要注意 decodeURIComponent。如果 id 里带了中文或者特殊字符URL 编码后在正则匹配时拿到的还是编码后的字符串不解码会出现乱码。这个细节很容易漏。3.3 把 Router 类封装出来顺手加上导航方法我不希望每个页面组件里都直接操作window.location.hash所以封装了一个 Router 类对外暴露两个方法start()启动路由监听navigate(path)用于跳转。class Router { constructor(options) { this.routes options.routes || []; this.container options.container || #app; this.notFound options.notFound || (() h1404/h1); } start() { window.addEventListener(hashchange, () this.render()); this.render(); } navigate(path) { window.location.hash path; } render() { const currentPath window.location.hash.slice(1) || /; const matched this.match(currentPath); const content matched ? matched.route.component(matched.params) : this.notFound(); document.querySelector(this.container).innerHTML content; } match(currentPath) { for (const route of this.routes) { const { regex, keys } pathToRegexp(route.path); const result regex.exec(currentPath); if (result) { const params {}; keys.forEach((key, index) { params[key] decodeURIComponent(result[index 1]); }); return { route, params }; } } return null; } }组件内部跳转时不再写window.location.hash /about而是调用路由实例的方法。也可以在渲染时把 navigate 传入组件const routes [ { path: /, component: () button onclickrouter.navigate(/about)关于/button, }, ];这里有个实际业务中会反复出现的需求跳转后是否需要滚动到顶部。hash 路由模式下hash 变化有时会触发锚点行为导致页面停在当前滚动位置而用户期望的是切入新页面后回到顶部。所以我会在 navigate 后手动window.scrollTo(0, 0)在 render 里处理这个细节不算复杂但直接影响体验。3.4 这一版实现里我为后续扩展留的口子写第一版时我就清楚后面至少有三处要扩展路由守卫、懒加载、以及切到 history 模式时保持接口一致。所以我没有把逻辑写死在hashchange的回调里而是把导航操作收敛到navigate方法中这样切换到 pushState 时只需要替换navigate和start的实现其它代码不用大改。另外我把“渲染组件”定义为“调用 component 函数返回 HTML 字符串”。如果想升级成支持 DOM 操作、生命周期钩子只需要把 component 的返回值从字符串改成元素节点或对象render 时做区分即可。这个松耦合的设计帮我省了很多后续的返工。如果你打算自己写我建议也从“路由表 match 函数 渲染函数”开始先把这三个基础做扎实再考虑进阶能力。框架文档里看到的“路由就是一段配置 一个渲染器”这个印象远比背 API 更重要。4. 第二版从 hash 切到 history范式改变了什么hash 路由跑通之后我开始把线上项目切换到 history 模式。这一步最直观的变化是 URL 干净了但接踵而来的坑也更多。这一节记录我在切换过程中的关键改动和配置方法。4.1 用 pushState 接管导航别再靠 location.hrefhistory 模式下跳转逻辑从“设置 location.hash”变成“调用 history.pushState”。navigate(path) { history.pushState(null, , path); this.render(); }注意几个细节。pushState有三个参数第一个参数可以传一个任意对象它会保存在 history 条目里第三个参数是新的 URL。如果你希望这个导航不产生新的历史记录比如用户跳过某个中间页用replaceState替代。这行代码不会触发任何事件所以需要在调用后手动this.render()渲染视图。这一点和 hash 模式完全不同hash 模式改 hash 会触发 hashchange可以依赖事件驱动pushState 改了之后什么都不触发必须手动驱动渲染。实际业务里组件间跳转时经常出现“点击同一个链接两次”的情况。如果当前 URL 和 pushState 的目标一致浏览器不会报错但 history 里会堆积重复记录点返回时要多按一次。我习惯在 navigate 里先判断一下navigate(path) { if (path window.location.pathname window.location.search) { return; // 相同路径不产生新历史记录 } history.pushState(null, , path); this.render(); }判断的时候要把 query 也带上否则/products?page2和/products会被当成同名路径。4.2 popstate 只监听浏览器按钮别和自定义导航混淆history 模式下的前进后退处理是通过监听 popstate 事件完成的window.addEventListener(popstate, () this.render());这里最容易踩的坑是写了 pushState 之后以为“pushState 修改 URL 会触发 popstate”于是 navigate 里又手动调一次 render结果重复渲染两次或者反过来以为监听 popstate 就够了结果点击页面内跳转组件不渲染。实际上popstate 只在用户点击浏览器前进/后退按钮或者调用history.back()/history.forward()/history.go(n)时触发。pushState 和 replaceState 都不会触发 popstate。所以自定义导航和浏览器导航是两条平行线都要各自驱动渲染。还有一点用得较少但需要知道popstate 事件对象里的state就是 pushState 传入的第一个参数。如果跳转时带了页面状态数据在 popstate 里可以取回来。比如列表页记录滚动位置pushState 时传入当前位置用户在详情页返回时就能恢复。4.3 刷新 404 的根因以及开发/生产环境的两种解法切到 history 之后第一次部署我踩了最经典的坑线上环境用户访问https://example.com/products直接刷新服务器返回 404。原因就是我在 2.2 里说的浏览器刷新时向服务器请求了/products这个路径服务器上没有 products 文件也没有 fallback 逻辑自然返回 404。hash 模式永远不会有这个问题因为 URL 的#后面的内容根本不会发给服务器。这个问题的解法核心是让服务器对“非静态文件路径”统一返回index.html。开发环境相对简单。如果是 webpack配置devServer.historyApiFallback: true如果是 ViteappType: spa是默认行为。本地开发一般不用太担心稍微新一点的脚手架都处理好了。生产环境要看具体的服务器。我用 Nginx 比较多典型配置如下server { listen 80; root /path/to/dist; location / { try_files $uri $uri/ /index.html; } }关键就是最后一行try_files $uri $uri/ /index.html。逻辑是先找有没有这个文件$uri再找有没有这个目录$uri/都找不到就回退到/index.html。注意这行配置必须写在 root 对应的 location 里如果项目部署在子路径下还要加上 base 路径的处理。Apache 服务器则是用 mod_rewriteRewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ /index.html [L]注意这里只对不存在的文件和目录做规则重写静态资源仍然能正常访问不会白屏。4.4 404 兜底页面和 base 路径的正确姿势配置好了 fallback 之后还会出现一个现象用户访问/definitely-not-exist服务器返回了 index.html但前端路由器发现这个路径没匹配到任何路由于是显示空白或默认的 404 提示。所以需要在路由表匹配流派里兜底这一步不管 hash 还是 history 模式都要做我在第 3 节的 Router 类里已经加上了 notFound 处理。另一个容易被忽视的问题是 base 路径。如果应用部署在https://example.com/app/下面而不是域名根目录那么服务器配置和前端路由都要相应调整。前端路由里所有路径都要以/app/开头比如/app/productsNginx 配置要写成location /app/ { alias /path/to/dist/; try_files $uri $uri/ /app/index.html; }构建工具里也要设置对应的 base。Vite 在build.base中配置webpack 在output.publicPath中配置。否则你会遇到一个很诡异的现象首页能打开直接访问/app/products也能打开但刷新时资源路径全乱CSS/JS 加载 404。关于 base 路径我给你一个重要提醒改 base 时前端路由的跳转路径要统一带上前缀否则手动输入 URL 没问题应用内导航却会跳到根路径下面去。5. 接上登录态路由守卫和 JWT 场景的完整接线做完基本路由能力后我立刻遇到了更实际的需求某些页面必须登录才能访问。这时候路由不再只是“URL 到视图的映射”还要承担“访问控制”的职责。这一节我把路由守卫的设计以及它和 JWT 登录态的配合写清楚。5.1 给路由加一个函数式守卫我在 Router 类里加了一个beforeEach的钩子设计上参考了中间件模式每次导航开始前先执行守卫函数由守卫决定放行、拦截还是跳转。class Router { constructor(options) { // ...已有的初始化 this.guard options.beforeEach || null; } navigate(path) { this.resolveNavigation(path); } resolveNavigation(path) { const matched this.match(path); const context { to: { path, params: matched ? matched.params : {} }, from: { path: this.currentPath }, next: (target) { if (target true) { this.performNavigation(path); } else if (typeof target string) { this.performNavigation(target); } else { this.performNavigation(path); } }, }; if (this.guard) { const result this.guard(context); if (result true) { this.performNavigation(path); } else if (typeof result string) { this.performNavigation(result); // 重定向 } } else { this.performNavigation(path); } } }这个设计在实际使用中有个很关键的点守卫里如果有异步逻辑必须返回 Promise。最典型的就是检查登录态时要从 localStorage 拿 token还要判断 token 是否过期这些是同步的但如果业务里要先调接口确认用户权限就得异步所以performNavigation里要支持 Promise 链。5.2 守卫里检查 tokenJWT 场景的具体接线现在已经是 SPA 项目的常见套路用户输入账号密码 验证码登录接口返回 JWT前端把 token 存进 localStorage 或内存之后请求接口时带上Authorization: Bearer token接口返回 401 说明 token 失效或过期。在这个前提下路由守卫要做的事情就很清晰了。我写了一个典型的守卫函数const router new Router({ routes, beforeEach({ to, next }) { // 需要登录的页面在路由表里通过 meta 标记 if (matchesSome(to.path, routes.filter(r r.meta?.requiresAuth))) { const token localStorage.getItem(token); if (!token) { return /login?redirect encodeURIComponent(to.path); } // token 过期判断也可以放在这里用 jwt-decode 解析 exp } return true; }, });这里是实际的业务代码思路function isTokenExpired(token) { // 只适用于 JWTpayload 里有 exp 字段 const payload JSON.parse(atob(token.split(.)[1])); return payload.exp * 1000 Date.now(); }如果 token 不存在或已过期直接跳转到/login并且带上redirect参数——登录成功后回跳原页面。这个“回跳”是登录流程里很容易做漏的细节不加的话用户登录成功永远落在首页体验很割裂。5.3 登录页验证码与路由跳转的配合在这类系统里登录页通常还有验证码。验证码的获取和校验发生在提交登录按钮时和路由跳转并不是一条链路但它和路由有一个交接点登录成功后触发跳转。实践中我见过不少代码在登录成功回调里直接router.navigate(/)完全忽略 redirect 参数也没有清掉密码框里残留的验证码状态。更稳妥的做法是登录成功后从 URL query 里取回 redirect再决定跳转目标。async function handleLogin() { const captcha validateCaptcha(); if (!captcha) return; const { token } await login({ captcha }); localStorage.setItem(token, token); const redirect new URLSearchParams(window.location.search).get(redirect); router.navigate(redirect || /dashboard); }验证码本身也有个细节很多验证码服务是一次性的登录失败后再提交同一个验证码会报错“验证码已过期”所以失败后要主动刷新验证码。这个流程看起来和路由无关但正是因为跳转发生在登录成功之后而验证码过期会阻塞登录间接影响路由容易出错。5.4 守卫通过后再懒加载组件我在第一版里是把所有组件的渲染函数直接放进路由表的项目大了之后首屏脚本体积非常难看。于是我顺势实现了懒加载路由表里的 component 不再直接返回 HTML 字符串而是一个返回 Promise 的函数守卫通过后再真正 import 组件。const routes [ { path: /products, component: () import(./views/Products.js) }, ];render 时async render() { const matched this.match(currentPath); const componentModule await matched.route.component(); // componentModule 可能是 ES module取 .default document.querySelector(#app).innerHTML componentModule.default(); }这里要注意 ES module 的动态 import 返回的是一个模块对象组件可能挂在default下面这和路由表里写() h1...的同步函数不一致所以最好在路由表定义时就统一封装成一个返回 Promise 的函数或者直接约定每个视图模块都 export default 一个 render 函数。懒加载配合守卫的完整链路是用户访问/products→ 守卫先跑没登录就跳登录页 → 已登录才去 import 组件脚本 → 加载完成后渲染。这样可以避免未登录用户白白下载业务代码也能让登录页首屏加载更快。我在实际项目里把登录页单独拆成了入口路由未登录时整个路由系统只加载登录相关代码整个首屏体积小了一半。6. 手搓过程中踩过的坑和一套可复用的排查思路这一节我想把踩过的坑集中梳理一遍。网上很多文章只讲“怎么配置”不讲“为什么会出这个错”。我把真实项目中遇到的坑和排查链路写出来遇到类似问题你可以按这个思路快速定位。6.1 坑一URL 里的 # 去不掉以及它引发的锚点冲突刚切到 history 模式时很多人会反馈“之前的 # 还在”这个问题几乎都是因为跳转行为没有全量接管。比如页面上残留了一个硬编码的a href#/about点击后 hash 变了但监听的是 popstate 而不是 hashchange结果视图不更新。解决方式是把所有a按钮改成调用navigate方法或者在根容器上用事件委托统一拦截带>curl -I https://example.com/products如果返回 404就去查 Nginx 配置里try_files是不是少了$uri/或者 root 路径写错了。常见错误写法是try_files $uri /index.html;这种写法在直接请求不存在的文件时能回退但如果请求的是一个目录路径可能有问题而且对子路径 base 的处理会漏。我最终用的是try_files $uri $uri/ /index.html;。还有一种情况项目部署在 CDN 或对象存储上这类平台大多不支持自定义 fallback 规则。如果 CDN 层面配不了 try_files就得考虑放弃 history 模式或者在网关层做重写。6.3 坑三popstate 与 pushState 之间的关系很多人理解反了我在 4.2 里说过 pushState 不会触发 popstate但这里还有一个反向的误解有人以为 popstate 是“路由变化”事件于是在事件里调用 navigate 里的 render结果每次只能渲染一次第二次渲染走了死循环。正确的理解是popstate 表示浏览器历史记录发生了变化它可能是因为用户点击返回也可能是因为调用了 history.back()。而 pushState 是修改历史记录但不触发事件。所以代码里只需两个钩子自定义导航navigate 方法里手动 render。popstate 事件回调里手动 render。两者互补但不重叠也不需要互相调用。这个坑最容易出现在写组件库或封装 hooks 的时候。如果你封装了一个useNavigate()的 hook里面顺手返回了一个“跳转后更新视图”的方法又在 popstate 里订阅了同样的渲染逻辑可能在页面返回时出现重复渲染。保险做法是在 render 外层加一个简单的当前路径比对路径没变就不执行渲染。6.4 一套可复用的排查链路从复现到分离踩过几次坑之后我总结了一个固定排查流程送给刚接触前端路由的读者第一步是复现。把问题固定在“刷新后 404”还是“应用内跳转后 404”。两者完全不同前者是服务器层后者通常是前端路由表没匹配上。第二步是分离。直接把 history 模式临时切回 hash看问题是否消失。如果切回 hash 一切正常基本可以断定问题在“服务器 response”或“history 模式下 URL 生成”那一侧和前端的渲染逻辑没有关系。第三步是看网络请求。打开 DevTools 的 Network 面板刷新页面看看到底是页面请求document返回了 404还是某个 JS/CSS 资源返回 404。页面请求 404 是 fallback 没配置好资源请求 404 是 base 路径或者资源路径写错了。第四步是看服务器日志。如果是自建服务器直接查 access log 里请求的路径是什么、返回了什么状态码。这一步能筛掉一半以上的“玄学问题”。这套方法同样适用于排查路由守卫失效、懒加载组件加载失败等问题。核心原则始终是先搞清楚“哪一层出了问题”再去动那一层的代码而不是在前端一顿猛改。7. 手搓路由之后我对“用框架路由”这件事的看法变了Router 是 SPA 里最容易被当成黑盒的部分。很多人用 Vue Router 只是写一段routes配置从没想过它背后是如何把 URL 变化同步到视图上的。自己实现一遍后最大的收获不是写了一个可用的路由此而是看路由相关 bug 时不再靠瞎猜。现在遇到一个“路由跳转了但页面没变”的问题脑子里会自动列出可能的原因是否 pushState 后没手动渲染是否路由表没匹配上参数解析出错还是守卫拦截了跳转这些判断能力完全来自手写的过程中积累的经验用框架是学不来的。当然我也要说清楚边界日常项目里我还是推荐直接用 React Router / Vue Router 这类成熟库。它们解决的不只是“URL 映射视图”还包括嵌套路由的渲染层级、动态路由的递归匹配、滚动位置恢复、路由过渡动画、路由按需预加载、SSR 路由配合等等这些要自己实现成本非常高。我在自研路由上花的时间更像是一种必要的“底层认知投资”不是为了替代框架。什么情况下可以坚持自研我总结了一个标准路由需求简单平级页面、无嵌套、无复杂动态路由、不想引入大依赖、且有多余的时间维护——如果这三个条件都满足自研一套 1000 行以内的路由完全是可行的代码可控、没有版本升级的烦恼遇到问题打开自己那套代码就能改。最后分享一个我在整个过程中得到的最有价值的经验前端路由的本质一句话就能说完——“URL 变化时不重新加载文档而是执行一段对应视图的渲染逻辑”。把这个本质刻在脑子里无论你用哪套路由方案、遇到什么幺蛾子都能顺着这个框架去思考和定位问题。手搓一遍值得每个人都试试。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询