Skip to content

记账本项目实战(二) ​

本页对应 记账本项目实战,共 7 段视频,约 1 小时 1 分。

学习目标 ​

  1. 能够完成项目环境搭建并配置路径别名,通过实际导入模块验证别名路径可正常解析。
  2. 能够基于接口返回的账单列表渲染单日账单,并通过映射表将账单类型英文标识转换为中文显示。
  3. 能够使用状态控制单日账单列表的展开与收起,并同步切换箭头方向与列表显隐。
  4. 能够封装账单类型图标组件,根据传入的账单类型动态渲染对应图标。
  5. 能够搭建新增账单页面结构,并实现支出/收入切换时按钮激活状态与账单列表数据的联动更新。
  6. 能够收集新增账单表单数据并完成提交,包括金额正负处理、账单类型选择、日期选择与格式化显示,以及提交后列表数据更新。

知识点 ​

本节目标与边界说明 ​

本节只做一件事:用 CRA 创建项目,并安装项目后续开发所需的基础依赖。需要先说明:本节笔记没有提供图片,也没有给出可保留的代码片段;转写稿讲的是月度账单列表、展开收起、图标组件、新增账单等业务功能,与环境搭建不是同一主题,不能硬套到本节。因此下面只围绕笔记中的依赖清单展开,不编造转写稿里不存在的环境搭建步骤。

为什么先做环境搭建 ​

环境搭建是后续所有功能的地基。没有统一的项目脚手架和依赖,后面写 Redux、路由、时间格式化、类名拼接、移动端组件、接口请求时,就会出现“代码能看懂但跑不起来”的问题。这里的思路不是一次性把所有高级配置做完,而是先保证项目最小可运行,再按职责安装基础包。做法是:先用 CRA 创建 React 项目,再进入项目目录,按笔记清单一次性安装依赖,最后启动项目验证。

使用 CRA 创建项目 ​

CRA 的作用是快速生成一个可运行的 React 项目结构,省去手动配置构建工具的成本。可以这样执行:

bash
npx create-react-app my-app
cd my-app

这里的 my-app 是示例项目名,实际可按项目命名。创建完成后先不要急着写业务代码,先运行:

bash
npm start

能正常打开页面,说明脚手架可用。易错点:命令要在目标父目录执行;进入项目目录后再安装依赖;不要把依赖装到项目外层目录,否则 package.json 不会记录到当前项目。

安装基础依赖清单 ​

本节笔记要求安装以下基础包:

bash
npm install @reduxjs/toolkit react-redux react-router-dom dayjs classnames antd-mobile axios

这条命令对应六类能力:Redux 状态管理、路由、时间处理、类名处理、移动端组件库、请求插件。安装完成后检查 package.json 的 dependencies 是否出现这些包名。易错点:包名要逐字核对,尤其是 @reduxjs/toolkit 带作用域和斜杠,react-router-dom 不是 react-router,antd-mobile 不是 antd,classnames 是复数。

Redux 状态管理:@reduxjs/toolkit 与 react-redux ​

为什么需要:项目后续会有跨组件共享状态和异步请求状态,单靠组件内部 state 不好维护。怎么想:@reduxjs/toolkit 负责创建 store、slice、reducer 和异步 action;react-redux 负责把 store 接入 React 组件。怎么做:先安装两个包,后续再创建 store 并用 Provider 包裹应用。易错点:只装 @reduxjs/toolkit 不装 react-redux,React 组件无法方便地读取和派发;不要把两个包名混成一个。

路由:react-router-dom ​

为什么需要:项目会有页面级切换,例如月度账单、新增账单等。怎么想:Web 端 React 路由使用 react-router-dom,它提供路由容器、路由匹配和跳转能力。怎么做:先安装,后续在入口处配置路由。易错点:不要安装成 react-router 后直接照 Web 示例写;包名要写完整 react-router-dom。

时间处理:dayjs ​

