Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next.js Rendering #18

Open
bouquetrender opened this issue May 27, 2024 · 0 comments
Open

Next.js Rendering #18

bouquetrender opened this issue May 27, 2024 · 0 comments
Labels

Comments

@bouquetrender
Copy link
Owner

bouquetrender commented May 27, 2024

在 Next.js 中有两种客户端路由的解决方案。

Pages Router 是 Next.js 最初提供的路由系统,它是基于文件系统的路由。开发者只需在 pages 目录下创建对应的 React 组件文件,Next.js 就会根据文件名和目录结构自动生成对应的路由。

随着时间推移,React 组件组合的复杂度不断增加,Pages Router 显得越来越臃肿和越来越难以维护。因此,Next.js 团队在 Next.js 13 中引入了全新的 App Router,它采用了不同的设计思路。

App Router 基于 React Server Components,拥有更好的开发体验和更高的性能。它改变了路由和渲染的方式,使得页面可以根据需要分块渲染,并且组件层次结构更加合理。

Pages Router

  • Client-side Rendering(CSR)
  • Server-side Rendering(SSR)
  • Static Site Generation (SSG)
  • Incremental Static Regeneration (ISR)

App Router

  • Client Components
  • Server Component

Pages Router

Client-side Rendering(CSR)

CSR 中译为客户端渲染,渲染工作主要在客户的进行。传统开发使用 React 框架渲染页面就是客户端渲染。浏览器先会下载 HTML 模板和 JS 文件。在 JS 中发送请求,获取数据,更新DOM,渲染结果到页面中。在下载、解析、执行 JavaScript以及请求数据没有返回前,页面不会完全呈现。

在 Next.js Pages Router 下有两种方式实现客户端渲染。一种是在页面中使用 React useEffect hook:

import React, { useState, useEffect } from 'react'
 
export default function Page() {
  const [data, setData] = useState(null)
 
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`)
      }
      const result = await response.json()
      setData(result)
    }
 
    fetchData().catch((e) => {
      console.error('An error occurred while fetching the data: ', e)
    })
  }, [])
 
  return <p>{data ? `Your data: ${JSON.stringify(data)}` : 'Loading...'}</p>
}

请求由客户端发出,同时页面显示 loading 状态,等数据返回后,主要内容在客户端进行渲染。当访问 /csr的时候,渲染的 HTML 文件为:

image

JS 获取到请求体数据后,渲染到页面:

image

第二种方法是在客户端使用数据获取的库,例如 SWR (stale-while-revalidate) 和 TanStack Query。

SWR 和 TanStack Query 都是用于处理数据获取和缓存的 React 库,它们的目标是简化远程数据请求的管理。

和封装 Axios 不同,SWR 和 TanStack Query 则是基于请求库之上的一层抽象,用于更高效地管理整个数据获取过程,包括数据缓存、重新验证、请求去重、在组件生命周期中集成数据获取等。

TanStack Query 之前称为 React Query,是一个功能更加强大的 React 库,专注于服务器状态管理,同时具有查询、缓存、更新和错误处理能力:

import {
  QueryClient,
  QueryClientProvider,
  useQuery,
} from '@tanstack/react-query'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
    <Example />
    </QueryClientProvider>
  )
}

function Example() {
  const { isPending, error, data } = useQuery({
    queryKey: ['repoData'],
    queryFn: () =>
      fetch('https://api.github.com/repos/TanStack/query').then((res) =>
        res.json(),
      ),
  })

  if (isPending) return 'Loading...'

  if (error) return 'An error has occurred: ' + error.message

  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.description}</p>
    </div>
  )
}

SWR 是一个轻量级的 React Hooks 库,它采用 stale-while-revalidate (陈旧数据可以被使用,同时在后台进行数据重新验证) 的策略,用于远程数据获取:

import useSWR from 'swr'
 
function Profile() {
  const { data, error, isLoading } = useSWR('/api/user', fetcher)
 
  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

Server-side Rendering(SSR)

服务端渲染,渲染工作主要在服务端执行。例如一篇资讯文章,博客文章等等类似的页面,可以由服务端直接请求接口、获取数据,然后渲染成静态的 HTML 文件返回给用户。

虽然与 CSR 相比同样是发送请求,但通常服务端的环境(网络环境、设备性能)要好于客户端,所以最终的渲染速度(首屏加载时间)也会更快。

CSR 响应时只用返回一个很小的 HTML,SSR 响应还要请求接口,渲染 HTML,所以其响应时间会更长,对应到性能指标 TTFB (Time To First Byte) 的话,SSR 则会更长。


TTFB (Time To First Byte) 是指从客户端发出请求到接收到第一个字节数据的时间。它是衡量网站响应速度的一个重要指标,反映了服务器响应的速度。

TTFB由以下几个阶段组成:

  1. DNS 查询时间
  2. TCP 连接时间
  3. 发送 HTTP 请求时间
  4. 服务器处理请求时间
  5. 响应第一个字节数据的时间

TTFB时间越短,说明服务器响应越快,用户的等待时间也就越短。

function getTTFB() {
  // 获取当前页面的 Navigation Timing 信息
  const navTiming = window.performance.getEntriesByType("navigation")[0];
  // 计算 TTFB 时间
  const ttfb = navTiming.responseStart - navTiming.requestStart;

  return ttfb;
}

// 调用函数获取 TTFB
const ttfbTime = getTTFB();
console.log(`TTFB: ${ttfbTime} ms`);

requestStart 表示浏览器发起页面请求的时间戳。它包含了重定向的时间,是浏览器发起页面请求的最早时间点。

responseStart 表示浏览器从服务器接收到第一个字节数据的时间戳。


Next.js Pages Router 中实现 SSR,需要导出一个 getServerSideProps 函数。这个函数会在每次请求的时候被调用。返回的数据会通过组件的 props 属性传递给组件。

export default function Page({ data }) {
  return <p>{JSON.stringify(data)}</p>
}
 
export async function getServerSideProps() {
  const res = await fetch(`https://jsonplaceholder.typicode.com/todos`)
  const data = await res.json()
 
  return { props: { data } }
}

