iT邦幫忙

2021 iThome 鐵人賽

DAY 23
0
Modern Web

從零開始學習 Next.js系列 第 23

Day23 - 在 Next.js 中如何共用 Layout

  • 分享至 

  • xImage
  •  

前言

在前一篇文章中,我們瞭解了怎麼使用 _app.tsx 撰寫共用 layout 的 component,由於 App 是一個頂層的 component,每個頁面都會執行 App 裡面的程式碼,而我們可以在 _app.tsx 檔案中覆寫預設 App 的行為。

如果想要重複利用一些 layout 時,在 React 中我們經常使用的技巧包括 composition、HOC 等等,而通常這寫技巧都會保留這些 layout 的狀態,在切換頁面時並不會刷新。如果我們想要在 Next.js 中實現頁面共用 layout 的模式,唯一可行的方式就是在 _app.tsx 中撰寫共用 layout 的邏輯,因為如果把邏輯撰寫在頁面中,在切換頁面時整個 UI 都會被重新渲染,而狀態當然也不會被保留,會讓體驗回到像是 10 年前的網站。

如果只能在 _app.tsx 中共用 layout 的邏輯,就會衍生出一些實作上的問題:

  • 如何在不同的頁面渲染不同的 layout,例如前台與後台會是不同的 layout
  • 如何抽象共用 layout 的邏輯,才不會使得 _app.tsx 的程式碼難以理解

以下我們來看幾個案例,漸進式了解怎麼在 Next.js 建立共用的 layout 邏輯,而且可以在切換頁面時仍然可以保留前個頁面的狀態。

定義 Layout 與 ProductsLayout

首先,我們要建立兩個 layout 的共用元件,分別為 <Layout /><ProductsLayout /> ,這兩個元件都會放置於 components/ 資料夾中。

第一個元件包 <Layout /> 含了兩個 <Link /> 可以用來切換兩個頁面,且這個元件包含了一個輸入匡,我們將會用這個輸入匡來測試如何在切換頁面時仍然可以包流狀態,這個元件以 composition 的方式建構,最後會渲染 children

import Link from "next/link";
import { FC, useState } from "react";

const Layout: FC = ({ children }) => {
  const [keyword, setKeyword] = useState("");

  return (
    <div>
      <Link href="/">home</Link>
      <Link href="/products">product</Link>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {children}
    </div>
  );
};

export default Layout;

另一個共用 layout 的元件是 <ProductsLayout /> ,這個元件是 /products 會使用到的元件,而這個元件內還包含了 <Layout /> ,為第一個建立的共用 layout 元件,這個元件的構成很簡單,只有渲染一個「product layout」的字串,以及 children 的內容。

import { FC } from "react";
import Layout from "./Layout";

const ProductsLayout: FC = ({ children }) => {
  return (
    <Layout>
      <div>product layout</div>
      {children}
    </Layout>
  );
};

export default ProductsLayout;

在頁面中單純使用 composition layout ?

我們先來試試看一種基本上在 Next.js 不可行的方式,亦即在切換頁面時 layout 中的狀態一定會消失。在 pages/index.tsx 裡面使用 <Layout /> 這個共用元件,並且單純渲染 You are in / 的字串內容。

// pages/index.tsx
import { NextPage } from "next";
import Layout from "@/components/Layout";

const Home: NextPage = () => {
  return (
    <Layout>
      <div>You are in /</div>
    </Layout>
  );
};

export default Home;

而在 pages/products.tsx 裡面使用 <ProductsLayout /> 這個用元件,並且單純渲染 You are in /products 的字串內容。

// pages/products.tsx
import { NextPage } from "next";
import ProductsLayout from "@/components/ProductsLayout";

const Products: NextPage = () => {
  return (
    <ProductsLayout>
      <div>you are in /products</div>
    </ProductsLayout>
  );
};

export default Products;

如果 <Layout /> 裡面只是單純的靜態內容,沒有讓 React 維護狀態,例如沒使用 useState ,這種建構頁面的方式是沒有問題的,而且程式碼也很直覺,不會很難維護。

但是缺點是在切換頁面時 layout 裡面的狀態完全不能夠保存,像是 layout 裡面包含 tab、 input 之類的會跟使用者互動的元件,在切換頁面後就會回到預設值,會讓使用者體驗非常地不好。

