构建一个Next.js购物车应用程序

1,048 阅读13分钟

Next.js是Vercel的React框架,随着越来越多的React开发者追求服务器端渲染、静态网站生成和增量静态再生,以及其他SEO和优化的好处,它的受欢迎程度持续增长。

由于Next.js的学习曲线比较平缓,在该框架内构建应用程序很容易,即使你以前只使用过React。

为了学习如何构建Next.js应用程序,本教程详细介绍了如何为一个虚构的游戏商店构建一个购物车网络应用,该应用可以从购物车中添加或删除物品,查看所有产品,按类别查看产品,等等。

本Next.js教程涵盖的内容

在构建该应用时,我们将涵盖以下功能。

  • 设置一个Next.js项目,其中包括create-next-app
  • Next.js中的路由系统
  • 用CSS模块进行造型
  • <Image> 组件进行图像优化
  • 集成Redux工具包以实现全局状态管理
  • 静态生成和服务器端渲染
  • Next.js的API路由
  • getStaticProps(),getStaticPaths, 和 获取数据。getServerSideProps()

你可以在这个GitHub资源库中找到已完成项目的源代码,以及部署在Vercel上的实时演示

开始使用create-next-app

要使用create-next-app 创建一个新的Next.js应用程序,在终端上运行以下命令,并等待安装过程完成。

npx create-next-app shopping-cart

完成后,通过运行npm run dev 脚本启动开发服务器。默认情况下,Next.js应用程序的文件夹结构看起来如下。

|-- node_modules
|-- package.json
|-- package-lock.json
|-- pages
|   |-- api
|   |   |-- hello.js
|   |-- _app.js
|   |-- index.js
|-- public
|   |-- favicon.ico
|   |-- vercel.svg
|-- README.md
|-- styles
    |-- globals.css
    |-- Home.module.css

要在没有任何现有样式的情况下开始,我们可以通过删除styles/globals.cssstyles/Home.module.css 的样式来清理最初的模板代码,并用一个简单的 React 功能组件来替换pages/index.js 内的代码,像这样。

const HomePage = () => {
  return (
    <main>
      <h1>Shopping Cart</h1>
    </main>
  );
};
export default HomePage;

此外,我们可以把所有全局样式放在styles/global.css 文件里面。这些样式将被应用于整个应用程序。现在,让我们添加一些基本的样式,并从Google Fonts导入Open Sans字体

@import url('https://fonts.googleapis.com/css2?family=Open+Sans:wght@300;400;600;700&display=swap');

*, *::before, *::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'Open Sans', sans-serif;
}

在这个项目中,我们将建立四个不同的页面。

  • 一个带有登陆页的主页,展示所有可用的类别
  • 一个展示某一特定类别所有产品的类别页
  • 一个商店页面,展示所有类别的所有产品
  • 一个购物车页面,管理购物车中的所有物品

但是,在建立这些页面之前,让我们先建立一些常见的组件,如导航栏和页脚。

NavbarFooter 组件

要开始添加NavbarFooter 组件,请在项目的根文件夹中创建一个名为components 的新文件夹。这使我们所有的组件都在一个地方,并使我们的代码有条理。

在该文件夹中,根据你的喜好,创建一个名为Navbar.jsNavbar.jsx 的新文件。

import Link from 'next/link';