为什么需要:账单日期、时间戳展示和格式化会反复出现。怎么想:用轻量时间库统一处理,避免手写补零和格式拼接。怎么做:安装后在需要格式化的组件中引入使用。易错点:不要和原生 Date 混用到难以排查;格式化模板要按实际展示需求写。

类名处理:classnames ​

为什么需要:条件类名、激活态类名在组件中很常见,字符串拼接容易漏空格或写出多余空格。怎么想:用 classnames 根据条件生成类名。怎么做:安装后在组件中引入,把条件对象或数组传进去。易错点:布尔条件与类名字符串要分清;不要手写一长串三元表达式导致可读性下降。

移动端组件库:antd-mobile ​

为什么需要:项目是移动端界面,需要按钮、弹窗、表单、选择器等基础组件。怎么想:使用 antd-mobile 提供移动端组件,而不是桌面端 antd。怎么做:安装后按需引入组件。易错点:不要把 antd-mobile 和 antd 混装混用;组件样式和交互要按移动端文档使用。

请求插件:axios ​

为什么需要:项目需要和后端接口通信,例如获取账单列表、提交新增账单。怎么想:用 axios 统一发请求,后续可封装请求模块。怎么做:安装后在需要请求的地方引入,或先建请求封装文件。易错点:不要把请求地址写死在多个组件里;安装后先确认包已进入 dependencies。

安装后的验证与易错点汇总 ​

安装完成后建议做三件事:第一,查看 package.json 中依赖是否齐全;第二,运行 npm start 确认项目能启动;第三,在入口文件临时引入一个依赖做编译验证,确认无包名错误后删除临时代码。常见易错点包括:在错误目录安装、包名拼写错误、把移动端组件库装成桌面端库、只装 Redux 核心包却漏装 react-redux、把路由包名写错。环境搭建不追求复杂,先把“能启动、依赖齐全、包名正确”这三件事做稳,后续业务代码才有可靠基础。

先核对:转写稿与本节标题不匹配 ​

本节笔记标题是「配置别名路径」,但提供的转写稿共 7 个视频,内容全部围绕「月度账单」与「新增账单」功能展开,没有出现“别名路径”“alias”“vite.config”“webpack 配置”“@ 路径映射”等相关讲解。具体对应关系如下:

  • 视频1:月度账单——单日账单列表渲染显示;
  • 视频2:月度账单——点击切换账单展开和收起;
  • 视频3:月度账单——账单类型图标组件封装;
  • 视频4:新增账单——功能熟悉和结构搭建;
  • 视频5:新增账单——支出和收入功能实现;
  • 视频6:新增账单——新增表单实现;
  • 视频7:新增账单——收尾优化。

因此,无法依据这份转写稿扩写出「配置别名路径」的教程正文。若强行补写 resolve.alias、jsconfig.json 的 paths、@ 指向 src 等内容,就会脱离给定材料,属于编造,不符合“不得编造”的要求。

转写稿中唯一与“路径”有关的片段 ​

视频3 封装图标组件时,提到图标地址的拼接思路:

“前面这个基地址呢可以不变,只更改后面这个地址的名字就可以了……把这个 type 咱给它拼接到这个 food 的位置。”

这里的“路径”指的是阿里云图标 URL 中最后一段文件名的替换,用于根据账单类型显示不同图标;它解决的是“不同类型渲染不同图标”的问题,并不是项目工程里的模块别名路径配置。两者不是同一个知识点。

建议补充的材料 ​

如果要按「配置别名路径」扩写,需要补充对应视频的转写稿,至少应包含以下信息,才能做到“为什么需要 → 怎么想 → 怎么做 → 易错点”的完整推导:

  1. 当前项目使用的是什么构建工具,例如 Vite 还是 Webpack;
  2. 配置写在哪个文件里,例如 vite.config.js 还是 webpack.config.js;
  3. 别名符号是什么,例如 @ 是否指向 src;
  4. 是否有配套的编辑器提示配置,例如 jsconfig.json 或 tsconfig.json;
  5. 讲师的调试过程:配置后如何重启服务、如何验证 @/xxx 能正常解析、报错时如何排查;
  6. 讲师强调的易错点,例如路径末尾是否带 /、是否只改配置未重启、是否与已有别名冲突等。

