← 首页 / 路由设计 · 稳定入口与可变目标 待审核 23 页

路由设计 · 稳定入口与可变目标

「路由」远不止浏览器地址栏里那条 URL。它是一层可控的 indirection —— 对外暴露一个稳定入口 (一条 URL、一个服务端 route、一个 App 里看不见的 deep link、一条短链),入口背后的目标则随时可换。正因为多了这一层,才能统一改址、按渠道统计、灰度上线,而那些已经发出去的入口一个字都不用动。拆开这一层,里面是两半:一张规则表决定入口该去哪 (rule,见匹配引擎),再用某种机制把请求真正送过去 (dispatch —— reverse proxy / redirect / rewrite / 系统级路由)。下面分五组,从「为什么需要它」讲起,沿服务端 → 浏览器客户端 → 基础设施与前端深层逐层展开,每页都能改输入、即时看到路由跑出的结果。

路由是什么 · 为什么需要这一层

先把「稳定入口 ↔ 可变目标」的心智模型立起来,再看它最日常的一个实例 —— 短链为什么会存在。

服务端路由 · 一张表 + 一次 dispatch

最经典的路由都在服务端:一张表把 path 匹配到 handler,命中后用 3xx 或 rewrite 把请求送达。先拆匹配引擎重定向语义两块内核,再落到三个真实场景 —— 分享统一入口、App deep link、远程配置入口。

路由匹配引擎 · path 怎么找到 handler

无论 Express 的服务端 route、React Router 的前端 route,内核都是同一件事:拿一张路由表自上而下匹配一条 path。拆开三种 segment——字面量 static、命名参数 :id、通配 * catch-all,看 path 怎样命中某条、提取出 params;再看顺序即优先级:为什么 /users/new 必须排在 /users/:id 前面,否则 new 会被当成 id 捕获 (route shadowing)。输入一条 path 当场看逐条匹配。

HTTP 重定向 · 301/302/303/307/308 到底差在哪

服务端回一个 3xx + Location,就把客户端「指」去别处。五个码差在两个正交维度:永久还是临时(能不能被缓存)、方法保不保持(POST 跟过去还是被改成 GET)。选状态码 + 原方法,看客户端逐跳 follow、方法是否被改、跳转链怎么收敛到 200。重点讲 308:永久且不改方法——服务端随需求改 Location 即可随时调整目标(如版本切换),这正是「可配置跳转链接」的底座。

分享地址统一入口 · 接住各路分享 → 统计 → 跳目标

真实场景:微信、短信、海报、站内、广告各有各的分享链接,与其让它们各自硬编码目标地址,不如全部先打到一个统一路由入口 /s/:code。命中后服务端做三件事:记一笔统计(哪个渠道 / utm 来源)、查出当前目标、再 302 跳过去。改一处即可换目标、按渠道出报表。同时分析 open redirect 安全风险:为什么目标必须过 allowlist,否则 ?u=//evil.com 能借你的域名钓鱼。最后落到前后端分离 (SPA) 怎么接:为什么选 history 模式、服务端只认三条路由(/api/* 反代 · /s/:code 只有服务端认识 · * fallback rewrite 给前端 router)。

App 里看不见的 route · deep link / Universal Link

路由也存在于没有地址栏的地方。点一条链接想直接跳进 App 的某个页面,要面对一道分叉:装了 App 就进 App,没装就退回网页或去商店。拆开 myapp:// 自定义 scheme(装了才有人接)、Universal Links / App Links(同一条 https 链接系统直接路由进 App、否则照常开网页)、以及 deferred deep link(没装 → 去商店 → 装完还原原本想去的目标)。切换「装了/没装」看两台设备并排走不同的路。

远程配置入口 · 让 App 不发版也能改地址

有些载体发出去就改不动了——印出去的二维码、已经装在用户手机上的 App、烧进设备的固件。它们里写死的地址想换,代价是重新发版、过审、还得等用户更新。于是 App 里不写死真实地址,只写死一个稳定入口 vega.link/s/help,真实地址放服务端配置,要改改一行——帮助中心换路径、甚至迁到第三方换域名,所有已装出去的版本下次点击就跟随,零发版。两台 App 并排:一台写死真实 url、一台写死 /s/help,让帮助中心改版,看谁成了死链。这是「稳定入口 ↔ 可变目标」在客户端发版周期这根轴上的极端体现。

客户端 / 浏览器路由 · 同一套模型搬进浏览器

