iT邦幫忙

2021 iThome 鐵人賽

DAY 6
0
Modern Web

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

Day06 - 用 Next.js 做一個簡易產品介紹頁,使用 file-based routing

前言

在前幾篇文章中,我們瞭解了 Next.js 的 file-based routing 機制,知道了三種不同的 routing 模式,並且也知道要怎麼使用 <Link /> 切換頁面,所以今天我們來練練手,從零開始建構一個「簡易產品介紹頁」吧!

使用者故事 (user story)

簡易產品介紹頁的使用者故事如下:

  • 我能夠從 /products 頁面看到商品列表
  • 我能夠在商品列表看到每個商品的名稱、介紹、圖片、價錢等以上四個資訊
    • 備註:商品的介紹只需出現一列,多餘的字以「...」取代
  • 我可以點擊商品的名稱,進入商品詳細頁面 /products/[id].tsx
  • 我在商品列表中可以依照價格排序商品,希望重新整理後仍然可以保留排序的結果
  • 我能夠從 /product/[id] 進入商品詳細頁面
  • 我可以在商品詳細頁面看到一個商品的名稱、完整的介紹、圖片、價錢

商品列表頁面 UI

商品列表頁面 UI

商品詳細頁面 UI

商品詳細頁面 UI


建立專案

我們用 create-next-app 啟動一個乾淨的專案:

yarn create next-app --typescript

建立完後,進入專案資料夾,接下來我們要來安裝這次專案中需要的套件。

styled components

雖然説 Next.js 原生支援 CSS Modules,但是因為筆者比較常用 styled-components ,所以便選擇了這個套件 ?,而由於我們使用了 TypeScript,因此需要裝上 @types/styled-components 讓 TypeScript 看得懂 styled-components 的語法:

yarn add styled-components
yarn add -D @types/styled-components

在 Next.js 中使用 styled components 會踩到的地雷 ?

下載完 styled-components 後,我們來寫一段程式碼測試看看:

import styled from "styled-components";

const Container = styled.div`
  text-align: center;
`;

const Home = () => {
  return <Container>home</Container>;
};

export default Home;

然後開啟開發用伺服器 yarn dev ,接著發生了一件奇怪的事情,當我們打開瀏覽器的開發者工具,會在 console 中看到以下的錯誤訊息。

根據 Next.js 的開發者在 issues#7322 中回應,這個問題是因為 Next.js 在 pre-rendering 階段 (SSR 或 SSG) 產生的 className 與 client-side 產生的 className 不一樣,導致 React 在 hydrate 時發現了這個問題,所以丟出了這個警告讓我們知道。

而要解決這個問題就要從 styled-components 下手,讓 pre-rendering 跟 client-side 產生的 className 一致。styled-components 有廣大的社群,所以已經有相對應的解決方案,各位讀者不用擔心要自己從零開始解決 XD

首先,安裝 babel-plugin-styled-components 這個 babel 的 plugin:

yarn add -D babel-plugin-styled-components

然後在 .babelrc 中設定這個 plugin 後,就可以解決這個問題:

{
  "presets": ["next/babel"],
  "plugins": [["babel-plugin-styled-components"]]
}

設定完後,重新啟動伺服器 yarn dev ,這個警告訊息就會從 console 中消失囉!

下載專案用的假資料

最後,我們需要的假資料以靜態檔案放置在 Next.js 的根目錄底下,在這個章節我們暫且還不需要用到 getServerSidePropsgetStaticProps 的方式渲染資料,而是採用直接讀檔的方式取得資料。

各位讀者可以從 gist 中下載: https://gist.github.com/leochiu-a/4a2c9e5dadb56fa26efb454ecb3cee4c


產品列表

首先,我們先來做以下幾個 user story 的功能:

  • 我能夠從 /products 頁面看到商品列表
  • 我能夠在商品列表看到每個商品的名稱、介紹、圖片、價錢等以上四個資訊
    • 備註:商品的介紹只需出現一列,多餘的字以「...」取代
  • 我可以點擊商品的名稱,進入商品詳細頁面 /products/[id].tsx

/pages/products/index.tsx

style: https://gist.github.com/leochiu-a/c4b8ac14ed823bcf6b8326717e594910

因為資料是寫死在 fake-data.ts 中,要取得資料,我們可以使用 getAllProduct 這個 function;每個產品呈現的樣式是重複的,所以我們可以把它變成是一個 component,稱作 <ProductCard /> ;而 styled-components 我們統一放在 *.style.ts 中,才不會跟 component 的程式碼混再一起。

渲染 <ProductCard /> 的方式也很單純,products.map 把每一個 product 物件取出來當作 props ,傳入到 <ProductCard /> 就可以了。

import { getAllProduct } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductGallery } from "./index.style";

const Home = () => {
  const products = getAllProduct();

  return (
    <>
      <PageTitle>商品列表</PageTitle>
      <ProductGallery>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </ProductGallery>
    </>
  );
};