当前可确认的结论 ​

在现有材料下,只能确认转写稿讲的是账单列表渲染、展开收起、图标组件封装以及新增账单的完整流程;不能据此写出「配置别名路径」的教程正文。请提供与本节标题对应的转写稿,我再按你要求的小标题分段、保留图片与代码、补充推导和调试过程,扩写成 1000–1800 字的教程正文。

本节范围与目标 ​

背景知识配置示意图

本节笔记只记录“背景知识”中的两项配置:一是 Webpack 的路径解析配置,把 @/ 解析为 src/;二是 VSCode 的路径联想配置,让编辑器在输入 @/ 时自动联想出 src/ 下的子级目录。转写稿主要记录月度账单列表、展开收起、图标组件封装、新增账单等业务实现,并未包含本节配置的逐步推导和调试录像。因此下面只依据笔记扩写,不把账单业务代码混入背景配置;其中“固定前缀不变、只替换后续路径名”的思路,可类比转写稿中图标地址“前面基地址不变,只改后面名字”的处理方式,用来帮助理解 @/ 的作用。

知识点一:Webpack 路径解析配置(@/ → src/) ​

为什么需要: 项目文件层级变深后,相对路径会出现大量 ../../,既难读又容易在移动文件后失效。把 @/ 固定映射到 src/,可以让导入路径更稳定、更短,也便于团队统一写法。

怎么想: 这里要区分“编辑器认识”和“构建工具认识”。Webpack 负责真正打包时的模块解析,所以必须在 Webpack 配置中告诉它:遇到以 @/ 开头的路径,不要当成 npm 包,而是去项目 src/ 下找。思路是配置 resolve.alias,把 @ 指向 src 的绝对路径。

怎么做: 在 Webpack 配置的 resolve.alias 中增加别名,示意如下,具体字段以项目实际配置文件为准:

js
// webpack 配置示意
const path = require('path')

module.exports = {
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
}

配置后,import xxx from '@/components/Icon' 应被解析到 src/components/Icon。如果项目使用 Vue CLI、Create React App 等封装工具,要找到其允许扩展 Webpack 配置的入口,而不是随意新建配置文件。

易错点: 一是只配了 VSCode 没配 Webpack,编辑器不报错但构建失败;二是只配了 Webpack 没配 VSCode,能构建但编辑器提示找不到模块;三是别名指向了相对路径或错误目录,导致解析到项目根目录之外;四是 @ 与 @/ 写法不统一,团队内要约定固定写法。

知识点二:VSCode 路径联想配置 ​

为什么需要: Webpack 配置只保证“能打包”,但 VSCode 的自动补全、跳转和类型提示还需要编辑器自己理解路径映射。否则输入 @/ 时没有联想,开发体验差,也容易手写错目录名。