image

服务端会在每次请求的时候编译 HTML 文件返回给客户端。

  • getServerSideProps 在服务器端运行。
  • getServerSideProps 只能从一个 页面 组件中导出。
  • getServerSideProps 返回 JSON 数据。
  • 当用户访问一个页面时,getServerSideProps 将被用于在请求时获取数据,这些数据用于渲染该页面的初始 HTML。
  • 传递给页面组件的 props 可以在客户端的初始 HTML 中查看。这样做是为了允许页面被正确地 hydrate。确保不要在 props 中传递任何不应在客户端可用的敏感信息。
  • 当用户通过 next/link 或 next/router 访问页面时,Next.js 会向服务器发送一个 API 请求,该请求会运行 getServerSideProps。
  • 在使用 getServerSideProps 时,不需要调用 Next.js API 路由来获取数据,因为该函数在服务器端运行。相反,可以在 getServerSideProps 内部直接调用 CMS、数据库或其他第三方 API。

hydrate(直译:使...吸入水分 / 注水)在 Next.js 和 React 中是指客户端渲染初始 HTML 的过程。

在服务器端渲染(SSR)应用中,服务器会预先渲染出初始 HTML 内容,发送给客户端浏览器。当浏览器接收到 HTML 内容后,React 需要将其 hydrate 到已有的 DOM 树上,使之拥有完整的交互能力。这个过程就叫做 hydration 。

hydration:溶质分子(HTML)和水分子(React)发生作用,形成水合分子(Result)的过程。

React 会尝试复用预渲染的静态 HTML 内容,只在必要时进行更新,而不是从头开始重新渲染整个视图。这有助于提高初始加载性能。

"hydration"的步骤概括如下:

  1. 服务器预先渲染并生成静态 HTML
  2. 浏览器加载 HTML 内容并解析为 DOM 树
  3. React 加载并识别预渲染的 DOM 结构
  4. React 将事件处理函数等动态行为 hydrate 到现有 DOM 上
  5. React 可以在此基础上进行后续的组件更新和交互处理

