写点什么

深入思考 Next.js App Directory 架构

  • 2023-08-14
    北京
  • 本文字数:6874 字

    阅读完需:约 23 分钟

深入思考 Next.js  App Directory 架构

写在前面:新的 App 目录架构一直是最近 Next.js 发布的主要亮点,这一点引发了许多讨论。在这篇文章中,Atila Fassina 探讨了这种新策略的优势和风险,并反思了您是否应该立即在生产环境中使用它。




自从 Next.js 13 release 发布以来,关于其描述的新功能的稳定性引发了一些争议。我们在 “What’s New in Next.js 13?” 一文中,对这个版本进行了解读,并做了一些有趣的测试,证明了 Next.js 13 是极其稳定的。从那时起,我们大多数人对于新的<Link><Image>组件,甚至(仍处于测试阶段的)@next/font都有了很清晰的了解;这些都是可用的,带来了立竿见影的收益。正如公告中明确指出的那样,Turbopack 仍处于测试阶段:严格针对 开发 构建,并且仍在积极开发中。您是否可以在日常工作中使用它取决于您的技术栈,因为有一些集成和优化仍在进行中。本文的范围仅限于公告中的主要亮点:新的 App 目录架构(以下简称为 AppDir)。


因为 App directory 与 React 生态系统中的重要演进 - React Server 组件(包含 edge runtimes)是相关联的,所以它注定是存在问题的。显然,它是我们 Next.js 应用程序未来的形态。然而,它明显还处于 实验阶段 ,其 路线图 可以看到,这个问题不是在短短的几周内可以处理好的。那么,你现在应该在生产中使用它吗?你可以从中获得哪些好处,又可能遇到哪些问题?与往常一样,在软件开发中的答案是一样的:要视情况而定。

App Directory 究竟是什么?

这是在 Next.js 中处理路由和渲染视图的新策略。它通过几个不同的功能相互结合实现,旨在充分发挥 React 并发特性的优势(React Suspense)。然而,它在如何思考 Next.js 应用程序中的组件和页面方面带来了一个重大的范式转变。这种构建应用程序的新方法对你的架构带来了 很多 受欢迎的改进。以下是一个简短的、非详尽的列表:


  • 部分路由

  • 路由组

  • 并行路由

  • 拦截路由

  • 服务器组件与客户端组件。

  • Suspense Boundaries。

  • 还有更多,请查阅新文档中的 特性概述

一份简要的对比

当涉及到当前的路由和渲染架构(在 Pages 目录中)时,开发人员需要针对每个路由考虑数据获取。


  • getServerSideProps:服务器端渲染;

  • getStaticProps:服务器端预渲染和/或增量静态再生成;

  • getStaticPaths + getStaticProps:服务器端预渲染或静态站点生成。


以前,基于每个页面来选择对应的渲染策略是不可能实现的。大多数应用程序要么完全采用服务器端渲染(SSR),要么完全采用静态站点生成(SSG)。Next.js 创建了足够的抽象,使得在其架构内单独考虑路由成为一种标准做法。


当应用程序在浏览器中加载,就会开始进行 hydration ,那么通过在 _app 组件中包装一个 React Context Provider 来实现路由共享数据是有可能的。这为我们提供了一种思路:可以将数据 提升 到渲染树的顶部,并将其向下传递到应用程序的叶子节点。