定義 .layout 於頁面上,並於 App 中渲染 ?

接下來,我們看另外一種抽象共用 layout 元件的方式,這種模式將是抽象 layout 元件變成使用注入的方式傳遞到 _app.tsx 中,這樣寫的好處是之後新增頁面時不需要每次都來維護這份 _app.tsx ,你能想像每次新增頁面時會需要使用大量的 if-else 判斷目前需要使用哪個 layout 嗎?

使用這種模式的情況下,每個頁面都只需要維護自己的 layout,新增一個新的頁面也只需要在新頁面中增加像是 Page.layout = layout 這種寫法,就可以讓該頁面使用共用的 layout 元件。

看起來很棒 ?

import { AppProps } from "next/app";

function MyApp({ Component, pageProps }: AppProps) {
  const Layout = Component.layout || ((page) => <div>{page}</div>);

  return (
    <Layout>
      <Component {...pageProps}></Component>
    </Layout>
  );
}
export default MyApp;

接下來看到前面看到的 pages/index.tsx 的程式碼,原本 <Layout /> 是用 composition 的方式放在 component 裡面,現在將 layout 抽離出來,變成使用 Home.layout 注入用的 layout 元件。

// pages/index.tsx
import { NextPage } from "next";
import Layout from "@/components/Layout";

const Home: NextPage = () => {
  return <div>You are in /</div>;
};

Home.layout = Layout;

export default Home;

以同樣的邏輯修改 pages/products.tsx 中的程式碼邏輯,把 <ProductsLayout /> 這個元件抽離出來,變成用 Products.layout 的方式注入 layout 元件。

// pages/products.tsx
import { NextPage } from "next";
import ProductsLayout from "@/components/ProductsLayout";

const Products: NextPage = () => {
  return <div>you are in /products</div>;
};

Products.layout = ProductsLayout;

export default Products;

看起來是一個很棒的 pattern,將共用 layout 的邏輯從 component 中抽離出來,讓 component 可以專注在自己的邏輯上,不會被額外的程式碼混淆。

實際上這個是一個錯誤的範例 ?,因為它不能夠解決在切換頁面時造成狀態不保留的問題。但也許讀者們會有些疑惑究竟是什麼原因造成狀態不保留,layout 已經抽象至 App 的層級,不論是 <Layout /><ProductsLayout /> ,裡面的 <Layout>{...}</Layout> 都是在最外層,應該是沒問題才對?

這裡要談到 React 的 component tree 與 reconciliation,以 ProductsLayout 為例,在 component tree 裡面會多出一個 ProductsLayout 的層級,而 Layout 會是 ProductsLayout 底下的節點。

component tree

在 React 中的 reconciliation 階段會比對同一層級的節點,而 ProductsLayout 跟 Layout 明顯是不一樣的節點,因此在切換頁面時,該節點以下的節點都會直接被砍掉,換上新的節點,所以因為這個情況在 layout 中的狀態才不能被保留。

定義 .getLayout 於頁面上,並於 App 中渲染 ?

以下終於要來介紹在 Next.js 中共用 layout 正確的方法 ?,基本上模式會與 .layout 的模式很像,都是抽離注入 layout 的邏輯,然後在 _app.tsx 取出 layout 並渲染元件。

不一樣的在於原本 .layout 傳入的是一個元件,但是我們在上面的範例中了解到 React 對一個 component 會在 component tree 中新增一個節點,因此在切換頁面時會因為節點不一樣在 reconciliation 被砍掉。

所以為了解決這個問題,使用另一種方式,以宣告 function 的方式定義共用 layout 的邏輯,如下方的 getLayout ,這不再是一個 component,也就不會在 component tree 中多一個節點。

// components/Layout.tsx
export const getLayout = (page) => <Layout>{page}</Layout>;

components/ProductsLayout.tsx 的內部程式碼也要做一些修改,原本 <Layout /> 放在元件裡面,這此改用 getLayout 的方式一層一層地包裹著另一個 getLayout ,如此一來就能夠以不包含 component 節點的情況下共用 layout。

// components/ProductsLayout.tsx
import { FC } from "react";
import { getLayout as getBasicLayout } from "./Layout";

