并行路由 / 拦截路由 · 一条 URL 同时填好几个槽
嵌套路由里,一条 URL 命中一条单链:外层 layout 套内层页面,outlet 只有一格。Next.js app router 再走一步:一个 layout 可以有多个具名 outlet (slot),一条 URL 同时把它们都填好——这叫 parallel routes(并行路由)。每个 slot 是一棵独立子树,各自有自己的 loading / error 状态,互不阻塞。
它和 intercepting routes(拦截路由)搭起来,解一个老需求:照片墙点开一张图,想要模态框浮在原列表上;但把那张图的链接分享出去、或刷新,又该是独立整页。同一个 URL
/photo/3,怎么到达的决定了渲染成什么——客户端软导航(点链接)被拦截进模态槽,硬导航(刷新 / 直接打开 / 分享链)不被拦截、走整页。下面切换两种到达方式,看同一 URL 分叉成两种样子。
同一个 URL,两种渲染,差别只在「怎么来的」。 点缩略图是软导航:router 在客户端改地址、@modal 槽里的拦截路由 (.)photo/[id] 命中 → 模态浮起,而
children 槽仍是那张列表(没卸载,背景还在)。按刷新是硬导航:请求重新到服务端,拦截规则不参与,@modal 退回 default.js(空),children 槽把
/photo/3 解析成整页。分享出去的链接,对方打开就是硬导航——所以拿到的是完整页面,而不是别人列表上的一个浮层。
1 · 这是怎么用文件结构表达的
Next.js 用目录约定表达这套路由:@modal 这种 @ 开头的文件夹是一个具名 slot,会作为同名 prop 传给父 layout;(.)photo 这种 (.) 前缀是拦截标记,意思「拦截同级的
photo 路由」((..) 拦上一级、(...) 从根拦)。default.js 是 slot 在「当前 URL 不该填它」时的回退(这里返回 null,所以硬导航时模态槽是空的)。
1.1 · app router 的目录结构
app/
layout.js # 父 layout: 同时接住 children 和 @modal 两个槽
page.js # children 槽默认内容 = Feed (照片列表)
photo/[id]/page.js # 硬导航 / 直接打开 → 整页照片
@modal/ # ← @ 开头 = 一个具名 slot, 作为 prop 传给 layout
default.js # 回退: 不该填模态时返回 null
(.)photo/[id]/page.js # ← (.) 前缀 = 拦截同级的 photo/[id]
# 软导航命中它 → 模态; 硬导航不参与 → 整页
1.2 · 父 layout 同时接住两个槽
// app/layout.js —— 具名 slot 作为 prop 进来, layout 决定怎么摆放它们
export default function Layout({ children, modal }) {
return (
<>
{children} {/* children 槽: 列表, 或 (硬导航时) 整页照片 */}
{modal} {/* @modal 槽: 软导航时是模态, 否则 default.js → null */}
</>
);
}
// (.) / (..) / (...) 的含义 —— 拦截「相对当前位置某一级」的路由:
// (.)photo 拦同级的 photo
// (..)photo 拦上一级的 photo
// (...)photo 从 app 根拦 photo
为什么不直接用一个 showModal 状态就好了? 因为那样模态不在 URL 里——不能分享、刷新即丢、浏览器后退键关不掉它。把模态做成路由,它就有了真实地址:可被分享(对方看到整页)、可被后退键关闭(回到列表)、可被刷新还原(成整页)。并行 +
拦截路由,本质是让「浮层」也纳入路由状态,同时不丢掉「列表还在背景里」这件事。这是把 URL 当唯一事实来源(single source of truth)的彻底做法。
2 · 相关页
- 本系列 · 嵌套路由 / layout——并行路由是嵌套路由的推广:把单个 outlet 扩成多个具名 slot,一条 URL 同时填好。先看那页的单链与 outlet 再看这页。
- 本系列 · 软导航——「软导航 vs 硬导航」正是那页的主题:软导航是客户端 router 的可插手过程,这页用它区分模态与整页。
- Next.js · Parallel & Intercepting Routes——
@slot具名槽、(.)/(..)/(...)拦截约定、default.js回退的官方文档——本页演示的就是它。