const Navbar = () => {
  return (
    <nav>
      <h6>GamesKart</h6>
      <ul>
        <li>
          <Link href="/">Home</Link>
        </li>
        <li>
          <Link href="/shop">Shop</Link>
        </li>
        <li>
          <Link href="/cart">Cart</Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navbar;

来自next/link<Link> 标签让用户在应用程序的不同页面上进行导航。这与React应用程序中使用的react-router-dom 中的<Link> 标签类似。

但是,Next.js<Link> 标签没有使用to 道具,而是要求我们传递href 道具。

样式化NavbarFooter

为了给Navbar ,在styles 文件夹中创建一个名为Navbar.module.css 的新文件,并在该文件中粘贴以下样式。你可以根据自己的喜好自由地改变这些样式。

.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 2rem;
}
.logo {
  font-size: 1.2rem;
  font-weight: 600;
  text-transform: uppercase;
}
.links {
  display: flex;
}
.navlink {
  list-style: none;
  margin: 0 0.75rem;
  text-transform: uppercase;
}
.navlink a {
  text-decoration: none;
  color: black;
}
.navlink a:hover {
  color: #f9826c;
}

现在,要在组件中使用这些样式,请导入CSS模块,并在JSX中添加className 。修改Navbar.jsx 里面的代码,并进行这些修改。

import Link from 'next/link';
import styles from '../styles/Navbar.module.css';

const Navbar = () => {
  return (
    <nav className={styles.navbar}>
      <h6 className={styles.logo}>GamesKart</h6>
      <ul className={styles.links}>
        <li className={styles.navlink}>
          <Link href="/">Home</Link>
        </li>
        <li className={styles.navlink}>
          <Link href="/shop">Shop</Link>
        </li>
        <li className={styles.navlink}>
          <Link href="/cart">Cart</Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navbar;

随着Navbar 组件的完成,让我们继续前进,建立Footer 组件。

components 文件夹中创建一个名为Footer.jsx 的新文件。

import styles from 'Footer.module.css';

const Footer = () => {
  return (
    <footer className={styles.footer}>
      Copyright <span className={styles.brand}>GamesKart</span>{' '}
      {new Date().getFullYear()}
    </footer>
  );
};

然后,在styles 文件夹中添加Footer.module.css

.footer {
  padding: 1rem 0;
  color: black;
  text-align: center;
}
.brand {
  color: #f9826c;
}

最后,在pages/_app.js 中导入NavbarFooter 组件,这样它们就可以在所有应用程序的页面上看到。另外,我们也可以将这些组件单独导入到我们想要显示的每个页面。

import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <div>
      <Navbar />
      <Component {...pageProps} />
      <Footer />
    </div>
  );
}

export default MyApp;

要查看我们的组件的运行情况,请在运行npm run dev 脚本后进入http://localhost:3000。然而,所有的组件都被挤到了页面的顶部。

View The Main Page Components, However, They Are Unfinished

这可以通过将我们的内容包裹在一个设置为column 的柔性容器内,并以空间间隔的方式在主轴上对内容进行调整来解决。只需在_app.js 的第7行的div上添加一个新的类。

<div className="wrapper"> // Line 7

然后,在globals.css 内对其进行全局样式化。

// Add this code at the bottom of globals.css
.wrapper {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

就这样,我们已经解决了这个问题。

Fixing Main Page Components With Styling

让我们继续构建主页。

建立主页

在主页上,将有五个卡片显示Xbox、PS5、Switch、PC和配件产品类别。让我们首先创建一个CategoryCard 组件。

components 文件夹中创建两个新文件,名为CategoryCard.jsx ,在styles 文件夹中创建两个新文件,名为CategoryCard.module.css

CategoryCard.jsx 文件中粘贴以下代码。

import Link from 'next/link';
import Image from 'next/image';
import styles from '../styles/CategoryCard.module.css';

const CategoryCard = ({ image, name }) => {
  return (
    <div className={styles.card}>
      <Image className={styles.image} src={image} height={700} width={1300} />
      <Link href={`/category/${name.toLowerCase()}`}>
        <div className={styles.info}>
          <h3>{name}</h3>
          <p>SHOP NOW</p>
        </div>
      </Link>
    </div>
  );
};
export default CategoryCard;

这个组件需要两个道具:显示的图片和类别的名称。<Image> 组件被内置到Next.js中,以提供图像优化。

CategoryCard.module.css 文件内粘贴下一个代码序列。

.card {
  margin: 0.5rem;
  flex: 1 1 auto;
  position: relative;
}
.image {
  object-fit: cover;
  border: 2px solid black;
  transition: all 5s cubic-bezier(0.14, 0.96, 0.91, 0.6);
}
.info {
  position: absolute;
  top: 50%;
  left: 50%;
  background: white;
  padding: 1.5rem;
  text-align: center;
  transform: translate(-50%, -50%);
  opacity: 0.8;
  border: 1px solid black;
  cursor: pointer;
}
.card:hover .image {
  transform: scale(1.2);
}
.card:hover .info {
  opacity: 0.9;
}

index.js 页面内导入CategoryCard 组件,也就是我们的主页,以测试我们新创建的组件。

import CategoryCard from '../components/CategoryCard';
import styles from '../styles/Home.module.css';

const HomePage = () => {
  return (
    <main className={styles.container}>
      <div className={styles.small}>
        <CategoryCard image="https://imgur.com/uKQqsuA.png" name="Xbox" />
        <CategoryCard image="https://imgur.com/3Y1DLYC.png" name="PS5" />
        <CategoryCard image="https://imgur.com/Dm212HS.png" name="Switch" />
      </div>
      <div className={styles.large}>
        <CategoryCard image="https://imgur.com/qb6IW1f.png" name="PC" />
        <CategoryCard
          image="https://imgur.com/HsUfuRU.png"
          name="Accessories"
        />
      </div>
    </main>
  );
};

export default HomePage;

对于样式设计,将CSS代码添加到styles 文件夹中的Home.module.css

.container {
  padding: 0 2rem;
}
.small {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
}
.large {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

虽然我们添加了样式,但迎接我们的是一个错误。

Error Message From Adding Styling

修复这个问题很简单,错误信息中包含一个Next.js文档的链接,可以解释和修复这个错误。

然而,在本教程中,在项目的根文件夹内创建一个名为next.config.js 的新文件,并添加以下代码。

module.exports = {
  images: {
    domains: ['imgur.com'],
  },
};

添加图片

由于我们使用imgur.com 来托管我们的图片,我们必须将它们添加到domains 数组中进行优化,确保外部URL不会被滥用。

完成后,重新启动开发服务器以启动这些变化。就这样,我们成功地建立了主页。

Final Home Page Layout

用Next.js的API路由构建一个API

在进入其他页面之前,我们必须建立一个API来获取产品。虽然Next.js是一个React框架,但我们可以利用其内置的API路由功能来构建一个简单的API。

在这个项目中,我们需要两个API路由。

  1. /api/products 来获取产品
  2. /api/products/<category> ,以获取属于某个特定类别的产品。

在我们Next.js应用程序的文件夹结构中,在pages 文件夹内有一个名为api 的文件夹。删除api 文件夹中的所有现有文件,因为我们将从头开始构建我们的文件。

为了保持简单,我们将把商店的所有产品存储在一个名为data.json 的JSON文件中。

然而,你可以使用数据库或Next.js的无头CMS来添加、编辑或删除产品,而无需JSON文件。

api 文件夹内创建一个名为products 的新文件夹,然后进入products 文件夹,创建三个文件:index.js[category].jsdata.json

index.js

我们要处理的第一个文件是index.js ,它对应于/api/products 路由,获取所有类别的所有产品。

import data from './data.json';

export function getProducts() {
  return data;
}

export default function handler(req, res) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', ['GET']);
    res.status(405).json({ message: `Method ${req.method} is not allowed` });
  } else {
    const products = getProducts();
    res.status(200).json(products);
  }
}

在这个API路由中,我们只是导入data.json 文件并检查请求的HTTP方法。

由于我们只想允许GET 请求,我们可以使用if 语句来检查请求对象的方法属性。对于GET 请求,我们可以用JSON格式的产品数据进行响应。

我们可以通过访问http://localhost:3000/api/products 来访问这个API路线。

[category].js

第二个文件是[category].js ,它对应于/api/products/<category> 路由,获取用户选择的某个类别的所有产品。文件名中的方括号表示这是一个动态路由。

import data from './data.json';

export function getProductsByCategory(category) {
  const products = data.filter((product) => product.category === category);
  return products;
}

export default function handler(req, res) {
  if (req.method !== 'GET') {
    res.setHeader('Allow', ['GET']);
    res.status(405).json({ message: `Method ${req.method} is not allowed` });
  } else {
    const products = getProductsByCategory(req.query.category);
    res.status(200).json(products);
  }
}

这个API路由与之前的路由类似,但有一个主要变化。由于我们只想要某个特定类别的产品,我们可以使用filter() JavaScript数组方法来检查产品的类别是否符合查询的类别,req.query.category

我们可以通过访问http://localhost:3000/api/products/xbox或其他任何一个类别,ps5,switch,pc, 或accessories 来访问这个API路由。

data.json

最后,我们将添加data.json ,这是一个简单的JSON文件,包含所有可用产品及其细节的数组。

[
  {
    "id": 1,
    "product": "Cyberpunk 2077",
    "category": "xbox",
    "image": "https://imgur.com/3CF1UhY.png",
    "price": 36.49
  },
  {
    "id": 2,
    "product": "Grand Theft Auto 5",
    "category": "xbox",
    "image": "https://imgur.com/BqNWnDB.png",
    "price": 21.99
  },
  {
    "id": 3,
    "product": "Minecraft",
    "category": "xbox",
    "image": "https://imgur.com/LXnUnd2.png",
    "price": 49.99
  },
  {
    "id": 4,
    "product": "PUBG",
    "category": "xbox",
    "image": "https://imgur.com/Ondg3Jn.png",
    "price": 5.09
  },
  {
    "id": 5,
    "product": "FIFA 21",
    "category": "xbox",
    "image": "https://imgur.com/AzT9YMP.png",
    "price": 17.49
  },
  {
    "id": 6,
    "product": "Battlefield 5",
    "category": "xbox",
    "image": "https://imgur.com/X3MQNVs.png",
    "price": 29.35
  },
  {
    "id": 7,
    "product": "Watch Dogs 2",
    "category": "xbox",
    "image": "https://imgur.com/v3lqCEb.png",
    "price": 18.99
  },
  {
    "id": 8,
    "product": "Fortnite",
    "category": "ps5",
    "image": "https://imgur.com/3lTxDpl.png",
    "price": 29.99
  },
  {
    "id": 9,
    "product": "Call of Duty: Black Ops",
    "category": "ps5",
    "image": "https://imgur.com/4GvUw3G.png",
    "price": 69.99
  },
  {
    "id": 10,
    "product": "NBA2K21 Next Generation",
    "category": "ps5",
    "image": "https://imgur.com/Mxjvkws.png",
    "price": 69.99
  },
  {
    "id": 11,
    "product": "Spider-Man Miles Morales",
    "category": "ps5",
    "image": "https://imgur.com/guV5cUF.png",
    "price": 29.99
  },
  {
    "id": 12,
    "product": "Resident Evil Village",
    "category": "ps5",
    "image": "https://imgur.com/1CxJz8E.png",
    "price": 59.99
  },
  {
    "id": 13,
    "product": "Assassin's Creed Valhalla",
    "category": "ps5",
    "image": "https://imgur.com/xJD093X.png",
    "price": 59.99
  },
  {
    "id": 14,
    "product": "Animal Crossing",
    "category": "switch",
    "image": "https://imgur.com/1SVaEBk.png",
    "price": 59.99
  },
  {
    "id": 15,
    "product": "The Legend of Zelda",
    "category": "switch",
    "image": "https://imgur.com/IX5eunc.png",
    "price": 59.99
  },
  {
    "id": 16,
    "product": "Stardew Valley",
    "category": "switch",
    "image": "https://imgur.com/aL3nj5t.png",
    "price": 14.99
  },
  {
    "id": 17,
    "product": "Mario Golf Super Rush",
    "category": "switch",
    "image": "https://imgur.com/CPxlyEg.png",
    "price": 59.99
  },
  {
    "id": 18,
    "product": "Super Smash Bros",
    "category": "switch",
    "image": "https://imgur.com/ZuLatzs.png",
    "price": 59.99
  },
  {
    "id": 19,
    "product": "Grand Theft Auto 5",
    "category": "pc",
    "image": "https://imgur.com/9LRil4N.png",
    "price": 29.99
  },
  {
    "id": 20,
    "product": "Battlefield V",
    "category": "pc",
    "image": "https://imgur.com/T3v629h.png",
    "price": 39.99
  },
  {
    "id": 21,
    "product": "Red Dead Redemption 2",
    "category": "pc",
    "image": "https://imgur.com/aLObdQK.png",
    "price": 39.99
  },
  {
    "id": 22,
    "product": "Flight Simulator 2020",
    "category": "pc",
    "image": "https://imgur.com/2IeocI8.png",
    "price": 59.99
  },
  {
    "id": 23,
    "product": "Forza Horizon 4",
    "category": "pc",
    "image": "https://imgur.com/gLQsp6N.png",
    "price": 59.99
  },
  {
    "id": 24,
    "product": "Minecraft",
    "category": "pc",
    "image": "https://imgur.com/qm1gaGD.png",
    "price": 29.99
  },
  {
    "id": 25,
    "product": "Rainbow Six Seige",
    "category": "pc",
    "image": "https://imgur.com/JIgzykM.png",
    "price": 7.99
  },
  {
    "id": 26,
    "product": "Xbox Controller",
    "category": "accessories",
    "image": "https://imgur.com/a964vBm.png",
    "price": 59.0
  },
  {
    "id": 27,
    "product": "Xbox Controller",
    "category": "accessories",
    "image": "https://imgur.com/ntrEPb1.png",
    "price": 69.0
  },
  {
    "id": 28,
    "product": "Gaming Keyboard",
    "category": "accessories",
    "image": "https://imgur.com/VMe3WBk.png",
    "price": 49.99
  },
  {
    "id": 29,
    "product": "Gaming Mouse",
    "category": "accessories",
    "image": "https://imgur.com/wvpHOCm.png",
    "price": 29.99
  },
  {
    "id": 30,
    "product": "Switch Joy-Con",
    "category": "accessories",
    "image": "https://imgur.com/faQ0IXH.png",
    "price": 13.99
  }
]

值得注意的是,我们将只从这个JSON文件中读取信息,而不是通过API路由写入或修改任何信息。

如果写或修改信息,使用数据库会更合适。

下面是这些变化后的文件夹结构的样子。

|-- api 
|   |-- products 
|       |-- [category].js 
|       |-- data.json 
|       |-- index.js 
|-- _app.js
|-- index.js

现在我们已经建立了简单的API,让我们再建立两个页面:商店页面和类别页面,它们使用我们的API路由。

添加商店和分类页面

商店页面承载了商店的所有产品,而分类页面是动态的,只展示某个特定类别的产品。

在这些页面上,我们将使用我们的自定义ProductCard 组件。让我们开始吧,在components 文件夹中创建一个名为ProductCard.jsx 的新文件。

import Image from 'next/image';
import styles from '../styles/ProductCard.module.css';

const ProductCard = ({ product }) => {
  return (
    <div className={styles}>
      <Image src={product.image} height={300} width={220} />
      <h4 className={styles.title}>{product.product}</h4>
      <h5 className={styles.category}>{product.category}</h5>
      <p>$ {product.price}</p>
      <button className={styles.button}>Add to Cart</button>
    </div>
  );
};

export default ProductCard;

这个简单的组件显示产品图片、名称、类别和价格以及一个简单的添加到购物车按钮。这个按钮目前没有任何功能,但一旦我们将Redux整合到我们的应用程序中,情况就会改变。

将下面给出的样式粘贴到styles 文件夹中一个名为ProductCard.module.css 的新文件中。

.card {
  display: flex;
  flex-direction: column;
}

.title {
  font-size: 1rem;
  font-weight: 600;
}

.category {
  font-size: 0.8rem;
  text-transform: uppercase;
}
.button {
  width: 100%;
  margin-top: 0.5rem;
  padding: 0.75rem 0;
  background: transparent;
  text-transform: uppercase;
  border: 2px solid black;
  cursor: pointer;
}
.button:hover {
  background: black;
  color: white;
}

现在,我们已经准备好建立实际的页面了。

构建商店页面shop.jsx

让我们从商店页面开始。在pages 文件夹中创建一个名为shop.jsx 的新文件。

import ProductCard from '../components/ProductCard';
import styles from '../styles/ShopPage.module.css';
import { getProducts } from './api/products/index';

const ShopPage = ({ products }) => {
  return (
    <div className={styles.container}>
      <h1 className={styles.title}>All Results</h1>
      <div className={styles.cards}>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

export default ShopPage;

export async function getStaticProps() {
  const products = await getProducts();
  return { props: { products } };
}

静态生成与getStaticProps()

对于这个页面,我们将使用Next.js的数据获取方法getStaticProps() ,在构建时获取所有产品并对页面进行预渲染。

一旦获取了产品,它们就会作为一个道具被发送到页面组件,在那里我们可以通过产品数组进行映射,并为每个产品渲染ProductCard 组件。

如果使用一个数据库,并且希望产品的数据随着时间的推移而变化,那么使用带有revalidate 属性的增量静态再生功能可以动态地更新产品的数据,确保其保持最新状态。

Products Pre-Rendered Under All Results

ALL RESULTS下预置的产品。

用以下方法建立分类页面[category].jsx

让我们继续建立使用动态路由的分类页面。在pages 文件夹内创建一个名为category 的新文件夹。现在,在这个文件夹中,创建一个名为[category].jsx 的新文件。

import { useRouter } from 'next/router';
import ProductCard from '../../components/ProductCard';
import styles from '../../styles/ShopPage.module.css';
import { getProductsByCategory } from '../api/products/[category]';

const CategoryPage = ({ products }) => {
  const router = useRouter();
  return (
    <div className={styles.container}>
      <h1 className={styles.title}>Results for {router.query.category}</h1>
      <div className={styles.cards}>
        {products.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
};

export default CategoryPage;

export async function getServerSideProps(ctx) {
  const category = ctx.query.category;
  const products = await getProductsByCategory(category);
  return { props: { products } };
}

要查看这个页面,请访问http://localhost:3000/category/xyz,然后用我们指定的任何类别替换 "xyz"。

用服务器端渲染getServerSideProps()

在分类页面上,我们将使用一种不同的数据获取方法,名为getServerSideProps() ,它使用服务器端渲染。

与之前的数据获取方法不同,getServerSideProps() 在请求时获取数据并预先渲染页面,而不是在构建时进行渲染。这种方法在每次请求时运行,意味着产品数据不会过期。

PC Category Page

PC分类页面。

产品和分类页面的样式

最后,这是我们刚刚建立的产品和分类页面的样式表。

.title {
  font-size: 2rem;
  text-transform: uppercase;
  margin: 0 1rem 1rem;
}
.container {
  padding: 0 2rem;
  margin-bottom: 2rem;
}
.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 2.5rem 1rem;
  place-items: center;
}

Redux的全局状态管理

虽然我们的应用程序不需要全局状态管理系统,因为它的功能有限,但如果我们增加更多的功能,随后处理更多的状态,我们将需要一个管理系统。

因此,让我们用Redux Toolkit将Redux整合到我们的Next.js应用中。Redux Toolkit是集成Redux的现代和推荐方式。

要添加Redux,请停止Next.js开发服务器,用以下命令安装Redux Toolkit。

npm install @reduxjs/toolkit react-redux

安装完成后,我们可以重新启动Next.js开发服务器,并配置Redux。

添加购物车与createSlice()

为了使事情井井有条,并将Redux逻辑与我们应用程序的其他部分分开,在项目根目录下创建一个名为redux 的新文件夹。

有了它,我们将执行四个动作。

  1. 向购物车中添加一个项目
  2. 增加购物车中物品的数量
  3. 减少购物车中某一物品的数量
  4. 从购物车中完全删除一个项目

让我们创建一个Redux分片来处理这些动作和购物车还原器。在redux 文件夹中创建一个名为cart.slice.js 的新文件。没有必要以这种方式命名文件,但它可以使一切井井有条。

import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: [],
  reducers: {
    addToCart: (state, action) => {
      const itemExists = state.find((item) => item.id === action.payload.id);
      if (itemExists) {
        itemExists.quantity++;
      } else {
        state.push({ ...action.payload, quantity: 1 });
      }
    },
    incrementQuantity: (state, action) => {
      const item = state.find((item) => item.id === action.payload);
      item.quantity++;
    },
    decrementQuantity: (state, action) => {
      const item = state.find((item) => item.id === action.payload);
      if (item.quantity === 1) {
        const index = state.findIndex((item) => item.id === action.payload);
        state.splice(index, 1);
      } else {
        item.quantity--;
      }
    },
    removeFromCart: (state, action) => {
      const index = state.findIndex((item) => item.id === action.payload);
      state.splice(index, 1);
    },
  },
});

export const cartReducer = cartSlice.reducer;

export const {
  addToCart,
  incrementQuantity,
  decrementQuantity,
  removeFromCart,
} = cartSlice.actions;

Redux Toolkit的createSlice() 方法接受切片的名称、它的初始状态和还原器函数,以自动生成对应于还原器和状态的动作创建者和动作类型。

addToCart reducer函数

addToCart reducer函数接收产品对象作为有效载荷,并使用JavaScriptfind() 数组方法检查购物车中是否已经存在产品。

如果确实存在,我们可以将产品在购物车中的数量增加到1 。如果不存在,我们可以使用push() 数组方法将其添加到购物车中,并将数量设置为1

incrementQuantity 减速器函数

incrementQuantity 一个简单的reducer函数,它接收一个产品ID作为有效载荷,并使用它来寻找购物车中的物品。然后,该商品的数量被递增,1

decrementQuantity 减速器函数

decrementQuantityincrementQuantity 减速器函数类似,但我们必须在递减之前检查产品的数量是否是1

如果是1 ,我们可以使用splice() 方法将该产品从购物车中清除,因为它不可能有0 的数量。但是,如果它的数量不是1 ,我们可以简单地用1 递减它。

removeFromCart 减速器函数

removeFromCart reducer函数接收产品ID作为有效载荷,并使用find()splice() 方法从购物车中删除产品。

最后,我们可以从cartSlice.reducer ,并将其传递给我们的Redux商店和cartSlice.actions 的动作,以便在我们的组件中使用。

配置Redux商店

由于Redux工具包中的configureStore() 方法,配置Redux商店是一个简单明了的过程。在redux 文件夹中创建一个名为store.js 的新文件,以保存所有与Redux商店有关的逻辑。

import { configureStore } from '@reduxjs/toolkit';
import { cartReducer } from './cart.slice';

const reducer = {
  cart: cartReducer,
};

const store = configureStore({
  reducer,
});

export default store;

现在,去_app.js 文件,用<Provider>react-redux 来包装我们的组件,它把我们的Redux存储作为一个道具,所以我们应用中的所有组件都可以使用全局状态。

import { Provider } from 'react-redux';       // Importing Provider
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import store from '../redux/store';           // Importing redux store
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <Provider store={store}>
      <div className="wrapper">
        <Navbar />
        <Component {...pageProps} />
        <Footer />
      </div>
    </Provider>
  );
}