在 hydrate 过程中,如果客户端生成的 DOM 与服务器端预渲染的 HTML 不匹配,React 将报错并退出整个 hydrate 过程。因此保持服务器端和客户端渲染结果一致非常重要。

hydrate 让 React 应用获得了服务器预渲染 HTML 的性能优势,同时又拥有了完整的客户端交互能力,是 SSR 应用不可或缺的一个环节。


可以在 getServerSideProps 中使用 Cache-Control 来缓存动态响应。

// 该值在十秒内被认为是新鲜的(s-maxage=10)。
// 如果在接下来的10秒内重复请求,之前缓存的值仍然是新鲜的。
// 如果在59秒内重复请求,缓存的值将会过期但仍然可以渲染(stale-while-revalidate=59)。

// 在后台,将会发起重新验证请求以用新值填充缓存。
// 如果刷新页面会看到新值。
export async function getServerSideProps({ req, res }) {
  res.setHeader(
    'Cache-Control',
    'public, s-maxage=10, stale-while-revalidate=59'
  )
 
  return {
    props: {},
  }
}

Static Site Generation (SSG)

静态站点生成,SSG 会在构建阶段,就将页面编译为静态的 HTML 文件。例如博客文章,作品集,帮助文档。

比如打开一篇博客文章页面,既然所有人看到的内容都是一样的,没有必要在用户请求页面的时候,服务端再请求接口。干脆先获取数据,提前编译成 HTML 文件,等用户访问的时候,直接返回 HTML 文件。

默认情况下,Next.js 使用静态生成预渲染页面,而不获取数据。这是一个例子:

function About() {
  return <div>About</div>
}
 
export default About

此页面不需要获取任何外部数据来进行预渲染。在这种情况下,Next.js 在构建期间为页面生成一个 HTML 文件。

有些页面需要获取外部数据进行预渲染。例如博客文章页面,获取标题时间内容。分两种情况:

  1. 页面内容取决于外部数据,使用 getStaticProps
  2. 页面路径取决于外部数据,使用 getStaticPaths

第一种情况,博客页面可能需要从 CMS(内容管理系统)获取博客文章列表,fetch 所有文件后传入这个组件

export default function Blog({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li>{post.title}</li>
      ))}
    </ul>
  )
}

这时需要在 pre-render 预渲染时获取该数据,导出一个名为 getStaticProps 的 async 函数,这个函数会在构建时调用在预渲染时获取数据传递给 Blog 组件:

export default function Blog({ posts }) {
  // Render posts...
}
 
// This function gets called at build time
export async function getStaticProps() {
  // Call an external API endpoint to get posts
  const res = await fetch('https://.../posts')
  const posts = await res.json()
 
  // By returning { props: { posts } }, the Blog component
  // will receive `posts` as a prop at build time
  return {
    props: {
      posts,
    },
  }
}

第二种情况,页面路径取决于外部数据。例如,可以创建一个名为 pages/posts/[id].js 的文件来显示基于 id 的单个博客文章。这将允许在访问 posts/1 时显示带有 id: 1 的博客文章。预渲染的页面不同 id 路径取决于外部数据。

从动态页面(在本例中为 pages/posts/[id].js ) export 一个名为 getStaticPaths 的 async 函数。该函数在构建时被调用,并允许指定要预渲染的路径。

// This function gets called at build time
export async function getStaticPaths() {
  // Call an external API endpoint to get posts
  const res = await fetch('https://.../posts')
  const posts = await res.json()
 
  // Get the paths we want to pre-render based on posts
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))
 
  // We'll pre-render only these paths at build time.
  // { fallback: false } means other routes should 404.
  return { paths, fallback: false }
}

同样在 pages/posts/[id].js 中,需要导出 getStaticProps ,以便可以使用此 id 获取有关帖子的数据并使用它来预渲染页面:

export default function Post({ post }) {
  // Render post...
}
 
export async function getStaticPaths() {
  // ...
}
 
