页面数据和 UI 呈现

在上节的内容中,我们大致了解了 Next.js 在两种模式下的数据获取方式。在这种中,我们深入这部分,既然有了数据就需要在页面中呈现。

初始的全局数据在页面中的呈现

这里的初始的全局数据是指:所有页面都需要用到的数据。比如站点的 SEO 信息,其他配置项。

这些数据必须在第一时间加载,并且为了 SSR 能够正确渲染 SEO 信息,这些数据的获取必须在服务端进行。

传统路由(Page Router)模式

在 Next.js 传统路由模式中,这个步骤一般在 _app.jsx 文件中进行,使用 getInitialProps 获取数据。

在这里 _app.jsx 入口,充当了页面根布局,在传统的路由模式下,获取初始的全局数据也只有下面的一种方式。

Important

而在前面章节提到的 getServerSideProps 在这里并不适用,这也是为什么虽然一直推 getServerSideProps 也没有删除 getInitialProps 方法。

_app.jsx
// _app.jsx
import type { AppProps } from 'next/app'
import Head from 'next/head';

export default function App({ Component, pageProps, ...props }: AppProps & {
  seo: {
    title: string
  };
  top: {
    posts: { title: string }[]
  }
}) {
  return (
    <>
      <Head>
        <title>
          {props.seo.title} 
        </title>
      </Head>
      <Component {...pageProps} />
    </>
  )
}

App.getInitialProps = async () => {
  return {
    seo: {
      title: 'Hello Nextjs'
    },
    siteOwner: {
      name: 'Innei'
    },
    top: {
      posts: [
        {
          title: 'Top Post1'
        }
      ]
    }
  }
}

上面的例子中,使用 getInitialProps 获取了初始的全局数据,并且用 <Head /><title /> 给站点加上了标题。当然你也可以加入其他的 SEO 信息,如果你的 SEO 信息都是动态的,那么这样会十分好用。

如上的根布局的数据需要传递到页面,页面中也会使用到全局的数据。

使用上下文传递

例如,在 posts 页面我需要改变其网页标题,需要使用页面的数据和原标题的拼接;然后使用全局数据的 siteOwner 字段。我们需要在 _app.jsx 使用 Context 向下传递数据。

// root-data-provider.tsx
import { createContext, useContext, type PropsWithChildren } from 'react'

export type RootData = {
  seo: {
    title: string
  }
  top: {
    posts: { title: string }[]
  }
  siteOwner: {
    name: string
  }
}

const RootDataContext = createContext<RootData>(null!)
export const RootDataProvider = (props: PropsWithChildren<RootData>) => {
  return (
    <RootDataContext.Provider value={props}>
      {props.children}
    </RootDataContext.Provider>
  )
}

export const useRootData = () => useContext(RootDataContext)

// _app.tsx
import type { AppProps } from 'next/app'
import Head from 'next/head'
import NextApp from 'next/app'

export default function App({
  Component,
  pageProps,
  rootData,
}: AppProps & { rootData: RootData }) {
  return (
    <>
      <Head>
        <title>{rootData.seo.title}</title>
      </Head>
      <RootDataProvider {...rootData}>
        <Component {...pageProps} />
      </RootDataProvider>
    </>
  )
}

App.getInitialProps = async (props: AppContext) => {
  const appProps = await NextApp.getInitialProps(props) // 需要调用页面的 getInitialProps
  return {
    ...appProps,
    rootData: await fetchRootData(),
  }
}

// 一个假设的数据获取方法
async function fetchRootData() {
  console.log('fetch root data')
  return {
    seo: {
      title: 'Hello Nextjs',
    },
    siteOwner: {
      name: 'Innei',
    },
    top: {
      posts: [
        {
          title: 'Top Post1',
        },
      ],
    },
  } as RootData
}

// posts/[id].tsx
import { NextSeo as Seo } from 'next-seo'

const PostPage: NextPage<{
  title: string
  text: string
}> = (props) => {
  const { title, text } = props

  const rootData = useRootData()
  const router = useRouter()

  return (
    <article>
      <Seo title={`${title} | ${rootData.seo.title}`} />

      <h1>{title}</h1>
      <small>{rootData.siteOwner.name}</small>

      <p>{text}</p>

      <p>
        <button
          onClick={() => {
            router.push('/posts/' + parseInt(router.query.id as any) + 1)
          }}
        >
          Next Post
        </button>
      </p>
    </article>
  )
}