export default MyApp;

就这样,我们完成了将Redux集成到我们的Next.js应用中,用于全局状态管理。

addToCart 功能

现在,让我们在我们的ProductCard 组件中使用addToCart 动作,将一个产品添加到购物车中。打开ProductCard.jsx 组件,从react-redux 中导入useDispatch Hook,以及从cart.slice.js 文件中导入addToCart 动作。

目前,"添加到购物车"按钮不做任何事情,但我们可以在用户点击该按钮时发送addToCart 动作。要做到这一点,在ProductCard.jsx 中添加以下内容。

import Image from 'next/image';
import { useDispatch } from 'react-redux';
import { addToCart } from '../redux/cart.slice';
import styles from '../styles/ProductCard.module.css';

const ProductCard = ({ product }) => {

  const dispatch = useDispatch();

  return (
    <div className={styles}>
      <Image src={product.image} height={300} width={220} />
      <h4 className={styles.title}>{product.product}</h4>
      <h5 className={styles.category}>{product.category}</h5>
      <p>$ {product.price}</p>
      <button
        onClick={() => dispatch(addToCart(product))}
        className={styles.button}
      >
        Add to Cart
      </button>
    </div>
  );
};

export default ProductCard;

现在,每次我们点击该按钮,都会有一个产品添加到购物车。然而,我们还没有建立购物车页面,所以我们不能确认这个行为。

