在前几篇文章中,我们了解了 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 模前的状态就可以用这种方式实作。
Laravel提供了一个方便且好用的方式包装资料,并提供一系列方法处理资料,方便你在处理业务逻辑的同...
Mean Squared Error例题 Training examples (x, y): (1...
Most of the aspiring professionals are getting cer...
今晚你要选哪一道菜呢? 初识料理这件事 各位第一次下厨,是帮自己泡面、煎荷包蛋或炒饭呢? 关於料理的...
前言 昨天我们套用了bootstrap4.6 今天来把登入画面也套上去 并且测试api吧 目标 新增...