ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

React+TypeScript+Zustand实战:从零构建外卖应用“死了么”

React+TypeScript+Zustand实战:从零构建外卖应用“死了么” 在实际移动应用开发项目中很多开发者都曾有过这样的困惑一个看似简单的想法从构思到上架中间到底有多少技术细节需要处理特别是当涉及到网络请求、数据解析、UI交互、状态管理等核心环节时如何组织代码才能既保证开发效率又具备良好的可维护性本文将以一个虚构但极具代表性的“死了么”应用为例模拟一个外卖或服务类APP的核心开发流程。我们将使用现代前端技术栈从零开始一步步构建一个具备列表展示、详情查看、模拟下单等功能的完整应用。整个过程将聚焦于工程实践涵盖项目初始化、核心功能开发、状态管理、数据模拟以及常见问题排查旨在为希望提升工程化开发能力的开发者提供一份可复现、可学习的实战指南。1. 理解“死了么”应用的核心架构与技术选型在开始编码之前明确应用的核心需求和选择合适的技术栈是成功的第一步。这能帮助我们避免在开发中途频繁更换工具也能让项目结构更清晰。1.1 应用核心功能拆解“死了么”应用是一个典型的消费服务类应用我们可以将其核心功能模块拆解如下商家列表模块以列表或网格形式展示附近的商家包含商家Logo、名称、评分、配送信息等。商家详情模块点击列表项进入展示商家的完整信息、菜单分类、具体商品等。购物车模块用户可以将商品加入购物车在购物车中调整数量并计算总价。订单模拟模块模拟提交订单的过程生成订单信息。数据状态管理全局管理用户身份、购物车数据、应用主题等状态。1.2 技术栈选型与理由为了高效、现代化地实现上述功能我们选择以下技术栈前端框架React (with Vite)。React的组件化思想非常适合构建此类UI交互复杂的应用Vite能提供极快的开发服务器启动和热更新速度提升开发体验。开发语言TypeScript。TypeScript提供了静态类型检查能在编码阶段就发现潜在的类型错误对于管理应用状态、API接口等复杂数据结构至关重要能显著减少运行时错误。UI组件库Ant Design Mobile。这是一个为移动端React应用设计的组件库提供了按钮、列表、弹窗、导航栏等高质量组件能让我们快速搭建出符合移动端交互规范的界面而无需从零编写样式。状态管理Zustand。相比于ReduxZustand的API更简洁概念更少学习成本低且能很好地处理中小型应用的全局状态。它完美契合我们管理购物车和用户信息的需求。路由管理React Router DOM。用于管理应用内多个页面如首页、详情页之间的跳转。HTTP客户端Axios。用于发起网络请求获取模拟的商家和商品数据。它提供了拦截器、请求/响应转换等强大功能。数据模拟JSON Server。在前后端分离的开发初期后端API可能尚未就绪。JSON Server可以在零编码的情况下基于一个db.json文件快速搭建一个具备RESTful API的模拟服务器为前端提供数据支持。这个技术栈组合平衡了开发效率、代码质量和学习曲线是构建现代Web应用的常见选择。2. 项目环境搭建与工程初始化一个规范的工程结构是团队协作和项目维护的基础。我们将从创建项目开始逐步配置开发环境。2.1 创建项目并安装核心依赖首先确保你的系统已安装Node.js建议版本16以上和npm或yarn。然后我们使用Vite的React-TypeScript模板快速创建项目。# 使用 npm 创建项目 npm create vitelatest died-app -- --template react-ts # 进入项目目录 cd died-app # 安装项目依赖 npm install接下来安装我们选定的核心依赖库。# 安装 UI 组件库、路由、状态管理、HTTP客户端 npm install antd-mobile axios react-router-dom zustand # 安装开发依赖JSON Server 和其类型定义用于TypeScript npm install -D json-server types/json-server2.2 配置项目结构与基础文件创建清晰的项目目录结构。在项目根目录下新建以下目录和文件died-app/ ├── public/ # 静态资源 ├── src/ │ ├── api/ # 所有API请求相关文件 │ │ └── index.ts │ ├── components/ # 公共组件 │ │ ├── ShopList.tsx │ │ ├── ShopDetail.tsx │ │ └── CartBar.tsx │ ├── pages/ # 页面组件 │ │ ├── Home.tsx │ │ └── Detail.tsx │ ├── stores/ # 状态管理 │ │ └── cartStore.ts │ ├── types/ # TypeScript 类型定义 │ │ └── index.ts │ ├── utils/ # 工具函数 │ │ └── request.ts │ ├── App.tsx # 应用根组件 │ ├── main.tsx # 应用入口 │ └── vite-env.d.ts ├── db.json # JSON Server 数据文件 ├── package.json └── vite.config.ts2.3 配置JSON Server模拟数据在根目录创建db.json文件用于模拟后端数据。这里我们定义商家和商品的数据结构。{ shops: [ { id: 1, name: 老王烧烤, logo: https://via.placeholder.com/100, rating: 4.7, monthSales: 1250, deliveryTime: 30分钟, deliveryPrice: 3, minPrice: 20起送, description: 十年老店炭火烧烤夜宵首选 }, { id: 2, name: 小李水果, logo: https://via.placeholder.com/100, rating: 4.9, monthSales: 890, deliveryTime: 45分钟, deliveryPrice: 0, minPrice: 15起送, description: 新鲜水果产地直供每日现切 } ], products: [ { id: 101, shopId: 1, name: 羊肉串, price: 5.0, image: https://via.placeholder.com/80, description: 精选羊肉肥瘦相间三瘦两肥 }, { id: 102, shopId: 1, name: 烤韭菜, price: 3.0, image: https://via.placeholder.com/80, description: 新鲜韭菜蒜香口味 }, { id: 103, shopId: 2, name: 麒麟西瓜, price: 25.0, image: https://via.placeholder.com/80, description: 沙瓤多汁清甜解渴约5kg } ] }在package.json的scripts中添加启动JSON Server的命令。{ scripts: { dev: vite, build: tsc vite build, preview: vite preview, server: json-server --watch db.json --port 3001 } }现在运行npm run server一个提供/shops和/products接口的模拟服务器就在http://localhost:3001启动了。2.4 配置Axios与TypeScript类型在src/types/index.ts中定义应用中使用的主要数据类型。// 商家类型 export interface Shop { id: number; name: string; logo: string; rating: number; monthSales: number; deliveryTime: string; deliveryPrice: string; minPrice: string; description: string; } // 商品类型 export interface Product { id: number; shopId: number; name: string; price: number; image: string; description: string; } // 购物车商品项包含商品信息和数量 export interface CartItem extends Product { count: number; }在src/utils/request.ts中创建一个配置好的Axios实例便于统一管理请求基地址、超时和拦截器。import axios from axios; const request axios.create({ baseURL: http://localhost:3001, // JSON Server 地址 timeout: 5000, // 请求超时时间 }); // 可以在这里添加请求拦截器例如添加token request.interceptors.request.use( (config) { // const token localStorage.getItem(token); // if (token) { // config.headers.Authorization Bearer ${token}; // } return config; }, (error) { return Promise.reject(error); } ); // 可以在这里添加响应拦截器例如统一处理错误 request.interceptors.response.use( (response) { return response.data; // 直接返回数据部分 }, (error) { console.error(API Request Error:, error); return Promise.reject(error); } ); export default request;在src/api/index.ts中封装具体的API请求函数。import request from ../utils/request; import { Shop, Product } from ../types; // 获取商家列表 export const getShopList () { return request.getShop[](/shops); }; // 根据商家ID获取商品列表 export const getProductsByShopId (shopId: number) { return request.getProduct[](/products, { params: { shopId }, // 查询参数JSON Server 支持 _embed 或 filter }); };3. 核心功能模块实现环境与基础架构准备就绪后我们开始实现应用的核心功能模块。我们将按照“状态管理 - 页面路由 - 组件开发”的顺序进行。3.1 使用Zustand实现购物车状态管理购物车状态需要在多个组件间共享如商品列表、详情页、底部购物车栏因此我们使用Zustand创建一个全局Store。在src/stores/cartStore.ts中import { create } from zustand; import { CartItem, Product } from ../types; interface CartStore { items: CartItem[]; // 购物车商品列表 totalPrice: number; // 总价 addItem: (product: Product) void; // 添加商品 removeItem: (productId: number) void; // 移除商品 increaseItem: (productId: number) void; // 增加商品数量 decreaseItem: (productId: number) void; // 减少商品数量 clearCart: () void; // 清空购物车 // 计算总价的派生状态 calculateTotal: () void; } const useCartStore createCartStore((set, get) ({ items: [], totalPrice: 0, addItem: (product) { set((state) { const existingItem state.items.find(item item.id product.id); if (existingItem) { // 如果已存在数量1 return { items: state.items.map(item item.id product.id ? { ...item, count: item.count 1 } : item ), }; } else { // 如果不存在新增一项 return { items: [...state.items, { ...product, count: 1 }], }; } }); get().calculateTotal(); // 添加后重新计算总价 }, removeItem: (productId) { set((state) ({ items: state.items.filter(item item.id ! productId), })); get().calculateTotal(); }, increaseItem: (productId) { set((state) ({ items: state.items.map(item item.id productId ? { ...item, count: item.count 1 } : item ), })); get().calculateTotal(); }, decreaseItem: (productId) { set((state) { const targetItem state.items.find(item item.id productId); if (targetItem targetItem.count 1) { // 数量大于1则减1 return { items: state.items.map(item item.id productId ? { ...item, count: item.count - 1 } : item ), }; } else { // 数量等于1则移除该商品 return { items: state.items.filter(item item.id ! productId), }; } }); get().calculateTotal(); }, clearCart: () { set({ items: [], totalPrice: 0 }); }, calculateTotal: () { const { items } get(); const total items.reduce((sum, item) sum item.price * item.count, 0); set({ totalPrice: parseFloat(total.toFixed(2)) }); // 保留两位小数 }, })); export default useCartStore;这个Store定义了购物车的完整状态和操作逻辑清晰且将计算总价封装为内部方法保证了状态变更的一致性。3.2 配置应用路由与主框架修改src/App.tsx设置应用的路由和整体布局。我们使用Ant Design Mobile的NavBar和TabBar来模拟移动端APP的常见布局。import { Routes, Route, useNavigate, useLocation } from react-router-dom; import { NavBar, TabBar } from antd-mobile; import { AppOutline, ShopbagOutline } from antd-mobile-icons; import Home from ./pages/Home; import Detail from ./pages/Detail; import ./App.css; function App() { const navigate useNavigate(); const location useLocation(); const { pathname } location; const tabs [ { key: /, title: 首页, icon: AppOutline /, }, { key: /cart, title: 购物车, icon: ShopbagOutline /, }, ]; // 简单的路由守卫如果访问/cart跳转到首页实际项目应有购物车页面 if (pathname /cart) { // 这里可以替换为真实的购物车页面组件 navigate(/); } return ( div classNameapp {/* 顶部导航栏 */} NavBar back{null} onBack{() navigate(-1)} 死了么 /NavBar {/* 主要内容区域 */} div classNamebody Routes Route path/ element{Home /} / Route path/detail/:shopId element{Detail /} / /Routes /div {/* 底部标签栏 */} TabBar activeKey{pathname} onChange{value navigate(value)} {tabs.map(item ( TabBar.Item key{item.key} icon{item.icon} title{item.title} / ))} /TabBar /div ); } export default App;同时在src/main.tsx中需要用BrowserRouter包裹App组件。import React from react; import ReactDOM from react-dom/client; import { BrowserRouter } from react-router-dom; import App from ./App; import antd-mobile/es/global; // 引入 Ant Design Mobile 全局样式 import ./index.css; ReactDOM.createRoot(document.getElementById(root)!).render( React.StrictMode BrowserRouter App / /BrowserRouter /React.StrictMode );3.3 实现商家列表页面首页(src/pages/Home.tsx)的核心是展示商家列表。我们将使用Ant Design Mobile的List和Card组件。import { useEffect, useState } from react; import { useNavigate } from react-router-dom; import { List, Card, Image, Tag, Space } from antd-mobile; import { Shop } from ../types; import { getShopList } from ../api; import CartBar from ../components/CartBar; import ./Home.css; const Home () { const [shopList, setShopList] useStateShop[]([]); const [loading, setLoading] useState(false); const navigate useNavigate(); useEffect(() { fetchShopList(); }, []); const fetchShopList async () { setLoading(true); try { const data await getShopList(); setShopList(data); } catch (error) { console.error(Failed to fetch shop list:, error); } finally { setLoading(false); } }; const handleShopClick (shopId: number) { navigate(/detail/${shopId}); }; return ( div classNamehome-page List header附近商家 modecard {shopList.map(shop ( List.Item key{shop.id} onClick{() handleShopClick(shop.id)} arrow{false} Card Space alignstart block Image src{shop.logo} width{80} height{80} fitcover / div style{{ flex: 1, marginLeft: 12 }} Space justifybetween block strong{shop.name}/strong Tag colorprimary filloutline {shop.rating}分 /Tag /Space div style{{ marginTop: 4, color: #666, fontSize: 12 }} Space justifybetween block span月售 {shop.monthSales}/span span{shop.deliveryTime} | {shop.deliveryPrice}/span /Space /div div style{{ marginTop: 4, color: #999, fontSize: 12 }} {shop.description} /div div style{{ marginTop: 4 }} Tag colorwarning filloutline {shop.minPrice} /Tag /div /div /Space /Card /List.Item ))} /List {/* 底部购物车栏 */} CartBar / /div ); }; export default Home;3.4 实现商家详情与购物车交互详情页(src/pages/Detail.tsx)需要展示特定商家的商品列表并提供加入购物车的功能。同时我们创建一个底部常驻的购物车栏组件(src/components/CartBar.tsx)。首先实现详情页import { useEffect, useState } from react; import { useParams } from react-router-dom; import { List, Image, Button, Space, Toast } from antd-mobile; import { Shop, Product } from ../types; import { getShopList, getProductsByShopId } from ../api; import useCartStore from ../stores/cartStore; import CartBar from ../components/CartBar; import ./Detail.css; const Detail () { const { shopId } useParams{ shopId: string }(); const [shopInfo, setShopInfo] useStateShop | null(null); const [productList, setProductList] useStateProduct[]([]); const { addItem } useCartStore(); useEffect(() { if (shopId) { fetchShopAndProducts(parseInt(shopId)); } }, [shopId]); const fetchShopAndProducts async (id: number) { try { // 并行请求商家信息和商品列表 const [shops, products] await Promise.all([ getShopList(), getProductsByShopId(id), ]); const shop shops.find(s s.id id); setShopInfo(shop || null); setProductList(products); } catch (error) { console.error(Failed to fetch detail:, error); Toast.show({ content: 加载失败, position: bottom }); } }; const handleAddToCart (product: Product) { addItem(product); Toast.show({ content: 已添加 ${product.name}, position: bottom }); }; if (!shopInfo) { return div加载中.../div; } return ( div classNamedetail-page {/* 商家头部信息 */} div classNameshop-header Image src{shopInfo.logo} width{60} height{60} fitcover / div classNameshop-info div classNameshop-name{shopInfo.name}/div div classNameshop-meta span评分 {shopInfo.rating}/span span月售 {shopInfo.monthSales}/span span{shopInfo.deliveryTime}送达/span /div /div /div {/* 商品列表 */} List header商品列表 {productList.map(product ( List.Item key{product.id} prefix{ Image src{product.image} width{60} height{60} fitcover / } description{product.description} extra{ Space directionvertical alignend div classNameproduct-price{product.price.toFixed(2)}/div Button colorprimary sizesmall onClick{() handleAddToCart(product)} 加入购物车 /Button /Space } {product.name} /List.Item ))} /List {/* 底部购物车栏 */} CartBar / /div ); }; export default Detail;然后实现底部购物车栏组件它实时显示购物车商品总数和总价import { Badge, Button, Popup, List } from antd-mobile; import { ShoppingCartOutline } from antd-mobile-icons; import useCartStore from ../stores/cartStore; import ./CartBar.css; const CartBar () { const { items, totalPrice, increaseItem, decreaseItem, removeItem, clearCart } useCartStore(); const [cartVisible, setCartVisible] useState(false); const totalCount items.reduce((sum, item) sum item.count, 0); return ( {/* 悬浮购物车按钮 */} div classNamecart-bar Badge content{totalCount || null} Button shaperounded colorprimary onClick{() setCartVisible(true)} Space ShoppingCartOutline / span{totalPrice.toFixed(2)}/span /Space /Button /Badge /div {/* 购物车详情弹窗 */} Popup visible{cartVisible} onMaskClick{() setCartVisible(false)} positionbottom bodyStyle{{ height: 60vh }} div classNamecart-popup div classNamecart-header strong购物车/strong Button sizesmall onClick{clearCart} 清空 /Button /div List {items.length 0 ? ( List.Item购物车是空的/List.Item ) : ( items.map(item ( List.Item key{item.id} prefix{ Image src{item.image} width{40} height{40} fitcover / } extra{ Space Button sizemini onClick{() decreaseItem(item.id)} - /Button span{item.count}/span Button sizemini onClick{() increaseItem(item.id)} /Button Button sizemini colordanger onClick{() removeItem(item.id)} 删除 /Button /Space } div div{item.name}/div div classNameitem-price {(item.price * item.count).toFixed(2)} /div /div /List.Item )) )} /List div classNamecart-footer div总计strong{totalPrice.toFixed(2)}/strong/div Button colorprimary disabled{items.length 0} 去结算 /Button /div /div /Popup / ); }; export default CartBar;4. 运行验证与关键逻辑解析完成核心代码后我们需要验证应用是否能按预期运行并理解关键交互背后的逻辑。4.1 启动应用与验证流程启动后端模拟服务器打开一个终端在项目根目录运行npm run server。你应该看到JSON Server在http://localhost:3001启动并可以访问http://localhost:3001/shops查看数据。启动前端开发服务器打开另一个终端运行npm run dev。Vite通常会启动在http://localhost:5173。功能验证访问首页打开浏览器访问http://localhost:5173应看到“附近商家”列表数据来自db.json。进入详情页点击任意商家卡片应能跳转到详情页URL如/detail/1并展示该商家的商品列表。购物车交互在详情页点击“加入购物车”按钮页面底部会出现Toast提示且悬浮购物车按钮上的角标数字和总价会更新。点击悬浮购物车按钮会弹出购物车详情面板里面显示已添加的商品、数量、小计和总价。在弹窗内可以点击/-调整数量点击“删除”移除商品点击“清空”清空购物车。状态持久性由于状态保存在Zustand Store的内存中刷新页面后购物车数据会丢失。在实际项目中需要结合localStorage或sessionStorage实现持久化。4.2 关键交互逻辑解析状态更新流程当用户点击“加入购物车”时Detail组件调用useCartStore的addItem方法。Zustand更新items数组并触发calculateTotal重新计算总价。所有订阅了该Store的组件如CartBar会自动重新渲染显示最新的数量和总价。这是一个典型的单向数据流。路由参数传递详情页的shopId通过React Router的动态路由参数/detail/:shopId传递。在Detail组件内通过useParams钩子获取该参数并用于发起API请求。组件通信Home、Detail和CartBar之间没有直接的父子组件props传递。它们都通过Zustand Store这个“全局单例”进行间接通信。这极大地降低了组件间的耦合度。数据模拟与请求所有数据请求都通过src/api/index.ts中封装的函数发起。这些函数内部使用了我们配置好的Axios实例。当前它们指向本地的JSON Server。当需要连接真实后端时只需修改src/utils/request.ts中的baseURL以及API函数的实现前端业务组件几乎无需改动。5. 常见问题排查与优化实践在开发过程中你可能会遇到一些典型问题。以下是一些常见问题的排查思路和优化建议。5.1 开发环境常见问题排查问题现象可能原因检查与解决步骤页面空白控制台报跨域错误前端(localhost:5173)请求后端(localhost:3001)端口不同触发浏览器同源策略限制。1.确认JSON Server已启动(npm run server)。2.检查request.ts中的baseURL是否与JSON Server端口一致。3.为Vite配置代理在vite.config.ts中添加代理配置将/api请求转发到后端。页面能打开但列表无数据控制台报404或网络错误API请求地址错误或后端服务未返回预期数据格式。1. 在浏览器开发者工具的Network面板查看请求的URL和状态码。2. 直接访问该URL如http://localhost:3001/shops看是否能返回JSON数据。3. 检查db.json文件格式是否正确确保是合法的JSON。点击加入购物车后角标和总价不更新状态未正确更新或组件未订阅Store。1. 确认CartBar组件是否从useCartStore中解构了items和totalPrice。2. 在addItem函数内添加console.log确认函数被调用且Store状态已改变。3. 检查Zustand Store的calculateTotal方法是否在addItem后被调用。TypeScript 编译报错“找不到模块”或“类型错误”依赖未安装或类型定义文件缺失。1. 运行npm install确保所有依赖已安装。2. 对于第三方库如json-server尝试安装对应的类型包types/json-server。3. 检查tsconfig.json中的paths或include配置。配置Vite代理示例(vite.config.ts)import { defineConfig } from vite import react from vitejs/plugin-react export default defineConfig({ plugins: [react()], server: { proxy: { /api: { target: http://localhost:3001, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ), }, }, }, })配置后将request.ts中的baseURL改为/apiVite开发服务器会自动代理请求避免跨域问题。5.2 生产环境优化建议状态持久化当前购物车数据在刷新后丢失。可以使用zustand/middleware的persist中间件将状态自动同步到localStorage。npm install zustand/middlewareimport { create } from zustand; import { persist } from zustand/middleware; const useCartStore createCartStore()( persist( (set, get) ({ /* ... store logic ... */ }), { name: cart-storage, // localStorage 中的 key } ) );API请求优化错误统一处理在request.ts的响应拦截器中可以根据HTTP状态码或业务错误码统一弹出错误提示而不是在每个组件中处理。请求防抖与节流对于搜索框等频繁触发的请求使用防抖函数避免过多无效请求。接口缓存对于不常变的数据如商家列表可以考虑使用swr或react-query等库进行缓存和自动重新请求。组件性能避免不必要的渲染使用React.memo包裹纯展示型组件或使用Zustand的shallow比较函数来避免Store状态变化时所有订阅组件都重新渲染。代码分割使用React.lazy和Suspense对路由页面进行懒加载减少初始包体积。样式与体验移动端适配确保在index.html中设置好视口(viewport)标签。Ant Design Mobile本身已做适配但自定义样式仍需注意。加载状态在数据请求时显示加载中状态如Spin组件提升用户体验。空状态列表无数据、购物车为空时应展示友好的空状态提示而不是一片空白。5.3 项目结构扩展方向当项目规模增长时可以考虑进一步优化结构按功能模块组织将components、pages、stores、api等目录改为按业务模块划分如modules/shop、modules/cart每个模块内包含自己的组件、状态和API。引入CSS-in-JS或CSS模块管理组件样式避免全局样式污染。配置环境变量使用.env文件管理开发、测试、生产环境的不同API地址。编写单元测试为核心工具函数、Store和组件添加测试使用Jest React Testing Library。代码规范集成ESLint和Prettier统一团队代码风格。通过这个从零到一的“死了么”应用开发实战我们不仅串联了React、TypeScript、Ant Design Mobile、Zustand、React Router和Axios等现代前端核心工具更重要的是实践了组件化设计、状态管理、路由配置和前后端分离协作的完整思路。在真实项目中你还需要对接真实后端API、处理用户认证、支付集成等更复杂的业务逻辑但本项目所搭建的工程化底座和问题排查经验将为你应对这些挑战打下坚实的基础。建议你在理解本项目的基础上尝试为其增加搜索筛选、用户登录、订单历史等功能以进一步巩固所学。
返回列表