如果你的浏览器上安装了Redux Devtools扩展,你可以用它来监控状态

构建购物车页面

最后,让我们建立购物车应用程序的最后一个页面:购物车页面。像往常一样,在pages 文件夹中创建一个名为cart.jsx 的新文件。

随后,在styles 文件夹中为样式表创建一个名为CartPage.styles.css 的新文件。

首先,从react-redux 中导入useSelectoruseDispatch Hooks。

useSelector Hook使用一个选择器函数从Redux商店提取数据。然后,useDispatch 钩子调度动作创建者。

import Image from 'next/image';
// Importing hooks from react-redux
import { useSelector, useDispatch } from 'react-redux';
import styles from '../styles/CartPage.module.css';

const CartPage = () => {

  // Extracting cart state from redux store 
  const cart = useSelector((state) => state.cart);

  // Reference to the dispatch function from redux store
  const dispatch = useDispatch();

  return (
    <div className={styles.container}>
      {cart.length === 0 ? (
        <h1>Your Cart is Empty!</h1>
      ) : (
        <>
          <div className={styles.header}>
            <div>Image</div>
            <div>Product</div>
            <div>Price</div>
            <div>Quantity</div>
            <div>Actions</div>
            <div>Total Price</div>
          </div>
          {cart.map((item) => (
            <div className={styles.body}>
              <div className={styles.image}>
                <Image src={item.image} height="90" width="65" />
              </div>
              <p>{item.product}</p>
              <p>$ {item.price}</p>
              <p>{item.quantity}</p>
              <div className={styles.buttons}></div>
              <p>$ {item.quantity * item.price}</p>
            </div>
          ))}
          <h2>Grand Total: $ {getTotalPrice()}</h2>
        </>
      )}
    </div>
  );
};

