简介
用pnpm、turbo搭建monorepo项目,服务端nestjs、客户端vue,通过 ts-rest 定义API合约
,共享前、后端Typescript Api类型,且自动推导出api的ts类型。
技术栈
- Monorepo和打包:pnpm、turbo
- Api合约库:ts-rest
- 校验工具库:zod
- 服务端:nestjs
- 前端:vue3 + Typescript
- http库:@tanstack/vue-query
小结
起初是在youtube
上看到一个关注的博主发布的学习视频,一开始没太在意主要是解决哪些问题。然后跟着敲了一遍代码以后(自己也有根据自己的开发体验进行了部分补充、完善和修改),发现这一套技术栈可以解决我遇到的开发疑惑。
因为现在大多都是基于Typescript
做开发,前后端一起开发的时候总会遇到重复的定义Api类型
这种问题。ts-rest
这个库就正好解决了这个问题,可能它和trpc
比较类似,但体验下来,感觉还是比较好集成和使用的。
在使用@tanstack/vue-query
库的过程中,发现它功能确实很强大,自动缓存、检测获焦自动刷新数据、分页、占位、预加载数据等等,开发的过程中不清楚它的这些功能,导致窗口获焦、失焦的时候频繁触发获取数据接口,后来去官网看了介绍才知道有refetchOnWindowFocus
这个参数设置。
1. 新建项目
# 新建文件夹
mkdir todos-monorepo
# 进入目录
cd todos-monorepo
# 初始化package.json
pnpm init
# 新建pnpm-workspace.yaml
touch pnpm-workspace.yaml
# 新建turbo.json
touch turbo.json
# 创建apps和packages文件夹,存放app和共享包
mkdir apps
mkdir packages
- package.json配置
当执行turbo命令,比如turbo run dev
,需要workspace
的包内都包含该命令。比如:nestjs的开发命令为start:dev
,vite项目为dev
,那么就会只运行vite项目内的dev
。
// todos-monorepo/package.json
{
"name": "todos-monorepo",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"build:release": "turbo run build --no-cache"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"turbo": "^1.13.2"
}
}
- pnpm-workspace.yaml配置
# todos-monorepo/pnpm-workspace.yaml
packages:
- 'apps/**'
- 'packages/*'
- turbo.json配置
// todos-monorepo/turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
2. 依赖安装和初始化项目
切换到todos-monorepo
目录
2.1 安装turbo
# todos-monorepo根目录安装
pnpm i turbo -D
2.2 初始化服务端nestjs项目和客户端ui项目
# 进入apps目录
cd apps
# 用nest-cli初始化api项目
nest new todo-api
# 用pnpm初始化基于vite的vue+ts项目
pnpm create vite
- 此时项目结构如下图
2.3 建立packages内shared-api包并配置
# 切换到packages目录
cd packages
# 创建共享api包
mkdir shared-api
# 进入shared-api文件夹
cd shared-api
# 创建package.json文件,配置在下面
pnpm init
# 创建tsconfig.json,配置在下面
touch tsconfig.json
# 创建src内的index.ts
mkdir src && touch src/index.ts文件
# 安装shared-api的依赖
pnpm add @ts-rest/core typescript zod
package.json
配置
// todos-monorepo/packages/shared-api/package.json
{
"name": "shared-api",
"version": "1.0.0",
"description": "",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"dev": "tsc --watch",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@ts-rest/core": "^3.44.0",
"typescript": "^5.4.5",
"zod": "^3.22.4"
}
}
tsconfig.json
配置
// todos-monorepo/packages/shared-api/tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"module": "CommonJS",
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"rootDir": "src",
"outDir": "dist",
"declaration": true
},
"exclude": ["node_modules", "./dist/**/*"]
}
shared-api/src/index.ts
// todos-monorepo/packages/shared-api/src/index.ts
import { initContract } from "@ts-rest/core";
import { z } from "zod";
const c = initContract();
export const TodoSchema = z.object({
id: z.number(),
title: z.string(),
description: z.string(),
completed: z.boolean(),
});
export type Todo = z.infer<typeof TodoSchema>;
export const contract = c.router(
{
todos: {
create: {
method: "POST",
path: "/todos",
body: TodoSchema.omit({ id: true }),
responses: {
201: TodoSchema,
},
},
getAll: {
method: "GET",
path: "/todos",
query: z.object({
title: z.string().optional(),
}),
responses: {
200: TodoSchema.array(),
},
},
getOne: {
method: "GET",
path: "/todos/:id",
pathParams: z.object({
id: z.coerce.number(),
}),
responses: {
200: TodoSchema,
404: z.object({
message: z.string(),
}),
},
},
update: {
method: "PATCH",
path: "/todos/:id",
pathParams: z.object({
id: z.coerce.number(),
}),
body: TodoSchema.omit({ id: true }).partial(),
responses: {
200: TodoSchema,
404: z.object({
message: z.string(),
}),
},
},
remove: {
method: "DELETE",
path: "/todos/:id",
pathParams: z.object({
id: z.coerce.number(),
}),
body: z.any(),
responses: {
204: z.object({}),
404: z.object({
message: z.string(),
}),
},
},
},
},
{
pathPrefix: "/api",
strictStatusCodes: true,
}
);
export type Contract = typeof contract;
export type UpdateTodoDto = z.infer<Contract["todos"]["update"]["body"]>;
3. 开发todo-api
进入todos-monorepo/apps/todo-api
目录,修改start:dev
命令为dev
,和根目录
的package.json
内配置的turbo run dev
命令保持一致。
// todos-monorepo/apps/todo-api/package.json
"scripts": {
// 主要修改地方
"dev": "nest start --watch"
// 其它部分
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
- 此时在终端,切换到根目录执行
pnpm run dev
会看到下图所示,两个项目都一起运行起来了:
3.1 给todo-api安装shared-api包
# todos-monorepo目录执行
pnpm add shared-api --filter todo-api
安装正常,会看到package.json
内安装的shared-api
依赖
3.2 用nest-cli生成todos模块
# todos-monorepo/apps/todo-api目录,生成todos模块目录
nest g res todos
# todos-monorepo/apps/todo-api目录,安装依赖包
pnpm add @ts-rest/nest @ts-rest/core @ts-rest/open-api @nestjs/swagger
todos.controller.ts
代码
// todos-monorepo/apps/todo-api/todos/todos.controller.ts
import { Controller } from '@nestjs/common';
import { TodosService } from './todos.service';
import { TsRestHandler, tsRestHandler } from '@ts-rest/nest';
import '@ts-rest/core';
import { contract } from 'shared-api';
@Controller()
export class TodosController {
constructor(private readonly todosService: TodosService) {}
@TsRestHandler(contract.todos)
async handler() {
return tsRestHandler(contract.todos, {
create: async ({ body }) => {
return {
status: 201,
body: this.todosService.create(body),
};
},
getAll: async ({ query: { title } }) => {
return {
status: 200,
body: this.todosService.getAll(title),
};
},
getOne: async ({ params: { id } }) => {
const item = await this.todosService.getOne(id);
if (!item) {
return {
status: 404,
body: {
message: '没有item',
},
};
}
return {
status: 200,
body: item,
};
},
update: async ({ params: { id }, body }) => {
const item = await this.todosService.update(id, body);
if (!item) {
return {
status: 404,
body: {
message: '没有item',
},
};
}
return {
status: 200,
body: item,
};
},
remove: async ({ params: { id } }) => {
const item = await this.todosService.remove(id);
if (!item) {
return {
status: 404,
body: {
message: '没有item',
},
};
}
return {
status: 204,
body: item,
};
},
});
}
}
todos.service.ts
代码
// todos-monorepo/apps/todo-api/todos/todos.service.ts
import { Injectable } from '@nestjs/common';
import { Todo, UpdateTodoDto } from 'shared-api';
@Injectable()
export class TodosService {
todos: Todo[] = [
{
id: 1,
title: 'Todo 1',
description: 'Todo 1 description',
completed: false,
},
{
id: 2,
title: 'Todo 2',
description: 'Todo 2 description',
completed: false,
},
{
id: 3,
title: 'Todo 3',
description: 'Todo 3 description',
completed: false,
},
];
create(todo: Omit<Todo, 'id'>) {
const newTodo = {
...todo,
id: Date.now(),
};
this.todos.push(newTodo);
return newTodo;
}
getAll(title?: string) {
if (title) {
return this.todos.filter((todo) =>
todo.title.toLowerCase().includes(title.toLowerCase()),
);
}
return this.todos;
}
getOne(id: number) {
return this.todos.find((todo) => todo.id === id);
}
// update(id: number, dto: Partial<Todo>) {
update(id: number, dto: UpdateTodoDto) {
const index = this.todos.findIndex((todo) => todo.id === id);
if (index === -1) {
return null;
}
this.todos[index] = { ...this.todos[index], ...dto };
return this.todos[index];
}
remove(id: number) {
const index = this.todos.findIndex((todo) => todo.id === id);
if (index === -1) {
return null;
}
this.todos = this.todos.filter((todo) => todo.id !== id);
return {};
}
}
main.ts
代码
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule } from '@nestjs/swagger';
import { generateOpenApi } from '@ts-rest/open-api';
import { contract } from 'shared-api';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const openApiDocument = generateOpenApi(contract, {
info: {
title: 'Todos API',
version: '1.0.0',
},
});
SwaggerModule.setup('openapi', app, openApiDocument);
// 开启跨域
app.enableCors();
await app.listen(3000);
console.log(`服务端运行在 http://localhost:3000`);
}
bootstrap();
- 此时打开
http://localhost:3000/openapi
地址会看到自动配置好的swagger文档
提示:这里没有用
@nestjs/swagger
的DocumentBuilder
创建
4. 开发todo-ui
切换到todos-monorepo/apps/todo-ui
目录
# 选择v4的版本,v5的版本发现会有对等依赖问题
pnpm add @ts-rest/vue-query @tanstack/vue-query@4.37.1
# 添加shared-api包
pnpm add shared-api
- 配置main.ts
// todos-monorepo/apps/todo-ui/src/main.ts
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { VueQueryPlugin } from '@tanstack/vue-query';
createApp(App).use(VueQueryPlugin).mount('#app');
- 新增api-client.ts
// todos-monorepo/apps/todo-ui/src/api-client.ts
import { initQueryClient } from "@ts-rest/vue-query";
import { contract } from "shared-api";
export const client = initQueryClient(contract, {
baseUrl: "http://localhost:3000",
baseHeaders: {},
});
export default client;
4.1 解决chrome提示commonjs
的问题
因为上面shared-api配置导出的是给
nestjs
使用的commonjs
,这里给客户端vue使用的时候,打开chrome console会提示does not provide an export named 'contract'
。
# 安装@rollup/plugin-commonjs
pnpm add @rollup/plugin-commonjs -D
- 修改vite.config.ts配置
// todos-monorepo/apps/todo-ui/vite.config.ts
# 配置如下:
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import commonjs from "@rollup/plugin-commonjs";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
plugins: [commonjs()],
},
},
optimizeDeps: {
include: ['shared-api/**/*']
}
});
4.2 新增todo-list组件
// todos-monorepo/apps/todo-ui/src/components/TodoList.vue
<script lang="ts" setup>
import { computed } from "vue";
import { client } from "../api-client";
import TodoItem from "./TodoItem.vue";
const { data } = client.todos.getAll.useQuery(["todos"], () => ({}), {
// 设置过期时间,如果设置为Infinity则是不过期
// staleTime: Infinity,
// refetchOnMount: true,
// TODO: 当窗口获焦的时候是否重新获取数据,这里暂时关闭,因为开发的时候会很频繁的触发
// 参考链接:https://tanstack.com/query/v4/docs/framework/vue/guides/important-defaults
refetchOnWindowFocus: false
});
const list = computed(() => data.value?.body);
</script>
<template>
<div class="todo-list">
<TodoItem v-for="item in list" :key="item.id" :item="item" />
</div>
</template>
<style>
.todo-list {
width: 100%;
/* background: #ecedf5; */
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
}
</style>
- 可以看到ts自动推导出请求类型,如下图:
4.3 新增todo-item组件
// todos-monorepo/apps/todo-ui/src/components/TodoItem.vue
<script lang="ts" setup>
import { Todo, UpdateTodoDto } from "shared-api";
import { computed, reactive, ref, watchEffect } from "vue";
import client from "../api-client";
import { useQueryClient } from "@tanstack/vue-query";
interface Props {
item: Todo;
}
const props = withDefaults(defineProps<Props>(), {
item: () => ({}) as Todo,
});
const isEdit = ref(false);
const queryClient = useQueryClient();
const item = computed(() => props.item);
const updateState = reactive<UpdateTodoDto>({
title: "",
description: "",
completed: undefined,
});
watchEffect(() => {
const { title, description, completed } = item.value;
Object.assign(updateState, { title, description, completed });
});
const { mutate: removeMutate } = client.todos.remove.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
const { mutate: updateMutate } = client.todos.update.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// 删除
function onDelete() {
removeMutate({
params: {
id: item.value.id,
},
body: {},
});
}
// 更新
function onUpdate() {
updateMutate(
{
params: {
id: item.value.id,
},
body: updateState,
},
{
onSuccess: () => {
isEdit.value = false;
},
}
);
}
// 完成
function onComplete() {
updateState.completed = !item.value.completed;
updateMutate({
params: {
id: item.value.id,
},
body: updateState,
});
}
</script>
<template>
<div
:class="['todo-item', updateState.completed && 'todo-item--completed']"
:key="item.id"
>
<div
class="flex-col-center"
style="margin-right: 10px"
v-if="!isEdit"
@click="onComplete"
>
<img
v-if="updateState.completed"
class="todo-item__icon"
src="../assets/checked.svg"
/>
<img
v-if="!updateState.completed"
class="todo-item__icon"
src="../assets/check.svg"
/>
</div>
<div class="todo-item__content">
<div class="todo-item__input">
<span v-if="!isEdit">{{ item.title }}</span>
<input type="text" v-model="updateState.title" v-if="isEdit" />
</div>
<div class="todo-item__input todo-item__desc">
<span v-if="!isEdit">{{ item.description }}</span>
<input type="text" v-model="updateState.description" v-if="isEdit" />
</div>
</div>
<div class="todo-item__btn">
<div v-if="!isEdit" class="flex-row-center">
<img
class="todo-item__icon"
src="../assets/delete.svg"
@click="onDelete"
/>
<img
class="todo-item__icon"
src="../assets/edit.svg"
@click="isEdit = true"
/>
</div>
<div class="flex-row-center" v-if="isEdit">
<div class="submit-btn" @click="onUpdate">确定</div>
<div class="cancel-btn" @click="isEdit = false">取消</div>
</div>
</div>
</div>
</template>
<style>
.todo-item {
background: #fff;
border-radius: 0.3em;
padding: 0.8em;
display: flex;
align-items: center;
user-select: none;
}
.todo-item__content {
text-align: left;
}
.todo-item__input {
height: 25px;
}
.todo-item__btn {
display: flex;
margin-left: auto;
}
.todo-item input {
width: 20dvw;
height: 100%;
}
.todo-item__icon {
width: 1.8em;
height: 1.8em;
cursor: pointer;
padding: 5px 3px;
}
.todo-item__icon + .todo-item__icon {
margin-left: 5px;
}
.submit-btn {
background: #6374e7;
color: #fff;
font-size: 13px;
padding: 0.3em 1em;
border-radius: 0.3em;
cursor: pointer;
}
.cancel-btn {
background: #cccfde;
color: #fff;
font-size: 13px;
padding: 0.3em 1em;
border-radius: 0.3em;
cursor: pointer;
margin-left: 10px;
}
.flex-col-center {
display: flex;
flex-direction: column;
align-items: center;
}
.flex-row-center {
display: flex;
align-items: center;
}
.todo-item--completed .todo-item__input {
text-decoration: line-through;
color: #a1a1a1;
}
.todo-item__desc {
font-size: 14px;
color: #555;
}
</style>
4.4 新增AddTodo组件
// todos-monorepo/apps/todo-ui/src/components/AddTodo.vue
<script lang="ts" setup>
import { ref } from "vue";
import { client } from "../api-client";
import { useQueryClient } from "@tanstack/vue-query";
const title = ref("");
const description = ref("");
const queryClient = useQueryClient();
const { mutate } = client.todos.create.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
function onAdd() {
if (!title.value && !description.value) return;
mutate({
body: {
title: title.value,
description: description.value,
completed: false,
},
});
}
</script>
<template>
<div class="add-todo">
<input
class="add-todo__input"
type="text"
v-model="title"
placeholder="标题"
/>
<input
class="add-todo__input"
type="text"
v-model="description"
placeholder="描述"
/>
<div class="add-todo__btn" @click="onAdd">添加Todo</div>
</div>
</template>
<style>
.add-todo {
background: #6374e7;
padding: 1em;
border-radius: 0.5em 0 0 0.5em;
display: flex;
flex-direction: column;
height: 100%;
}
.add-todo__input {
margin-bottom: 1em;
padding: 0.5em 1em;
border-radius: 0.1em;
border: none;
outline: none;
}
.add-todo__btn {
background: #65657e;
color: #fff;
font-size: 14px;
padding: 0.5em 1em;
border-radius: 0.2em;
cursor: pointer;
}
</style>
4.5 完善App.vue
// todos-monorepo/apps/todo-ui/src/App.vue
<script setup lang="ts">
import AddTodo from "./components/AddTodo.vue";
import TodoList from "./components/TodoList.vue";
</script>
<template>
<h2>TODO LIST</h2>
<div class="todo-wrapper">
<div class="add-todo-layout">
<AddTodo />
</div>
<div class="todo-list-layout">
<TodoList />
</div>
</div>
</template>
<style>
* {
box-sizing: border-box;
}
body {
background: #f8f9ff;
}
h2 {
color: #65657e;
margin-top: 0;
}
.todo-wrapper {
display: flex;
width: 80dvw;
justify-content: center;
}
.add-todo-layout {
width: 30dvw;
max-width: 250px;
}
.todo-list-layout {
width: 50dvw;
background: #ecedf5;
border-radius: 0 0.5em 0.5em 0;
}
@media screen and (max-width: 767px) {
.todo-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.add-todo-layout {
width: 100%;
max-width: none;
}
.todo-list-layout {
width: 100%;
margin-top: 1em;
}
}
</style>