本博客基于掘金小册子《Node + React 实战:从 0 到 1 实现记账本》的内容进行整合与编写,旨在构建一个系统的知识网络。

本项目使用的Node框架是Egg

本项目github链接xxMudCloudxx/TallyBook: React+Eggjs+MySql

Eggjs项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
egg-project
├── package.json
├── app.js(可选)
├── agent.js(可选)
├── app
| ├── router.js
│ ├── controller
│ │ └── home.js
│ ├── service(可选)
│ │ └── user.js
│ ├── middleware(可选)
│ │ └── response_time.js
│ ├── schedule(可选)
│ │ └── my_task.js
│ ├── public(可选)
│ │ └── reset.css
│ ├── view(可选)
│ │ └── home.tpl
│ └── extend(可选)
│ ├── helper.js(可选)
│ ├── request.js(可选)
│ ├── response.js(可选)
│ ├── context.js(可选)
│ ├── application.js(可选)
│ └── agent.js(可选)
├── config
| ├── plugin.js
| ├── config.default.js
│ ├── config.prod.js
| ├── config.test.js(可选)
| ├── config.local.js(可选)
| └── config.unittest.js(可选)
└── test
├── middleware
| └── response_time.test.js
└── controller
└── home.test.js

如上,由框架约定的目录:

  • app/router.js 用于配置 URL 路由规则,具体参见 Router
  • app/controller/** 用于解析用户的输入,处理后返回相应的结果,具体参见 Controller
  • app/service/** 用于编写业务逻辑层,建议使用,具体参见 Service
  • app/middleware/** 用于编写中间件,具体参见 Middleware
  • app/public/** 用于放置静态资源,具体参见内置插件 egg-static
  • app/extend/** 用于框架的扩展,具体参见 框架扩展
  • config/config.{env}.js 用于编写配置文件,具体参见 配置
  • config/plugin.js 用于配置需要加载的插件,具体参见 插件
  • test/** 用于单元测试,具体参见 单元测试
  • app.jsagent.js 用于自定义启动时的初始化工作,具体参见 启动自定义。关于 agent.js 的作用,参见 Agent 机制

由内置插件约定的目录:

  • app/public/** 用于放置静态资源,具体参见内置插件 egg-static
  • app/schedule/** 用于定时任务,具体参见 定时任务

若需自定义自己的目录规范,参见 Loader API

  • app/view/** 用于放置模板文件,具体参见 模板渲染
  • app/model/** 用于放置领域模型,如 egg-sequelize 等领域类相关插件。

略讲

本项目的后端接口编写的过程中会频繁地和controller,middleware,service和config文件夹打交道

app/router.js

用于配置 URL 路由规则,比如上述初始化代码中的 get 请求,npm run dev 启动项目之后,你可以直接在浏览器中访问启动的端口 + 路径,默认是 http://localhost:7001/,你将会拿到 app/controller 文件夹下,home.js 脚本中 index 方法返回的内容。

app/controller/xx

用于解析用户的输入,处理后返回相应的结果。上述我们也提到了,通过请求路径将用户的请求基于 methodURL 分发到对应的 Controller 上,而 Controller 要做的事情就是响应用户的诉求。举个例子,我想拿到 A 用户的个人信息,于是我们要在控制器(Controller)里,通过请求携带的 A 用户的 id 参数,从数据库里获取指定用户的个人信息。我画了一个简易流程图如下:

img

app/service/xx

简单来说,Service 就是在复杂业务场景下用于做业务逻辑封装的一个抽象层。上述初始化项目中未声明 service 文件夹,它是可选项,但是官方建议我们操作业务逻辑最好做一层封装。我们换一种理解方式,Service 层就是用于数据库的查询,我们尽量将粒度细化,这样以便多个 Controller 共同调用同一个 Service。后续我们链接数据库操作的时候,再进行详细分析。更加详细的描述请移步至 Service 文档

app/middleware/xx

用于编写中间件,中间件的概念就是在路由配置里设置了中间件的路由,每次请求命中后,都要过一层中间件。在我们后续的开发中,也会利用到这个中间件的原理做用户鉴权。当用户未登录的情况下,是不能调用某些接口的。

当然,你可以每次都在 Controller 判断,当前请求是否携带有效的用户认证信息。接口一多,到处都是这样的判断,逻辑重复。所以,中间件在某种程度上,也算是优化代码结构的一种方式。更加详细的描述请移步至 Middleware 文档

简而言之

我们每次编写好接口后都需要在router.js文件中添加对应的getpost路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
module.exports = (app) => {
const { router, controller, middleware } = app;
const _jwt = middleware.jwtErr(app.config.jwt.secret);
router.post("/api/user/register", controller.user.register);
router.post("/api/user/login", controller.user.login);
router.post("/api/user/edit_userinfo", _jwt, controller.user.editUserInfo);
router.post("/api/upload", controller.upload.upload);
router.post("/api/bill/add", _jwt, controller.bill.add);
router.post("/api/bill/update", _jwt, controller.bill.update);
router.post("/api/bill/delete", _jwt, controller.bill.delete);

router.get("/api/user/get_userinfo", _jwt, controller.user.getUserInfo);
router.get("/api/user/test", _jwt, controller.user.test);
router.get("/api/user/upload", controller.user.upload);
router.get("/api/bill/list", _jwt, controller.bill.list);
router.get("/api/bill/detail", _jwt, controller.bill.detail);
router.get("/api/bill/data", _jwt, controller.bill.data);
};

Controller文件里编写的是我们接口所调用的函数,它会解析用户输入,然后处理后返回对应的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
'use strict';

const Controller = require('egg').Controller;

class HomeController extends Controller {
async index() {
const { ctx } = this;
const { id } = ctx.query;
ctx.body = id;
}
}

module.exports = HomeController;
  • get方法一般用ctx.query来获取输入
  • post方法一般用ctx.request.bodyctx.request.header来获取输入
  • ctx.body则是我们服务器的响应

service文件则是用来编写Controller获取数据库数据的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// app/service/home.js
'use strict';

const Service = require('egg').Service;

class HomeService extends Service {
async user() {
// 假设从数据库获取的用户信息
return {
name: '嘎子',
slogen: '网络的世界太虚拟,你把握不住'
};
}
}
module.exports = HomeService;

我们可以在 Controller 内拿到上述方法,如下所示:

1
2
3
4
5
6
7
8
9
10
//  app/controller/home.js
// 获取用户信息
async user() {
const { ctx } = this;
const { name, slogen } = await ctx.service.home.user();
ctx.body = {
name,
slogen
}
}

该项目的知识点

  • token鉴权
  • egg 中间件编写
  • 数据库的资源获取
  • Egg 文件资源处理
  • 一套增删改查
  • egg-mysql的多种使用

该项目实现的功能

  • 用户的登录注册
  • 用户的信息获取及其更改
  • 用户资源的上传及获取
  • 账单的增删查改
  • 账单详细及其数据的获取

登录注册

用户鉴权,一种用于在通信网络中对试图访问来自服务提供商的服务的用户进行鉴权的方法。用于用户登陆到DSMP或使用数据业务时,业务网关或Portal发送此消息到DSMP,对该用户使用数据业务的合法性和有效性(状态是否为激活)进行检查。

鉴权的机制,分为四种:

  • HTTP Basic Authentication
  • session-cookie
  • Token 令牌
  • OAuth(开放授权)

本项目用的鉴权模式是 token 令牌模式,出于多端考虑,token 可以运用在如网页、客户端、小程序、浏览器插件等等领域。如果选用 cookie 的形式鉴权,在客户端和小程序就无法使用这套接口,因为它们没有域的概念,而 cookie 是需要存在某个域下。

登录注册的流程如下

img

img

注册

  • UserController通过ctx.request.body获取传入的名字和密码
  • 将获得的信息传入UserServiceUserService使用get方法获取数据库中的用户信息
    • 若存在信息,则响应返回账户名已被注册
  • 将用户的名字,密码及其默认头像默认签名传入UserService进行注册,UserService使用insert方法将信息插入数据库中
  • 编写response的消息体
    • 返回状态码
    • 注册成功与否

登录

  • UserController通过ctx.request.body获取传入的名字和密码

  • 将获得的信息传入UserServiceUserService使用get方法获取数据库中的用户信息

    • 若不存在信息,则返回账户不存在
    • 若账号密码不对,则返回账号密码错误
  • 使用app.jwt.sign方法配置token

  • 编写response的消息体

    • 返回状态码

    • 登录成功

    • token

登录的中间件

app\middleware\jwtErr.js中编写如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
'use strict';

module.exports = (secret) => {
return async function jwtErr(ctx, next) {
const token = ctx.request.header.authorization; // 若是没有 token,返回的是 null 字符串
let decode
if(token != 'null' && token) {
try {
decode = ctx.app.jwt.verify(token, secret); // 验证token
await next();
} catch (error) {
console.log('error', error)
ctx.status = 200;
ctx.body = {
msg: 'token已过期,请重新登录',
code: 401,
}
return;
}
} else {
ctx.status = 200;
ctx.body = {
code: 401,
msg: 'token不存在',
};
return;
}
}
}

router.js路由中使用

1
2
3
4
5
module.exports = app => {
const { router, controller, middleware } = app;
const _jwt = middleware.jwtErr(app.config.jwt.secret); // 传入加密字符串
router.get('/api/user/test', _jwt, controller.user.test); // 放入第二个参数,作为中间件过滤项
};

_jwt虽然 只传了一个参数(app.config.jwt.secret,但它实际上 返回了一个函数,而这个返回的函数才是真正的中间件。

get展开实际上是:

1
router.get("/api/user/test", async function jwtErr(ctx, next) { ... }, controller.user.test);

Egg.js 在执行 GET /api/user/test 这个请求时:

  1. 先执行 jwtErr(ctx, next)
    • 解析 token,如果 token 不存在或无效,返回 401
    • 如果 token 有效,继续执行 next()
  2. 执行 controller.user.test(ctx)
    • next() 允许请求进入 controller.user.test
    • 这样,只有通过 JWT 验证的请求,才能进入 test 方法

后续所有的接口都需要_jwt这个中间件来验证登录

信息获取及其更改

信息获取

  • 先获取消息头中的token
  • token传入app.jwt中进行解密
  • 将解密后的用户名字传入UserService中,UserService通过get方法获取数据库中的信息
  • 编写响应的消息体
    • 状态码
    • 请求成功消息
    • 用户数据

信息更改

  • 因为是post方法,通过ctx.request.body获取需要更改的信息

  • 获取消息头中的token

  • token传入app.jwt中进行解密

  • 将解密后的用户名字传入UserService中,UserService通过get方法获取数据库中的信息

  • 使用{...userInfo, signature, avatar}将原本的签名和头像覆盖掉,并传入UserServiceUserService使用update方法更新数据库中的信息

    • app.mysql.update("bill", { ...params }, { id: params.id, user_id: params.user_id })

      • 功能:更新表中的记录。
      • API:app.mysql.update(table, values, conditions)其中:
        • table 是表名。
        • values 是一个对象,包含要更新的字段和值。
        • conditions 是一个对象,包含更新条件。
    • 使用

      • let result = await app.mysql.update(
                "user",
                {
                  ...params,
                },
                {
                  id: params.id,
                }
              );
        
        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        24
        25
        26
        27
        28
        29
        30
        31
        32
        33
        34
        35
        36
        37
        38
        39
        40
        41
        42
        43
        44
        45
        46
        47
        48
        49
        50
        51
        52
        53
        54
        55
        56
        57

        - 返回响应的消息体
        - 状态码
        - 请求成功与否
        - 更改后的信息

        # 资源的上传及获取

        图片地址的结构是 `host + IP + 图片名称 + 后缀`

        ```js
        // Controller/upload.js
        async upload() {
        const { ctx } = this;
        // ✅ 确保 request.files 不为空
        if (!ctx.request.files || ctx.request.files.length === 0) {
        ctx.body = { code: 400, msg: "没有上传文件" };
        return;
        }
        // 需要前往 config/config.default.js 设置 config.multipart 的 mode 属性为 file
        let file = ctx.request.files[0];

        // 声明存放资源的路径
        let uploadDir = "";

        try {
        // ctx.request.files[0] 表示获取第一个文件,若前端上传多个文件则可以遍历这个数组对象
        let f = fs.readFileSync(file.filepath);

        // <-----创建保存文件夹------>
        // 1.获取当前日期
        let day = moment(new Date()).format("YYYYMMDD");
        // 2.创建图片保存的路径
        let dir = path.join(this.config.uploadDir, day);
        // this.config.uploadDir: 'app/public/upload'
        // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓
        mkdirp.sync(dir); // 新版本的mkdirp要这样子写
        // ↑↑↑↑↑↑↑↑↑↑↑↑↑↑
        // 旧写法 await mkdirp(dir);
        // <-----创建保存文件夹------>

        let date = Date.now(); // 毫秒数
        // 返回图片保存的路径
        uploadDir = path.join(dir, date + path.extname(file.filename));
        // 写入文件夹
        fs.writeFileSync(uploadDir, f);
        } finally {
        // 清除临时文件
        ctx.cleanupRequestFiles();
        }

        ctx.body = {
        code: 200,
        msg: "上传成功",
        data: uploadDir.replace(/app/g, ""),
        };
        }

这里要注意的是,需要将 app 去除,因为我们在前端访问路径的时候,是不需要 app 这个路径的,比如我们项目启动的是 7001 端口,最后我们访问的文件路径是这样的 http://localhost:7001/public/upload/20250315/1741973858632.jpg

账单的增删查改

image-20250315231626163

  • 根据数据库的bill表的属性可知我们要从ctx.request.body获取6个属性
  • 先获取消息头中的token
  • token传入app.jwt中进行解密
  • 将解密的id同获取的六个属性一同传入BillService中, BillService使用insert将数据插入数据库中
  • 返回响应

  • 获取ctx.request.bodyid即我们要删除表的id

  • 鉴权操作获取user_id

  • iduser_id传入BillService中,BillService使用delete方法删除对应数据

    • app.mysql.delete("bill", { id, user_id })

      • 功能:删除表中的记录。
      • API:app.mysql.delete(table, conditions)其中:
        • table 是表名。
        • conditions 是一个对象,表示删除条件。
  • 返回响应

  • 获取ctx.queryid即我们要查表的id

    • get方法用query
  • 鉴权操作获取user_id

  • iduser_id传入BillService中,BillService使用get方法删除对应数据

    • app.mysql.get("bill", { id, user_id })

      • 功能:根据条件获取表中的单条记录。
      • API:app.mysql.get(table, conditions)其中:
        • table 是表名。
        • conditions 是一个对象,表示查询条件。
  • 返回响应

  • 通过ctx.request.body获取账单的所有属性

  • 鉴权操作获取user_id

  • 所有属性及其user_id传入BillService中,BillService通过update方法更新数据

    • app.mysql.update("bill", { ...params }, { id: params.id, user_id: params.user_id })

      • 功能:更新表中的记录。
      • API:app.mysql.update(table, values, conditions)其中:
        • table 是表名。
        • values 是一个对象,包含要更新的字段和值。
        • conditions 是一个对象,包含更新条件。
  • 返回响应

账单详细及其数据获取

账单列表获取

我们需要的数据类型是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[
{
date: '2020-1-1',
bills: [
{
// bill 数据表中的每一项账单
},
{
// bill 数据表中的每一项账单
}
]
},
{
date: '2020-1-2',
bills: [
{
// bill 数据表中的每一项账单
},
]
}
]

并且我们前端还需要做滚动加载更多,所以服务端是需要给分页的。于是就需要在获取 bill 表里的数据之后,进行一系列的操作,将数据整合成上述格式。

当然,获取的时间维度以月为单位,并且可以根据账单类型进行筛选。上图左上角有当月的总支出和总收入情况,我们也在列表数据中给出,因为它和账单数据是强相关的。

于是,我们打开 /controller/bill.js 添加一个 list 方法,来处理账单数据列表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
async list() {
const { ctx, app } = this;
// 获取前端传递的参数,包括日期、分页信息、以及账单类型
const { date, page = 1, page_size = 5, type_id = "all" } = ctx.query;

try {
// 解析 token,获取 user_id
const token = ctx.request.header.authorization;
const decode = await app.jwt.verify(token, app.config.jwt.secret);
if (!decode) return; // token 解析失败则直接返回
const user_id = decode.id;

// 查询数据库,获取当前用户的所有账单数据
const list = await ctx.service.bill.list(user_id);

// 过滤符合日期和类型的账单,并使用 reduce 进行数据归类和收支统计
const { listMap, totalExpense, totalIncome } = list
.filter(item => {
// 格式化账单日期(年月)
const itemDate = moment(Number(item.date)).format("YYYY-MM");
return itemDate === date && (type_id === "all" || item.type_id == type_id);
})
.reduce(
(acc, item) => {
// 格式化账单日期(年月日)
const formattedDate = moment(Number(item.date)).format("YYYY-MM-DD");

// 计算收支总额
if (item.pay_type == 1) acc.totalExpense += Number(item.amount); // 支出
if (item.pay_type == 2) acc.totalIncome += Number(item.amount); // 收入

// 查找当前日期是否已经存在于 listMap 数组中
const index = acc.listMap.findIndex(i => i.date === formattedDate);

if (index > -1) {
// 如果已有该日期的数据,则直接往该日期的 bills 数组中追加
acc.listMap[index].bills.push(item);
} else {
// 否则,新建一个对象,包含该日期的账单
acc.listMap.push({ date: formattedDate, bills: [item] });
}

return acc; // 返回累积对象
},
{ listMap: [], totalExpense: 0, totalIncome: 0 } // reduce 的初始值
);

// 按日期倒序排序,让最新的账单排在最前
listMap.sort((a, b) => moment(b.date) - moment(a.date));

// 分页处理,获取当前页的数据
const filterListMap = listMap.slice((page - 1) * page_size, page * page_size);

// 返回数据
ctx.body = {
code: 200,
msg: "请求成功",
data: {
totalExpense, // 当月总支出
totalIncome, // 当月总收入
totalPage: Math.ceil(listMap.length / page_size), // 总页数
list: filterListMap // 经过分页处理后的数据
}
};
} catch (error) {
console.error(error); // 捕获异常,打印错误信息
ctx.body = {
code: 500,
msg: "系统错误",
data: null
};
}
}

需要注意的是我们在数据库中的date是时间戳,其单位是毫秒,所以对于2025-03-15 23:30:03,我们在数据库中存储的是1742052603000多了3个0

账单数据

我们最终要返回的数据机构如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
total_data: [
{
number: 137.84, // 支出或收入数量
pay_type: 1, // 支出或消费类型值
type_id: 1, // 消费类型id
type_name: "餐饮" // 消费类型名称
}
],
total_expense: 3123.54, // 总消费
total_income: 6555.80 // 总收入
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
async data() {
const { ctx, app } = this;
const { date = "" } = ctx.query;
const token = ctx.request.header.authorization;
const decode = app.jwt.verify(token, app.config.jwt.secret);
const user_id = decode.id;
if (!decode) return;
try {
const result = await ctx.service.bill.list(user_id);
const start = moment(date).startOf("month").unix() * 1000;
const end = moment(date).endOf("month").unix() * 1000;
const _data = result.filter(
(item) => Number(item.date) > start && Number(item.date) < end
);
const total_expense = _data.reduce((arr, cur) => {
if (cur.pay_type == 1) {
arr += Number(cur.amount);
}
return arr;
}, 0);
const total_income = _data.reduce((arr, cur) => {
if (cur.pay_type == 2) {
arr += Number(cur.amount);
}
return arr;
}, 0);
let total_data = _data.reduce((arr, cur) => {
const index = arr.findIndex((item) => item.type_id == cur.type_id);
if (index == -1) {
arr.push({
type_id: cur.type_id,
type_name: cur.type_name,
pay_type: cur.pay_type,
number: Number(cur.amount),
});
}
if (index > -1) {
arr[index].number += Number(cur.amount);
}
return arr;
}, []);
total_data = total_data.map((item) => {
item.number = Number(Number(item.number).toFixed(2));
return item;
});
ctx.body = {
code: 200,
msg: "请求成功",
data: {
total_expense: Number(total_expense).toFixed(2),
total_income: Number(total_income).toFixed(2),
total_data: total_data || [],
},
};
} catch (error) {
console.log(error);
}
}

语法

reduce() 方法详解

reduce()JavaScript 数组的高阶方法,用于对数组中的元素进行 累加、归纳、合并,最终返回一个 累积计算的结果


📌 语法

1
2
3
array.reduce((accumulator, currentValue, currentIndex, array) => {
// 处理逻辑
}, initialValue);
  • accumulator(累加器):保存上一次 reduce 迭代的返回值。

  • currentValue(当前值):当前正在处理的数组元素。

  • currentIndex(当前索引):当前元素的索引(可选)。

  • array(原数组):正在被遍历的数组(可选)。

  • initialValue(初始值)accumulator 的初始值(可选)。

    • 如果提供了 initialValue,则 accumulator 的初始值就是 initialValue,并从 array[0] 开始迭代。
    • 如果 **未提供 initialValue**,则 accumulator 默认等于 array[0],并从 array[1] 开始迭代。

🔍 reduce() 的基本用法

1️⃣ 计算数组总和

1
2
3
const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 15

acc 初始值是 0,每次累加当前 cur

  • 计算过程:

    1
    2
    3
    4
    5
    (0 + 1) -> 1
    (1 + 2) -> 3
    (3 + 3) -> 6
    (6 + 4) -> 10
    (10 + 5) -> 15

2️⃣ 找最大值

1
2
3
const numbers = [10, 25, 88, 3, 50];
const max = numbers.reduce((acc, cur) => Math.max(acc, cur));
console.log(max); // 88
  • 计算过程:

    1
    2
    3
    4
    Math.max(10, 25) -> 25
    Math.max(25, 88) -> 88
    Math.max(88, 3) -> 88
    Math.max(88, 50) -> 88

3️⃣ 统计元素出现次数

1
2
3
4
5
6
7
8
9
const fruits = ['apple', 'banana', 'apple', 'orange', 'banana', 'apple'];

const count = fruits.reduce((acc, cur) => {
acc[cur] = (acc[cur] || 0) + 1;
return acc;
}, {});

console.log(count);
// { apple: 3, banana: 2, orange: 1 }
  • acc[cur] 记录当前水果出现的次数,如果不存在则默认为 0

4️⃣ 数组对象按属性归类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const people = [
{ name: 'Alice', age: 25 },
{ name: 'Bob', age: 30 },
{ name: 'Charlie', age: 25 },
{ name: 'David', age: 30 },
{ name: 'Eve', age: 35 }
];

const groupedByAge = people.reduce((acc, person) => {
(acc[person.age] = acc[person.age] || []).push(person);
return acc;
}, {});

console.log(groupedByAge);
/*
{
25: [{ name: 'Alice', age: 25 }, { name: 'Charlie', age: 25 }],
30: [{ name: 'Bob', age: 30 }, { name: 'David', age: 30 }],
35: [{ name: 'Eve', age: 35 }]
}
*/
  • 作用: 把相同 age 的人归类到同一个数组。