export default CartPage;

我们还必须添加三个按钮,用于增加、减少和删除购物车中的物品。因此,让我们继续从购物车片断中导入incrementQuantitydecrementQuantityremoveFromCart

import Image from 'next/image';
import { useSelector, useDispatch } from 'react-redux';
// Importing actions from  cart.slice.js
import {
  incrementQuantity,
  decrementQuantity,
  removeFromCart,
} from '../redux/cart.slice';
import styles from '../styles/CartPage.module.css';

const CartPage = () => {

  const cart = useSelector((state) => state.cart);
  const dispatch = useDispatch();

  const getTotalPrice = () => {
    return cart.reduce(
      (accumulator, item) => accumulator + item.quantity * item.price,
      0
    );
  };

  return (
    <div className={styles.container}>
      {cart.length === 0 ? (
        <h1>Your Cart is Empty!</h1>
      ) : (
        <>
          <div className={styles.header}>
            <div>Image</div>
            <div>Product</div>
            <div>Price</div>
            <div>Quantity</div>
            <div>Actions</div>
            <div>Total Price</div>
          </div>
          {cart.map((item) => (
            <div className={styles.body}>
              <div className={styles.image}>
                <Image src={item.image} height="90" width="65" />
              </div>
              <p>{item.product}</p>
              <p>$ {item.price}</p>
              <p>{item.quantity}</p>
              <div className={styles.buttons}>
                <button onClick={() => dispatch(incrementQuantity(item.id))}>
                  +
                </button>
                <button onClick={() => dispatch(decrementQuantity(item.id))}>
                  -
                </button>
                <button onClick={() => dispatch(removeFromCart(item.id))}>
                  x
                </button>
              </div>
              <p>$ {item.quantity * item.price}</p>
            </div>
          ))}
          <h2>Grand Total: $ {getTotalPrice()}</h2>
        </>
      )}
    </div>
  );
};