// This also gets called at build time
export async function getStaticProps({ params }) {
  // params contains the post `id`.
  // If the route is like /posts/1, then params.id is 1
  const res = await fetch(`https://.../posts/${params.id}`)
  const post = await res.json()
 
  // Pass post data to the page via props
  return { props: { post } }
}

在 App Router 中使用 generateStaticParams 替换 getStaticPaths 来生成 SSG,以我个人博客为例:
image

Incremental Static Regeneration (ISR)

ISR 中译为增量静态再生。以博客文章页面为例,博客的主体内容是不变的,但点击数、点赞、收藏这些数据是在变化的。如果使用使用 SSG 编译成 HTML 文件后,这些数据就无法准确获取了。考虑到这种情况,Next.js 提出了 ISR,当用户第一次访问了文章页面,依然是老的 HTML 内容,但 Next.js 同时静态编译成新的 HTML 文件,当第二次访问或者其他用户访问的时候,就会变成新的 HTML 内容。

演示:https://github.com/vercel/on-demand-isr

添加 revalidate 属性到 getStaticProps 中使用 ISR:

function Blog({ posts }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}
 
// This function gets called at build time on server-side.
// It may be called again, on a serverless function, if
// revalidation is enabled and a new request comes in
export async function getStaticProps() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()

  if (!res.ok) {
    // If there is a server error, you might want to
    // throw an error instead of returning so that the cache is not updated
    // until the next successful request.
    throw new Error(`Failed to fetch posts, received status ${res.status}`)
  }
 
  return {
    props: {
      posts,
    },
    // Next.js will attempt to re-generate the page:
    // - When a request comes in
    // - At most once every 10 seconds
    revalidate: 10, // In seconds
  }
}
 
// This function gets called at build time on server-side.
// It may be called again, on a serverless function, if
// the path has not been generated.
export async function getStaticPaths() {
  const res = await fetch('https://.../posts')
  const posts = await res.json()
 
  // Get the paths we want to pre-render based on posts
  const paths = posts.map((post) => ({
    params: { id: post.id },
  }))
 
  // We'll pre-render only these paths at build time.
  // { fallback: 'blocking' } will server-render pages
  // on-demand if the path doesn't exist.
  return { paths, fallback: 'blocking' }
}
 
export default Blog

当访问页面时,首先显示的是缓存的页面。在初始请求后和接下来的 10 秒内,页面都会使用之前构建的 HTML。10s 后第一个请求发生的时候,依然使用之前编译的 HTML。但 Next.js 会开始构建更新 HTML,从第二个请求起,就会使用新的 HTML(如果构建失败回退到用第一次,等下次再触发更新)。

混合使用

Next.js 是自动判断每个页面的渲染模式,可以支持混合使用多种渲染模式。

当页面有 getServerSideProps 的时候,Next.js 切成 SSR 模式。没有 getServerSideProps 则会预渲染页面为静态的 HTML。就算用 CSR 模式,Next.js 也要提供一个静态的 HTML,所以还是要走预渲染流程,只不过相比 SSG 渲染的内容少了些。

页面可以是 SSG + CSR 的混合,由 SSG 提供初始的静态页面,提高首屏加载速度。CSR 动态填充内容,提供交互能力。例如下面的例子,初始的文章列表数据就是在构建的时候写入 HTML 里的,在点击换一批按钮的时候,则是在客户端发送请求重新渲染内容:

import React, { useState } from 'react'

