在前幾篇文章中,我們瞭解了 Next.js 的 file-based routing 機制,知道了三種不同的 routing 模式,並且也知道要怎麼使用 <Link />
切換頁面,所以今天我們來練練手,從零開始建構一個「簡易產品介紹頁」吧!
簡易產品介紹頁的使用者故事如下:
/products
頁面看到商品列表/products/[id].tsx
/product/[id]
進入商品詳細頁面我們用 create-next-app
啟動一個乾淨的專案:
yarn create next-app --typescript
建立完後,進入專案資料夾,接下來我們要來安裝這次專案中需要的套件。
雖然説 Next.js 原生支援 CSS Modules,但是因為筆者比較常用 styled-components
,所以便選擇了這個套件 ?,而由於我們使用了 TypeScript,因此需要裝上 @types/styled-components
讓 TypeScript 看得懂 styled-components
的語法:
yarn add styled-components
yarn add -D @types/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 的根目錄底下,在這個章節我們暫且還不需要用到 getServerSideProps
或 getStaticProps
的方式渲染資料,而是採用直接讀檔的方式取得資料。
各位讀者可以從 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>
的 width
跟 height
,在 <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>
,所以要加上一個 passHref
的 props
,讓 <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
→ 它能夠接受 ASC
跟 DES
兩個值,所以在宣告 useState
時可以使用 fake-data.ts
中的 type Direction
定義狀態的型別。handleSortingDirectionChange
→ 這是一個放在 <select>
上的 onChange
,通常我們會用 React.ChangeEvent<HTMLSelectElement>
定義 function 參數中 event
的型別。最後,我們要處理比較進階的 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 模前的狀態就可以用這種方式實作。
next.config.js
那邊設定 domains 的 markdown 沒作用,變成:domains: ["[fakestoreapi.com](http://fakestoreapi.com/)"]
不知道會不會讓人誤會 @@
感謝提醒,已修正