怎么想: VSCode 通常通过项目根目录的 jsconfig.json 或 tsconfig.json 的 compilerOptions.paths 理解路径别名。关键是让编辑器知道 @/* 对应 src/*,并且 baseUrl 要设置正确。它和 Webpack alias 是“两套系统、同一套映射”,必须保持一致。

怎么做: 在项目根目录配置示意如下,按项目实际使用 JS 或 TS 选择文件:

json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

保存后,在代码中输入 @/ 时,VSCode 应能联想 src/ 下的子级目录。若没有立即生效,可重启 VSCode 或检查文件是否在项目根目录、是否被工作区正确加载。

易错点: 一是 paths 写成 "@": ["src"] 还是 "@/*": ["src/*"] 要与实际导入写法匹配;二是 baseUrl 写错导致映射基准不对;三是项目已有 tsconfig.json 却又新建 jsconfig.json,两者冲突;四是 Webpack 的 alias 与 VSCode 的 paths 不一致,出现“编辑器能跳、构建报错”或相反情况。

联调与验证顺序 ​

先配 Webpack,保证构建解析正确;再配 VSCode,保证输入联想和跳转正确。验证时不要只看编辑器是否联想,还要实际运行构建,确认 @/ 导入能被打包。转写稿没有给出本节配置的运行结果,因此这里不虚构“已成功”的结论,只给出可执行的检查顺序:输入 @/ 看联想是否出现;打开导入文件看能否跳转;执行构建看是否报模块找不到;若失败,优先核对 alias 与 paths 是否一一对应。这样能把“编辑器体验”和“构建解析”分开排查,避免把两类问题混在一起。

代码示例 ​

示例一:单日账单列表渲染 + 账单类型中英文适配 ​

jsx
// ① src/contants/index.js —— 中英文映射表
const useForList = [
  { type: 'food', name: '餐饮' },
  { type: 'drinks', name: '酒水饮料' },
  { type: 'dessert', name: '甜品零食' },
  { type: 'shopping', name: '购物' },
  { type: 'salary', name: '工资' },
  { type: 'traffic', name: '出行交通' }
]

// 用 reduce 把数组累加成对象:{ food: '餐饮', drinks: '酒水饮料', ... }
export const itemType = useForList.reduce((acc, item) => {
  acc[item.type] = item.name
  return acc
}, {})

// ② src/pages/Month/components/Day/index.jsx —— 单日账单列表
import dayjs from 'dayjs'
import { itemType } from '@/contants'
import './index.scss'

export default function Day({ date, billList = [] }) {
  return (
    <div className="dailyBill">
      <div className="header">
        <span className="date">{dayjs(date).format('DD')}</span>
        <span className="month">{dayjs(date).format('YYYY/MM')}</span>
      </div>

      <div className="billList">
        {billList.map((item) => (
          <div className="bill" key={item.id}>
            <div className="detail">
              {/* 核心:用接口返回的英文 useFor 当 key,取出中文 */}
              <p className="useFor">{itemType[item.useFor]}</p>
              <p className="time">{dayjs(item.date).format('MM-DD HH:mm')}</p>
            </div>
            <div className="money">{item.money.toFixed(2)}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

关键行与易错点

  • reduce 的初始值必须传 {},否则第一个元素会被当成累加器,映射表直接错位。
  • itemType[item.useFor] 是整段适配的核心,useFor 是接口返回的英文(drinks/salary),映射表把它翻译成中文。
  • 易错点:billList 要给默认值 = [],否则父组件数据还没回来时 map 直接报错白屏。
  • 易错点:key 用接口返回的 id,不要用 index,列表新增/删除时会出现渲染错乱。
  • 传进来的 billList 应该是已经按天分好组的数据,分组逻辑放在父组件,子组件只负责渲染。

示例二:点击展开/收起 + 账单类型图标组件封装 ​

jsx
// ① src/components/Icon/index.jsx —— 图标组件
// 图标都存在阿里云,前缀固定,只有最后的文件名随 type 变化
const BASE_URL = 'https://xxx.oss-cn-beijing.aliyuncs.com/'

export default function Icon({ type }) {
  return <img className="icon" src={`${BASE_URL}${type}.svg`} alt={type} />
}

// ② src/pages/Month/components/Day/index.jsx —— 展开收起 + 使用图标
import { useState } from 'react'
import classNames from 'classnames'
import dayjs from 'dayjs'
import Icon from '@/components/Icon'
import { itemType } from '@/contants'
import './index.scss'