把这一层搬进浏览器 —— 单页应用 (SPA) 不刷新整页、自己维护一份「当前 location」与 history 栈,共用同一套「稳定入口 ↔ 可变目标」模型,只是状态从服务端挪到客户端。末两页是浏览器为路由新做的原生接口。阅读前建议先了解 URL 的组成 (scheme/host/path/query/fragment),可参考 URL Anatomy 系列

客户端路由 · push / replace 与 history 栈

路由搬进浏览器后,SPA 不刷新整页,自己维护一份「当前 location」并和浏览器 history 栈同步——核心动作就是 push / replace / back / forward / go。把 history 栈画成「一列条目 + 一个指针」:push 叠一条(栈 +1、能 back 回来)、replace 原地顶掉(栈不变、back 跳过它,正是登录跳转/PRG 的客户端版)。再串成一台 router 内核(navigate → 改栈 → matchTable → 渲染),看清两处易错点:pushState 不发 popstate、站内 <a> 要手动拦截。同一套栈也存在于 App 的 navigation / back stack 里。

history 模式 vs hash 模式 · 谁刷新会 404

前端把「当前路由」写进地址栏有两套写法,分水岭是一个底层事实:URL 里 # 后面那段不发给服务端。两台浏览器并排跑同一条深层路由,按「此刻刷新」:history 模式/products/42 原样发给服务端——没配 fallback 就 404;hash 模式 永远只请求 /、永远 200,路由全在前端读 location.hash 自己解。history 模式更干净、对 SEO/分享友好,代价是服务端一句 * → index.html 的 fallback rewrite(接分享入口页);hash 模式则零服务端配置就能跑,这是它今天的存活理由。

软导航 · 守卫 / 滚动恢复 / 异步

导航搬进客户端后,它从「发请求、整页换」的一次性动作,变成一个你能中途插手的过程——多出一批服务端跳转根本没有的能力。拆三个最常用的:导航守卫(跳转发生前跑一串 guard,可放行 / 取消 / 改道,第一个表态者短路,典型如未登录改道 /login、未保存表单拦下)、滚动恢复(SPA 原地换 DOM,得自己存下离开时的 scrollTop、后退再还原——真的可滚的框,开/关对比)、异步导航(先把导航挂起、await 数据到位再提交切视图,而非白屏,即 React Router 的 loader)。

Navigation API · 一个事件接住所有导航

History API 搭 router 留下三处不便:pushState 不发 popstate、站内 <a> 要手动拦截、读不到完整历史条目。Navigation API 是为路由重做的一代:链接点击、表单提交、navigate()、前进后退,全汇成一个 navigate 事件,用 e.intercept({ handler }) 一处接管(async handler、pending、提交时机全包),还能直接读 navigation.entries()。本页真实调用你浏览器的 window.navigation:看那条没绑任何 click 的普通链接怎么照样被接住、看 navigationType 把各类导航分类、看条目列表实时增长(需 Chromium)。

URLPattern · 浏览器内建的 URL matcher

那台手工实现的 path → handler 匹配引擎,浏览器把它标准化、内建成了 URLPattern——而且更全。两点关键不同:匹配范围从「只切 pathname 一段」扩到整条 URL 的每个组件(protocol / hostname / port / pathname / search / hash,全部命中才算命中、子域也能提取);语法从「:param / *」扩到带正则约束的命名组 :id(\d+)、可选分组 {/:slug}?、可重复段 :rest*、枚举 (en|zh)。本页真实调用你浏览器的 window.URLPattern:改 pattern 或输入,当场看 test() 判命中、exec() 把每个命名组取出来,以及一条 URL 卡在哪个组件上。

基础设施层的 dispatch · 那台 matcher 搬到最前层

把匹配引擎搬到服务端最前面那一层,决定一条请求转发给谁 (网关按 = > 最长 prefix > regex 择优)、按什么比例 (加权流量切分,canary / 灰度 / 蓝绿的底座)、按哪个维度 (多租户用 host 而非 path)。

反向代理 / API 网关 · 一个入口分流到一群后端

把那台 path → handler 引擎搬到服务端最前面那一层:外界只看见一个入口,网关拿一张 location 规则表决定这条请求转发给哪个 upstream(后端 / 静态 CDN / SPA 前端)。它的消歧规则和平表不同——不是「写在前面的先命中」,而是精确 = > 最长前缀 > 正则的固定优先级。重点演示那个著名陷阱:正则会反超普通前缀/static/app.js 命中 CDN 而非 /static/ 前缀,除非标成 ^~ 优先前缀);以及命中后剥前缀再转发proxy_pass 末尾斜杠的一字之差)。输入一条请求看它怎么被择优、改写、转发。

