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


<<:  Day 06 - Lenses (Basic)

>>:  Day09 - 网站开发从Django开始

Day23 跟着官方文件学习Laravel-Collection

Laravel提供了一个方便且好用的方式包装资料,并提供一系列方法处理资料,方便你在处理业务逻辑的同...

课堂笔记 - 深度学习 Deep Learning (9)

Mean Squared Error例题 Training examples (x, y): (1...

CompTIA SY0-601 Braindumps - All About The SY0-601 Exam

Most of the aspiring professionals are getting cer...

大共享时代系列_004_共享料理资讯

今晚你要选哪一道菜呢? 初识料理这件事 各位第一次下厨,是帮自己泡面、煎荷包蛋或炒饭呢? 关於料理的...

[Day20] 第二十章 - 修改登入画面 (使用bootstrap 4.6的范例)

前言 昨天我们套用了bootstrap4.6 今天来把登入画面也套上去 并且测试api吧 目标 新增...