export default CartPage;

getTotalPrice 函数使用reduce() 数组方法来计算购物车中所有物品的成本。

对于JSX部分,我们通过访问cart.length 属性来检查购物车是否为空。如果是空的,我们可以显示文本,通知用户购物车是空的。

否则,我们可以使用map() 数组方法来渲染一个包含所有产品细节的div。请注意,我们还有三个带有onClick 的按钮,当点击时,可以执行相应的购物车操作。

为了给购物车页面添加样式,在CartPage.module.css

.container {
  padding: 2rem;
  text-align: center;
}
.header {
  margin-top: 2rem;
  display: flex;
  justify-content: space-between;
}
.header div {
  flex: 1;
  text-align: center;
  font-size: 1rem;
  font-weight: bold;
  padding-bottom: 0.5rem;
  text-transform: uppercase;
  border-bottom: 2px solid black;
  margin-bottom: 2rem;
}
.body {
  display: flex;
  justify-content: space-between;
  align-items: center;
  text-align: center;
  margin-bottom: 1rem;
}
.body > * {
  flex: 1;
}
.image {
  width: 100px;
}
.buttons > * {
  width: 25px;
  height: 30px;
  background-color: black;
  color: white;
  border: none;
  margin: 0.5rem;
  font-size: 1rem;
}