加权流量切分 / canary · 同一入口按比例发往不同版本

前面页里「目标」都是确定值;这页把它变成概率分布——同一入口背后 stable、canary 多个版本同时在线,路由层按权重分流(90% 稳定 / 10% 金丝雀)。这是灰度、蓝绿、A/B 的共同底座:出问题把权重调回 0 即瞬间回滚,已发请求一行不改。拖权重滑块发一批请求看实际落点逼近配置比例(大数定律);再看两个关键机制——请求头定向x-canary 绕过权重强制进新版)与会话黏滞(按 user-id 哈希,保证同一用户每次落同一版本,不在新旧版间横跳)。

多租户 · 用 host 而不是 path 路由到租户

SaaS 给每个客户一个专属子域 (acme.app.com),路由层从 Host 头把租户标识解析出来——路由维度从 path 换成 host。它靠一条通配子域 *.app.com + 一张通配证书撑起,新客户开通零运维。但通配会吞掉一切,于是要处理三类例外:保留子域www / api 必须在注册时拉黑,否则有人注册租户 api 就劫持了你的 API 子域)、站点根域(官网)、以及客户用自有域名时的 CNAME 映射。还演示一个常被忽略的边界:多级子域被拒,因为通配证书只覆盖一层

前端更深的路由机制 · 超出「平表匹配」的进阶

回到前端,几个超出「一条 path 命中一条路由」的机制:一条 path 命中一整条嵌套链 (layout 层层复用)、一条 URL 同时填多个槽 (并行 / 拦截路由)、locale 写在前缀或域名的国际化路由、把无数等价写法收敛到唯一地址的 URL 规范化

嵌套路由 / layout · 一条 path 命中的不是一个,是一串

平表里一条 path 命中一条路由;真实前端 (React Router / Vue Router / Next.js) 的路由是一棵树,一条 path 命中从根到叶的一整条链,链上每个 layout 层层嵌套地包住页面,各在自己的 outlet 槽里渲染下一层。这带来平表给不了的性质:切换兄弟页面时共享外层 layout 原地不动(顶栏 / 侧边栏 / 标签条不卸载、状态不丢),只换最内一格。点不同 path 看命中链、看 layout 嵌套,以及每次导航复用了哪几层、新挂了哪几层;并讲清 index 路由填充父 layout 默认那一格。

并行路由 / 拦截路由 · 一条 URL 同时填好几个槽

嵌套路由的 outlet 只有一格;Next.js app router 再走一步:一个 layout 有多个具名 slot (@modal),一条 URL 同时把它们都填好 (parallel routes),每个 slot 是独立子树。它和 intercepting routes 搭起来解一个老需求:照片墙点开一张图要模态浮在列表上,但把链接分享 / 刷新又该是独立整页。同一个 /photo/3,软导航(点链接)被拦截进模态槽、硬导航(刷新 / 直接打开)不拦截走整页。切换两种到达方式,看同一 URL 分叉成两种样子,以及 @slot / (.) / default.js 的目录约定。

移动端页面栈 / 模态栈 · 返回时上一页还在

移动端「从 B 退回 A 时 A 原封不动」的关键只有一条:A 从来没被销毁过。把它做成一台可点的页面栈——同 UINavigationController 语义:push 时下层全部保留(深度有限,内存无所谓)、pop 时离场动画结束才销毁顶层 DOM;再叠一条设置模态栈从底部滑入、盖在原页上(原页是背景,不卸载)。引擎是声明式的:URL →「这一刻该挂哪几层」,和实际挂载做 diff——公共前缀保活、多出来的销毁、缺的补上,点击 / 系统返回 / 刷新全收敛到一处。最后一道坎是直达:直接用 URL 打开深层设置页时底下本没有背景,引擎合成一个(默认首页或从 sessionStorage 取回来源)。真实 history 驱动,手机上系统返回手势直接弹栈;附 vue-router 对照实现

移动端页面栈 · 整页独立 demo

页面栈引擎放进一台占满舞台的手机里直接开:push 保活、pop 即销毁、设置模态从底部滑入、iOS 式左缘侧滑返回,直达深层地址时凭空合成背景。与讲解版同一套声明式 reconcile,这里是一个不碰浏览器真实 history 的受控模拟,专注体验手感。