export const getServerSideProps = async (ctx) => {
  return {
    props: {
      title: `My ${ctx.query.id} post`,
      text: 'Provident quis veritatis ratione eum sapiente ipsa repellat corrupti. Commodi asperiores tempore perspiciatis nisi. Itaque inventore facere excepturi ea maiores doloremque at quos culpa.',
    },
  }
}
export default PostPage

那么,这样的改造之后页面成功获取到全局数据,SSR 渲染的 HTML 是 SEO 完备的。我们观察 /posts/1 页面的 view-source:

避免重复的数据获取

在上面的 _app.jsx 中,我们虚拟了 fetchRootData 函数去获取数据,注意这个方法中的 console.log,可以用它鉴定数据的获取频次。

当我现在在同一个路由下,更换 params 以切换页面,你会发现全局数据一直在刷新。

这是因为在传统路由中并没有 Layout 的概念,所有的页面 + _app.jsx 为一个整体,所以在每次跳转新页面都需要重新执行一遍 _app 的逻辑。

我们需要缓存这个数据以便每次路由跳转都会重新刷新数据。然而官方并没有解决这个问题,直到新版路由系统的出现。但是不幸的是两者是无法共存的,那么在传统的路由模式下解决这个问题,我们需要利用一点手段。

首先,转换页面的数据获取方式为 getInitialProps 而不是 getServerSideProps

PostPage.getInitialProps = async () => {
  return {
    title: `My note`,
    text: 'Provident quis veritatis ratione eum sapiente ipsa repellat corrupti. Commodi asperiores tempore perspiciatis nisi. Itaque inventore facere excepturi ea maiores doloremque at quos culpa.',
  }
}

然后,利用 getInitialProps 是混合请求,即前面章节提到的,在转换为 CSR 之后,路由的跳转时请求的数据都由浏览器实现,而不是服务器的特征。那么,我们对 _app.jsxgetInitialProps 也进行改造。

App.getInitialProps = async (props: AppContext) => {
  const appProps = await NextApp.getInitialProps(props)
  return {
    ...appProps,
    rootData: await fetchRootData(),
  }
}

let rootData: any
// 一个假设的数据获取方法
async function fetchRootData() {
  if (rootData) return rootData
  console.log('fetch root data')
  const data = {
    seo: {
      title: 'Hello Nextjs',
    },
    siteOwner: {
      name: 'Innei',
    },
    top: {
      posts: [
        {
          title: 'Top Post1',
        },
      ],
    },
  } as RootData

  if (typeof window !== 'undefined') {
    rootData = data
  }
  return data
}

这里利用了如果在浏览器调用,那么使用缓存的数据;而服务器只会在页面的 SSR 所以不受影响。

上面的代码示例,可以在 codesandbox 中亲自尝试。

App Router 模式

在 App Router 模式下,不再有 _app.jsx 的页面路由。而是所有的页面都是由一个或者多个 Layout 构成的,Layout 之间相互嵌套最后组成完整的页面。

所以获取全局数据的写法就变得非常的简单了。我们只需要定义一个根布局。

// app/layout.jsx