然后,我们就有了最终的购物车页面

Final Cart Page Showing Added Products

修改Navbar

目前,点击添加到购物车按钮时,没有反馈或通知来确认产品是否被添加到购物车中。

因此,让我们把购物车项目计数添加到Navbar 组件中,这样每次添加产品时它就会递增。转到Navbar.jsx ,修改代码,从全局状态中选择购物车,并创建一个自定义函数来获取项目计数。

import Link from 'next/link';
import { useSelector } from 'react-redux';
import styles from '../styles/Navbar.module.css';
const Navbar = () => {

  // Selecting cart from global state
  const cart = useSelector((state) => state.cart);

  // Getting the count of items
  const getItemsCount = () => {
    return cart.reduce((accumulator, item) => accumulator + item.quantity, 0);
  };

  return (
    <nav className={styles.navbar}>
      <h6 className={styles.logo}>GamesKart</h6>
      <ul className={styles.links}>
        <li className={styles.navlink}>
          <Link href="/">Home</Link>
        </li>
        <li className={styles.navlink}>
          <Link href="/shop">Shop</Link>
        </li>
        <li className={styles.navlink}>
          <Link href="/cart">
            <p>Cart ({getItemsCount()})</p>
          </Link>
        </li>
      </ul>
    </nav>
  );
};

export default Navbar;

结语

这个项目就到此为止!我希望这能让你对整合了Redux的Next.js基础知识有一个扎实的了解。欢迎为这个项目添加更多的功能,如认证、单一产品页面或支付集成。

如果你在构建这个项目时遇到任何困难,请访问这个GitHub仓库,将你的代码与我的进行比较。

The postBuilding a Next.js shopping cart appappeared first onLogRocket Blog.