import { type AppProps } from 'next/app';
export default function MyApp({ Component, pageProps }: AppProps) { return ( <SomeProvider> <Component {...pageProps} /> </SomeProvider>}
复制代码


根据路由渲染和组织所需的数据的能力,使得这种方法(译者注:指的是「组件中包装一个 React Context Provider 来实现路由共享数据」的方法),几乎成为在应用程序中任何地方需要数据时候,就能轻松获得的好工具。虽然这种策略将允许数据在整个应用程序中可用,但将所有内容都包装在上下文提供者(Context Provider)中会将预加载绑定到应用程序的根部。在服务器上不再可能渲染该树上的任何分支(提供者上下文内的任何路由)。


在这里,引入了 布局模式 。通过在页面周围创建包装器,我们可以选择是否针对每个路由再次采用渲染策略,而不是在应用程序范围内进行一次性决策。在文章“Next.js 中的状态管理”和Next.js 文档上阅读更多关于在“页面目录”中管理状态的内容。


布局模式 被证明是一个很好的解决方案。能够细致地定义渲染策略是一个非常受欢迎的功能。因此,App 目录的出现将布局模式置于核心位置。作为 Next.js 架构的一等公民,它在性能、安全性和数据处理方面带来了巨大的改进。


借助 React 的并发特性,现在可以将组件流式传输到浏览器,让每个组件处理自己的数据。因此,渲染策略变得更加细致(不再是基于整个页面,而是基于组件)。默认情况下,布局是嵌套的,这使开发人员更清楚地知道每个页面如何受到文件系统架构的影响。除此之外,为了使用上下文(Context),必须明确地将组件设置为客户端端(通过“use client”指令)。

App Directory 模式下构建模块

这个架构是围绕着 每页布局架构 构建的。现在,不再有_app,也没有_document组件。它们都被根目录下的layout.jsx组件所取代。正如您所预期的那样,这是一个特殊的布局,将包装整个应用程序。


export function RootLayout({ children }: { children: React.ReactNode }) {    return (        <html lang="en">            <body>                {children}            </body>        </html>}
复制代码


根布局 是我们在服务器上一次性为整个应用程序返回的 HTML 的方式。它是一个服务器组件,不会 在导航时重新渲染。这意味着布局中的任何数据或状态将在应用程序的整个生命周期内持续存在。


虽然 根布局 是我们整个应用程序的特殊组件,但我们还可以为其他构建模块创建根组件:


  • loading.jsx:用于定义整个路由的暂停边界(Suspense Boundary);

  • error.jsx:用于定义整个路由的错误边界(Error Boundary);

  • template.jsx:类似于布局,但在每次导航时重新渲染。特别适用于处理路由之间的状态,例如进入或退出过渡。


所有这些组件和约定都是 默认嵌套 的。这意味着 /about 会自动嵌套在 / 的包装器内部。


最后,我们还需要为每个路由创建一个 page.jsx ,因为它将定义要为该 URL 段渲染的主要组件(也就是您放置组件的地方!)。这些组件显然不是默认嵌套的,只有在与它们对应的 URL 段完全匹配时,它们才会显示在我们的 DOM 中。


这个架构还有很多内容(而且还会有更多内容!),但这应该足够让您在考虑从 Pages 目录 迁移到生产环境中的 App 目录 之前正确地构建您的思维模型。还请务必查阅官方的升级指南

简要介绍服务器组件

React Server Components 允许应用程序利用基础设施实现更好的性能和提升整体用户体验。例如,显著的改进在于捆绑包大小,因为 RSC 不会将它们的依赖项传递到最终的捆绑包中。因为它们在服务器上渲染,任何类型的解析、格式化或组件库都将保留在服务器代码中。其次,由于它们的异步性质,服务器组件被 流式传输 到客户端。这使得渲染的 HTML 在浏览器上可以逐步增强。


因此,服务器组件导致最终捆绑包的大小更加可预测、可缓存和稳定,打破了应用程序大小与捆绑包大小之间的线性关系。这立即将 RSC 定位为一种与传统 React 组件最佳实践(现在称为客户端组件,以便消除歧义)。


在服务器组件中,数据获取也非常灵活,并且在我看来,更接近原生 JavaScript —— 这总是平滑学习曲线的。例如,了解 JavaScript 运行时使得可以将数据获取定义为并行或顺序,并因此对资源加载瀑布流有更精细的控制。


  • 并行数据获取 ,等待全部完成:


import TodoList from './todo-list'
async function getUser(userId) { const res = await fetch(`https://<some-api>/user/${userId}`); return res.json()}
async function getTodos(userId) { const res = await fetch(`https://<some-api>/todos/${userId}/list`); return res.json()}
export default async function Page({ params: { userId } }) { // Initiate both requests in parallel. const userResponse = getUser(userId) const = getTodos(username)
// Wait for the promises to resolve. const [user, todos] = await Promise.all([userResponse, todosResponse])
return ( <> <h1>{user.name}</h1> <TodoList list={todos}></TodoList> </> )}
复制代码


  • 并行 ,等待一个请求,同时流式传输另一个:


async function getUser(userId) {  const res = await fetch(`https://<some-api>/user/${userId}`);  return res.json()}
async function getTodos(userId) { const res = await fetch(`https://<some-api>/todos/${userId}/list`); return res.json()}
export default async function Page({ params: { userId } }) { // Initiate both requests in parallel. const userResponse = getUser(userId) const todosResponse = getTodos(userId)
// Wait only for the user. const user = await userResponse
return ( <> <h1>{user.name}</h1> <Suspense fallback={<div>Fetching todos...</div>}> <TodoList listPromise={todosResponse}></TodoList> </Suspense> </> )}
async function TodoList ({ listPromise }) { // Wait for the album's promise to resolve. const todos = await listPromise;
return ( <ul> {todos.map(({ id, name }) => ( <li key={id}>{name}</li> ))} </ul> );}
复制代码


在这种情况下,<TodoList> 接收一个正在进行中的 Promise,并在渲染之前需要使用 await 进行等待。应用程序将渲染 暂停回退 组件,直到所有操作完成。


  • 串行数据获取 一次只发送一个请求,并等待每个请求完成:


async function getUser(username) {  const res = await fetch(`https://<some-api>/user/${userId}`);  return res.json()}
async function getTodos(username) { const res = await fetch(`https://<some-api>/todos/${userId}/list`); return res.json()}
export default async function Page({ params: { userId } }) { const user = await getUser(userId)

return ( <> <h1>{user.name}</h1> <Suspense fallback={<div>Fetching todos...</div>}> <TodoList userId={userId} /> </Suspense> </> )}
async function TodoList ({ userId }) { const todos = await getTodos(userId);
return ( <ul> {todos.map(({ id, name }) => ( <li key={id}>{name}</li> ))} </ul> );}
复制代码


现在,Page 将获取并等待 getUser,然后开始渲染。一旦它到达 <TodoList>,它将获取并等待 getTodos。这仍然比我们在 Pages 目录中习惯的更细粒度。


需要注意的重要事项:


  • 在同一组件范围内触发的请求将并行执行(有关详细信息,请参阅下文的 扩展的 Fetch API)。

  • 在同一服务器运行时内触发的相同请求将进行去重处理(只有一个请求实际发生,是具有最短缓存到期时间的请求)。

  • 对于不会使用 fetch 的请求(例如第三方库,如 SDK、ORM 或数据库客户端),路由缓存不会受到影响,除非通过 分段缓存配置进行手动配置。


export const revalidate = 600; // revalidate every 10 minutes
export default function Contributors({ params}: { params: { projectId: string };}) { const { projectId } = params const { contributors } = await myORM.db.workspace.project({ id: projectId })
return <ul>{*/ ... */}</ul>;}
复制代码


要强调这给开发人员带来了多么大的控制权:在 Pages 目录中,渲染将被阻塞,直到所有数据都可用。当使用 getServerSideProps 时,用户仍然会看到加载旋转图标,直到整个路由的数据可用为止。要在 App 目录中模拟这种行为,获取请求必须在该路由的 layout.tsx 中进行,因此应始终避免这样做。"全有或全无" 的方法很少是您所需要的,与这种细粒度策略相比,它会导致性能感知更差。

Fetch API 的扩展

语法保持不变:fetch(route, options)。但根据 Web Fetch 规范options.cache 将确定此 API 如何与 浏览器缓存 交互。但在 Next.js 中,它将与 框架的服务器端 HTTP 缓存 交互。


当涉及到 Next.js 的扩展 Fetch API 及其缓存策略时,有两个重要的值需要了解:


  • force-cache:默认值,查找新的匹配项并返回它。

  • no-storeno-cache:在每次请求时从远程服务器获取。

  • next.revalidate:与 ISR 具有相同的语法,设置一个硬阈值来判断资源是否为新鲜的。


fetch(`https://route`, { cache: 'force-cache', next: { revalidate: 60 } })
复制代码


缓存策略允许我们对请求进行分类:


  • 静态数据:持久性较长。例如,博客文章。

  • 动态数据:经常更改和/或是用户交互的结果。例如,评论部分、购物车。


默认情况下,每个数据都被视为 静态数据。这是因为默认的缓存策略是 force-cache。要想完全排除静态数据的缓存,可以定义为 no-storeno-cache


如果使用了动态函数(例如,设置 cookie 或标头),默认策略将从 force-cache 切换到 no-store


最后,要实现更类似于增量静态再生成(Incremental Static Regeneration,ISR)的功能,您需要使用 next.revalidate。这具有一个好处,就是不必为整个路由定义,而只需定义其所在组件即可。

从 Pages 迁移到 App

将逻辑从 Pages 目录 迁移到 App 目录 可能看起来需要做很多工作,但 Next.js 已经准备好让这两种架构共存,因此 迁移可以逐步完成 。此外,文档中还有一个非常好的 迁移指南 ;我建议您在进行重构之前充分阅读该指南。


引导您完成迁移路径超出了本文的范围,而且会使文档变得多余。作为替代,为了在官方文档所提供的基础上提供更多价值,我将尝试提供一些关于我经验所示的可能遇到的摩擦点的见解。

关于 REACT CONTEXT

为了在本文中提到的所有优势,RSC 不能是交互式的,这意味着它们没有钩子。因此,我们决定尽可能将客户端逻辑推迟到渲染树的末端;一旦添加了交互性,该组件的子组件将在客户端上运行。


然而,在某些情况下,将某些组件推迟到客户端上运行可能是不可能的(特别是如果一些关键功能依赖于 React Context)。因为大多数库都准备好保护其用户免受 Prop Drilling,许多库创建上下文提供者来跳过从根组件传递到远处后代的组件。因此,完全放弃 React Context 可能会导致一些外部库无法正常工作。


作为一个临时解决方案,这里有一个技巧。一个用于提供者的客户端包装器:


// /providers.jsx‘use client’
import { type ReactNode, createContext } from 'react';
const SomeContext = createContext();
export default function ThemeProvider({ children }: { children: ReactNode }) { return ( <SomeContext.Provider value="data"> {children} </SomeContext.Provider> );}
复制代码


因此,布局组件将不会对跳过客户端组件的渲染有任何关联。


// app/.../layout.jsximport { type ReactNode } from 'react';import Providers from ‘./providers’;
export default function Layout({ children }: { children: ReactNode }) { return ( <Providers>{children}</Providers> );}
复制代码


重要的是要意识到,一旦您这样做,整个分支将变为客户端渲染。这种方法将导致位于 <Providers> 组件内部的所有内容不会在服务器上渲染,因此请仅在万不得已时使用它。

TypeScript 和异步 React 元素

在 Layouts 和 Pages 之外使用 async/await 时,TypeScript 会基于它预期匹配 JSX 定义的响应类型产生错误。尽管它在运行时仍然支持并正常工作,但根据 Next.js 的文档,这需要在 TypeScript 中进行上游修复。


目前的解决方案是在上述行中添加一个注释 {/* @ts-expect-error Server Component */}

在处理客户端获取数据时

在历史上,Next.js 并没有内置的数据变更处理方式。从客户端发出的请求是开发人员自行决定如何处理的。随着 React Server Components 的推出,这种情况正在发生改变;React 团队正在开发一个 use 钩子,它将接受一个 Promise,然后处理该 Promise 并直接返回值。


在未来,这将取代大多数不好的 useEffect 用例(关于这点在优秀的演讲“ GoodBye,UseEffect ”中有更多讨论),并可能成为处理客户端 React 中的异步操作(包括数据获取)的标准方式。


然而,在目前,仍然建议依赖诸如 React-Query 和 SWR 这样的库来满足您的客户端获取数据需求。但请特别注意 fetch 的行为!

所以,准备好了吗

实验是前进的本质,我们不能制作出美味的煎蛋卷而不打破鸡蛋。我希望本文能帮助您针对您自己的特定用例回答这个问题。


如果是一个全新的项目,我可能会尝试使用 App 目录 ,并将 Page 目录 作为备用方案,或者用于业务关键功能。如果是重构项目,这将取决于我有多少客户端获取数据。如果只有少量:可以尝试;如果有很多:可能会等待全面的解决方案。


请在下方的评论中与我分享您的想法。

更多阅读


原文链接:https://www.smashingmagazine.com/2023/02/understanding-app-directory-architecture-next-js/


发布于: 刚刚阅读数: 3
用户头像

前端技术创新 体验优化 分享经验 共同进步 2018-11-25 加入

通过技术的创新和优化,为用户创造更好的使用体验,并与更多的前端开发者分享我们的经验和成果。我们欢迎对前端开发感兴趣的朋友加入我们的团队,一同探讨技术,共同进步。

评论

发布
暂无评论
深入思考 Next.js  App Directory 架构_架构_汽车之家客户端前端团队_InfoQ写作社区