export default function Blog({ posts }) {
  const [data, setData] = useState(posts)
  return (
    <>
      <button onClick={async () => {
          const res = await fetch('https://jsonplaceholder.typicode.com/posts')
          const posts = await res.json()
          setData(posts.slice(10, 20))
      }}>换一批</button>
      <ul>
        {data.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  )
}

export async function getStaticProps() {
  const res = await fetch('https://jsonplaceholder.typicode.com/posts')
  const posts = await res.json()
  return {
    props: {
      posts: posts.slice(0, 10),
    },
  }
}

App Router

Next.js 在 13 推出 App Router 路由解决方案。

回到之前 Pages Router 路由解决方案,通过 getServerSideProps 方法开启 SSR,在页面层级获取数据,然后通过 props 传给每个组件,然后将整个组件树在服务端渲染为 HTML。

export default function Page({ data }) {
  return <p>{JSON.stringify(data)}</p>
}

export async function getServerSideProps() {
  const res = await fetch(`https://jsonplaceholder.typicode.com/todos`)
  const data = await res.json()

  return { props: { data } }
}

从这个例子中可以看出 SSR 都是通过 getServerSideProps 方法,在页面层级获取数据,然后通过 props 传给每个组件,然后将整个组件树在服务端渲染为 HTML。

但是 HTML 是没有交互性的(non-interactive UI),客户端渲染出 HTML 后,还要等待 JavaScript 完全下载并执行。JavaScript 会赋予 HTML 交互性,也就是上面说的 hydration。此时内容变为可交互的(interactive UI)。

从这个过程中,可以看出 SSR 的几个缺点:

  • SSR 的数据获取必须在组件渲染之前
  • 组件的 JavaScript 必须先加载到客户端,才能开始 hydration
  • 所有组件必须先 hydration,然后才能跟其中任意一个组件交互

SSR 这种技术,加载整个页面的数据,加载整个页面的 JavaScript,然后进行 hydration,还必须按此顺序串行执行。如果有某些部分慢了,都会导致整体效率降低。

可以在服务端就划分组件模块来获取数据,而不是等待所有组件请求完成再渲染吗?

Server Components

RSC 允许服务器和客户端(浏览器)协作渲染页面。RSC 可以专注于获取数据和呈现内容,RCC 可以专注于有状态交互,从而实现更快的页面加载、更小的 JavaScript 包大小以及更好的用户体验。

错误的概念:RSC 就是 SSR ❌

表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。RSC 和 SSR 既可以各自单独使用,又可以搭配在一起使用。

RSC 和 RCC 组合:
image

RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据。而非像使用 getServerSideProps 通过 SSR 顶层获取数据。RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中。

SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式称之为 RSC Payload。这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。

image
image
上面响应体内容就是 RSC Payload。

在服务端上的序列化树结构,所有的 Props 必须是可序列化,整个 React 树序列化为 JSON,发送到客户端:
image

在客户端根据 RSC Payload 数据重构 React 树,用真正的客户端组件填充占位,再渲染最终的结果:
image

使用 React Server Component,因为服务端组件的代码不会打包到客户端代码中,它可以减小包(bundle)的大小。且在 React Server Component 中,可以直接访问后端资源。当然因为在服务端运行,对应也有一些限制,比如不能使用 useEffect 和客户端事件等。

上面比较的是 React Demo 展示的 RSC 特性和 getServerSideProps 所代表的传统 SSR。跟 Next.js 的服务端组件、客户端组件并不完全一样。 Next.js 的服务端组件虽然是基于 RSC 提出的用于区分组件类型的概念,但在具体实现上为了追求高性能,技术上融合了 RSC 和 SSR(也就是互补)。

Client Components

reactwg/server-components#4

我们所熟悉的React:

image

Next.js App router 路由解决方案中:

image

客户端渲染:

  • 交互,使用 state,effects,event listeners
  • 浏览器 API

使用客户端组件需要在文件顶部加入 use client 指令

'use client'
 
import { useState } from 'react'
 
export default function Counter() {
  const [count, setCount] = useState(0)
 
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

如果不加 use client,会处理成 RSC,使用事件和 hooks 会报错:
image

因为 RSC 需要在服务器和客户端之间传输数据。为了实现这一点,Next.js 将整个 React 树序列化为 JSON,然后在客户端重新创建。由于 JSON 格式的限制,所有传递给服务器组件的 props 必须是可序列化的。

不可序列化的对象包括函数、Promise 等等。这就是为什么服务器组件无法使用事件处理程序作为 props 传递给子组件的原因。事件处理程序是一个函数,因此不可序列化。同理,RSC 也无法使用 effects(如 useEffect、useMemo 等),因为这些 Hook 通常会涉及到不可序列化的对象,如事件处理程序或者浏览器 API。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant