DAY1结束,我合上了“苍穹外卖”的项目文件夹,决定暂时停更这个前端实战的推进计划。原因很简单:我想先系统学一遍React,学完再回来继续做这个外卖项目。如果你也因为类似的原因在某个项目上按下暂停键,这篇内容应该能帮你想清楚,停下来到底是不是在逃避,以及停下来的这段时间该怎么安排。
苍穹外卖这套业务前端的复杂程度,比我预想中高了不少:登录鉴权、角色路由、商品点餐、购物车同步、订单状态流转,还有实时推送。这些东西如果只是照着一个Demo敲代码,确实能跑通页面,但只要业务条件一变,我就明显感觉到自己的组件化设计和状态管理底子撑不住了。所以我做了一个不太常规的选择:先不硬写,停下来,把React系统学完,再回过头用这套项目验证学习成果。
这篇文章就把我的判断过程、学习路径、以及准备回来改写的模块完整分享一下。
1. DAY1复盘:外卖前端的复杂度比教程Demo高在哪
先说清楚我正在做的这个项目是什么。苍穹外卖是一套典型的外卖平台业务项目,覆盖用户端点餐、商家端管理、后台运营等多个角色。哪怕只聚焦前端部分,它也包含了不少高频业务模块:登录和权限控制、首页分类与商品展示、加购和购物车、下单流程、订单状态跟进,以及订单被接单或配送时的实时状态刷新。
我当时选这个项目,是因为它比普通的管理后台模板更接近真实业务。普通Demo只需要展示数据,而外卖业务的核心是“状态变化”。用户下单之后,订单状态会从待支付变成已支付、商家接单、骑手配送、已完成,整个过程中要展示的内容完全不同。这也意味着前端不能只是写死页面,而是必须让页面跟着数据状态走。
DAY1的实际进度是怎样的?我打开接口文档整理页面结构,把路由和基础布局搭好,完成了登录页面和首页的静态骨架。说实话,写到这一步还挺顺畅,问题出现在我试图把“登录成功后的角色跳转”和“购物车数据跨页面同步”接进来的那一刻。
当时卡住我的主要有三个问题:
- 不同角色登录后能看到的路由和菜单不一样,如果全部靠按钮级权限判断,代码会乱成一团;
- 商品列表页加购之后,购物车页和结算页要读取同一份数据,这份数据放在组件内部还是全局状态里,我没有把握;
- 订单状态推送过来后,如何只更新对应的订单卡片而不是刷新整个页面列表,我脑子里没有清晰方案。
这三个问题其实都不是“某个API不会调”的问题,而是对前端框架的状态管理、路由控制和渲染机制没有形成体系化理解。当时我用的技术栈里虽然也有路由和全局状态管理的对应方案,但我处于一种“知道关键词、不会从原理层面使用”的状态。继续照着模板往下写,也许能换来一个看似完整的页面,可一旦需求改成“多一个角色”或“订单加一种状态”,我大概率还是不知道怎么处理。
所以我停下来,不是因为这个项目太难,而是因为我意识到,我缺的不是某个项目的视频讲解,而是把前端工程化思维补齐。
2. 为什么不是硬着头皮写完,而是转向先学React
很多人的直觉是:项目做一半换技术栈,等于前面的努力白费了。我当时也犹豫过,但最终判断是继续硬写的性价比已经很低了。
先说一个很现实的理由:招聘市场上React相关岗位的占比相当高,而且很多中大型前端项目用的是React加TypeScript的组合。如果我日后希望往深水区走,React迟早要补。与其到时候用React去面试、却只会Vue那套写法,不如在项目实战阶段就把React的完整学习过程经历一遍。
更关键的是技术栈选择背后的心智模型差异。我手里的苍穹外卖项目属于重交互、多状态业务,这类业务在React里通常会被组织成“状态到哪里,UI就更新到哪里”的模式。页面是状态的函数,这句话听起来简单,但它比“在某个生命周期里更新DOM”要更贴近现代前端工程的思考方式。
Vue和React都能实现同样的外卖页面,这一点没有优劣之分,但两个框架处理数据和视图关系的方式很不一样。我当时最大的感受是:Vue的响应式系统帮我省掉了手动更新的过程,让我写起来快,但也意味着我可能很长时间都没真正理解数据变更在框架内部是如何流动的。而React因为状态更新是显式的,并且配套了一套极强的组合工具链,反而能逼着我把“数据怎么来、怎么变、到哪里去”从头理清楚。
这也是我把它们放在一起对比的原因。以路由为例,Vue Router和React Router看起来都在做页面跳转,设计思路却有明显差异。我整理了一个简单对比表,方便有同样困惑的人参考:
| 对比项 | Vue Router | React Router |
|---|---|---|
| 路由声明方式 | new VueRouter 或 createRouter,通过 routes 数组配置 | createBrowserRouter 配合 RouterProvider,也支持组件式 Routes |
| 路由守卫 | 通过 beforeEach 等全局钩子统一拦截 | 没有内置全局守卫,通常用包裹组件或 loader 里重定向 |
| 页面内取参 | this.$route.params / useRoute() | useParams / useSearchParams |
| 嵌套路由实现 | children 配置加 router-view | children 配置加 Outlet |
| 导航跳转 | router.push | useNavigate 或 Navigate 组件 |
这个差异说明了一个关键点:Vue Router倾向于把导航作为一个整体机制集中管理,而React Router把导航权限也当成组件渲染的一部分。比如在React里,如果用户未登录,外层套一个 RequireAuth 组件,内部判断不到用户信息就直接渲染跳转组件。这种方式更加符合React的声明式思维:页面是否展示,取决于当前状态是否满足条件。
对当时的我来说,这种“思想钢印”的转变比多会一个框架重要得多。因为我发现,苍穹外卖这种项目里最棘手的“登录权限”“菜单过滤”“数据联动”,本质上都可以用一套统一的思维模型解决:先定义状态,再根据状态推导视图。学React的过程,恰好就是把这套模型练熟的过程。
3. 系统学React时真正值回票价的几个主题
如果只是看一遍React教程然后继续回来写项目,其实意义不大。我给自己定的目标是:不仅会用React写页面,还要知道React内部为什么这样设计,以及它在哪些场景下比Vue更顺手。下面这些内容是我系统学下来后觉得最值回票价的主题,也是我准备回来重构苍穹外卖时用得上的核心能力。
3.1 从“页面是一堆标签”到“UI是状态的函数”
第一周我做的第一件事,不是急着写组件,而是先建立最基本的心智模型。React的核心公式非常简单:
UI = f(state)
组件就是这样一个函数:拿状态当输入,返回界面作为输出。状态一变,界面自动重新计算并对比更新。和Vue通过响应式依赖追踪自动更新DOM相比,React更强调每次都是重新执行一遍组件函数,而框架负责高效地找出哪些界面真的需要变化。
这个模型在写简单页面时看不出多大威力,但它注定能处理复杂业务。比如苍穹外卖的商详页要展示当前购物车里某个口味已经选了几份,这个数据从哪里来?按“UI是状态的函数”的思路,我只需要把购物车状态放到一个能被商详页和购物车页共同读取的地方,然后让两个页面都根据它计算自己的展示内容即可。
当时我为了给自己建立直觉,把外卖首页的“分类切换”和“商品列表联动”用React写了一个最小版本。CategoryList 和 DishList 都放在同一个页面组件下,通过 useState 保存当前选中的分类ID,列表部分根据分类ID过滤数据。虽然业务简单,但这一遍写下来,比看十节视频都有用。
3.2 React 18的批处理机制:setState拿到的新值可能不是你以为的那个
这个点属于“不看文档进坑,看完文档可以吹”的知识点。我在学React 18更新批处理时印象特别深。
什么叫批处理?简单说,React会把同一时刻发生的多次状态更新合并到一次渲染里执行,而不是改一次状态就立刻渲染一次。React 18之前,React只在事件处理函数内部做批处理;React 18之后,几乎所有的状态更新都会被自动批处理,包括Promise回调、setTimeout、原生事件里。
举个例子:
const [count, setCount] = useState(0); function handleClick() { setCount(count + 1); setCount(count + 1); console.log(count); // 这里打印的还是点击时的旧值 }很多人第一次接触时会疑惑,明明调用了两次setCount,为什么界面上的count只加了1?因为在同一个事件处理函数里,两次更新被合并了。官方推荐的做法是使用函数式更新:
setCount((prev) => prev + 1); setCount((prev) => prev + 1);这样React会按顺序把每次更新函数的结果传给下一次计算,最终得到count加2的效果。
这个机制对真实业务很重要。比如外卖订单页面同时收到了多条WebSocket推送,如果每来一条消息都立刻触发一次渲染,列表会频繁闪烁。React的批处理机制可以把同一时间段的多次消息合并到同一次更新中,提升页面性能。反过来,如果我在更新状态后立刻去读值或者做路由跳转,就必须注意当时拿到的仍然是当前渲染周期的旧状态,应该在useEffect里或基于最新状态计算后处理。
3.3 从Vue Router切换到React Router,最难的不是API,是思路
前面已经列过两个框架在路由层面的差异。我个人的体会是,API变化其实还好,真正需要扭转思路的是“路由守卫”这一步。
Vue Router提供beforeEach全局钩子,可以在跳转前统一做登录校验、角色判断。React Router没有把这种机制内置为全局钩子,React社区的常见做法是用一个容器组件包住需要受保护的页面,或者在各路口的loader函数里做判断并重定向。
我准备回写苍穹外卖时,会创建一个RequireAuth组件:
function RequireAuth({ children, roles }) { const { user } = useAuth(); if (!user) { return <Navigate to="/login" replace />; } if (roles && !roles.includes(user.role)) { return <Navigate to="/403" replace />; } return children; }实际使用的时候,把需要登录态的页面包进RequireAuth里面即可。
<Route path="/order/list" element={ <RequireAuth roles={['merchant', 'admin']}> <OrderList /> </RequireAuth> } />这种写法看起来比全局守卫啰嗦,但它有个很明显的好处:权限控制的“声明位置”和保护页面的“定义位置”在一起,读代码的人能直接看到这个页面到底谁可以访问,而不是在项目里找一套分散的钩子逻辑。对于苍穹外卖这种多角色项目,这个模式反而清晰得多。
3.4 Hooks规则和使用场景:为什么不能条件式调用Hook
React Hooks是我从“会写组件”到“会设计组件”之间最重要的桥梁。而理解Hooks背后的规则,能帮我避免很多奇怪的Bug。
React要求Hooks必须在组件顶层调用,不可以放在if条件里。原因是React靠调用顺序来记住每个Hook对应的状态。如果某次渲染时一个Hook因为条件不满足没有被调用,下一次渲染时它又被执行了,React就不知道上一轮的状态对应哪个Hook了。
我刚开始写时不理解这条规则,后来看到代码运行中状态错乱的例子才意识到,这就像两个人约定按固定顺序交换信件,其中一个人某一天跳过了某一封,后面的对应关系就全乱了。
对苍穹外卖这类项目,我会把很多业务逻辑抽成自定义Hook。比如登录态相关逻辑可以封装成useAuth,返回用户信息和登录方法;订单状态推送可以封装成useWebSocketOrder,在组件里只负责关系订阅和状态消费。这种拆分能让页面组件变薄,也让多个组件共享同一套逻辑时保持一致。
3.5 状态管理不要一步上Redux,按业务规模来
我之前被Redux的名声吓到过,总觉得只要React项目用到全局状态,就一定得上Redux。系统学习后,我的选择原则变得很朴素:能用本地状态解决的,不用全局;能用派生状态解决的,不额外存;确实需要跨组件共享的,再考虑Context或轻量状态库。
比如购物车这个场景,多个页面都要读写它,我准备在重构时先用useReducer加Context来管理。useReducer的好处是把所有更新购物车的操作收敛成几种Action,比如addItem、increase、decrease、removeItem、clearCart。这样在任何页面触发加购,流程都是dispatch一个Action,没有哪个组件能随便改掉购物车状态,出了问题也容易追踪。
如果项目里需要缓存服务端数据、有复杂的异步更新流程,再考虑引入Zustand或Redux Toolkit。Zustand比Redux代码量少,心智负担低,用起来像直接操作一个跨组件可读写的仓库。Redux Toolkit则是团队协作规模较大时的稳妥选择。
下面是我整理的一个简单选型参考:
| 业务复杂度 | 推荐方案 | 适用场景 |
|---|---|---|
| 状态只在单页面内 | useState | 表单、弹窗、局部开关 |
| 页面内有多个子状态联动 | useReducer | 购物车增减、订单状态机 |
| 跨多个页面共享基础状态 | Context + useReducer | 登录信息、主题偏好 |
| 多模块数据共享或异步请求复杂 | Zustand / Redux Toolkit | 中后台项目、多端状态同步 |
不要一上来就把所有状态都塞到全局仓库里,否则过一个月回看代码,会发现自己根本想不起某个字段从哪来、被谁改过。
4. 学成回炉:我会用React重写苍穹外卖的四个核心模块
学完React之后,我重新打开苍穹外卖项目,脑子里不再是“这个页面怎么套模板”,而是“先定义什么状态、状态怎么流转、哪些状态该放在哪个层级”。整个项目的实现思路变化很大。下面这四个模块是我准备回来后做重点重构的地方。
4.1 登录与角色路由:用AuthProvider替换分散的权限判断
原计划里的登录流程是:页面拿到接口返回的token和用户角色,存储到本地,然后在路由跳转处写一堆if判断。改成React结构后,我会把登录态收敛到一个AuthProvider中,由它统一管理当前用户、token的保存和清除,并提供登录和退出登录方法。任何需要用户信息的组件,都通过useAuth读取。
路由部分则用RequireAuth包裹不同角色的页面,把“是否需要登录”和“能否访问某页面”的判断收敛到声明式的组件级别。这样以后增加一个店长或者运营角色,我只需要配置角色对应的菜单和路由路径即可,不需要在多个页面里查找删改权限代码。
4.2 商品加购与购物车:从散落的setState变成统一的Reducer
外卖项目里最容易出现Bug的位置就是购物车。商详页加一份,分类页也加一份,购物车页还要改份数。如果每个页面都维护自己的一份购物车状态,同步问题会让人头疼。
我会改用useReducer管理一份全局购物车状态。状态结构大致是:
{ "items": { "dishId": { "dishId": "101", "name": "红烧肉", "price": 28, "count": 2, "specId": "large" } } }更新操作统一设计为:
dispatch({ type: 'addItem', payload: {...} }) dispatch({ type: 'increase', payload: { dishId, specId } }) dispatch({ type: 'decrease', payload: { dishId, specId } }) dispatch({ type: 'removeItem', payload: { dishId, specId } }) dispatch({ type: 'clearCart' })这样购物车相关的业务逻辑全部在reducer里,其它组件只需要根据items计算总价、总份数。改份数、切换规格等操作不再散落各处,测试和排查变得容易很多。
另外还要处理一个细节:购物车数据要不要持久化到localStorage。我的方案是在购物车每次变化后用useEffect把最新items同步到本地,下次打开页面时初始化阶段恢复,这样用户刷新页面后购物车不会丢。
4.3 实时订单状态推送:在useEffect中正确管理WebSocket
订单状态推送在外卖项目里属于锦上添花但体验差异大的功能。用户下单后,后台会通过WebSocket推送商家接单、骑手取餐等事件。前端收到推送后,要把订单列表中的对应订单状态更新掉。
在React里做WebSocket的关键点,是连接必须在useEffect内部建立,同时清理函数里关闭连接。这样可以避免组件卸载后连接还开着,造成内存泄漏。
下面是我准备采用的简化写法:
useEffect(() => { const socket = new WebSocket('wss://api.example.com/ws/order'); socket.onmessage = (event) => { const message = JSON.parse(event.data); if (message.type === 'ORDER_STATUS_CHANGED') { setOrderStatusMap((prev) => ({ ...prev, [message.orderId]: message.status })); } }; return () => { socket.close(); }; }, []);这中间有几个容易踩的坑。连接不能放在组件外层,否则每次刷新页面都会重复建连;也不能在拿到了登录token之前就连接,因为服务器会校验身份。实际的写法通常是在用户登录成功后,用一个authReady状态触发useEffect执行。
在收到推送更新时,我会用合并状态的方式更新orderStatusMap,而不是通过订单数组重新拉取。这样页面上对应的订单行会根据status字段重新渲染,已经完成的订单还可以自动跑到历史列表。
4.4 订单状态标签的组件化封装
外卖项目中订单状态是一个高频展示字段,到处都是状态标签、状态文案、状态颜色。比较常见的问题是状态一多,大家都按照自己的理解写颜色和文案,等状态从4个扩到8个时,样式和逻辑就散掉了。
React的组件化设计能很好地处理这个情况。我会封装一个OrderStatusTag组件,专门负责把订单状态映射为带颜色和文案的标签。
状态设计成枚举后,在组件里做一个映射表:
const statusMap = { WAIT_PAY: { label: '待支付', type: 'warning' }, PAID: { label: '已支付', type: 'default' }, ACCEPTED: { label: '商家已接单', type: 'processing' }, DELIVERING: { label: '配送中', type: 'processing' }, COMPLETED: { label: '已完成', type: 'success' }, CANCELED: { label: '已取消', type: 'error' } };页面里只需要这样使用:
<OrderStatusTag status={order.status} />只要状态文案或颜色发生调整,只需要改组件里的映射表,不需要满项目查找和修改。这个思路不仅适用于订单状态,商品分类、角色类型等所有固定枚举类字段都可以用类似的封装方式处理。
5. 暂停项目换技术栈,我总结出的几条判断标准
文章的最后,结合这次“苍穹外卖前端DAY1暂停事件”,分享几条我亲测有效的方法论。如果你也在某个项目里卡了很久,正在犹豫到底是停下来补基础还是硬撑着继续,可以参考一下。
第一种该停的情况是:你对自己当前写出的东西没有解释能力。页面能跑起来,但你说不清楚它为什么能跑。这时候继续写下去,大概率只是在积累“看起来正常的代码”,而不是在积累能力。停下来去把底层概念学透,反而会让后面的进度快几倍。
第二种情况是:项目的核心难点已经超出当前知识体系,而你又没有老师的实时指导。比如现在的苍穹外卖需要跨页面共享购物车状态,需要处理角色路由,需要对接实时推送,而我当时的React知识只停留在JSX和基础组件层面。硬写就是试错,试错的成本可能比系统补基础更高。
但也不是所有时候都该停。如果项目只剩收尾逻辑,页面之间没有复杂联动,先完成它拿到一个完整成果,给自己建立信心更重要。学习中的“完整交付感”本身就是一种动力,用它来支撑下一阶段的学习,效果好过永远在准备的路上。
确定要学React之后,我的做法是把苍穹外卖这个项目当成“翻译练习”素材。每学一个React特性,就问自己一个问题:这个东西可以用在外卖项目的哪个页面?比如学useReducer,我会想购物车能不能用;学自定义Hook,我会想登录态刷新能不能抽成一个useAuth。这样学完React之后,回看项目不是从零开始,而是立刻有了一份改造清单。
最后再说一点关于学习节奏的心得。前端这两年变化确实快,React 18的批处理机制、Hooks生态、各种状态管理库层出不穷,很容易让人产生学不完的焦虑。但框架的核心始终没有变:清晰的数据流、可维护的组件结构、对业务复杂度的掌控力。只要把这几个能力练扎实,用Vue还是React,不过是一个不断切换的皮肤。
我暂停更新项目的这段时间,表面上是去学了一套新框架,实际上是在补齐我之前跳过的组件设计课。等我把这些心得用苍穹外卖前端验证完毕,再回来和你聊改造后的代码结构。到时候我相信,这套项目不再是从Day1到Day几十的简单进度更新,而是一次从“会仿写”到“会设计”的实质升级。