export default function Day({ date, billList = [] }) {
  // 默认收起
  const [visible, setVisible] = useState(false)

  return (
    <div className="dailyBill">
      <div className="header">
        <span className="date">{dayjs(date).format('DD')}</span>

        <img
          className={classNames('arrow', visible && 'extend')}
          onClick={() => setVisible(!visible)}
          src="/arrow.png"
          alt=""
        />
      </div>

      {/* 用状态驱动显示隐藏 */}
      <div className="billList" style={{ display: visible ? 'block' : 'none' }}>
        {billList.map((item) => (
          <div className="bill" key={item.id}>
            <Icon type={item.useFor} />
            <div className="detail">
              <p className="useFor">{itemType[item.useFor]}</p>
            </div>
            <div className="money">{item.money.toFixed(2)}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

关键行与易错点

  • useState(false) 决定初始收起,setVisible(!visible) 是取反,点一次开、再点一次关。
  • classNames('arrow', visible && 'extend') 里 visible && 'extend' 在 false 时会返回 false,classnames 会自动忽略,所以不会出现 arrow false 这种脏类名。
  • 图标组件的关键:src={${BASE_URL}${type}.svg},路径前缀不变,只把 type 拼到文件名位置,所以传 drinks 就显示酒杯、传 dessert 就显示甜甜圈。
  • 易错点:模板字符串里 ${} 不要写成 {},也不要漏掉 .svg 后缀,否则图片 404。
  • 易错点:Icon 的 props 名要和调用处一致(组件里叫 type,父组件就传 type={item.useFor}),命名对不上会渲染出一张 undefined.svg。

示例三:新增账单——支出/收入切换 + 表单收集与提交 ​

jsx
// ① src/store/modules/billSlice.js —— 同步 reducer 与异步 action
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import axios from 'axios'

const billStore = createSlice({
  name: 'bill',
  initialState: { billList: [] },
  reducers: {
    // 同步:把新增的一条塞进列表
    addBillList(state, action) {
      state.billList.push(action.payload)
    }
  }
})

export const { addBillList } = billStore.actions

// 异步:先发请求,成功后再走同步 reducer
export const addBillListAsync = createAsyncThunk(
  'bill/addBillListAsync',
  async (payload, { dispatch }) => {
    await axios.post('http://localhost:8888/ka', payload)
    dispatch(addBillList(payload))
  }
)

export default billStore.reducer

// ② src/pages/New/index.jsx —— 新增账单页面
import { useState } from 'react'
import classNames from 'classnames'
import dayjs from 'dayjs'
import { useDispatch } from 'react-redux'
import { addBillListAsync } from '@/store/modules/billSlice'
import { billList } from '@/contants/billList' // { pay: [...], income: [...] }
import Icon from '@/components/Icon'
import './index.scss'

export default function New() {
  const [billType, setBillType] = useState('pay')        // pay 支出 / income 收入
  const [money, setMoney] = useState(0)                  // 金额,受控
  const [useFor, setUseFor] = useState('')               // 选中的账单类型
  const [date, setDate] = useState(dayjs().valueOf())    // 记账时间
  const dispatch = useDispatch()

  const saveBill = () => {
    const data = {
      type: billType,
      money: billType === 'pay' ? -money : +money,       // 支出为负、收入为正
      date,
      useFor
    }
    dispatch(addBillListAsync(data))
  }

  return (
    <div className="keepAccounts">
      <div className="header">
        <button
          className={classNames('pay', { selected: billType === 'pay' })}
          onClick={() => setBillType('pay')}
        >
          支出
        </button>
        <button
          className={classNames('income', { selected: billType === 'income' })}
          onClick={() => setBillType('income')}
        >
          收入
        </button>
      </div>

      <div className="time">{dayjs(date).format('MM月DD日')}</div>

      <div className="money">
        <input
          type="text"
          value={money}
          onChange={(e) => setMoney(e.target.value)}
          placeholder="0.00"
        />
      </div>

      <div className="type">
        {/* 用 billType 当 key 取列表,支出/收入自动切换 */}
        {billList[billType].map((item) => (
          <div
            key={item.type}
            className={classNames('item', { selected: useFor === item.type })}
            onClick={() => setUseFor(item.type)}
          >
            <Icon type={item.type} />
            <span>{item.name}</span>
          </div>
        ))}
      </div>

      <button className="save" onClick={saveBill}>
        保存
      </button>
    </div>
  )
}

关键行与易错点

  • billType 的取值 'pay' / 'income' 不是随便起的,要和后端接口、billList 静态数据的 key 完全一致,否则 billList[billType] 取到 undefined 直接崩。
  • billList[billType].map(...) 一行同时完成"支出/收入列表切换",不用写两套 JSX。
  • selected 用 classNames 的对象写法,条件为 true 时才追加类名,这是按钮高亮和分类项高亮的统一做法。
  • 金额适配易错点:money 存的是用户输入的正数,提交前才按 billType 转成 -money / +money,不要在 onChange 里就取负,否则用户切换支出收入时会出错。
  • addBillList 里用 state.billList.push(...),这是 RTK 内置 Immer 的写法,直接改就行,不要写成 state.billList = [...state.billList, payload] 之外的多余拷贝,也别忘了 dispatch(addBillList(payload)) 才会真正入列表。
  • 易错点:useFor 初始值是空串,如果用户没选分类就点保存,接口会收到 useFor: '',实际项目里要做非空校验。

常见错误 ​

  1. 别名 @/ 配完不生效,或编辑器不联想

    • 现象:终端编译报 Module not found: @/xxx;或代码能跑但 VS Code 输入 @/ 没有任何提示。
    • 原因:只配了一边——webpack 的 resolve.alias 和编辑器读取的 jsconfig.json(compilerOptions.paths)是两套东西,缺一个就瘸腿;也有配完不重启导致缓存旧配置。
    • 修法:两处同时配,@/* → src/*;改完必须重启 dev server,并在 VS Code 里执行一次 "Restart TS Server / Reload Window" 再验证联想。
  2. 点开一天的账单,所有天一起展开

    • 现象:点某一个箭头,列表里每一天的明细同时显示/收起。
    • 原因:useState 声明在了父级列表组件里,被 map 出来的所有单日项共享了同一个 visible。
    • 修法:把"展开/收起"的状态下沉到每个单日子组件内部,父组件只负责传 billList;若确实要父级统一控制,就把状态改成以 date 为 key 的对象({ [date]: true })。
  3. 图标组件渲染出来是裂图 / 空白

    • 现象:<img> 有元素但图片 404,控制台一堆资源加载失败。
    • 原因:直接把接口返回的 type 拼进 URL,接口给的是 drinks、salary 这类值,而图标文件名大小写或命名规则不一致;另外 type 为空(未选中)时会拼出残缺地址。
    • 修法:不要裸拼字符串,先过一层映射表 { drinks: 'Drink', salary: 'Salary' },映射不到时给默认图标兜底;拼接前判空。
  4. 中英文适配后页面显示 undefined

    • 现象:账单类型位置不显示中文,或直接报 Cannot read properties of undefined。
    • 原因:reduce 里忘记 return(箭头函数写成块体却没返回值),累加器变成 undefined;或初始值没给 {}。
    • 修法:reduce 回调必须显式 return,初始值传 {};在组件里取值时再加一层兜底 map[item.useFor] || item.useFor。
  5. 金额提交上去正负号不对,统计全乱

    • 现象:明明是支出,接口收到的是正数;或者收到字符串 "+100"。
    • 原因:没根据 billType 做正负处理;或用了 +value 而 value 来自 input 是字符串,+ 变成了字符串拼接。
    • 修法:先 Number(money) 转数值,再按 billType === 'pay' ? -n : n 处理;同时校验空值/0 不允许提交。
  6. 输入框打字打不进去 / 输入后状态不更新

    • 现象:光标闪烁但内容不变,控制台警告 "You provided a value prop to a form field without an onChange handler"。
    • 原因:只写了受控的 value,漏了 onChange;或 onChange 里取的是 e.target.value 之外的东西。
    • 修法:value 与 onChange 成对出现,onChange 内 setMoney(e.target.value),并在提交时统一做类型转换。
  7. 新增成功但列表不刷新,要手动刷新页面才看到

    • 现象:接口 200,Network 里能看到数据,但页面列表没有新记录。
    • 原因:异步 thunk 里只调了接口,没有 dispatch 同步 reducer 去更新 store;或 reducer 里直接 push 到 state 之外的新数组却没被 Immer 追踪到。
    • 修法:thunk 内 await 请求成功后立刻 dispatch(addBill(payload));RTK 里放心写 state.list.push(...),别整体重新赋值成 state = ...。
  8. 时间选择器确定后弹层不关 / 显示的还是今天

    • 现象:点了确定,弹窗还挂着;或者选完 4 月 3 日,输入区仍写"今天"。
    • 原因:visible 状态没在 onConfirm 里置回 false;显示区直接写死了 dayjs() 而不是格式化用户选中的时间戳。
    • 修法:onConfirm 中先 setDate(value)、再 setVisible(false);展示用 dayjs(date).format('MM月DD日') 绑定同一份状态。

练习 ​

  1. 单日汇总:在单日账单列表头部加一个"当日支出合计 / 收入合计",用 reduce 按 type 分组累加。要求:只统计 pay 与 income 两类,金额取绝对值展示。
  2. 图标组件健壮化:把现在"字符串拼 URL"的写法改成映射表 + 默认兜底图标,并在 type 为空时不渲染 <img>(渲染占位)。思考:如果后端新增了类型而前端没更新映射表,用户会看到什么?如何用 propTypes/默认参数降低风险?
  3. 表单校验与提交反馈:给新增账单加上三条校验——金额必填且大于 0、必须选中类型、日期必选;提交成功后清空表单并给出成功提示(antd-mobile 的 Toast),失败时提示错误并保留已填内容。
  4. 日期可选范围:给时间选择器加 max={new Date()} 限制不可选未来日期;并额外做一件事——把选中日期持久化到 Redux,使"切到收入/支出"时不会丢失已选日期。
  5. 展开状态记忆:将展开/收起状态改为"默认展开当天,其他收起",并实现点击箭头时同时切换箭头方向的图标类名。思考:如果要记住用户上次展开的是哪一天(刷新后仍保留),状态应该放在哪里?

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1月度账单-列表区域-单日账单列表渲染显示04:47
2月度账单-列表区域-点击切换账单展开和收起06:01
3月度账单-账单类型图标组件封装07:42
4新增账单-功能熟悉和结构搭建04:23
5新增账单-支出和收入功能实现09:47
6新增账单-新增表单实现17:30
7新增账单-收尾优化11:24

小结 ​

  • 别名配置是"编译期 + 编辑器"双轨制,任何一边缺失都会表现为"代码能跑但开发体验崩",配置类问题优先怀疑缓存与重启。
  • 列表类 UI 的状态粒度决定 bug 形态:状态放在哪一层,就决定了"一项操作影响一片还是只影响一项",展开收起、选中高亮都遵循这个规律。
  • 组件封装的价值在于"把变化点外置为 props"——图标组件的变化点只是 URL 尾部的类型名,因此 type 就是它唯一该关心的入参。
  • 凡是"英文 key → 中文文案/资源地址"的适配,都要经过映射层,而不是在渲染处硬拼;映射层必须考虑 key 缺失的兜底分支。
  • 数据驱动视图的固定套路是:先有 state → 再绑定切换事件 → 最后用 state 适配样式和内容,三步顺序不能颠倒,否则会写出"手动操作 DOM"的返工代码。
  • 提交表单的难点不在 dispatch,而在"字段名、类型、正负号、时间格式"是否与后端契约严格一致,这类对齐问题应在 console.log 阶段先验证完再往下写。
  • 异步 action 的完整链路是"组件收集 → thunk 请求 → dispatch 同步 reducer → store 更新 → 视图刷新",任何一环断开,表现都是"接口成功但页面不动"。
  • 收尾工作(点击激活、时间选择、时间格式化、弹层关闭)永远比核心逻辑更耗时,功能排期时应把这类"细节优化"单独留出时间。