TS + React + AntD + Koa2 + MongoDB 打造 TodoList 全栈应用
February 20, 2019
前言
JavaScript 作为一门弱类型语言,时常受到 Java,C# 等老牌编程语言的“歧视”,并且诞生之初,只能运行在浏览器端,被戏称为 “Tool Language”.
早前 NodeJS 的出现,让 JavaScript 向后端领域进军。
如今 TypeScript 的横空出世则让它焕发了新的生命,从本质上向这个语言添加了可选的静态类型和基于类的面向对象编程,作为 JavaScript 的一个超集,与当下最流行的前端框架 React 有着天生的契合度,目前可谓大红大紫。
2019 年,我们有必要全面掌握 TypeScript,并应用到实践中。
本项目以功能清晰的 TodoList 为切入点,结合 React 全家桶、AntD 打造代码健壮性强,用户界面简洁的前端应用,以 Koa2 + MongoDB 为核心构建可维护性高的后端服务,是一个全栈应用 TodoList 的最佳实践。
技术选型
前端
- TypeScript(使 JS 成为强类型语言)
- React(当下最流行的前端框架)
- Axios(处理 HTTP 请求)
- Ant-Design(UI 框架)
- React-Router(处理页面路由)
- Redux(数据状态管理)
- Redux-Saga(处理异步 Action)
后端
- Koa2(基于 Node.js 平台的下一代 web 开发框架)
- Mongoose(内置数据验证, 查询构建,业务逻辑钩子等,开箱即用)
应用包含的特性
- RESTful 风格接口设计
- HTTP 请求封装,错误处理
- 组件化,代码分层
- 用户登录注册
- Todo 的关键词查询
- Todo 内容修改
- Todo 状态更改
- Todo 记录删除
实践分析
TypeScript
TS 最基础的赋予我们给 JS 变量设置类型的能力,还带来了接口,泛型,枚举,类,装饰器,命名空间等全新内容。
tsx
let a: number = 1; // int a = 1;let b: string = "Hello"; // string b = 'Hello'let arr: number[] = [1, 2, 3]; // int arr[] = {1,2,3};
tsx
let a: number = 1; // int a = 1;let b: string = "Hello"; // string b = 'Hello'let arr: number[] = [1, 2, 3]; // int arr[] = {1,2,3};
TS 可以约束我们的传参,变量类型,接口类型,从而避免在开发时产生不必要的错误。
以 /store/user/types.ts
为例,导出了一个接口
tsx
export interface IUserState {userId: string;username: string;errMsg: string;}
tsx
export interface IUserState {userId: string;username: string;errMsg: string;}
user 继承 UserState 接口,具有属性推导及智能提示,而在 JS 中,我们需要自己手动输入 user.userId 来获取 id 属性,繁琐易出错。
在 React,我们主要通过无状态组件 function,和有状态组件 class 来构建应用,包括 props 的传递,函数的传参,类的继承等都非常需要类型约定,可以说 TS 和 React “天生一对”,使用他们,我们的代码健壮性提高了一个档次。
redux 状态管理
状态管理是目前构建单页应用中不可或缺的一环,简单应用使用组件内 State 方便快捷,但随着应用复杂度上升,会发现数据散落在不同的组件,组件通信会变得异常复杂,这时候就需要 redux 来管理全局状态。
它遵循三个原则:
- 组件数据来源于 Store,单向流动
- 只能通过触发 action 来改变 State,通过定义 actionTypes,做到全局惟一
- Reducer 是纯函数
由于 Reducer 只能是纯函数,而当处于请求(Fetch)场景时,Action 需要发起异步请求,包含了副作用,所以使用借助 Redux-Saga 来处理异步 Action,处理后返回成功的同步 Action 并触发,此时是一个纯函数,最终改变 store 数据。
简单来说,一个函数的返回结果只依赖于它的参数,并且在执行过程里面没有副作用,我们就把这个函数叫做纯函数。
以 FETCH_TODO(获取 Todo 资源)为例,数据流向如下图所示:
接口设计
由于采用的是前后端分离开发,我们通过约定接口来进行数据交换,而当下最流行的便是 RESTful 风格接口,它有以下几个要点:
- 根据请求目的,设置对应 HTTP Method,如 GET 对应读取资源(Read),PUT 对应更新资源(Update),POST 对应创建资源(Created),DELETE 代表删除资源(Delete),对应数据库 CRUD 操作
- 动词表示请求方式,名词表示数据源,一般采用复数形式,如
GET/users/2
表示获取 id 为 2 的用户 - 返回相应的 HTTP 状态码,常见的有:
- 200 OK 请求成功
- 201 CREATED 创建成功
- 202 ACCEPTED 更新成功
- 204 NO CONTENT 删除成功
- 401 UNAUTHORIZED 未授权
- 403 FORBIDDEN 禁止访问
- 404 NOT FOUND 资源不存在
- 500 INTERNAL SERVER ERROR 服务器端内部错误
以 Todo 的路由为例,我们可以设计出以下接口
tsx
const todoRouter = new Router({prefix: "/api/todos",});todoRouter.get("/search", async (ctx: Context) => {}) // 关键词搜索.get("/:userId", async (ctx: Context) => {}) // 获取指定用户所有 Todo.put("/status", async (ctx: Context) => {}) // 修改状态.put("/content", async (ctx: Context) => {}) // 修改内容.post("/", async (ctx: Context) => {}) // 新增 Todo.delete("/:todoId", async (ctx: Context) => {}); // 删除 Todo
tsx
const todoRouter = new Router({prefix: "/api/todos",});todoRouter.get("/search", async (ctx: Context) => {}) // 关键词搜索.get("/:userId", async (ctx: Context) => {}) // 获取指定用户所有 Todo.put("/status", async (ctx: Context) => {}) // 修改状态.put("/content", async (ctx: Context) => {}) // 修改内容.post("/", async (ctx: Context) => {}) // 新增 Todo.delete("/:todoId", async (ctx: Context) => {}); // 删除 Todo
代码分层
服务端目录:
bash
├── src│ ├── db│ ├── routes│ ├── services│ ├── utils│ ├── app.ts│ └── config.ts├── package.json├── tsconfig.json└── yarn.lock
bash
├── src│ ├── db│ ├── routes│ ├── services│ ├── utils│ ├── app.ts│ └── config.ts├── package.json├── tsconfig.json└── yarn.lock
我们关注于 db,services,routes 这三个文件夹。
- db 建立数据模型(Model),相当于 MySQL 的建表环节
- services 调用数据模型处理数据库的业务逻辑,对数据库进行 CURD,返回加工后的数据
- routes 调用 services 中的方法处理路由请求,设置请求响应
学过 Java 的小伙伴都知道,一个接口要通过 Domain 层,DAO 层,Service 层,才会进入 Controller 层调用,我们的项目类似于这种思想,更好的逻辑分层不仅能 提高项目的维护性,还能降低耦合度,这在大型项目中尤为重要。
错误处理
以 services/user.ts
为例,我们定义了 userService 类,用于处理 user 的业务逻辑,其中的 addUser 为注册用户时调用的方法。
tsx
export default class UserService {public async addUser(usr: string, psd: string) {try {const user = new User({usr,psd,});// 如果 usr 重复,mongodb 会抛出 duplicate key 异常,进行捕获return await user.save();} catch (error) {throw new Error("用户名已存在 ( ̄o ̄).zZ");}}}
tsx
export default class UserService {public async addUser(usr: string, psd: string) {try {const user = new User({usr,psd,});// 如果 usr 重复,mongodb 会抛出 duplicate key 异常,进行捕获return await user.save();} catch (error) {throw new Error("用户名已存在 ( ̄o ̄).zZ");}}}
由于我们设置了 usr 字段唯一。
tsx
export const UserSchema: Schema = new Schema({usr: {type: String,required: true,unique: true,},psd: String,todos: [{type: Schema.Types.ObjectId,ref: "Todo",},],});
tsx
export const UserSchema: Schema = new Schema({usr: {type: String,required: true,unique: true,},psd: String,todos: [{type: Schema.Types.ObjectId,ref: "Todo",},],});
所以当用户注册时,输入已经注册过的用户名,就会抛出异常。这时候,我们要 catch,并向调用此方法的路由抛出异常,随后路由层会捕获错误,返回用户名已存在的 HTTP 响应。
这就是一个较为典型的的错误处理过程。
tsx
userRouter.post("/", async (ctx: Context) => {const payload = ctx.request.body;const { username, password } = payload;try {const data = await userService.addUser(username, password);if (data) {createRes({ctx,statusCode: StatusCode.Created,});}} catch (error) {createRes({ctx,errorCode: 1,msg: error.message,});}});
tsx
userRouter.post("/", async (ctx: Context) => {const payload = ctx.request.body;const { username, password } = payload;try {const data = await userService.addUser(username, password);if (data) {createRes({ctx,statusCode: StatusCode.Created,});}} catch (error) {createRes({ctx,errorCode: 1,msg: error.message,});}});
统一响应
关于 API 调用的返回结果,为了格式化响应体,我们在 /utils/response.ts
编写处理响应的通用函数。
返回一组消息,指明调用是否成功。这类消息通常具有共同的消息体样式。
通用返回格式是由 msg
、error_code
及 data
三个参数组成的 JSON 响应体:
tsx
import { Context } from "koa";import { StatusCode } from "./enum";interface IRes {ctx: Context;statusCode?: number;data?: any;errorCode?: number;msg?: string;}const createRes = (params: IRes) => {params.ctx.status = params.statusCode! || StatusCode.OK;params.ctx.body = {error_code: params.errorCode || 0,data: params.data || null,msg: params.msg || "",};};export default createRes;
tsx
import { Context } from "koa";import { StatusCode } from "./enum";interface IRes {ctx: Context;statusCode?: number;data?: any;errorCode?: number;msg?: string;}const createRes = (params: IRes) => {params.ctx.status = params.statusCode! || StatusCode.OK;params.ctx.body = {error_code: params.errorCode || 0,data: params.data || null,msg: params.msg || "",};};export default createRes;
当我们请求 GET /api/todos/:userId
时,获取指定用户的所有 Todo,返回以下响应体:
json
{"error_code": 0,"data": [{"_id": "5e9b0f1b576bd642796dd7d0","userId": "5e9b0f08576bd642796dd7cf","content": "成为全栈工程师~~~","status": false,"__v": 0}],"msg": ""}
json
{"error_code": 0,"data": [{"_id": "5e9b0f1b576bd642796dd7d0","userId": "5e9b0f08576bd642796dd7cf","content": "成为全栈工程师~~~","status": false,"__v": 0}],"msg": ""}
这不仅仅加强了规范性,而且利于前端接收请求,做出更好的判断或排错。