export default async function RootLayout({ children }) {
  const rootData = await fetchRootData()

  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

同理在 posts 页面也可以定义异步服务端组件。

// app/posts/[id]/page.tsx
export default async function (
  props: NextPageParams<{
    id: string;
  }>
) {
  const postData = await fetchPostData(props.params.id);
  const { title, text } = postData;
  return (
    <article>
      <h1>{title}</h1>

      <p>{text}</p>
    </article>
  );
}

const fetchPostData = async (id: string) => {
  return {
    title: `My ${id} post`,
    text: 'Provident quis veritatis ratione eum sapiente ipsa repellat corrupti. Commodi asperiores tempore perspiciatis nisi. Itaque inventore facere excepturi ea maiores doloremque at quos culpa.',
  };
};

这里有两种方式在页面中获取全局数据。

一是和传统路由模式下一样,使用 Context 传递,不过这样的话,由于服务端组件无法使用 Hooks,所以如果这样做你就需要把服务端组件转换为客户端组件。这个方法会丢失服务端组件带来的好处,所以没有特殊情况我们不选择这个方法。

export default async function RootLayout({ children }) {
  const rootData = await fetchRootData();

  return (
    <html lang="en">
      <body className={inter.className}>
        <RootDataProvider {...rootData}>{children}</RootDataProvider>
      </body>
    </html>
  );
}
// root-data-provider.tsx
'use client';

import type { RootData } from '@/api/root-data';
import { createContext, type PropsWithChildren, useContext } from 'react';

const RootDataContext = createContext<RootData>(null!);
export const RootDataProvider = (props: PropsWithChildren<RootData>) => {
  return (
    <RootDataContext.Provider value={props}>
      {props.children}
    </RootDataContext.Provider>
  );
};

export const useRootData = () => useContext(RootDataContext);

我们可以按需把用到全局数据的单独抽离一个成客户端组件,以消费 Context。

// site-owner.tsx
'use client';

import { useRootData } from '@/providers/root-data-provider';

export const SiteOwner = () => {
  const rootData = useRootData();
  return <p>{rootData.siteOwner.name}</p>;
};

页面定义维持服务端组件。

import React from 'react';
import { BottomNav } from './nav';
import { SiteOwner } from './site-owner';

export default async function (
  props: NextPageParams<{
    id: string;
  }>
) {
  const postData = await fetchPostData(props.params.id);
  const { title, text } = postData;

  return (
    <article>
      <h1>{title}</h1>
      <SiteOwner />
      <p>{text}</p>

      <BottomNav id={props.params.id} />
    </article>
  );
}

const fetchPostData = async (id: string) => {
  return {
    title: `My ${id} post`,
    text: 'Provident quis veritatis ratione eum sapiente ipsa repellat corrupti. Commodi asperiores tempore perspiciatis nisi. Itaque inventore facere excepturi ea maiores doloremque at quos culpa.',
  };
};

由于 RootLayout 在路由切换之后并不会被卸载,所以 Layout 上的数据会被缓存,因为,你也不必担心页面跳转会重新请求全局数据。

第二种,使用 cache 函数缓存本次渲染的数据。这样可以所有的渲染仅在服务端完成。

// 一个假设的数据获取方法
export const fetchRootData = cache(async () => {
  console.log('fetch root data');
  return {
    seo: {
      title: 'Hello Nextjs',
    },
    siteOwner: {
      name: 'Innei',
    },
    top: {
      posts: [
        {
          title: 'Top Post1',
        },
      ],
    },
  } as RootData;
});

然后在 posts 中这样调用。

export default async function (
  props: NextPageParams<{
    id: string;
  }>
) {
  const postData = await fetchPostData(props.params.id);
  const { title, text } = postData;

  const rootData = await fetchRootData();

  return (
    <article>
      <h1>{title}</h1>
      <div>From server: {rootData.siteOwner.name}</div>

      <p>{text}</p>

      <BottomNav id={props.params.id} />
    </article>
  );
}

由于被缓存,所以并不会因为这个方法在 RootLayout 和 PostPage 中调用二次而请求数据两次。

但是,在路由切换时,会重复调用获取全局数据的方法。

这时候我们还需要配合 Next.js 魔改的 fetch 进行使用才能解决 Dedup。

我们模拟一个真实接口。

// app/api/root/route.tsx
import { NextResponse } from 'next/server';

export const GET = async () => {
  console.log('fetch real api to get root data');
  return NextResponse.json({
    seo: {
      title: 'Hello Nextjs',
    },
    siteOwner: {
      name: 'Innei',
    },
    top: {
      posts: [
        {
          title: 'Top Post1',
        },
      ],
    },
  });
};

调用方法为:

export const fetchRootDataFromServer = cache(async () => {
  return fetch('http://127.0.0.1:3000/api/root', {
    next: {
      revalidate: 10000000, // 设定大的缓存时间
    },
  }).then(res => res.json());
});
// layout.tsx
export default async function RootLayout({ children }) {
  const rootData = await fetchRootData();

  await fetchRootDataFromServer();

  return (
    <html lang="en">
      <body className={inter.className}>
        <RootDataProvider {...rootData}>{children}</RootDataProvider>
      </body>
    </html>
  );
}
// posts/[id]/page.tsx

export default async function (
  props: NextPageParams<{
    id: string;
  }>
) {
  const postData = await fetchPostData(props.params.id);
  const { title, text } = postData;

  const rootData = await fetchRootData();

  await fetchRootDataFromServer();

  return (
    <article>
      <h1>{title}</h1>
      <SiteOwner />

      <p>{text}</p>

      <BottomNav id={props.params.id} />
    </article>
  );
}

现在我们页面之间跳转,观察真实 API 的调用。

发现数据都被 Next.js 缓存了,真实接口并没有调用。但是此方法,只有服务端执行的 fetch 才有效,这也是 all in rsc 的数据获取方式。

上面的代码示例在:

那么,到这里这节的内容差不多就结束了。下一节,将描述如何让死的数据在接入 WebSocket 之后活起来。


最后更新于 2024/4/27 20:14:01

更新历史

本书还在编写中..

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