export default Home;

/components/ProductCard.tsx

style: https://gist.github.com/leochiu-a/cbe9bfbcf88c5516ce0721da350073fb

還記得我們在第一篇文章中有提到 Next.js 有優化圖片的功能,能夠自動化產生 WebP 的圖片,並且可以根據瀏覽器是否支援 WebP,而選擇給與 WebP 或其他格式的圖片檔案。

如果我們要在 Next.js 中使用這個功能,要使用 next/image 中的 <Image /> 。各位讀者可以再注意到 fake-data.ts 中的 image ,因為假資料是從 fakestoreapi.com 這個網站中取得的,圖片也是掛載在 fakestoreapi.com 這個 domain 上。

所以,如果我們要使用第三方的圖片,並且想要用 <Image /> 優化圖片檔案,就要在 next.config.js 中設定 domain

module.exports = {
  images: {
    domains: ["fakestoreapi.com"],
  },
};

通常為了不讓畫面重繪 (reflow),在使用圖片時,可以在 <img /> 外面包一層 <div> ,然後限定 <div>widthheight ,在 <Image /> 這個 component 傳入 layout="fill"objectFit="cover" 兩個 props,讓圖片可以直接吃到 <div> 的寬跟高,而在圖片載入的過程中讓頁面重繪,導致 web vitals 的 CLS(Cumulative Layout Shift) 分數提高。

在了解了 <Image /> 怎麼用之後,我們再來看看下方的 <Link /> ,還記得 <Link /> 的 child node 只能是 <a> 或一般的字串,這樣才能保證能夠把 <Link />href 嵌入到 <a> 上。

然而,我們使用了 styled-components ,雖說 <ProductTitle> 是定義為 <a> ,但是 <Link /> 實際上不知道它是 <a> ,所以要加上一個 passHrefprops ,讓 <Link />href 可以強制被設定到 child node 上面。

剩下的排版就比較單純,只是把資料搭配 styled-components 渲染到畫面上。

import Image from "next/image";
import Link from "next/link";

import { Product as ProductType } from "../fake-data";
import {
  Product,
  ImageWrapper,
  ProductDetail,
  ProductTitle,
  ProductDescription,
  ProductPrice,
} from "./Product.style";

interface ProductCardProps {
  product: ProductType;
  all?: boolean;
}

const ProductCard = ({ product, all }: ProductCardProps) => {
  const { id, image, title, description, price } = product;
  return (
    <Product key={id}>
      <ImageWrapper>
        <Image src={image} alt="product" layout="fill" objectFit="cover" />
      </ImageWrapper>
      <ProductDetail>
        <Link href={`/product/${id}`} passHref>
          <ProductTitle>{title}</ProductTitle>
        </Link>
        <ProductDescription $all={all}>{description}</ProductDescription>
        <ProductPrice>${price}</ProductPrice>
      </ProductDetail>
    </Product>
  );
};

export default ProductCard;

做完以上兩個 component 後,現在應該可以看得到一個「產品列表頁面」,接著我們要來實作 /product/[id] 這個頁面的 component 囉!

產品詳細頁面

產品詳細頁面的 user story 很單純,主要是根據 router.query 顯示資料:

  • 我能夠從 /product/[id] 進入商品詳細頁面
  • 我可以在商品詳細頁面看到一個商品的名稱、完整的介紹、圖片、價錢

/pages/products/[id].tsx

style: https://gist.github.com/leochiu-a/56106bd3bd24efb7d75082f0fb60b2f3

要實現這個頁面,我們會用到 dynamic routes 這個功能,從 router.query 中取得 id ,並再用 getProductById 這個 function 拿到相對應的產品物件。

這裡要特別注意的是,還記得我們在 dynamic routes 的章節有說到,因為 Next.js 有 pre-rendering 這個階段,導致 router.query 第一次渲染時是空物件 {} ,所以用解構賦值 (Destructuring assignment) 拿到的 id 會是 undefined ,因此要用 conditionally render 的方式繞開,避免 <ProdcutCard /> 爆掉。

import { useRouter } from "next/router";
import Link from "next/link";

import { getProductById } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductContainer, BackLink } from "./[id].style";

const Product = () => {
  const router = useRouter();
  const { id } = router.query;

  if (!id) return <></>;

  const product = getProductById(id as string);

  return (
    <>
      <PageTitle>商品詳細頁面</PageTitle>
      <BackLink>
        <Link href="/products">回產品列表</Link>
      </BackLink>
      <ProductContainer>
        <ProductCard product={product} all />
      </ProductContainer>
    </>
  );
};

export default Product;

在做完「產品列表頁面」跟「產品詳細頁面」後,現在我們在產品列表頁面中點擊商品標題進入產品詳細頁面,實現了一個基本的 file-based routing。

使用價格排序產品列表