页面栈 · vue-router

页面栈 · vue-router 版 · 同一套 scheme,换框架原语拼

原生版把页面栈引擎从零手写;这页把完全相同的 scheme——基础 push、设置模态栈、返回销毁、直达合成背景——落到 vue-router 的现成原语上:meta.layer 定滑动方向、<transition> 分进退、<keep-alive :include> 白名单就是「还活着的栈」、beforeEach 守卫合成背景。看框架替你接管了哪几块、哪几块仍要自己补。

vue-router · keep-alive · transition

页面栈 · vue-router 整页 App 对照

把「移动端页面栈」这套 scheme 落到 vue-router 的现成原语上,整个视口就是一个 App。meta.layer 决定滑动方向、<transition> 给进 / 退转场、<keep-alive :include> 白名单做「push 保活 / pop 销毁」、beforeEach 守卫在直达深层页时合成背景。同一张路由表,换框架原语拼。

国际化路由 · 同一个页面,哪个语言版本

多语言站点要回答:同一个 /about 给英文还是中文?分两件事。locale 写在哪——多写进 URL 让它可分享、对 SEO 友好:路径前缀 (/en/about) 或域名 (site.fr);URL 自带的优先,且不应被「猜」覆盖。URL 不带时靠什么猜——读 Accept-Language协商:它不是单值而是一串带权重 (q) 的偏好,还要走 fallback 链(要 zh-TW 没有就剥地区退 zh,再退默认),结果通常是一次 302 跳到带前缀的规范地址。两个 lab 分别拆策略与协商算法。

URL 规范化 / canonical · 让一个内容只有一个地址

同一篇文章可被无数写法不同却等价的 URL 指向:host 大小写、末尾斜杠、/index.html、一串 utm_*、query 顺序颠倒…… 内容一样,但在缓存、统计、SEO 眼里是不同页面。规范化用一条变换流水线把它们收敛到唯一 canonical 地址(小写 host → 去默认端口 → 合并斜杠 → 删默认文档 → 统一末尾斜杠 → 剥跟踪参数 → 排序 query → 去 fragment),擦完不一样就回 301。强调两条底线:跟踪参数要剥但影响内容的参数必须留;流水线必须幂等 (normalize(normalize(x)) === normalize(x)),否则会形成 301 无限环。

🔗 相关链接

  • MDN · HTTP Redirections developer.mozilla.org 对应「重定向」页:301/302/303/307/308 各自语义、永久 vs 临时、方法保持的逐项说明与典型用途。
  • RFC 7231 §6.4 · Redirection 3xx IETF 权威定义:为什么 307/308 是「方法明确版」的 302/301,以及 303 强制改 GET 的规范出处。
  • React Router · Routing reactrouter.com 对应「匹配引擎」页:前端路由的 dynamic segment :id、splat *、嵌套与最具体者优先的真实实现。
  • Apple · Universal Links developer.apple.com 对应「deep link」页:iOS 用 apple-app-site-association 把 https 链接路由进 App 的官方机制 (Android 侧对应 App Links + assetlinks.json)。
  • OWASP · Unvalidated Redirects and Forwards owasp.org 对应「分享入口」页的安全小节:open redirect 的危害与 allowlist 防御 —— 跳转目标为什么必须校验。
  • MDN · History API developer.mozilla.org 对应「客户端路由」「history 模式」两页:pushState / replaceState / go / back / forward 与 popstate 事件的底层动作。
  • MDN · Navigation API developer.mozilla.org 对应「Navigation API」页:navigate 事件、intercept、entries()、currententrychange —— 为客户端路由重做的新一代接口。
  • MDN · URLPattern API developer.mozilla.org 对应「URLPattern」页:构造器、test() / exec()、整条 URL 各组件的 pattern 语法与命名组提取 —— 浏览器内建的 URL matcher。
  • nginx · location 指令 nginx.org 对应「反向代理 / API 网关」页:= / ^~ / ~ / 普通前缀四种 location 的匹配优先级与求值顺序 —— 那个「正则反超前缀」的陷阱出处。
  • Istio · Traffic Management istio.io 对应「加权流量切分」页:service mesh 怎样用 VirtualService / DestinationRule 按 weight 切 subset、按 header 定向 —— canary / 灰度的生产实现。
  • Next.js · Parallel & Intercepting Routes nextjs.org 对应「并行 / 拦截路由」页:@slot 具名槽、(.) / (..) / (...) 拦截约定、default.js 兜底的官方模型。