路由模式与页面数据

Next.js 13 正式推出的 App Router 实现了画饼很久的 Layouts Rfc。可惜的是这与原先的路由模式并不兼容,导致迁移起来非常复杂。

Layout 的加入,让服务端数据的承载不再是单一页面。而每个嵌套的 Layout 和当前页面都可以承载数据。

传统路由模式

在传统的 Page Router 模式中并没有 Layout 的概念,而只有当前页面和顶层的页面入口组件(_app.tsx)。

在早期的 Next.js 中,想要承载页面的数据可以通过 NextPage.getInitialProps 去完成。如下所示:

const PageA: NextPage<{
  foo: string
}> = ({ foo }) => {
  return null
}

PageA.getInitialProps = async (ctx: NextPageContext) => { 
  return {
    foo: 'bar',
  }
}

export default PageA

getInitialProps 可以返回一个对象,然后把数据脱水到当前的页面中,在水合和注入到页面之中。使用 getInitialProps 是最原始的 SSR 渲染方式,但是需要注意,这个方法并不是只在 Server Side 进行调用,而是只有在第一次请求页面时会在服务端渲染当前页面的结构时被调用,随后页面转为完全 CSR 之后,在 Client Side 进行路由跳转,getInitialProps 只会在浏览器端进行调用。那么,这里就会存在问题,getInitialProps 是被混合调用的,你需要去判断调用方是 Server Side 还是 Client Side。如果是 Client Side 你将无法获取到 ctx 中的 req res 等参数并且需要考虑请求跨域的问题;如果是在 Service Side 中调用,就需要考虑用户鉴权的 jwt 存放的位置,如果是 localStorage 则无法获取到。这样或许会带来很多问题。

Excalidraw Loading...

后来,Next.js 新增了 getServerSideProps,这种数据的获取方式永远都在 Service Side,慢慢的官方不再建议使用使用 getInitialProps 去获取数据,转而使用 getServerSideProps 或者 getStaticProps 获取数据,前者是在 runtime 的 Server Side 调用,后者则是在编译阶段调用并且数据在编译时被确定。

一旦使用 getServerSideProps 去获取页面数据,那么 Next.js Server 完全成为 api 服务的反向代理服务器。此时 Next.js Server 遇到不可逆转的宕机时,页面数据请求将全部失效。而前者的 getInitialProps 模式,如果页面已经加载完成,后续页面跳转的请求只要 api server 不出现宕机,网站就能正常工作。

Excalidraw Loading...

然后,直到现在 getInitialProps 依然被保留,因为在某些情况下依然要使用它。则将会在下一节中展开。

Layouts 路由模式

Layouts 的数据获取模式和 React Server Component 是强耦合的。在这种路由模式下,组件即是数据获取的入口。

得益于 Server Component 支持异步的特征,获取数据变成了下面的形式。

const Page = async () => {
  const data = await fetchPageData()
  return <>{/** render your page structure */}</>
}

export default Page

由于引入了 Layout 的概念使得每个页面的组成有本身页面组件定义和上层嵌套的 Layout 组件定义一同形成。每个 Layout 可以是 RSC 同样支持异步获取数据,然后把数据向下子组件注入。那么,作为当前的页面来说,数据的来源不止是由自身决定,而是和上层 Layout 传递的数据共同决定。

当在同一个布局的页面之间跳转(比如动态参数的页面)时,因为 Layout 是同一个,由 Layout 承载的数据源或者组件状态(State)也会继续保持而不会发生改变。而在传统的路由模式中是无法实现这一点的。

*: 无法实现是指框架未提供。在业务中,一般可以通过在 _app.jsx 中通过 pathname 获取其他方式判断需要渲染的 Layout 组件。而这种方式让 Layout 与入口文件相耦合。

由于异步组件返回的是一个 Promise,所以数据的加载延迟会影响整体的页面渲染时长。如下面的代码:

// app/long-request/page.tsx
export default async () => {
  // Mocking a long request
  await sleep(5000) 

  return <div>Long request</div>
}

// app/long-request/layout.tsx
export default ({ children }) => {
  return (
    <div>
      Layout:
      {children}
    </div>
  )
}

此时页面需要等待至少 5 秒才会渲染内容,而且浏览器一直处于等待 HTML 返回阶段并且没有渲染任何内容,在 Chrome 呈现为 Tab 一直转圈状态。

在导读中 Next.js 部分提到了 Suspense 的在这一场景下的用法,那么只需要在 children 包装 Suspense 就能防止浏览器一直处于等待状态,而是优先渲染没有数据依赖的组件。

// app/long-request/page.tsx
export default async () => {
  // Mocking a long request
  await sleep(5000) 

  return <div>Long request</div>
}

// app/long-request/layout.tsx
import { Suspense } from 'react'
export default ({ children }) => {
  return (
    <div>
      Layout:
       <Suspense>
         {children}
       </Suspense>
    </div>
  )
}

这样就会优先渲染出 Layout,然后等待 Page 的结果。效果为:

或者,使用 loading.jsx。这里的效果和使用 Suspense 相同。

// app/long-request/page.tsx
export default async () => {
  // Mocking a long request
  await sleep(5000) 

  return <div>Long request</div>
}
// app/long-request/layout.tsx
export default ({ children }) => {
  return (
    <div>
      Layout:
      {children}
    </div>
  )
}

// app/long-request/loading.tsx
export default function Loading() { 
  return <div>Loading....</div> 
} 

而在传统的路由模式中,只有当 getServerSideProps 或者 getInitialProps 的 Promise 被 resolve,下一个页面才能进行渲染。

那么,处在 Suspense 中的组件会不会影响 SSR 最终的渲染结果呢,如果处在 Suspense 组件无法在 view-source 中观察到其 DOM 结构,也许会影响 SEO 的结构。事实是,并不会影响。

通过访问 view-source:<dev-url>/long-request-suspense 可以观察到被 Suspense 组件的元素在 HTML 上的内容。

注意,你可能会观察到被 Suspense 的组件虽然在 SSR 渲染出来的 HTML 结构里存在,但是他出现的位置并不在相对应对于 HTML 节点中。如上图的 HTML 结构中,Long Request 的文字应该出现在 <div>Layout: 的后面一个 DOM 节点,但是却出现在了 HTML 的最后的位置。

因为 Suspense 的组件不是立即渲染的所以请求的 HTML 也一直处于加载状态,当 Suspense 被 resolve,Next.js 就会把 Suspense 的组件渲染的 DOM 结构附加到 HTML 末尾,然后调用 JS 方法插入到原位置。也正因为这样,浏览器在请求 HTML 时,即便 Suspense 组件没有被 resolve,依然可以优先渲染其他组件。这种渲染方式称之为“渐进式渲染”(Progressive Rendering)。

前面提到,一个页面数据的来源不单纯是自身,更是上层 Layout 提供(如何在页面中使用上层 Layout 中的数据源在后面的章节中描述)。那么,我们可以把页面渲染拆分成多个部分。

Excalidraw Loading...

每个 Layout 获取自身的数据并提供给下文,同时也被 Suspense。从 Root Layout 逐个向下渲染,一旦 Promise 被 resolve 立即开始渲染 Layout 最大程度的提升页面的 First Meaningful Print 指标。

那么,在这种模式下的页面渲染和路由跳转大致流程为:

Excalidraw Loading...

OK,下一节,我们看看在这种路由模式下是如何在业务中获取数据,然后呈现 UI 的。


最后更新于 2024/7/26 16:15:59

更新历史

本书还在编写中..

前往 https://innei.in/posts/tech/my-first-nextjs-book-here#comment 发表你的观点吧。