在「產品列表頁面」中有個 user story 如下:

  • 我在商品列表中可以依照價格排序商品,希望重新整理後仍然可以保留排序的結果

我們分成兩個階段來做,先讓產品可以依照價格排序,再來做比較進階的「重新整理頁面後仍然可以保留排序結果」這個功能。

/pages/products/index.tsx

fake-data.ts 中有提供一個 function 叫做 sortByPrice ,可以透過傳入期望排序的順序,回傳相對應的結果。

所以,我們要做的事情就是用 useState 保存目前使用者選擇的排序順序,再使用 sortByPrice 根據 direction 排序 products 物件,最後將它渲染在畫面上。

import { useState, ChangeEvent } from "react";

import { sortByPrice, Direction } from "../../fake-data";
import ProductCard from "../../components/ProductCard";
import { PageTitle, ProductGallery, PriceFilter } from "./index.style";

const Home = () => {
  const [direction, setDirection] = useState<Direction>("ASC");

  const products = sortByPrice(direction);

  const handleSortingDirectionChange = (e: ChangeEvent<HTMLSelectElement>) => {
    setDirection(e.target.value as Direction);
  };

  return (
    <>
      <PageTitle>商品列表</PageTitle>
      <PriceFilter>
        Price:
        <select value={direction} onChange={handleSortingDirectionChange}>
          <option value="ASC">價格由低到高</option>
          <option value="DES">價格由高到低</option>
        </select>
      </PriceFilter>
      <ProductGallery>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </ProductGallery>
    </>
  );
};

export default Home;

由於我們使用的是 TypeScript,所以在定義狀態跟 function 時要注意:

  • direction → 它能夠接受 ASCDES 兩個值,所以在宣告 useState 時可以使用 fake-data.ts 中的 type Direction 定義狀態的型別。
  • handleSortingDirectionChange → 這是一個放在 <select> 上的 onChange ,通常我們會用 React.ChangeEvent<HTMLSelectElement> 定義 function 參數中 event 的型別。

使用 shallow routing 讓頁面記住排序規則

最後,我們要處理比較進階的 user story:

  • 我在商品列表中可以依照價格排序商品,希望重新整理後仍然可以保留排序的結果

要實現這個功能,勢必要找個個地方儲存「使用者選擇的排序順序 direction 」,儲存的方式有很多種,例如:儲存進資料庫、 localStorage 、query string 上,每一種做法都有其優缺點,要看產品的需求是什麼?

因為前面學習到 shallow routing 這個技巧,那我們就把 direction 使用 shallow routing 把它儲存到 url 的 query string 上吧!

要實作這個功能,改動的地方是 handleSortingDirectionChange 裡面的實作,把原本 setDirection 改成用 router.push 的方式加上 shallow routing 動態地修改 direction 的數值。

然後可以用 router.query 取得 url 上的 query string,所以搭配 useEffect 監聽 router.query.direction 的數值,然而 router.query 在第一次渲染時是空物件 {} ,所以要判斷能不能從中取得 direction ,再將結果用 setDirection 儲存到狀態中。

const Home = () => {
  const [direction, setDirection] = useState<Direction>("ASC");
  const router = useRouter();

  const products = sortByPrice(direction);

  const handleSortingDirectionChange = (e: ChangeEvent<HTMLSelectElement>) => {
    const dir = e.target.value;
    router.push(`${router.pathname}?direction=${dir}`, undefined, {
      shallow: true,
    });
  };

  useEffect(() => {
    if (router.query.direction) {
      setDirection(router.query.direction as Direction);
    }
  }, [router.query.direction]);

  // render component ...
};

現在使用者選擇的排序順序就不會因為重新整理也面而回復初始值囉!這種搭配 shallow routing 的方式也可以被用在很多地方,如果想要修改 url 上的 query string,但是想要保留 component 模前的狀態就可以用這種方式實作。

Reference


上一篇
Day05 - 使用 Link 實作換頁
下一篇
Day07 - 在 Next.js 中使用 pre-rendering (getStaticProps) — Part 1
系列文
從零開始學習 Next.js30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
馬克
iT邦研究生 3 級 ‧ 2021-09-21 18:34:29

『 會在 console 中看到以下的錯誤訊息。 』 下面的圖掉囉 ~

Leochiu iT邦新手 5 級 ‧ 2021-09-21 18:40:41 檢舉

馬克 iT邦研究生 3 級 ‧ 2021-09-21 22:33:01 檢舉

0
Dylan
iT邦新手 3 級 ‧ 2021-10-03 18:16:48

next.config.js 那邊設定 domains 的 markdown 沒作用,變成:
domains: ["[fakestoreapi.com](http://fakestoreapi.com/)"]
不知道會不會讓人誤會 @@

Leochiu iT邦新手 5 級 ‧ 2021-10-03 18:44:33 檢舉

感謝提醒,已修正 /images/emoticon/emoticon12.gif

我要留言

立即登入留言