const ProductsLayout: FC = ({ children }) => {
  return (
    <div>
      <div>product layout</div>
      {children}
    </div>
  );
};

export const getLayout = (page) =>
  getBasicLayout(<ProductsLayout>{page}</ProductsLayout>);

export default ProductsLayout;

由於共用 layout 的邏輯以 getLayout 的方式注入,所以在 _app.tsx 裡面也改從 Component.getLayout 取得頁面中相對應的 layout,最後將 getLayout 包裹在外面渲染內部的元件。

// pages/_app.tsx
import { AppProps } from "next/app";

function MyApp({ Component, pageProps }: AppProps) {
  const getLayout = Component.getLayout || ((page) => page);

  return getLayout(<Component {...pageProps}></Component>);
}
export default MyApp;

而在 pages/index.tsx 裡面原本的 .layout 也改成用 .getLayout 的方式注入 layout。

// pages/index.tsx
import { NextPage } from "next";
import { getLayout } from "@/components/Layout";

const Home: NextPage = () => {
  return <div>You are in /</div>;
};

Home.getLayout = getLayout;

export default Home;

同樣地,在 pages/products.tsx 裡面原本的 .layout 也改成用 .getLayout 的方式注入 layout。

// pages/products.tsx
import { NextPage } from "next";
import { getLayout } from "@/components/ProductsLayout";

const Home: NextPage = () => {
  return <div>you are in /products</div>;
};

Home.getLayout = getLayout;

export default Home;

接下來你可以重新運行網頁,在 layout 中的狀態在切換頁面時順利被保留了,原本在 input 中輸入的文字,在切換頁面後都會被清空,但是透過 .getLayout 這個 pattern,讓保留狀態的得以被實現。

再來,以 /products 這個頁面為例,我們透過 React devtool 看到 MyApp 底下第一層即是 Layout ,並不會像 .layout 的模式會再增加一層 component 的節點,因此在切換頁面時,Layout 這個節點是一樣的,所以在 reconciliation 才不會被當作是不同的節點被砍掉。

component tree

TypeScript 的正確起手式

使用 TypeScript 時,我們必須為 getLayoutAppProps 重新定義型別,如果你在嘗試跟著一起撰寫上述的程式碼時,TypeScript 會報錯描述 getLayout 並不存在,所以需要修改 App 與每一個頁面中所引用的型別。

// next.d.ts
import { NextPageWithLayout } from "next";
import { AppProps } from "next/app";

declare module "next" {
  type NextPageWithLayout = NextPage & {
    getLayout?: (page: ReactElement) => ReactNode;
  };
}

declare module "next/app" {
  type AppPropsWithLayout = AppProps & {
    Component: NextPageWithLayout;
  };
}

在 App 中原本會使用 next/appAppProps ,但是 Component 裡面沒有 getLayout 這個屬性,所以會過不了 ts compile,因此透過 AppPropsWithLayout 覆寫原本的 AppProps ,讓 Component 裡面多一個 getLayout 的屬性。

// _app.tsx
import { AppPropsWithLayout } from "next/app";

function MyApp({ Component, pageProps }: AppPropsWithLayout) {
  const getLayout = Component.getLayout || ((page) => page);

  return getLayout(<Component {...pageProps}></Component>);
}
export default MyApp;

而在頁面中原本使用的是 nextNextPage ,與 App 是同樣的理由, NextPage 裡面沒有 getLayout 這個屬性,所以改用 NextPageWithLayout 取代 NextPage ,如此一來就可以注入 layout 至頁面上了。

// pages/index.tsx
import { NextPageWithLayout } from "next";
import { getLayout } from "@/components/Layout";

const Home: NextPageWithLayout = () => {
  return <div>You are in /</div>;
};

Home.getLayout = getLayout;

export default Home;

Reference


上一篇
Day22 - 錯誤捕捉、全域 CSS、共用 Layout,就用 _app.tsx 來搞定吧!
下一篇
Day24 - 遇到 404 或 500 怎麼辦,客製化錯誤頁面
系列文
從零開始學習 Next.js30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
AndrewYEE
iT邦新手 3 級 ‧ 2023-02-17 19:21:53

感謝

我要留言

立即登入留言