并行路由与拦截路由
嵌套路由里,一条 URL 命中一条单链:外层 layout 套内层页面,outlet 只有一格。Next.js app router 再走一步——一个 layout 可以有多个具名 outlet(slot),一条 URL 同时把它们都填好,这叫 parallel routes(并行路由)。每个 slot 是一棵独立子树,各自有自己的 loading 与 error 状态,互不阻塞。
它和 intercepting routes(拦截路由)搭起来,解一个常见需求:照片墙点开一张图,想要模态框浮在原列表上;但把那张图的链接分享出去或刷新,又该是独立整页。同一个 URL
/photo/3,怎么到达的决定了渲染成什么——客户端软导航(点链接)被拦截进模态槽,硬导航(刷新、直接打开、分享链)不被拦截,走整页。
/photo/3 在两种到达方式下的渲染差异。可切换软导航与硬导航,观察 @modal 槽与 children 槽各自填了什么。
点缩略图是软导航: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
2 · 为什么不用一个状态变量
用 showModal 这样的组件状态当然也能弹出模态,代价是模态不在 URL 里:不能分享、刷新即丢、浏览器后退键关不掉它。
把模态做成路由,它就有了真实地址——可被分享(对方看到整页)、可被后退键关闭(回到列表)、可被刷新还原(成整页)。并行与拦截路由本质上是让浮层也纳入路由状态,同时不丢掉「列表还在背景里」这件事,是把 URL 当作唯一事实来源的彻底做法。
软导航与硬导航的区别本身是软导航与守卫一页的主题:软导航是客户端 router 可以插手的过程,本页正是用这一点区分模态与整页。