文章首发:fujia.site
文章采用意译,而非直译,如有不准确的,请在评论区指出!
我们塑造了工具,然后工具塑造了我们 ------马歇尔·麦克卢汉(Marshall McLuhan)
在本文中,我们将从零开始构建一个全栈的、端到端类型安全的pnpm monorepo风格的项目,使用的技术栈如下:
- NestJS;
- NextJS 13(使用新的
/app
目录); - tRPC。
最后, 我们将使用docker
部署到云服务器上。
对前端工程师来说,我非常喜欢这个技术栈,因为它的开发者体验非常好!阅读完本文后,我相信你也会喜欢的。
下面我先简单概述下使用这种技术栈的好处:
- 完全"端到端"的类型安全;
- tRPC 服务被完全集成进了 NestJS,因此你可以使用 NestJS的所有功能,如:依赖注入;
- 与 NextJS 13结合使用,客户端使用服务端渲染;
- 前后端分离,但使用monorepo 风格的项目组织,可以将他们无缝地组合在一起。
前置准备: 你需要在本机上安装 pnpm 和 NestJS CLI。
本文的示例代码已上传到 github repo,你可以点击查看。
准备好了吗?让我们开始吧~
使用 pnpm workspaces
设置 Monorepo
首先,打开终端,切换到工作目录下,使用下面的命令创建一个空白的项目,推荐使用 VSCode 编辑编写项目:
sh
mkdir nestjs-trpc
cd nestjs-trpc
pnpm init
code .
上面的命令会在根目录下创建一个 package.json 文件。
接下来,初始化git并添加.gitignore
文件阻止git追踪声明的文件和文件夹:
sh
git init
touch .gitignore
在.gitignore
文件中,添加下面的内容:
sh
node_modules
dist
build
.env
我们使用 pnpm workspace构建我们的 monorepo,这样我们在同一个仓库中构建多个应用并使用pnpm进行包管理。
在项目根目录下新建 pnpm-workspace.yaml
文件用来设置pnpm 工作区并编写下面的内容:
sh
touch pnpm-workspace.yaml
# pnpm-workspace.yaml
packages:
# all apps in direct subdirs of apps/
- 'apps/*'
文件内容表示apps/
目录下的目录都会包含在pnpm 的工作区中。
最后,在项目根目录下创建apps/
目录:
sh
mkdir apps
很好!现在我们就可以在apps/
目录下创建"前端"和"后端"应用了。
TIPS: 如果不愿意敲命令,可以直接在VSCode中操作。
添加 NestJS 应用
我们新增一个名为"server"的NestJS 应用,名字可以取你喜欢的,但一般建议要"语义明确"。
使用之前在本机安装的NestJS CLI,运行下面的命令:
sh
cd apps/
nest new server --strict --skip-git --package-manager=pnpm
上面命令中参数都比较重要,我们注意解释下:
- --strict,确保 NestJS 的 TypeScript 编译器配置使用了"严格模式",因为不使用严格模式,就没有必要使用typescript了;
- --skip-git,当我们创建一个NestJS应用时,默认会为我们初始化git,由于我们已经在根目录下初始化了git,所以要跳过默认的git行为;
- --package-manager=pnpm(简写形式:-p pnpm),确保应用使用pnpm作为包管理器。
在开始之前,我们再次查看NestJS 服务器的目录结构并启动本地服务器:
sh
cd /server
pnpm start:dev
很好!我们已经在monorepo项目中添加了一个NestJS 应用。
在开始之前,我们需要稍微调整下NestJS 服务器的监听端口。默认情况下,NestJS使用 3000
端口,这与 NextJS使用端口一致。
为了避免端口冲突,我们将NestJS的端口改为4000
。
这里我们建议使用一个环境变量来设置服务端口并使用4000
作为备用端口。这在部署应用时非常有用,服务器提供商可以自动替换端口。
修改后的内容如下:
ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT || 4000);
}
bootstrap();
要使用环境变量,需要使用"config module",可以查看这篇文章了解更多NestJS Config Module: Using environment variables。
现在我们的本地服务器就运行在:http://locahost:4000
。
接下来,我们设置NextJS应用。
添加 NextJS 应用
我们添加一个名为"web"的NextJS 应用。
首先返回到apps/
目录并运行下面的命令:
sh
cd ..
pnpx create-next-app@latest
在安装过程,会给出一些提示,可以参考下面进行配置:
sh
What is your project named? # web (change to whatever you want)
Would you like to use TypeScript with this project? # Yes
Would you like to use ESLint with this project? # Yes
Would you like to use Tailwind CSS with this project? Yes # Yes
Would you like to use `src/` directory with this project? # No
Use App Router (recommended)? # Yes
Would you like to customize the default import alias? # No
这样,在 apps/
目录下,我们就创建了一个新的NextJS项目(这里名为web/
)。
启动一个本地服务检查下NextJS 项目是否能正常运行。
sh
cd /web
pnpm dev
这样在我们的monorepo项目中,有两个应用在本地运行。
Monorepo 配置更新和DX(Developer Experience)技巧
接下来,我们将会添加 tRPC
,tRPC 服务端安装在NestJS 应用中,而tRPC客户端安装NextJS应用中。
tRPC客户端需要访问定义在NestJS应用中的AppRouter
类型(我们将在下一部分讨论)。
在当前的设置中,这是不可能的 ------ 你尽可以从你所在的应用中引入文件。
因此,我们需要修改 TypeScript 编译器的配置来实现它。在项目的根目录下创建一个新的tsconfig.json
文件,让其他的应用继承它。
回到项目根目录创建tsconfig.json
文件:
sh
touch tsconfig.json
首先,更新NestJS 的tsconfig:
json
{
"extends": "../../tsconfig.json", // Extend the config options from the root
"compilerOptions": {
// The following options are not required as they've been moved to the root tsconfig
// "baseUrl": "./",
// "emitDecoratorMetadata": true,
// "experimentalDecorators": true,
// "incremental": true,
// "skipLibCheck": true,
// "strictNullChecks": true,
// "noImplicitAny": true,
// "strictBindCallApply": true,
// "forceConsistentCasingInFileNames": true,
// "noFallthroughCasesInSwitch": true
"module": "commonjs",
"declaration": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
}
}
然后更新 NextJS 的 tsconfig:
json
{
"extends": "../../tsconfig.json", // Extend the config options from the root,
"compilerOptions": {
// The following options are not required as they've been moved to the root tsconfig
// "paths": {
// "@/*": ["./*"]
// }
// "incremental": true,
// "forceConsistentCasingInFileNames": true,
// "skipLibCheck": true,
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
由于我们修改了两个应用的TypeScript 编译器选项,重新启动下两个本地服务确保它们能正常运行。
我们不需要切换目录并为每个应用新建一个终端,而是在根目录下定义一个命令一次启动多个应用,修改步骤如下:
在项目根目录下,打开package.json
文件,添加一个新的名为dev
的脚本命令:
package.json
{
"name": "nestjs-trpc",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "pnpm run --parallel dev",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这样在apps/
目录下的任何名为dev
的脚本都会并行运行。因此,我们需要将 NestJS本地开发服务脚本由start:dev
修改为dev
。
在根目录下运行pnpm dev
命令,可以看到两个应用已经并行运行起来了。
在安装 tRPC之前的最后两件事:
第一是一个小提示:
安装npm 包时,我们需要在相应的应用下安装它们,而不是在根目录。这需要不断地切换目录,有些麻烦,一种更好的方式是使用 --filter
的参数。
举个例子,在根目录下为NestJS应用安装@nestjs/config
包,可以运行下面的命令:
sh
pnpm add @nestjs/config --filter=server
这导致了第二件事情,如果你在根目录下安装了一个NestJS 应用的包,可能会引起相应依赖缺失的错误:
sh
apps/server
└─┬ ts-loader 9.4.3
└── ✕ missing peer webpack@^5.0.0
Peer dependencies that should be installed:
webpack@^5.0.0
该错误在单独的 NestJS 应用中不会发生,为什么在这里发生呢?
原因是monorepo的设置。在workspace
中,包管理器不会自动地为每个应用处理相应的依赖。因此,我们需要在 NestJS应用下手动安装它:
sh
pnpm add -D webpack@^5.0.0 --filter=server
干的好!这样我们就处理好这个错误了。
在 NestJS & NextJS中使用 tRPC
先安装依赖,在 NestJS 应用中安装tRPC server
和在 NextJS应用中安装tRPC client
。
在 NestJS中添加 tRPC server
执行下面的命令安装相关依赖:
sh
pnpm add @trpc/server zod --filter=server
接下来,我们添加 tRPC 模块,它会封装与 tRPC server相关的代码。
我们新建一个trpc的目录并创建三个文件:
sh
mkdir apps/server/src/trpc
touch apps/server/src/trpc/trpc.module.ts
touch apps/server/src/trpc/trpc.service.ts
touch apps/server/src/trpc/trpc.router.ts
接着编写模块内容:
ts
// trpc.module.ts
import { Module } from '@nestjs/common';
@Module({
imports: [],
controllers: [],
providers: [],
})
export class TrpcModule {}
并在 AppModule
模块中引入该模块:
ts
// app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TrpcModule } from '@server/trpc/trpc.module';
@Module({
imports: [TrpcModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
在 trpc.service.ts
文件中添加一个类,导出必要的属性:
ts
// trpc.service.ts
import { Injectable } from '@nestjs/common';
import { initTRPC } from '@trpc/server';
@Injectable()
export class TrpcService {
trpc = initTRPC.create();
procedure = this.trpc.procedure;
router = this.trpc.router;
mergeRouters = this.trpc.mergeRouters;
}
在 TrpcModule 文件中引入上面的service
作为一个provider
:
ts
// trpc.module.ts
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
@Module({
imports: [],
controllers: [],
providers: [TrpcService],
})
export class TrpcModule {}
在 trpc.router.ts
文件中添加一个类,实现下面的功能:
- 定义 trpc 路由(即,trpc客户端可以调用的方法);
- 添加中间件方法导出 NestJS server 的trpc api;
- 导出
AppRouter
类型(我们将在trpc客户端中使用)。
代码如下:
ts
// trpc.router.ts
import { INestApplication, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';
@Injectable()
export class TrpcRouter {
constructor(private readonly trpc: TrpcService) {}
appRouter = this.trpc.router({
hello: this.trpc.procedure
.input(
z.object({
name: z.string().optional(),
}),
)
.query(({ input }) => {
const { name } = input;
return {
greeting: `Hello ${name ? name : `Bilbo`}`,
};
}),
});
async applyMiddleware(app: INestApplication) {
app.use(
`/trpc`,
trpcExpress.createExpressMiddleware({
router: this.appRouter,
}),
);
}
}
export type AppRouter = TrpcRouter[`appRouter`];
现在我们可以在路由中使用依赖注入了,这是多么美好的事情。
这意味着我们可以在trpc 路由中注入其它的services,从而保证路由器的纯净、最小化并与业务逻辑解耦。请参考下面的实例说明:
ts
// trpc.router.ts
import { INestApplication, Injectable } from '@nestjs/common';
import { z } from 'zod';
import { TrpcService } from '@server/trpc/trpc.service';
import * as trpcExpress from '@trpc/server/adapters/express';
@Injectable()
export class TrpcRouter {
constructor(
private readonly trpc: TrpcService,
private readonly userService: UserService // injected service
) {}
appRouter = this.trpc.router({
getUsers: this.trpc.procedure
.input(
z.object({
name: z.string(),
}),
)
.query(async ({ input }) => {
const { name } = input;
return await this.userService.getUsers(name); // random example showing you how you can now use dependency injection
}),
});
async applyMiddleware(app: INestApplication) {
app.use(
`/trpc`,
trpcExpress.createExpressMiddleware({
router: this.appRouter,
}),
);
}
}
export type AppRouter = TrpcRouter[`appRouter`];
请注意,在上面的示例中,我们添加了一个简单的路由示例,这是有意的。因为我们将在下一步的trpc客户端中展示如何使用它。
随着添加的路由越来越多,这会导致路由文件内容变得非常长且混乱。此时我们可以考虑使用merging routers进行优化。
在 TrpcModule 模块文件中引入它并注册为一个 Provider。
ts
// trpc.module.ts
import { Module } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service';
import { TrpcRouter } from '@server/trpc/trpc.router';
@Module({
imports: [],
controllers: [],
providers: [TrpcService, TrpcRouter],
})
export class TrpcModule {}
完成tRPC server
编写的最后一件事是更新 main.ts
文件,包括:
- 应用我们在上面路由器中定义的中间件;
- 启用
CORS(即,允许跨域)
。
ts
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { TrpcRouter } from '@server/trpc/trpc.router';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors();
const trpc = app.get(TrpcRouter);
trpc.applyMiddleware(app);
await app.listen(4000);
}
bootstrap();
注意:一定要配置服务器允许跨域,否则在客户端会遇到一个「跨域异常」。
到这里,我们就开发完了 tRPC server
,下一步我们就开始创建 tRPC client
。
在 NextJS 中添加 tRPC
在开始之前,我们简单地讨论下 NextJS 13,这对我们后面的开发有帮助,它最新的 App 路由器会影响我们如何使用 tRPC client。
在 NextJS 13之前,所有的网络请求都是在客户端发起的(即,来自浏览器)。所有开发者都是如此使用且它也能很好地工作。我们构建了一个庞大冗余的状态管理系统(PS: 这里原文是crazy
, 可以译为:极度烦恼的,(角度)极其歪斜的,因为翻译时小编正在做低代码项目的开发,项目中维护了一棵非常庞大的状态树,认为它被滥用了,因此就如此翻译了。多说一句,这里其实也可以看出,译文很受翻译者本身认知影响,强烈建议优先阅读原文!!!)并到处使用 useEffects hook
。
但是现在,NextJS 尝试了一些范式转变(有些讽刺的是,这种转变跟那些旧式的框架,如:Ruby-on-rails & Laravel的工作方式越来越一致),将更多的职责转移到服务端。
注意,这里讨论的「服务端」不是上面运行的 NestJS 服务端应用,而是 NextJS 应用的服务端。
例如,当用户在 NextJS 13应用中不同路由之间跳转时,他们每改变一次路由都会向 NextJS server 发生一个请求并将UI返回给浏览器。
因此,在NextJS 应用的服务端我们就有机会在 UI 渲染之前发起网络调用,要实现这一点,需要引入Server Components。
使用这种方式有很多的优势,但对于过去几年前端工程师构建应用的方式来说,需要很大的思维模式转变。
NextJS的讨论先到这里,我们先添加 tRPC client
。
运行下面的命令在 NextJS 应用中安装客户端包:
sh
pnpm add @trpc/client @trpc/server --filter=web
注意:@trpc/server
包是需要安装的,否则,会出现peer dependency
异常。
在 app/
目录中,添加一个新文件 trpc.ts
。
sh
touch apps/web/app/trpc.ts
在该文件中,我们将定义 tRPC client
。为了创建 tRPC client
,我们需要使用 AppRouter
类型,这是在上一步从tRPC server
导出的。而也是为什么可以保证应用是一个「端到端类型安全」的。
ts
// trpc.ts
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { AppRouter } from '@server/trpc/trpc.router'
export const trpc = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:4000/trpc", // you should update this to use env variables
}),
],
});
因为我们一致化了 TSconfig 并配置了paths
,所以我们可以在 NextJS 应用中导入 AppRouter
(即使类型来自于另一个应用)。
url
属性指向了 tRPC 服务器,在我们的示例中是:http://localhost:4000/trpc
。如果你要部署,需要使用「环境变量」来更新它。
现在我们可以在 NextJS 中调用服务器侧的 tRPC了。
在 NextJS 应用 /app
目录下,定位到 page.tsx
文件。将函数更新为 async function
,然后使用 trpc client
调用我们在 tRPC server
中定义的hello
程序:
ts
// apps/web/app/page.tsx
import { trpc } from "@web/app/trpc";
export default async function Home() {
const { greeting } = await trpc.hello.query({ name: `Tom` });
return <div>{greeting}</div>;
}
上面展示的是如何使用tRPC client
发起一个服务器侧的调用。客户端侧如何调用呢?
让我们尝试一下吧。
新增一个名为ClientSide.tsx
的组件,内容如下:
ts
"use client";
import { trpc } from "@web/app/trpc";
import { useEffect, useState } from "react";
export default function ClientSide() {
const [greeting, setGreeting] = useState("");
useEffect(() => {
trpc.hello
.query({ name: `Tom` })
.then(({ greeting }) => setGreeting(greeting));
}, []);
return <p>I am client side: {greeting}</p>;
}
就是这样简单!可以看到,在IDE中"一切"都是类型安全的,当你尝试从返回的query
中访问一个不存在的属性时,就会得到一个错误提示。
总结
文章到这里,我们先告一段落,一个全栈(前端技术栈)项目的基础技术选型和"架子"就确定好了。
但我们知道这远远不够,项目中的真正的"硬菜"都还没上桌,所以不着急,我们一步步来。
另外,我们预告下,因为最近正在使用上面的技术栈做个人站点的重构。刚开始的时候只是觉得文章不错想翻译,写着写着就多出了一些其它的想法,如:看下能不能把他做成一个系列(包括但不限于:部署,数据库,工程化,脚手架(已经开源了一套),支付,AI接入(最近也一直在学习本地优先的LLM部署,如 ollama.ai)等等),一边重构网站,一边输出,想想还不错...
最后的最后,想提一点"非常个人"的建议:对前端工程师来说,在学习新的技术时,请一定考虑把"英语"加入你的课程表,持之以恒地精进她。关于学习英语的利弊或有什么难点并不想讨论,先试一试!
坚定相信的力量,重复做正确的事情! 这世上聪明的人好多,"笨笨"的把自己认为值得的事情做好,也不错哦...
敬请期待... :)
参考资料
- Building a full-stack, fully type-safe pnpm monorepo with NestJS, NextJS & tRPC - www.tomray.dev/nestjs-next... 。
- tRPC - trpc.io/ 。
- Difference between dependencies, devDependencies and peerDependencies - www.geeksforgeeks.org/difference-...