🚀 在你的 Egg.js 代码中的 reduce() 作用

你的代码里用 reduce() 按照 YYYY-MM-DD 归类账单

1
2
3
4
5
6
7
8
9
10
11
12
13
let listMap = _list.reduce((curr, item) => {
const date = moment(Number(item.date)).format('YYYY-MM-DD');

// 查找是否已有相同日期的分组
if (curr.length && curr.findIndex(item => item.date == date) > -1) {
const index = curr.findIndex(item => item.date == date);
curr[index].bills.push(item);
} else {
curr.push({ date, bills: [item] });
}

return curr;
}, []).sort((a, b) => moment(b.date) - moment(a.date));

👀 它在干什么?

  1. reduce() 遍历账单列表 _list

    • curr:存储已归类的账单数据。
    • item:当前账单项。
  2. YYYY-MM-DD 归类账单

    • 如果 curr 里已经存在该日期,就把 item 加入已有的 bills
    • 如果 curr 里没有该日期,新建 { date, bills: [item] }
  3. 最后按时间倒序排序


🎯 reduce() vs map() vs forEach()

方法 作用 是否返回新数组 主要用途
reduce() 归纳 & 计算 ❌ 需要手动返回 统计、分组、累加
map() 映射转换 ✅ 返回新数组 对数组元素进行转换
forEach() 仅遍历 ❌ 无返回值 仅执行操作,无返回值

📝 结论:如果你要 累加、归类、统计reduce() 是最合适的选择!