白筱汐

想都是问题,做都是答案

0%

React Router v7 入门指南

本教程基于 react router v7 版本,旨在快速实现一个 react router 的案例功能。

详细内容请查看 react router官网

快速启动一个 react 项目

使用 vite 快速创建一个 react 项目

1
pnpm create vite my-vue-app --template react

安装依赖

1
pnpm i

启动项目

1
pnpm dev

实现基础路由功能

安装 react router 库

1
pnpm i react-router

新建路由页面

在 src 里新建 pages 目录,里面新建 about.jsx,home.jsx,list.jsx, 各 react 元素可以随意返回一些内容,以供路由匹配展示。

内容类似下面这样:

1
2
3
4
5
6
7
8
// about.jsx
export default function About() {
return (
<div>
About Page
</div>
)
}

配置路由

修改 main.jsx 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router'
import './index.css'
import App from './App.jsx'
import Home from './pages/home.jsx'
import List from './pages/list.jsx'
import About from './pages/about.jsx'

createRoot(document.getElementById('root')).render(
<BrowserRouter>
<Routes>
<Route path='/' element={<App />}>
<Route index element={<Home />}></Route>
<Route path='list' element={<List />}></Route>
<Route path='about' element={<About />}></Route>
</Route>
</Routes>
</BrowserRouter>,
)

react router 有2种路由模式, BrowserRouterHashRouter

  • BrowserRouter: 使用现代浏览器的 History API(pushState 和 replaceState)来同步 URL 和应用程序状态。
  • HashRouter:使用 URL 中的哈希片段(#)来保存路由状态。

使用 Routes 包括 Route 组件,可以创建我们的路由。Route 组件的 path 属性代表路由绑定的路径,element 代表路由要展示的元素内容。

有一个 Route 没有 path 属性,但是它出现 index 属性,代表它是父路由的默认子路由,也就是说当我们访问“根路径” 就能看到 Home 元素。

完成路由跳转和页面展示

修改 main,jsx 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { Link } from 'react-router'
import { Outlet } from 'react-router'
import './App.css'

function App() {

return (
<div>
<h1>react router demo v6</h1>
<nav>
<Link to="/" style={{ marginRight: '15px' }}>首页</Link>
<Link to="/list" style={{ marginRight: '15px' }}>列表</Link>
<Link to="/about" style={{ marginRight: '15px' }}>关于</Link>
</nav>

<Outlet />
</div>
)
}

export default App

使用 Link 组件的 to 属性可以配置路由的跳转地址,Outlet 组件用于展示当前路由匹配的页面(组件)。

重新启动项目,现在你可以看到如下图展示的内容,点击 home、about、list,可以看到路由的底部展示的渲染的内容发生了变化,并浏览器地址也对应更新。

页面效果

至此,我们已经完成了基础的路由配置和、跳转以及页面的展示。

匹配 404 页面

通常当我们访问一个不存在的页面的地址的时候,需要展示一个 404 页面。通常这个页面不属于根页面的子路由。

在 src/pages 目录里面新建一个 404.jsx 文件,内容如下:

1
2
3
4
5
export default function NotFound() {
return (
<div>Not Found 404</div>
)
}

修改 main.jsx 路由配置,就能完成我们需要的功能了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import {createRoot} from 'react-dom/client'
import {BrowserRouter, Routes, Route} from 'react-router'
import './index.css'
import App from './App.jsx'
import Home from './pages/home.jsx'
import List from './pages/list.jsx'
import About from './pages/about.jsx'
import NotFound from "./pages/404.jsx";

createRoot(document.getElementById('root')).render(
<BrowserRouter>
<Routes>
<Route path='/' element={<App/>}>
<Route index element={<Home/>}></Route>
<Route path='list' element={<List/>}></Route>
<Route path='about' element={<About/>}></Route>
</Route>
{/* 匹配 404 页面 */}
<Route path='*' element={<NotFound/>}></Route>
</Routes>
</BrowserRouter>,
)

现在在浏览器访问 http://localhost:5173/1 , 页面就会展示 “Not Found 404” 了。

获取路由参数 params 和 query

获取路由 params

配置 list 路由的子路由 list/id

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 省略相关重复代码
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<Routes>
<Route path='/' element={<App/>}>
<Route index element={<Home/>}></Route>
<Route path='list' element={<List/>}>
{/*新增子路由,list/id*/}
<Route path=':id' element={<ListDetail/>}></Route>
</Route>
<Route path='about' element={<About/>}></Route>
</Route>
<Route path='*' element={<NotFound/>}></Route>
</Routes>
</BrowserRouter>,
)

在 src/pages 目录新建 list-detail.jsx 文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {useParams} from "react-router";

export default function ListDetail() {

// 获取 params 参数对象
const params = useParams()
console.log(params)

return (
<div>
List Detail Page id: {params.id}
</div>
)
}

修改 list.jsx 文件,内容如下:

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
import {Link, Outlet} from "react-router";

export default function List() {
const data = [
{
id: 1,
name: '订单1'
},
{
id: 2,
name: '订单2'
}
];

return (
<div>
{ data.map( (item) => <Link style={{marginRight: '15px'}} to={ `/list/${item.id}`} key={item.id} >
detail {item.name}
</Link> ) }

{/* 展示子页面的内容 */}
<Outlet></Outlet>
</div>
)
}

现在重新启动项目,你会看到下面这样的界面:

页面效果

点击 列表 下面的 detail 订单 1 , 可以看到展示了 List Detail Page id: 1。

使用 useParams() 方法可以获取到 params 对象,里面包含了相关的参数信息。

获取路由 query

修改 list.jsx 文件,新增一个携带 query 参数的 Link 组件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 省略部分重复内容...
return (
<div>
{ data.map( (item) => <Link style={{marginRight: '15px'}} to={ `/list/${item.id}`} key={item.id} >
detail {item.name}
</Link> ) }

{/* 新增一个携带 query 参数的 Link 组件 */}
<Link to='/list/3?type=a' >detail type=a</Link>

{/* 展示子页面的内容 */}
<Outlet></Outlet>
</div>
)

修改 list-detail.jsx 文件,加入获取路由 query 参数的代码,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {useParams, useSearchParams} from "react-router";

export default function ListDetail() {
const params = useParams()
console.log(params)

// searchParams 是一个 URLSearchParams 对象,具体请参考 MDN
let [searchParams] = useSearchParams();
console.log(searchParams)
console.log(searchParams.get('type')) // 获取具体 query 参数

return (
<div>
List Detail Page id: {params.id}
</div>
)
}

点击 新增的 Link 组件,跳转到 list-detail 页面,展示内容为 “List Detail Page id: 3”。

页面效果

查看控制台可以发现我们获取去到了路由的 query 参数 type=a; 这里说明一下 useSearchParams() 方法返回的数组中,第一个元素是 一个 URLSearchParams 实例; URLSearchParams 接口定义了一些实用的方法来处理 URL 的查询字符串。URLSearchParams 接口 MDN

如果需要获取更多路由信息,可以使用 useLocation 方法:

1
2
let location = useLocation();
console.log(location)

location 内容

编程式导航

有些时候我们需要使用 js 主动去触发页面的跳转,可以使用方法 useNavigate

params 和 query 参数你可以直接写在跳转链接里面, 例如 ‘/list/1?type=a’,不在演示。

1
2
3
4
5
// 编程式导航
const navigate = useNavigate();
const backToHome = () => {
navigate('/')
}

重定向

1
redirect("/login")

自定义导航守卫

在项目开发的过程中,一般我们会有这样的需求:有些页面可以随意访问,但是部分页面必须用户登录之后才能访问。我们可以给路由配置一个白名单,如果当前访问页面的路径在白名单里面,就能正常访问。反之,如果访问的页面需要权限,用户必须登录之后才能正常访问。不能访问的页面当用户触发跳转时,重定向到登录页面。在登录完成之后,在回到之前的页面。

封装一个校验路由权限并且重定向的组件。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
// eslint-disable-next-line react/prop-types
export default function RequireAuth({children}) {
const whiteList = ["/", "/about"]

const { pathname } = useLocation();

// 不需要权限的页面
const notRequiredAuth = whiteList.includes(pathname);

// 访问的页面在白名单里,或者用户拥有权限 hasToken,直接跳转。否则重定向到到 login
return (hasToken || notRequiredAuth ? children : <Navigate to="/login" replace state={pathname} />)
}

接下来我们只要使用该组件包裹我们的路由组件就好了

1
2
3
<RequireAuth>
<Link to="/list" style={{marginRight: '15px'}}>列表</Link>
</RequireAuth>

Data Mode

react router v7 官网现在出现了3种模式,Framework Mode、Data Mode、Declarative Mode。

  • Framework Mode:
    • 强依赖 React Router Data APIs(loader、action、errorElement 等)
    • 文件即路由:如 app/routes/dashboard.tsx 就是 /dashboard
    • 默认 SSR 支持(如 Remix)
    • 路由定义抽象于文件系统,不用手动写 createBrowserRouter
  • Data Mode:
    • 使用 createBrowserRouter 配置路由并定义 loader、action 等函数。
    • 强调路由组件初始化前就完成数据加载
  • Declarative Mode:
    • 使用 “Routes” “Route” 等 JSX 方式声明路由,数据处理在组件内部完成。(传统模式)

Framework Mode 架构模式更适合于 SSR 应用,对于一般的管理后台,把路由根据文件来配置有点不太方便,管理后台通常有路由权限的问题,所以不建议使用此模式。

Declarative Mode 也就是传统的声明组件的方式配置路由,大部分使用过 react router 的用户都能接受,但是有点繁琐不够灵活。

Data Mode 支持配置路由的 loader、action 等,灵活性较好,可以学习这种模式,方便以后过度到架构模式

Data Mode 处理路由权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ProtectedLayout.tsx
import { Outlet } from 'react-router-dom';

export function ProtectedLayout() {
return <Outlet />;
}

export function authLoader() {
const token = localStorage.getItem('token');
if (!token) {
throw redirect('/login');
}
return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
path: '/',
element: <RootLayout />,
children: [
{
element: <ProtectedLayout />,
loader: authLoader,
children: [
{
path: 'dashboard',
element: <DashboardPage />,
},
{
path: 'settings',
element: <SettingsPage />,
},
],
},
]
}

该文章内容在文章 Nestjs 和 Prisma 实现 Restful Api的基础上实现,是该系列最后一篇文章,如有需要,请先搜索查看前面的文章。

在REST API中实现身份验证

这一节我们将学习给用户相关的 REST 接口添加权限认证。

  • GET /users
  • GET /users/:id
  • PATCH /users/:id
  • DELETE /users/:id

权限认证的方案主要有2种,一种是基于 session 的方案,一种是基于 token 的方案。接下来我们将学习如何在 Nestjs 中使用 Json Web Tokens

在开始之前,我们先要生成 auth 模块的资源文件

1
npx nest generate resource

根据终端的提示,选择相应的回答。

  1. What name would you like to use for this resource (plural, e.g., “users”)? auth
  2. What transport layer do you use? REST API
  3. Would you like to generate CRUD entry points? No

现在,您应该在src/auth目录中找到一个新的 auth 模块。

安装和配置 passport

Passport 是一个流行的 Node.js 认证库,功能强大,支持多种认证策略,并具有很高的可配置性。它通常与 Express 框架一起使用,而 NestJS 本身也是基于 Express 构建的。NestJS 提供了一个官方的 Passport 集成库,称为 @nestjs/passport,使其在 NestJS 应用中更加容易使用和集成。

通过安装下面的这些库开始我们的工作

1
2
npm install --save @nestjs/passport passport @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt

你已经安装了所需要的包,现在可以在应用里配置 passport 了,打开 src/auth.module.ts 文件并添加以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';

export const jwtSecret = 'zjP9h6ZI5LoSKCRj';

@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: jwtSecret,
signOptions: { expiresIn: '5m' }, // e.g. 30s, 7d, 24h
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}

@nestjs/passport 模块提供了一个可以导入到应用程序中的 PassportModule。PassportModule 是 passport 库的包装器,该库提供了特定于 NestJS 的实用程序。您可以在官方文档中阅读更多关于 PassportModule 的信息。

您还配置了一个 JwtModule,您将使用它来生成和验证jwt。JwtModule 是 jsonwebtoken 库的包装器。secret 提供了一个用于对 jwt 签名的密钥。expiresIn 对象定义jwt的过期时间。当前设置为1分钟。

注意:在实际的应用程序中,永远不应该将密钥直接存储在代码库中。NestJS提供了@nestjs/config包,用于从环境变量中加载秘密。您可以在官方文档中阅读更多相关内容。

实现 POST /auth/login 接口

POST /login 将用于验证用户。它将接受用户名和密码,如果认证通过,则返回JWT。首先创建一个LoginDto类,它将定义请求体 Body 的结构。

用 email 和 password 字段来定义 LoginDto 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//src/auth/dto/login.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';

export class LoginDto {
@IsEmail()
@IsNotEmpty()
@ApiProperty()
email: string;

@IsString()
@IsNotEmpty()
@MinLength(6)
@ApiProperty()
password: string;
}

您还需要定义一个新的 AuthEntity 来描述JWT有效负载的形状。在src/auth/entity目录下创建一个新文件auth.entity.ts:

1
2
mkdir src/auth/entity
touch src/auth/entity/auth.entity.ts

按照下面的内容定义 AuthEntity 类。

1
2
3
4
5
6
7
//src/auth/entity/auth.entity.ts
import { ApiProperty } from '@nestjs/swagger';

export class AuthEntity {
@ApiProperty()
accessToken: string;
}

AuthEntity只有一个名为accessToken的字符串字段,它将包含JWT。

在 AuthService 新增一个 login 方法。

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
//src/auth/auth.service.ts
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from './../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { AuthEntity } from './entity/auth.entity';

@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwtService: JwtService) {}

async login(email: string, password: string): Promise<AuthEntity> {
// Step 1: Fetch a user with the given email
const user = await this.prisma.user.findUnique({ where: { email: email } });

// If no user is found, throw an error
if (!user) {
throw new NotFoundException(`No user found for email: ${email}`);
}

// Step 2: Check if the password is correct
const isPasswordValid = user.password === password;

// If password does not match, throw an error
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}

// Step 3: Generate a JWT containing the user's ID and return it
return {
accessToken: this.jwtService.sign({ userId: user.id }),
};
}
}

login方法首先获取具有给定电子邮件的用户。如果没有找到用户,则抛出NotFoundException异常。如果找到用户,则检查密码是否正确。如果密码不正确,则会抛出UnauthorizedException异常。如果密码正确,则生成包含用户ID的JWT并返回。

现在我们在 AuthController 创建 POST /auth/login 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//src/auth/auth.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AuthEntity } from './entity/auth.entity';
import { LoginDto } from './dto/login.dto';

@Controller('auth')
@ApiTags('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('login')
@ApiOkResponse({ type: AuthEntity })
login(@Body() { email, password }: LoginDto) {
return this.authService.login(email, password);
}
}

启动项目(包括docker服务),访问本地 http://localhost:3000/api 调用 POST /auth/login, 传入一个正确的邮箱和密码。

1
2
3
4
{
"email": "alex@ruheni.com",
"password": "password-alex"
}

你可以看到接口成功的返回了 accessToken。

实现JWT身份验证策略

在 Passport 中,策略负责对请求进行身份验证,这是通过实现身份验证机制来完成的。在本节中,您将实现用于对用户进行身份验证的JWT身份验证策略。

您将不会直接使用 passport 包,而是与包装器包@nestjs/passport交互,它将在底层调用 passport 包。要用@nestjs/passport配置策略,需要创建一个类来扩展PassportStrategy类。在这门课上,你需要做两件主要的事情:

  1. 您将把JWT策略特定的选项和配置传递给构造函数中的super()方法。
  2. validate()回调方法,它将与数据库交互,根据JWT有效负载获取用户。如果找到用户,validate()方法将返回用户对象。

首先在 src/auth/strategy 目录下新建一个 jwt.strategy.ts 文件。

1
touch src/auth/jwt.strategy.ts

现在实现一下 JwtStrategy 类。

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
//src/auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { jwtSecret } from './auth.module';
import { UsersService } from 'src/users/users.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: jwtSecret,
});
}

async validate(payload: { userId: number }) {
const user = await this.usersService.findOne(payload.userId);

if (!user) {
throw new UnauthorizedException();
}

return user;
}
}

您已经创建了一个JwtStrategy类,它扩展了PassportStrategy类。PassportStrategy类接受两个参数:策略实现和策略名称。这里使用的是passport-jwt库中的预定义策略。

您正在向构造函数中的super()方法传递一些选项。jwtFromRequest选项需要一个可用于从请求中提取JWT的方法。在这种情况下,您将使用在API请求的Authorization头中提供承载令牌的标准方法 (BearerToken)。secretOrKey选项告诉策略使用什么密钥来验证JWT。还有更多的选项,您可以在 passport-jwt github 查看。

对于 passport-jwt, Passport首先验证JWT的签名并解码JSON。然后将解码后的JSON传递给validate()方法。基于JWT签名的工作方式,你可以保证收到一个有效的令牌,这个令牌是之前由你的应用程序签名和发出的。validate()方法预计会返回一个用户对象。如果没有找到用户,validate()方法会抛出一个错误。

ps: passport 还可以生成基于 session 的权限认证方案,具体可以查看 NestJs 官网 Passport 部分。

在 authModule 中将新增的 JwtStrategy 放入 providers 中。

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
//src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module';
import { UsersModule } from 'src/users/users.module';
import { JwtStrategy } from './jwt.strategy';

export const jwtSecret = 'zjP9h6ZI5LoSKCRj';

@Module({
imports: [
PrismaModule,
PassportModule,
JwtModule.register({
secret: jwtSecret,
signOptions: { expiresIn: '5m' }, // e.g. 7d, 24h
}),
UsersModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
})
export class AuthModule {}

现在,JwtStrategy 可以被其他模块使用。您还在导入中添加了UsersModule,因为在JwtStrategy类中使用了UsersService

为了在JwtStrategy类中访问UsersService,你还需要在UsersModule的导出中添加它。

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
controllers: [UsersController],
providers: [UsersService],
imports: [PrismaModule],
exports: [UsersService],
})
export class UsersModule {}

实现JWT授权守卫

Guards (守卫)是 NestJS 的一个结构,它可以觉得请求是否可以继续下去。在这一部分,你将会实现一个自定义的 JwtAuthGuard,它将保护那些需要认证的路由。

在 src/auth 目录下新建一个 jwt-auth.guard.ts 文件。

1
touch src/auth/jwt-auth.guard.ts

现在我们来实现一下, JwtAuthGuard 类。

1
2
3
4
5
6
//src/auth/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

AuthGuard类需要策略的名称。在本例中,您使用的是在前一节中实现的JwtStrategy,它被命名为jwt。

现在我们可以使用这个 guard 作为一个装饰器来保护我们的路由接口了。给 UsersController 的路由添加 JwtAuthGuard 。

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
// src/users/users.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';

@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post()
@ApiCreatedResponse({ type: UserEntity })
async create(@Body() createUserDto: CreateUserDto) {
return new UserEntity(await this.usersService.create(createUserDto));
}

@Get()
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity, isArray: true })
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => new UserEntity(user));
}

@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.findOne(id));
}

@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiCreatedResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return new UserEntity(await this.usersService.update(id, updateUserDto));
}

@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiOkResponse({ type: UserEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.remove(id));
}
}

现在你调用这些接口中的任意一个,如果没有授权,都会返回 401,结果如下:

1
2
3
4
{
"message": "Unauthorized",
"statusCode": 401
}

在Swagger中集成身份验证

目前在 Swagger 上还没有迹象表明这些接口需要权限。你可以在控制器中添加一个 @ApiBearerAuth() 装饰器来指示需要进行身份验证。

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
// src/users/users.controller.ts

import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
UseGuards,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiBearerAuth, ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';
import { JwtAuthGuard } from 'src/auth/jwt-auth.guard';

@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post()
@ApiCreatedResponse({ type: UserEntity })
async create(@Body() createUserDto: CreateUserDto) {
return new UserEntity(await this.usersService.create(createUserDto));
}

@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserEntity, isArray: true })
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => new UserEntity(user));
}

@Get(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.findOne(id));
}

@Patch(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiCreatedResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return new UserEntity(await this.usersService.update(id, updateUserDto));
}

@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.remove(id));
}
}

现在刷新 Swagger 接口文档,你会发现在代码中加上装饰器的接口后面出现了一个 “锁” 的标记。这表明那个接口需要用户权限。

目前还不可能在Swagger中直接对自己进行“身份验证”,这样您就可以测试这些端点。要做到这一点,你可以在main.ts中的SwaggerModule设置中添加.addBearerAuth()方法调用

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
// src/main.ts

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}
bootstrap();

现在,您可以通过单击Swagger中的Authorize按钮来添加令牌。Swagger会将令牌添加到您的请求中,以便您可以查询受保护的接口。

首先通过调用 /auth/login 进行授权登录,然后拿到接口返回的 accessToken 添加到 Swagger 接口文档弹出的令牌认证窗口中。

哈希密码

目前,密码以明文的形式存储到数据库中。这是有安全风险的,如果数据库被泄漏,那么所有密码都会被泄漏。要解决这个问题,我们需要先对密码进行哈希处理,然后再存储到数据库中。

我们需要安装 bcrypt 这个库

1
2
npm install bcrypt
npm install --save-dev @types/bcrypt

crate 和 update 这2个接口涉及到存储密码到数据库,所以我们需要修改这个它们对应的逻辑,在 UsersService 中找到这对应的方法,在调用 prisma 操作数据库之前,我们先将密码哈希。

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
// src/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';
import * as bcrypt from 'bcrypt';

export const roundsOfHashing = 10;

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}

async create(createUserDto: CreateUserDto) {
const hashedPassword = await bcrypt.hash(
createUserDto.password,
roundsOfHashing,
);

createUserDto.password = hashedPassword;

return this.prisma.user.create({
data: createUserDto,
});
}

findAll() {
return this.prisma.user.findMany();
}

findOne(id: number) {
return this.prisma.user.findUnique({ where: { id } });
}

async update(id: number, updateUserDto: UpdateUserDto) {
if (updateUserDto.password) {
updateUserDto.password = await bcrypt.hash(
updateUserDto.password,
roundsOfHashing,
);
}

return this.prisma.user.update({
where: { id },
data: updateUserDto,
});
}

remove(id: number) {
return this.prisma.user.delete({ where: { id } });
}
}

bcrypt.hash 哈希函数接受两个参数:哈希函数的输入字符串和哈希的轮数(也称为成本因子)。增加哈希的轮数会增加计算哈希所需的时间。这里需要在安全性和性能之间进行权衡。随着哈希次数的增加,计算哈希值需要更多的时间,这有助于防止暴力攻击。然而,当用户登录时,更多的哈希轮也意味着更多的时间来计算哈希

bcrypt 还自动使用另一种称为salt的技术来增加暴力破解哈希的难度。Salting是一种在散列之前将随机字符串添加到输入字符串中的技术。这样,攻击者就不能使用预先计算的哈希表来破解密码,因为每个密码都有不同的盐值。

您还需要更新数据库种子脚本,以便在将密码插入数据库之前对密码进行散列处理:

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
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';

// initialize the Prisma Client
const prisma = new PrismaClient();

const roundsOfHashing = 10;

async function main() {
// create two dummy users
const passwordSabin = await bcrypt.hash('password-sabin', roundsOfHashing);
const passwordAlex = await bcrypt.hash('password-alex', roundsOfHashing);

const user1 = await prisma.user.upsert({
where: { email: 'sabin@adams.com' },
update: {
password: passwordSabin,
},
create: {
email: 'sabin@adams.com',
name: 'Sabin Adams',
password: passwordSabin,
},
});

const user2 = await prisma.user.upsert({
where: { email: 'alex@ruheni.com' },
update: {
password: passwordAlex,
},
create: {
email: 'alex@ruheni.com',
name: 'Alex Ruheni',
password: passwordAlex,
},
});

// create three dummy posts
// ...
}

// execute the main function
// ...

运行 npx prisma db seed, 查看终端或者数据库发现用户密码已经被哈希处理了。

现在,如果您尝试使用正确的密码登录,您将面临HTTP 401错误。这是因为登录方法试图将来自用户请求的明文密码与数据库中的散列密码进行比较。更新登录方法以使用散列密码:

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
//src/auth/auth.service.ts
import { AuthEntity } from './entity/auth.entity';
import { PrismaService } from './../prisma/prisma.service';
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
constructor(private prisma: PrismaService, private jwtService: JwtService) {}

async login(email: string, password: string): Promise<AuthEntity> {
const user = await this.prisma.user.findUnique({ where: { email } });

if (!user) {
throw new NotFoundException(`No user found for email: ${email}`);
}

const isPasswordValid = await bcrypt.compare(password, user.password);

if (!isPasswordValid) {
throw new UnauthorizedException('Invalid password');
}

return {
accessToken: this.jwtService.sign({ userId: user.id }),
};
}
}

现在我们可以使用正确的密码来调用 /auth/login 接口来拿到 jwt token了。

总结

在本章中,您学习了如何在您的NestJS REST API中实现JWT身份验证。您还了解了如何设置密码和将身份验证与Swagger集成。

完整代码以及说明文档请查看 Nestjs 和 Prisma 实现 Restful Api

使用 Lit 构建 Web Components

介绍 Lit

Lit 是一个用于构建 Web Components 的现代框架,它基于 Web Components
标准构建,旨在帮助开发者更高效地创建高性能、可复用的组件。

使用 vite 快速创建一个包含 Lit 的项目

1
pnpm create vite lit-web-components --template lit-ts

安装依赖

1
pnpm install

启动项目

1
pnpm dev

组件的基本概念

一个 Lit 组件是一个可复用的 UI 单元。你可以将 Lit 组件看作一个包含一些状态的容器,并根据其状态展示
UI。它还可以响应用户输入、触发事件——也就是说,能够完成你对一个 UI 组件的所有期望。此外,Lit 组件是一个 HTML 元素,因此它拥有所有标准的元素
API。

创建一个 Lit 组件涉及到以下几个概念:

  1. 定义组件:Lit 组件被实现为自定义元素(Custom Element),需要注册到浏览器中。
  2. 渲染:每个组件都有一个 render 方法,用于渲染组件的内容。在该方法中,你可以定义组件的模板。
  3. 响应式属性:属性用于保存组件的状态。当组件的响应式属性发生变化时,会触发更新周期,从而重新渲染组件。
  4. 样式:组件可以定义封装的样式,用于控制自身的外观,样式默认与组件外部隔离。
  5. 生命周期:Lit 定义了一组生命周期回调方法,开发者可以重写这些方法来在组件的生命周期中插入自定义逻辑。

在 src 目录下面新建一个 simple-greeting.ts 文件,加入以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import {css, html, LitElement} from "lit";
import {customElement, property} from "lit/decorators.js";

@customElement("simple-greeting")
export class SimpleGreeting extends LitElement {
// 使用普通的 css 定义作用域内的样式,这些样式默认通过 Shadow Dom 实现隔离
// 仅作用于组件内部,不会影响外部样式

// css`` 是 es6 “标签模板”功能(tagged template)
static styles = css`
:host {
color: blue;
}
`

// 声明响应式属性
@property()
name?: string = 'World';

// 根据组件状态渲染 ui
render() {
return html`<p>Hello ${this.name}</p>`
}
}

在 index.html 文件 head 标签的底部引入 simple-greeting.ts

1
2

<script type="module" src="/src/simple-greeting.ts"></script>

在 body 标签的底部使用 lit 组件

1
2

<simple-greeting/>

运行项目,页面上会出现蓝色的 Hello World

定义组件

观察 simple-greeting.ts 代码,你会发现 @customElement() 装饰器,实际上它只是 customElements.define 的语法糖。

删除 @customElement(‘simple-greeting’) 在底部添加以下代码:

1
customElements.define('simple-greeting', SimpleGreeting);

装饰器语法是 ts 环境下的,如果你的项目使用是 js,就可以用上面的方式实现同样的效果。

lit 组件是一个 html element

当你定义了一个 lit 组件,实际上你也定义了一个 custom HTML element

所以你也可以像创建 html 元素一样,去创建一个 lit 组件。

1
2
3
4
5

<script>
const greeting = document.createElement('simple-greeting');
document.body.appendChild(greeting);
</script>

使用上面的代码,你就能在页面上看到新增了一个 Hello World

LitElement 是 HTMLElement 的子类,它继承了所有 HTMLElement 的属性和方法。

实际上,LitElement 继承了 ReactiveElement,而 ReactiveElement 继承自 HTMLElement。

他们的关系就像下面这样。

1
LitElement <- ReactiveElement <- HTMLElement

提供 TYpeScript 类型声明

TypeScript 会根据标签名推断出某个 DOM api 返回的HTML元素的类。例如,document.createElement(’img’)返回一个带有src:
string属性的HTMLImageElement实例。

自定义元素可以通过添加HTMLElementTagNameMap来获得相同的处理,如下所示:

1
2
3
4
5
declare global {
interface HTMLElementTagNameMap {
'simple-greeting': SimpleGreeting
}
}

建议为所有用TypeScript编写的元素添加一个HTMLElementTagNameMap条目

渲染

向组件添加一个模板来定义它应该呈现的内容。模板可以包含表达式,表达式是动态内容的占位符。要为一个Lit组件定义一个模板,添加一个render()
方法

1
2
3
4
5
6
7
8
9
10
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
class MyElement extends LitElement {

render() {
return html`<p>Hello from my template.</p>`;
}
}

在 Lit 中,模板是通过 html 函数定义的,它使用 JavaScript 标签模板文字(tagged template literals)。这种方式支持将 HTML 和
JavaScript 表达式结合,用于动态设置内容、属性、事件监听器等。

可渲染的数据类型

  1. 基本数据类型:字符串、数字、布尔值
  2. TemplateResult 对象 : 由 html 标签函数创建的内容,例如 html<p>Hello, Lit!</p>
  3. DOM 节点(DOM Nodes):

    内容

  4. 特殊值:nothing 和 noChange:
    • nothing:表示不渲染任何内容,适用于条件渲染
    • noChange:表示不更新现有内容
  5. 数组或者可迭代对象:遍历对象渲染多个元素
    1
    2
    3
    4
    5
    render()
    {
    const items = [1, 2, 3];
    return html`<ul>${items.map(item => html`<li>${item}</li>`)}</ul>`;
    }
  6. SVG 模板: 使用 svg 函数创建的 SVGTemplateResult,仅能渲染为 标签的子节点,并且不能被 render 方法的直接返回。

组合模板

你可以从其他模板组合Lit模板。下面的例子为一个名为的组件合成了一个模板,这个模板是由页面的页眉、页脚和主内容的小模板组成的:

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
import {LitElement, html} from 'lit';
import {customElement, property} from 'lit/decorators.js';


@customElement('my-page')
class MyPage extends LitElement {

@property({attribute: false})
article = {
title: 'My Nifty Article',
text: 'Some witty text.',
};

headerTemplate() {
return html`<header>${this.article.title}</header>`;
}

articleTemplate() {
return html`<article>${this.article.text}</article>`;
}

footerTemplate() {
return html`<footer>Your footer here.</footer>`;
}

render() {
return html`
${this.headerTemplate()}
${this.articleTemplate()}
${this.footerTemplate()}
`;
}
}

在这个例子中,单个模板被定义为实例方法,因此子类可以扩展该组件并覆盖一个或多个模板。你也可以通过导入其他元素并在你的模板中使用它们来组成模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import {LitElement, html} from 'lit';
import {customElement} from 'lit/decorators.js';

import './my-header.js';
import './my-article.js';
import './my-footer.js';

@customElement('my-page')
class MyPage extends LitElement {
render() {
return html`
<my-header></my-header>
<my-article></my-article>
<my-footer></my-footer>
`;
}
}

对组件的响应式模板进行修改会触发组件的更新,lit 组件是异步批量更新操作,同时修改多个属性值,只会触发一次更新,并在微任务定时异步执行

Lit使用Shadow DOM来封装组件渲染的DOM。Shadow DOM允许元素创建自己的、独立于主文档树的DOM树。它是web组件规范的核心特性,支持互操作性、样式封装和其他好处。

响应式属性

Lit组件接收输入并将其状态存储为JavaScript类字段或属性。响应式属性是在被更改、重新呈现组件以及可选地读取或写入属性时触发响应式更新周期的属性。

1
2
3
4
class MyElement extends LitElement {
@property()
name?: string;
}

Lit 管理组件的 响应式属性 及其对应的 HTML 属性,为开发者提供了高效和便捷的状态管理机制。以下是 Lit 的具体处理方式:

  • 响应式更新:属性值改变会自动调度更新,触发重新渲染
  • 属性处理:属性值与 HTML 属性默认双向同步,可通过 reflect: true 实现属性到 HTML 的反射
  • super 属性继承:自动继承超类中声明的属性选项,避免重复声明。
  • 元素升级逻辑:在组件定义后,自动处理 DOM 中已存在的实例,确保属性更新正确触发副作用。

公共属性与内部响应式状态

在 Lit 中,公共属性和内部响应式状态是两种不同用途的属性设计,它们的使用和管理方式也有所不同:

公共属性(Public Properties):

  • 定义:
    公共属性是组件 公开 API 的一部分,通常用于接收组件外部传入的输入数据。
  • 特点:
    • 输入性质:公共属性通常是组件的输入,组件应该尽量避免主动修改它们,除非是响应式用户交互时需要更新。
    • 响应式:公共属性可以是响应式的,当它们发生变化时,组件会重新渲染
    • 属性反射:公共属性可以选择是否反射到 HTML 属性 (通过 reflect: true)

公共属性可以通过 @property 装饰器去声明,例如下面这种写法:

1
2
3
4
5
6
7
class MyElement extends LitElement {
@property({type: String})
mode?: string;

@property({attribute: false})
data = {};
}

或者在静态属性类字段中声明属性:

1
2
3
4
5
6
7
8
9
10
11
class MyElement extends LitElement {
static properties = {
mode: {type: String},
data: {attribute: false},
};

constructor() {
super();
this.data = {};
}
}

内部响应式状态(Internal Reactive State)

  • 定义:内部响应式状态是组件的私有状态,不是组件 api 的一部分。这些状态通常不与 HTML 属性对应,且在 TypeScript 中会标记为
    protected 或 private。
  • 特点:
    • 私有性:外部无法直接访问或修改这些属性
    • 用途:用于组件内部的逻辑控制或状态管理
    • 非反射:这些属性不会映射到 DOM 属性

使用 @state 装饰器可以声明一个内部响应式状态

1
2
@state()
protected _active = false;

使用静态属性类字段,您可以使用state: true选项来声明内部响应状态

1
2
3
4
5
6
7
static properties = {
_active: {state: true}
};

constructor() {
this._active = false;
}

内部反应状态不应该从组件外部引用。在TypeScript中,这些属性应该被标记为private或protected。我们还建议使用像前导下划线(_
)这样的约定来标识JavaScript用户的私有或受保护的属性

样式

在 Lit 中,组件的模板会渲染到它的shadow root中。你为组件添加的样式会自动作用于shadow root中的元素,只影响该shadow
root中的内容。

Shadow DOM 提供了强大的样式封装功能。如果 Lit 不使用 Shadow
DOM,你将不得不非常小心,以避免无意中样式化组件外部的元素(包括父级元素或子级元素)。这通常意味着需要写出冗长、难以使用的类名来确保样式只应用于特定的元素。而使用
Shadow DOM,Lit 确保了你编写的选择器只会影响 Lit 组件的影子根中的元素。

给组件添加样式

使用 css 标签函数可以定义有作用域的样式,这种方式定义样式可以获得最优的性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import {LitElement, html, css} from 'lit';
import {customElement} from 'lit/decorators.js';

@customElement('my-element')
export class MyElement extends LitElement {
static styles = css`
p {
color: green;
}
`;

protected render() {
return html`<p>I am green!</p>`;
}
}

添加到组件中的样式使用shadow DOM限定作用域,静态样式类字段的值可以是:

1
static styles = css`...`;

或者添加多个

1
static styles = [ css`...`, css`...`];

使用表达式定义静态样式

静态样式适用于组件的所有实例。CSS中的任何表达式只计算一次,然后在所有实例中重用。对于基于树或每个实例的样式定制,使用CSS自定义属性来允许元素被主题化。为了防止Lit组件评估潜在的恶意代码,css标签只允许嵌套表达式本身是css标记的字符串或数字。

1
2
3
4
5
const mainColor = css`red`;
...
static styles = css`
div { color: ${mainColor} }
`;

如果需要在样式中使用未标记的动态表达式(如普通字符串),可以通过 unsafeCSS() 函数包裹它。 但注意:此方法仅适用于完全可信的表达式,否则会带来安全风险。

1
2
3
4
5
const mainColor = 'red';
...
static styles = css`
div { color: ${unsafeCSS(mainColor)} }
`;

共享样式

可以通过创建一个导出带标签样式的模块来在组件之间共享样式。

1
2
3
4
5
6
7
8
export const buttonStyles = css`
.blue-button {
color: white;
background-color: blue;
}
.blue-button:disabled {
background-color: grey;
}`;

然后,你的元素可以导入这些样式,并将它们添加到它的静态样式类字段中。

1
2
3
4
5
6
7
8
9
10
11
import { buttonStyles } from './button-styles.js';

class MyElement extends LitElement {
static styles = [
buttonStyles,
css`
:host { display: block;
border: 1px solid black;
}`
];
}

另外,你可以使用 Shadow DOM 来处理样式隔离问题,具体你可以查看我以往的文字 web components 里的 Shadow DOM 部分的内容,或者查看 MDN 文档。

生命周期

Lit组件使用标准的自定义元素生命周期方法。此外,Lit还引入了一个响应式更新周期,当响应式属性发生变化时,它会将更改呈现给DOM。

标准自定义元素的生命周期

constructor():

创建元素时调用。此外,在升级现有元素时也会调用它,当自定义元素的定义在元素已经在DOM中之后加载时,就会发生这种情况。

1
2
3
4
5
constructor() {
super();
this.foo = 'foo';
this.bar = 'bar';
}
connectedCallback()

当元素被插入到 DOM 中时调用。

1
2
3
4
connectedCallback() {
super.connectedCallback()
window.addEventListener('keydown', this._handleKeydown);
}
disconnectedCallback()

当元素从 DOM 中移除时调用。

1
2
3
4
disconnectedCallback() {
super.disconnectedCallback()
window.removeEventListener('keydown', this._handleKeydown);
}
attributeChangedCallback()

当观察的属性值发生变化时调用。Lit 通常通过响应式属性来代替直接操作 attributeChangedCallback。

Lit使用这个回调将属性的变化同步到响应属性。具体来说,当设置一个属性时,相应的属性也被设置。Lit还会自动设置元素的 observedatattributes 数组,以匹配组件的响应属性列表。

响应式更新机制

响应式更新周期的触发条件:

  1. 响应式属性的值发生变化:
    使用 @property 或 @state 定义的属性,如果值被修改,会自动触发更新。
  2. 显式调用 requestUpdate() 方法:
    如果需要手动触发更新(例如某些非响应式数据变化),可以调用 requestUpdate()。

异步更新和批量处理:

  1. 当响应式属性发生变化时,更新不会立即进行,而是等待完成所有可能的属性修改后再批量执行。
  2. 在更新周期开始前,所有响应式属性的最终值都会被收集,确保一个更新周期处理所有变化。

更新发生在微任务时间,这意味着它们发生在浏览器将下一帧绘制到屏幕之前。

每当组件的更新完成并且元素的DOM被更新和呈现时调用 updated 方法,我们可以处理一些自定义的逻辑

1
2
3
4
5
updated(changedProperties: Map<string, any>) {
if (changedProperties.has('collapsed')) {
this._measureDOM();
}
}

更多详细内容可以查看 Lit 官网

该文章内容在文章 Nestjs 和 Prisma 实现 Restful Api的基础上实现,如有需要,请先搜索查看前面的文章

第三章

给数据库添加 User 模型

用户与文章是一对多的关系,我们可以自己填写完整的依赖关系,也可以利用 prisma 插件帮助我们生成,更新 prisma/schema.prisma 文件, 新增以下内容

1
2
3
4
5
6
7
8
9
10
11
model User {
id Int @id @default(autoincrement())
name String?
email String @unique
password String
createAt DateTime @default(now())
updateAt DateTime @updatedAt
articles Article[]

@@map("user")
}

保存文件之后,prisma 插件会自动帮我生成 Article model 内对应的字段,内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
model Article {
id Int @id @default(autoincrement())
title String @unique
description String?
body String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User? @relation(fields: [userId], references: [id])
userId Int?

@@map("article")
}

可以看到 Article model 新增了 User和userId 字段, 它们是可选的,也就是说我们可以创建没有作者的文章。我们可以修改一下字段的名称,最终结果如下:

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
// prisma/schema.prisma

// ...

model Article {
id Int @id @default(autoincrement())
title String @unique
description String?
body String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
author User? @relation(fields: [authorId], references: [id])
authorId Int?
}

model User {
id Int @id @default(autoincrement())
name String?
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
articles Article[]
}

要将更改应用到数据库,可以运行 prisma migrate dev

1
npx prisma migrate dev --name "add-user-model"

访问本地 http://localhost:8080/ 通过 Adminer 查看 user 表已经添加成功

更新 seed 脚本文件

seed 脚本负责用虚拟数据填充数据库,更新脚本以在数据库中创建几个用户。

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// 初始化 Prisma Client
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
const user1 = await prisma.user.upsert({
where: { email: 'sabin@adams.com' },
update: {},
create: {
email: 'sabin@adams.com',
name: 'Sabin Adams',
password: 'password-sabin',
},
});
console.log(user1);

const user2 = await prisma.user.upsert({
where: { email: 'alex@ruheni.com' },
update: {},
create: {
email: 'alex@ruheni.com',
name: 'Alex Ruheni',
password: 'password-alex',
},
});
console.log(user2);

// 创建2个虚拟文章
const post1 = await prisma.article.upsert({
where: { title: 'Prisma Adds Support for MongoDB' },
update: {
authorId: user1.id,
},
create: {
title: 'Prisma Adds Support for MongoDB',
body: 'Support for MongoDB has been one of the most requested features since the initial release of...',
description:
"We are excited to share that today's Prisma ORM release adds stable support for MongoDB!",
published: false,
authorId: user1.id,
},
});
console.log(post1);

// upsert:用于创建或更新,确保在满足 where 条件时更新,否则创建新记录。
const post2 = await prisma.article.upsert({
where: { title: "What's new in Prisma? (Q1/22)" },
update: {
authorId: user2.id,
},
create: {
title: "What's new in Prisma? (Q1/22)",
body: 'Our engineers have been working hard, issuing new releases with many improvements...',
description:
'Learn about everything in the Prisma ecosystem and community from January to March 2022.',
published: true,
authorId: user2.id,
},
});
console.log(post2);

const post3 = await prisma.article.upsert({
where: { title: 'Prisma Client Just Became a Lot More Flexible' },
update: {},
create: {
title: 'Prisma Client Just Became a Lot More Flexible',
body: 'Prisma Client extensions provide a powerful new way to add functionality to Prisma in a type-safe manner...',
description:
'This article will explore various ways you can use Prisma Client extensions to add custom functionality to Prisma Client..',
published: true,
},
});
console.log(post3);
}

// 执行 main 函数
main()
.catch((e) => {
console.log(e);
// 非正常退出进程,不会执行后续任何代码
process.exit(1);
})
.finally(async () => {
// 关闭 Prisma Client
await prisma.$disconnect();
});

执行 seed 脚本,生成数据

1
npx prisma db seed

向ArticleEntity添加一个authorId字段

运行迁移后,运行项目,你可能会注意到一个新的TypeScript错误。ArticleEntity类实现了Prisma生成的Article类型。Article类型有一个新的authorId字段,但是ArticleEntity类没有定义这个字段。TypeScript识别出了这种类型的不匹配,并抛出了一个错误。您可以通过将authorId字段添加到ArticleEntity类来修复此错误

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
// src/articles/entities/article.entity.ts

import { Article } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';

export class ArticleEntity implements Article {
@ApiProperty()
id: number;

@ApiProperty()
title: string;

@ApiProperty({ required: false, nullable: true })
description: string | null;

@ApiProperty()
body: string;

@ApiProperty()
published: boolean;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;

@ApiProperty({ required: false, nullable: true })
authorId: number | null;
}

实现 User 模块的 CRUD

在本节中,我们将实现 User 模块的 rest api,可以对数据库进行 crud。

生成 user 模块的 rest 资源文件

使用下面的命令自动生成文件:

1
npx nest generate resource

跟随 cli 提示,选择对应的功能,

  1. What name would you like to use for this resource (plural, e.g., “users”)? users
  2. What transport layer do you use? REST API
  3. Would you like to generate CRUD entry points? Yes

现在你应该看到 src/users 文件夹了,生成了对应的资源文件。

将 PrismaClient 添加到 User 模块

1
2
3
4
5
6
7
8
9
10
11
12
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { PrismaModule } from 'src/prisma/prisma.module';

@Module({
controllers: [UsersController],
providers: [UsersService],
imports: [PrismaModule],
})
export class UsersModule {}

现在你可以在UsersService中注入PrismaService,并使用它来访问数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/users/users.service.ts

import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}

// CRUD operations
}

定义 User 模块的 entity 和 DTO class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/users/entities/user.entity.ts
import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';

export class UserEntity implements User {
@ApiProperty()
id: number;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;

@ApiProperty()
name: string;

@ApiProperty()
email: string;

password: string;
}

@ApiProperty装饰器用于使属性对Swagger可见。注意,您没有将@ApiProperty装饰器添加到密码字段。这是因为该字段很敏感,您不希望在API中公开它。

DTO(数据传输对象)是一个定义如何通过网络发送数据的对象。您将需要实现CreateUserDto和UpdateUserDto类,分别定义在创建和更新用户时将发送给API的数据。在create-user.dto中定义CreateUserDto类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/users/dto/create-user.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
@IsString()
@IsNotEmpty()
@ApiProperty()
name: string;

@IsString()
@IsNotEmpty()
@ApiProperty()
email: string;

@IsString()
@IsNotEmpty()
@MinLength(6)
@ApiProperty()
password: string;
}

定义 UserService class

完善 UserService class 内部 create(), findAll(), findOne(), update() and remove() 的方法。

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
// src/users/users.service.ts

import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PrismaService } from 'src/prisma/prisma.service';

@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}

create(createUserDto: CreateUserDto) {
return this.prisma.user.create({ data: createUserDto });
}

findAll() {
return this.prisma.user.findMany();
}

findOne(id: number) {
return this.prisma.user.findUnique({ where: { id } });
}

update(id: number, updateUserDto: UpdateUserDto) {
return this.prisma.user.update({ where: { id }, data: updateUserDto });
}

remove(id: number) {
return this.prisma.user.delete({ where: { id } });
}
}

定义 UserController class

UsersController负责处理客户端的请求和响应。它将利用UsersService来访问数据库,利用UserEntity来定义响应体,利用CreateUserDto和UpdateUserDto来定义请求体。

下面我们来完善下面5个接口:

  • create() - POST /users
  • findAll() - GET /users
  • findOne() - GET /users/:id
  • update() - PATCH /users/:id
  • remove() - DELETE /users/: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
// src/users/users.controller.ts

import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { UserEntity } from './entities/user.entity';

@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post()
@ApiCreatedResponse({ type: UserEntity })
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}

@Get()
@ApiOkResponse({ type: UserEntity, isArray: true })
findAll() {
return this.usersService.findAll();
}

@Get(':id')
@ApiOkResponse({ type: UserEntity })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.usersService.findOne(id);
}

@Patch(':id')
@ApiCreatedResponse({ type: UserEntity })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return this.usersService.update(id, updateUserDto);
}

@Delete(':id')
@ApiOkResponse({ type: UserEntity })
remove(@Param('id', ParseIntPipe) id: number) {
return this.usersService.remove(id);
}
}

从响应体中排除密码字段

当我们查询某个用户的时候,响应体将用户的 password 也返回了,这是不符合实际需求的。

有2种方法可以修复这个问题:

  1. 在控制器路由处理程序中手动从响应体中删除密码
  2. 使用拦截器自动从响应体中删除密码

第一种方法很容易出错,所以我们将学习如何使用拦截器

使用 ClassSerializerInterceptor 从响应体中删除字段

NestJS有一个内置的ClassSerializerInterceptor,可以用来转换对象。您将使用这个拦截器从响应对象中删除密码字段。

首先更新 main.ts 全局启用ClassSerializerInterceptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/main.ts

import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}
bootstrap();

ClassSerializerInterceptor 使用类转换器包来定义如何转换对象。使用 @Exclude() 装饰器来排除UserEntity类中的password字段:

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
// src/users/entities/user.entity.ts

import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';
import { Exclude } from 'class-transformer';

export class UserEntity implements User {
@ApiProperty()
id: number;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;

@ApiProperty()
name: string;

@ApiProperty()
email: string;

@Exclude()
password: string;
}

再次查询用户详情,你会发现 password 字段依旧被返回了。这是因为,当前控制器中的路由处理程序返回由Prisma Client生成的User类型。ClassSerializerInterceptor只适用于用@Exclude()装饰器装饰的类。在本例中,它是UserEntity类。所以,你需要更新路由处理程序来返回UserEntity类型。

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
// src/users/entities/user.entity.ts

import { ApiProperty } from '@nestjs/swagger';
import { User } from '@prisma/client';
import { Exclude } from 'class-transformer';

export class UserEntity implements User {
constructor(partial: Partial<UserEntity>) {
Object.assign(this, partial);
}

@ApiProperty()
id: number;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;

@ApiProperty()
name: string;

@ApiProperty()
email: string;

@Exclude()
password: string;
}

构造函数接受一个对象,并使用 object.assign() 方法将部分对象的属性复制到UserEntity实例。partial 的类型是 partial。这意味着部分对象可以包含 UserEntity 类中定义的属性的任何子集。

下一步,更新 UserController 路由处理程序,返回 UserEntity 而不是 Prisma.User。

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
// src/users/users.controller.ts

@Controller('users')
@ApiTags('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post()
@ApiCreatedResponse({ type: UserEntity })
async create(@Body() createUserDto: CreateUserDto) {
return new UserEntity(await this.usersService.create(createUserDto));
}

@Get()
@ApiOkResponse({ type: UserEntity, isArray: true })
async findAll() {
const users = await this.usersService.findAll();
return users.map((user) => new UserEntity(user));
}

@Get(':id')
@ApiOkResponse({ type: UserEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.findOne(id));
}

@Patch(':id')
@ApiCreatedResponse({ type: UserEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateUserDto: UpdateUserDto,
) {
return new UserEntity(await this.usersService.update(id, updateUserDto));
}

@Delete(':id')
@ApiOkResponse({ type: UserEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new UserEntity(await this.usersService.remove(id));
}
}

再次查询用户详情,发现 password 字段已经不再返回了。

将作者连同文章一起返回

前面我们已经实现查询文章的接口,只需要简单修改一下就能将关联的作者信息返回了。

1
2
3
4
5
6
7
8
9
10
// src/articles/articles.service.ts

findOne(id: number) {
return this.prisma.article.findUnique({
where: { id },
include: {
author: true,
},
});
}

现在文章关联的作者信息也返回出来了,但是用户信息里携带了 password, 这个问题跟前面的问题类似。首先修改 ArticleEntity,将 author 返回改为 UserEntity。(这个 UserEntity 前面我们已经使用拦截器去除了 password 字段)。然后修改 ArticlesController 将返回从 prisma.article 改成 ArticleEntity。

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
// src/articles/entities/article.entity.ts

import { Article } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';
import { UserEntity } from 'src/users/entities/user.entity';

export class ArticleEntity implements Article {
@ApiProperty()
id: number;

@ApiProperty()
title: string;

@ApiProperty({ required: false, nullable: true })
description: string | null;

@ApiProperty()
body: string;

@ApiProperty()
published: boolean;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;

@ApiProperty({ required: false, nullable: true })
authorId: number | null;

@ApiProperty({ required: false, type: UserEntity })
author?: UserEntity;

constructor({ author, ...data }: Partial<ArticleEntity>) {
Object.assign(this, data);

if (author) {
this.author = new UserEntity(author);
}
}
}
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
// src/articles/articles.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
} from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ArticleEntity } from './entities/article.entity';

@Controller('articles')
@ApiTags('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}

@Post()
@ApiCreatedResponse({ type: ArticleEntity })
async create(@Body() createArticleDto: CreateArticleDto) {
return new ArticleEntity(
await this.articlesService.create(createArticleDto),
);
}

@Get()
@ApiOkResponse({ type: ArticleEntity, isArray: true })
async findAll() {
const articles = await this.articlesService.findAll();
return articles.map((article) => new ArticleEntity(article));
}

@Get('drafts')
@ApiOkResponse({ type: ArticleEntity, isArray: true })
async findDrafts() {
const drafts = await this.articlesService.findDrafts();
return drafts.map((draft) => new ArticleEntity(draft));
}

@Get(':id')
@ApiOkResponse({ type: ArticleEntity })
async findOne(@Param('id', ParseIntPipe) id: number) {
return new ArticleEntity(await this.articlesService.findOne(id));
}

@Patch(':id')
@ApiCreatedResponse({ type: ArticleEntity })
async update(
@Param('id', ParseIntPipe) id: number,
@Body() updateArticleDto: UpdateArticleDto,
) {
return new ArticleEntity(
await this.articlesService.update(id, updateArticleDto),
);
}

@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
async remove(@Param('id', ParseIntPipe) id: number) {
return new ArticleEntity(await this.articlesService.remove(id));
}
}

参考资料 Building a REST API with NestJS and Prisma: Handling Relational Data

该文章内容在文章 Nestjs 和 Prisma 实现 Restful Api的基础上实现,如有需要,请先搜索查看基础文章

第二章

参数验证与转换

为了执行输入验证,您将使用NestJS Pipes。管道对路由处理程序正在处理的参数进行操作。Nest在路由处理程序之前调用一个管道,该管道接收用于路由处理程序的参数。管道可以做很多事情,比如验证输入、向输入添加字段等等。管道类似于中间件,但管道的作用域仅限于处理输入参数。NestJS提供了一些开箱即用的管道,但是您也可以创建自己的管道

管道有两个典型的用例:

  • 验证:评估输入的数据,如果有效,则不加修改地传递;否则,当数据不正确时抛出异常。
  • 转换:将输入数据转换为所需的形式(例如,从字符串转换为整数)。

全局设置ValidationPipe

在 NestJS 中,可以使用内置的 ValidationPipe 来进行输入验证。ValidationPipe 提供了一种便捷的方法,能够强制对所有来自客户端的请求数据进行验证。验证规则通过 class-validator 包的装饰器来声明,装饰器用于定义 DTO 类中的验证规则,以确保传入的数据符合预期的格式和类型。
首先我们需要安装2个库

1
pnpm install class-validator class-transformer

main.ts 引入 ValidationPipe,然后使用 app.useGlobalPipes

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

app.useGlobalPipes(new ValidationPipe());

const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}
bootstrap();

向CreateArticleDto添加验证规则

使用 class-validator 库给 CreateArticleDto 添加验证装饰器。

打开 src/articles/dto/create-article.dto.ts 文件,替换成以下内容:

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
// src/articles/dto/create-article.dto.ts

import { ApiProperty } from '@nestjs/swagger';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';

export class CreateArticleDto {
@IsString()
@IsNotEmpty()
@MinLength(5)
@ApiProperty()
title: string;

@IsString()
@IsOptional() // 字段可以不存在
@IsNotEmpty() // 如果存在不能为空
@MaxLength(300)
@ApiProperty({ required: false })
description?: string;

@IsString()
@IsNotEmpty()
@ApiProperty()
body: string;

@IsBoolean()
@IsOptional()
@ApiProperty({ required: false, default: false })
published?: boolean = false;
}

articles.service 中 create 方法接受的参数就是 CreateArticleDto 类型,我们来新增一篇文章来测试一下。

1
2
3
4
5
6
{
"title": "Temp",
"description": "Learn about input validation",
"body": "Input validation is...",
"published": false
}

接口返回的内容如下:

1
2
3
4
5
6
7
{
"message": [
"title must be longer than or equal to 5 characters"
],
"error": "Bad Request",
"statusCode": 400
}

这说明添加的验证规则生效了。客户端发起了一个 post 请求,ValidationPipe 验证参数未通过直接就返回给前端了,并没有到后续的路由。

从客户端请求中删除不必要的属性

如果我们在新建文章的时候加入 CreateArticleDto 中未定义的其他的属性,也是能新增成功的。但这很有可能会造成错误,比如新增下面这样的数据

1
2
3
4
5
6
7
8
{
"title": "example-title",
"description": "example-description",
"body": "example-body",
"published": true,
"createdAt": "2010-06-08T18:20:29.309Z",
"updatedAt": "2021-06-02T18:20:29.310Z"
}

通常来说,新增文件的 createdAt 是 ORM 自动生成的当前时间,updateAt 也是自动生成的。当我们传递的额外参数通过了校验,这是非常危险的,所幸 Nestjs 为我们提供了白名单的机制,只需要在初始化 ValidationPipe 的时候加上 ** whitelist:true ** 就可以了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

// 新增 whitelist: true
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));

const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}
bootstrap();

现在 ValidationPipe 将自动删除所有非白名单属性,也就是没有添加验证装饰器的额外参数都会被删除。

使用 ParseIntPipe 转换动态URL路径参数

目前我们的 api 接口中有很多利用到了路径参数,例如 GET /articels/:id, 路径参数获取到是 string 类型,我们需要转为 number 类型,通过 id 查询数据库。

1
2
3
4
5
6
7
// src/articles/articles.controller.ts

@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
remove(@Param('id') id: string) { // id 被解析成 string 类型
return this.articlesService.remove(+id); // +id 将id转为 number 类型
}

由于id被定义为字符串类型,因此Swagger API在生成的API文档中也将此参数作为字符串记录。这是不直观和不正确的。

使用 Nestjs 内置的 ParseIntPipe 管道可以在路由处理之前,拦截字符串参数并转化为整数,并且修正 Swagger 文档的参数类型。

修改我们的控制器代码

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
// src/articles/articles.controller.ts

import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
NotFoundException,
ParseIntPipe,
} from '@nestjs/common';

export class ArticlesController {
// ...

@Get(':id')
@ApiOkResponse({ type: ArticleEntity })
findOne(@Param('id', ParseIntPipe) id: number) {
return this.articlesService.findOne(id);
}

@Patch(':id')
@ApiCreatedResponse({ type: ArticleEntity })
update(
@Param('id', ParseIntPipe) id: number,
@Body() updateArticleDto: UpdateArticleDto,
) {
return this.articlesService.update(id, updateArticleDto);
}

@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
remove(@Param('id', ParseIntPipe) id: number) {
return this.articlesService.remove(id);
}
}

刷新 Swagger 接口文档,id 参数修改成了 number 类型。

通过文章的学习,我们完成了以下功能:

  • 集成 ValidationPipe 来验证参数类型
  • 去除不必要的额外参数
  • 使用 ParseIntPipe 将 string 转化成 number 类型

Nestjs 和 Prisma 实现 Restful Api

技术:Nestjs、Prisma、PostgresSQL、Swagger、TypeScript

前提条件

  • Node 18 +
  • Docker + PostgresSQL
  • Prisma 插件,安装在 VSCode 或者 WebStorm
  • Linux 或 macOS shell 终端

(windows 机器的终端命令可能有所不同,需要自行修改)

创建 Nestjs 项目

1
npx @nestjs/cli new median

建议使用 pnpm 安装管理依赖

安装依赖

1
pnpm install

运行

1
pnpm start:dev

访问 http://localhost:3000/ 可以看到 ‘Hello World!’

创建 PostgresSQL 数据库

在项目根目录创建 touch docker-compose.yml 文件

1
touch docker-compose.yml

配置 docker-compose 内容,参考配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
services:
db:
image: postgres
restart: always
environment:
- POSTGRES_DB=mydb
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypassword
volumes:
- postgres:/var/lib/postgresql/data
ports:
- '5432:5432'
adminer:
image: adminer
restart: always
ports:
- 8080:8080
volumes:
postgres:

启动你的 Docker Desktop, 在项目根目录终端运行以下命令

1
docker-compose up -d

-d 选项,保证在你关闭终端之后,容器在后台持续运行

安装 Prisma

1
pnpm install -D prisma

初始化 prisma,请在终端运行以下命令

1
npx prisma init

之后在你的项目根目录,会创建一个 prisma 目录,里面包含一个 schema.prisma 文件,此外还会生成一个 .env 文件

修改 .env 文件,配置 PostgresSQL 连接

1
DATABASE_URL="postgresql://myuser:mypassword@localhost:5432/mydb?schema=public"

理解 prisma schema

打开 prisma/schema.prisma 文件,内容如下

1
2
3
4
5
6
7
8
generator client {
provider = "prisma-client-js"
}

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

这个文件是用Prisma模式语言编写的,Prisma使用这种语言来定义数据库模式。Prisma文件有三个主要部分:

  • Data source(数据源):指定您的数据库连接。上面的配置意味着您的数据库提供程序是PostgresSQL,数据库连接字符串在DATABASE_URL环境变量中可用
  • Generator(生成器):指示您想要为数据库生成一个类型安全的查询生成器Prisma Client。它用于向数据库发送查询。
  • Data model(数据模型):定义数据库模型。每个模型将被映射到底层数据库中的一个表。现在您的模式中还没有模型,您将在下一节中探索这一部分

数据模型

在 prisma/schema.prisma 文件中添加以下内容

1
2
3
4
5
6
7
8
9
10
11
model Article {
id Int @id @default(autoincrement())
title String @unique
description String?
body String
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@map("article")
}

模型定义了一个名为 Article 的表,包含以下字段:
• id: 主键,自动递增的整型。
• title: 唯一的字符串字段,用于存储文章标题。
• description: 可选的字符串字段,用于存储文章描述。
• body: 字符串字段,存储文章的主体内容。
• published: 布尔值,默认值为 false,指示文章是否已发布。
• createdAt: 日期时间字段,默认值为当前时间,指示创建时间。
• updatedAt: 日期时间字段,自动更新为当前时间,指示最后更新时间。

数据库映射:

  • @@map(“article”) 用于将 Prisma 模型映射到数据库中的 article 表。这在数据库中表名与模型名不同时非常有用。

tips:每次更改模型后,确保重新生成 Prisma 客户端,以便更新你的客户端代码:

1
npx prisma generate

迁移数据库

定义了Prisma模式后,您将运行迁移以在数据库中创建实际的表。要生成并执行第一次迁移,请在终端运行以下命令:

1
npx prisma migrate dev --name "init"

命令做了下面三件事:

  • 保存迁移:Prisma Migrate将获取模式的快照,并找出执行迁移所需的SQL命令。Prisma将把包含SQL命令的迁移文件保存到新创建的Prisma
    /migrations文件夹中。
  • 执行迁移:Prisma Migrate将执行迁移文件中的SQL,以在数据库中创建基础表。
  • 生成Prisma客户端:Prisma将根据您的最新架构生成Prisma客户端。由于没有安装Client库,因此CLI也将为您安装它。您应该在包的依赖项中看到@prisma/client包。json文件。Prisma Client是一个从你的Prisma模式自动生成的TypeScript查询生成器。它是为您的Prisma模式量身定制的,将用于向数据库发送查询。

在 prisma/migrations 目录下面可以找到 migration.sql 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- CreateTable
CREATE TABLE "article" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"body" TEXT NOT NULL,
"published" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "article_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "article_title_key" ON "article"("title");

为数据库插入数据

首先,我们需要创建一个脚本文件 seed.ts ,在数据库里添加一些数据

1
touch prisma/seed.ts

创建文件之后,在里面写入下面这些代码

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
import { PrismaClient } from '@prisma/client';

// 初始化 Prisma Client
const prisma = new PrismaClient();

async function main() {
// 创建2篇虚拟文章
const post1 = await prisma.article.create({
data: {
title: 'Prisma Adds Support for MongoDB',
body: 'Support for MongoDB has been one of the most requested features since the initial release of...',
description:
"We are excited to share that today's Prisma ORM release adds stable support for MongoDB!",
published: false,
},
});

// upsert:用于创建或更新,确保在满足 where 条件时更新,否则创建新记录。
const post2 = await prisma.article.upsert({
where: {
title: "What's new in Prisma ? (Q1 / 22)",
},
update: {},
create: {
title: "What's new in Prisma? (Q1/22)",
body: 'Our engineers have been working hard, issuing new releases with many improvements...',
description:
'Learn about everything in the Prisma ecosystem and community from January to March 2022.',
published: true,
},
});
console.log(post1, post2);
}

// 执行 main 函数
main()
.catch((e) => {
console.log(e);
// 非正常退出进程,不会执行后续任何代码
process.exit(1);
})
.finally(async () => {
// 关闭 Prisma Client
await prisma.$disconnect();
});

在项目根目录 package.json 里新增一个脚本命令

1
2
3
4
5
6
7
8
{
"scripts": {
// ...
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
}

然后手动执行以下命令执行种子脚本:

1
npx prisma db seed

在运行 prisma db push 或 prisma migrate dev 等命令时,可以自动执行种子脚本。

查看数据库

还记得前面我们配置 docker-compose.yml 文件吗?里面的 Adminer 现在可以派上用场了。

访问 http://localhost:8080/ 选择 PostgresSQL 数据库,输入 docker-compose.yml 中配置的数据库名称、用户名以及密码,就可以查看数据库了。现在我们可以看见里面新增了2篇文章。

创建一个 Nestjs Prisma Service

使用 Nestjs 内置的指令,快速创建一个 prisma 模块和服务,具体请参考 Nestjs 官网

1
2
nest g mo prisma
nest g s prisma

创建服务的时候可以也可以加上命令 –no-spec, 这样就不会生成对应的测试文件了。

1
nest g s prisma --no-spec

现在,在根目录下 /src/prisma 里会有2个文件:

  • prisma.module.ts
  • prisma.service.ts
1
2
3
4
5
6
// src/prisma/prisma.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient{}
1
2
3
4
5
6
7
8
9
// src/prisma/prisma.module.ts
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';

@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

安装 Swagger

1
pnpm install --save @nestjs/swagger swagger-ui-express

现在打开 main.ts 使用SwaggerModule类初始化Swagger

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

const config = new DocumentBuilder()
.setTitle('Median')
.setDescription('The Median API description')
.setVersion('0.1')
.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}

bootstrap();

重新运行应用,访问 http://localhost:3000/api 就能看到 Swagger 接口文档了。

实现文章的 CRUD

使用以下命令用于快速生成一个包含基本 CRUD 功能的资源模块。它会自动创建服务、控制器、DTOs(数据传输对象)等文件,帮助你快速搭建一个 RESTful API 或 GraphQL API

1
nest g resource

您将得到一些CLI提示。请回答以下问题:

  1. What name would you like to use for this resource (plural, e.g., “users”)? articles
  2. What transport layer do you use? REST API
  3. Would you like to generate CRUD entry points? Yes

现在你可以看到 src/articles 里面自动帮我们生成了一系列代码。

将 PrismaClient 添加到 Articles 模块

1
2
3
4
5
6
7
8
9
10
11
12
// src/articles/articles.module.ts
import { Module } from '@nestjs/common';
import { ArticlesService } from './articles.service';
import { ArticlesController } from './articles.controller';
import { PrismaModule } from '../prisma/prisma.module';

@Module({
controllers: [ArticlesController],
providers: [ArticlesService],
imports: [PrismaModule],
})
export class ArticlesModule {}

现在可以在ArticlesService中注入PrismaService,并使用它来访问数据库。要做到这一点,像这样添加一个构造函数到articles.service.ts:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/articles/articles.service.ts

import { Injectable } from '@nestjs/common';
import { CreateArticleDto } from './dto/create-article.dto';
import { UpdateArticleDto } from './dto/update-article.dto';
import { PrismaService } from 'src/prisma/prisma.service';

@Injectable()
export class ArticlesService {
constructor(private prisma: PrismaService) {}

// CRUD operations
}

实现 GET /articles 接口

使用 @Get() 设置接口为 get 请求

1
2
3
4
5
6
// src/articles/articles.controller.ts

@Get()
findAll() {
return this.articlesService.findAll();
}

实现查询所有文章的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/articles/articles.service.ts

@Injectable()
export class ArticlesService {
constructor(private prisma: PrismaService) {}

create(createArticleDto: CreateArticleDto) {
return 'This action adds a new article';
}

findAll() {
// return `This action returns all articles`;
return this.prisma.article.findMany({ where: { published: true } });
}

启动项目,访问 http://localhost:3000/articles 你可以看到成功的返回了一篇文章

实现 GET /articles/drafts 接口

Nestjs 不会帮我们自动生成查询所有未发布的文章的接口,我们需要自己动手实现它,非常的简单

新增接口

1
2
3
4
5
// src/articles/articles.controller.ts
@Get('drafts')
findDrafts() {
return this.articlesService.findDrafts();
}

实现查询逻辑

1
2
3
4
5
6
7
8
// src/articles/articles.service.ts
findDrafts() {
return this.prisma.article.findMany({
where: {
published: false,
},
});
}

访问 http://localhost:3000/articles/drafts 可以看到返回了一条未发布的文章

实现 GET /articles/:id 查询文章详情的接口

接口代码 Nestjs 已经为我们生成了,路由接受一个动态id参数,该参数传递给findOne控制器路由处理程序。由于Article模型有一个整数id字段,因此需要使用+运算符将id参数强制转换为一个数字。

路由参数获取到数据类型是 string,现在我们还没有学习 Nestjs 的管道操作,它可以帮助我们完成数据的校验和类型转换,暂时我们先这样模拟处理

1
2
3
4
5
6
// src/articles/articles.controller.ts

@Get(':id')
findOne(@Param('id') id: string) {
return this.articlesService.findOne(+id);
}

接下来我们来实现一下查询逻辑

1
2
3
4
5
findOne(id: number) {
return this.prisma.article.findUnique({
where: { id },
});
}

访问 http://localhost:3000/articles/1 可以看到查询到了一篇文章

实现 POST /articles 接口

新增接口 Nestjs 已经自动实现了

1
2
3
4
5
6
// src/articles/articles.controller.ts

@Post()
create(@Body() createArticleDto: CreateArticleDto) {
return this.articlesService.create(createArticleDto);
}

CreateArticleDto 是请求参数的类型,接下来我们完善字段的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { ApiProperty } from '@nestjs/swagger';

export class CreateArticleDto {
@ApiProperty()
title: string;

@ApiProperty({ required: false })
description?: string;

@ApiProperty()
body: string;

@ApiProperty({ required: false, default: false })
published: boolean = false;
}

通过使用 @ApiProperty 装饰器(来自 @nestjs/swagger),可以为 DTO 类中的属性提供描述信息。这些描述信息会被用于生成 Swagger 文档

实现新增文章的具体逻辑

1
2
3
4
5
6
// src/articles/articles.service.ts
create(createArticleDto: CreateArticleDto) {
return this.prisma.article.create({
data: createArticleDto,
});
}

实现 PATCH /articles/:id 更新文章的接口

Nestjs 已经为我们生成了接口代码

1
2
3
4
5
6
// src/articles/articles.controller.ts

@Patch(':id')
update(@Param('id') id: string, @Body() updateArticleDto: UpdateArticleDto) {
return this.articlesService.update(+id, updateArticleDto);
}

查看 UpdateArticleDto 类

1
2
3
4
import { PartialType } from '@nestjs/swagger';
import { CreateArticleDto } from './create-article.dto';

export class UpdateArticleDto extends PartialType(CreateArticleDto) {}

PartialType 用于生成一个新的类,其中所有的属性都继承自 CreateArticleDto,但都变成了可选字段,这样就不必手动设置每个属性的 ?

实现一下更新文章的逻辑

1
2
3
4
5
6
7
8
// src/articles/articles.service.ts
update(id: number, updateArticleDto: UpdateArticleDto) {
console.log(updateArticleDto);
return this.prisma.article.update({
where: { id },
data: updateArticleDto,
});
}

更新的时候必须传入 id 作为条件,如果数据库里找不到这条数据就会报错,我们暂时不用关注这个问题,在后续我们会学习如何处理错误。

实现 DELETE /articles/:id 删除文章接口

Nestjs 已经为我们生成了接口

1
2
3
4
5
6
// src/articles/articles.controller.ts

@Delete(':id')
remove(@Param('id') id: string) {
return this.articlesService.remove(+id);
}

实现一下删除的代码逻辑

1
2
3
4
5
6
// src/articles/articles.service.ts
remove(id: number) {
return this.prisma.article.delete({
where: { id },
});
}

将 Swagger 接口分支归类

在 NestJS 中,@ApiTags() 装饰器来自 @nestjs/swagger 包,用于为控制器的所有路由生成分组标签。这个标签会出现在 Swagger 文档的分组中,便于 API 使用者快速定位和查找相关的路由和接口。

1
2
3
4
5
6
7
8
9
// src/articles/articles.controller.ts

import { ApiTags } from '@nestjs/swagger';

@Controller('articles')
@ApiTags('articles')
export class ArticlesController {
// ...
}

更新Swagger响应类型

现在我们的 Swagger 接口文档还没有 Responses 响应类型的描述,因为 Swagger 不知道响应类型,我们需要用装饰器完善

首先,需要定义一个实体,Swagger可以使用它来标识返回的实体对象的形状。要做到这一点,在articles.entity.ts文件中更新ArticleEntity类如下:

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
// src/articles/entities/article.entity.ts

import { Article } from '@prisma/client';
import { ApiProperty } from '@nestjs/swagger';

export class ArticleEntity implements Article {
@ApiProperty()
id: number;

@ApiProperty()
title: string;

@ApiProperty({ required: false, nullable: true })
description: string | null;

@ApiProperty()
body: string;

@ApiProperty()
published: boolean;

@ApiProperty()
createdAt: Date;

@ApiProperty()
updatedAt: Date;
}

这是一个由Prisma客户端生成的Article类型的实现,每个属性都添加了@ApiProperty装饰器。现在,是时候用正确的响应类型注释控制器路由处理程序了。为此,NestJS有一组装饰器。”

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
// src/articles/articles.controller.ts

import { ApiCreatedResponse, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ArticleEntity } from './entities/article.entity';

@Controller('articles')
@ApiTags('articles')
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}

@Post()
@ApiCreatedResponse({ type: ArticleEntity })
create(@Body() createArticleDto: CreateArticleDto) {
return this.articlesService.create(createArticleDto);
}

@Get()
@ApiOkResponse({ type: ArticleEntity, isArray: true })
findAll() {
return this.articlesService.findAll();
}

@Get('drafts')
@ApiOkResponse({ type: ArticleEntity, isArray: true })
findDrafts() {
return this.articlesService.findDrafts();
}

@Get(':id')
@ApiOkResponse({ type: ArticleEntity })
findOne(@Param('id') id: string) {
return this.articlesService.findOne(+id);
}

@Patch(':id')
@ApiOkResponse({ type: ArticleEntity })
update(@Param('id') id: string, @Body() updateArticleDto: UpdateArticleDto) {
return this.articlesService.update(+id, updateArticleDto);
}

@Delete(':id')
@ApiOkResponse({ type: ArticleEntity })
remove(@Param('id') id: string) {
return this.articlesService.remove(+id);
}
}

相关装饰器的解释:

  • @ApiCreatedResponse({ type: ArticleEntity }):用于 POST /articles 路由,表示成功创建文章后返回 ArticleEntity 类型的数据,状态码为 201 Created。
  • @ApiOkResponse({ type: ArticleEntity }):用于 GET、PATCH 和 DELETE 路由,表示请求成功时返回 ArticleEntity 类型的数据,状态码为 200 OK。
  • isArray: true:用于 findAll() 和 findDrafts() 方法,表明返回的是 ArticleEntity 数组。

总结:我们实现了 Nestjs 搭配 prisma 操作数据库 CRUD 的 restful 接口,并且提供了 Swagger 接口文档。

介绍

2024-08-28 Rspack 1.0 正式发布了,Rspack 是基于 Rust 编写的下一代 JavaScript 打包工具, 兼容 webpack 的 API 和生态,并提供 10 倍于 webpack 的构建性能。Rsbuild是由 Rspack 驱动的构建工具。

简单来说 Rsbuild 就是让你用和 webpack 差不多的配置,更快的构建项目。它是字节推出的产品,说是内部的很多前端项目已经迁移到 Rspack。

使用 Rsbuild 构建项目

下面跟着官方的案例,使用 Rsbuild 快速创建一个项目

1
pnpm create rsbuild@latest

选择你熟悉的框架模版,比如 vue3。安装依赖之后运行 pnpm dev, 跑不起来了。

是的,你没有看错!官方给的模版不是开箱即用的,因为你没有配置 mode,想一想 webpack 的 config 你就会知道怎么回事了。

修改 rsbuild.config.mjs 文件, 新增 mode 配置。

1
2
3
export default defineConfig({
mode: 'development'
})

现在运行项目,可以运行起来了。

打造通用的 vue3 项目模版

构建前端项目框架的时候,经常接触的问题包括:开发模式配置、生产打包配置、环境变量设置、各种库和依赖、打包优化等等。

这些问题都可以从官方的目录指南找到答案,但是也许你不是 webpack 或者 vite 的熟练使用者。那么你可以直接查看我的项目 rsbuild-vue3

查看一个项目最快的方式,就是直接看 package.json 文件。

package.json

不难看出项目的一些依赖,包括:vue,vue-router,pinia,sass,element-plus,unoss,lint-staged,biome 等。

这些都是我根据以往的经验添加的一些依赖,当然你也可以自己添加一些,比如说 axios,这很简单,你完全可以轻松解决。

下载项目,安装依赖,运行 npm run start:dev, 如果运行成功,你会看到如下内容:

localhost:3002

样式有点丑,不过我们更在意的是功能。这里展示了 unocss, vue-router,pinia, vue-jsx 组件的使用。点击按钮触发 store 里面的 count++,点击 Go to About 触发 router-link 的页面跳转。很简单,你直接看源码的 src/app.vue 就行。

查看项目目录:

项目目录

  • dist: 项目打包输出的文件夹
  • envConfig: 环境变量配置
  • public: 公共文件,目前存放着 index.html 模版,后面在配置文件会用到
  • src/components: 公共组件目录,可以被 unplugin-vue-components 自动加载
  • src/pages: 路由页面
  • src/router: 路由配置
  • src/store: pinia store 的配置
  • styles: 样式文件
    • /element/index: element plus 自定义主题颜色的配置
    • /common: 公共样式
  • index.js: 入口文件
  • uno.css: unocss 样式文件
  • .biomelintrc-auto-import.json: unplugin-auto-import 生成的文件,防止 biome 校验不通过
  • biome.json: 帮助我们完成代码格式化与规则校验,相当于 prettier 与 eslint 的集合,具体可以查看我的文章 前端工具库-biome
  • package.json: …
  • postcss.config.mjs: postcss 配置,主要是使用 Unocss
  • rsbuild.config.mjs: rsbuild 配置文件,很重要,类似于 vite.config.js
  • uno.config.js: Unocss 配置,使用了 presetWind 预设,兼容 tailwind css 的语法

unplugin-auto-import,unplugin-vue-components 还没有接触过的朋友,可以去查看我的文章,使用vite搭建vue3项目,里面有相关使用说明。

unplugin 是一个适用于不同构建工具的统一插件系统, 查看 github 你可以发现它们适用于 webpack、vite、rsbuild 不同构建工具的文档。

具体配置分析

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
import { defineConfig } from '@rsbuild/core';
import { pluginBabel } from '@rsbuild/plugin-babel';
import { pluginImageCompress } from '@rsbuild/plugin-image-compress';
import { pluginSass } from '@rsbuild/plugin-sass';
import { pluginVue } from '@rsbuild/plugin-vue';
import { pluginVueJsx } from '@rsbuild/plugin-vue-jsx';
import AutoImport from 'unplugin-auto-import/rspack';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
import AutoComponents from 'unplugin-vue-components/rspack';

console.log('BASE_URL:', import.meta.env.BASE_URL);

export default defineConfig(({ env, command, envMode }) => {
console.log('env:', env);
console.log('command:', command);
console.log('envMode:', envMode);

return {
// root: './foo', 指定项目根目录, 默认为 process.cwd()
// mode 本项目通过 process.env.NODE_ENV 设置
plugins: [
// Vue 的 JSX 插件依赖 Babel 进行编译
pluginBabel({
include: /\.(?:jsx|tsx)$/,
}),
pluginVue(),
pluginVueJsx(), // 支持 jsx 语法
pluginSass(), // 支持 sass 语法
pluginImageCompress(), // 使用图片压缩
],
tools: {
rspack: {
plugins: [
AutoImport({
resolvers: [
ElementPlusResolver({
importStyle: 'scss',
}),
],
dts: false,
imports: ['vue', 'vue-router', 'pinia'],
biomelintrc: {
// 已存在文件设置默认 false,需要更新时再打开,防止每次更新都重新生成
enabled: false,
// 生成文件地址和名称
filepath: './.biomelintrc-auto-import.json', // Default `./.biomelintrc-auto-import.json`
},
}),
AutoComponents({
// 自动加载组件的目录配置,默认的为 'src/components'
dirs: ['src/components'],
// 组件支持的文件后缀名
extensions: ['vue', 'jsx', 'tsx'],
dts: false,
resolvers: [
ElementPlusResolver({
importStyle: 'scss',
}),
],
}),
],
},
},
source: {
entry: {
index: './src/index.js',
},
// 路径别名
alias: {
'@': './src',
},
},
output: {
target: 'web', // 默认 environment
polyfill: 'off', // 不需要兼容 IE 11
minify: true, // 默认在生产模式下压缩 js css
cleanDistPath: env === 'production',
// sourceMap: 使用默认配置,
},
dev: {
lazyCompilation: true, // 开发模式启动,按需编译
hmr: true, // 模块热更新,开发模式下默认启用
},
server: {
open: true,
port: 3002,
htmlFallback: 'index', // 默认情况下,当请求满足以下条件且未找到对应资源时,会回退到 index.html
proxy: {
'/api': 'http://localhost:3000',
},
},
html: {
// 设置页面 title
title: 'Rsbuild Vue3',
template: './public/index.html',
},
performance: {
// 代码分割配置
chunkSplit: {
strategy: 'split-by-experience',
// strategy: 'split-by-size',
// minSize: 30000,
// maxSize: 500000,
},
removeConsole: true, // 生产模式构建时,是否自动移除代码中所有的 console.[methodName]
bundleAnalyze: {}, // 开启分析产物体积,生成 ./dist/report-web.html 文件
},
};
});

上面是 rsbuild.config.mjs 的内容,这些官网都可以找到,我也是查找指南和配置一步步来修改的。中文文档很好,点赞!不多说,直接看备注,找官网和谷歌,很容易明白。项目基本上已经包含了日常使用的功能,简单修改一下就好。

之前我用 vite 打造了 vue3 的项目模版,它的打包速度大约是 8s 多。现在使用 rsbuild, 打包只要 1s 多。我使用的是 m2 pro 的 mac book pro 16寸,rsbuild 它真的很快。希望以后会更加繁荣,稳定。

node15及以下版本不支持 arm 芯片 mac

  • 安装 Rosetta,可以转译 运行在 x86 intel 的 mac 软件运行在 m1 芯片上。
1
softwareupdate --install-rosetta

输入 ‘A’ 同意协议,即可安装。

  • 在终端运行以下命令,以 Rosetta 方式运行终端。
1
arch -x86_64 zsh
  • 查看是否启用 Rosetta 运行终端
1
uname -m

输出 x86_64 为 Rosetta 2 下运行,arm64 为原生 Apple Silicon 。

  • 安装较低版本 node
1
nvm install 14.20

介绍

前端上传文件是项目中经常遇见的一个功能,但是如果文件太大了,我们就需要分片上传了,简单的说就是把文件切割成n个小片段,依次上传到服务器,最后再把这些片段拼接起来,组成完整的文件。

这里我不过多介绍服务端的逻辑,因为大部分上传文件的场景中后端是 oss 处理的,不需要我们写后端代码,大家大概知道个流程就好了。你需要掌握的基础知识包括, File 对象,Blob 对象, node express, stream 。

实现一个简单的上传文件的功能

首先,我们使用 node 写一个简单的后端服务,实现一个简单的文件上传功能。

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
// 普通的单文件上传
const express = require('express')
const multer = require('multer')
const path = require('path')
const fs = require('fs')
const cors = require('cors')

const app = express()

const PORT = 3000

app.use(cors())
app.use(express.static('public'))
app.use(express.json())
app.use(express.urlencoded({ extended: true }))

const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './uploads') // 项目的根目录需要 uploads 文件夹,不然会报错
},
filename: function (req, file, cb) {
// 解决中文乱码问题
file.originalname = Buffer.from(file.originalname, "latin1").toString(
"utf8"
);
cb(null, file.originalname)
}
})

const upload = multer({ storage: storage })

app.post('/upload', upload.single('file'), (req, res) => {
res.send('File uploaded successfully')
})

app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
})

这里使用 express 框架构架了一个简单的服务,需要注意的是代码中注释的内容,因为在我写测试代码的时候发现上传文件名称包含中文的文件时,到服务器端保存后文件名称会出现乱码。

谷歌一下之后发现是 Multer 这个库的问题,具体不多说明了,按照备注的代码操作就可以了。

前端代码实现:

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload</title>
</head>

<body>
<p>upload single file</p>
<input type="file" id="single-file">

<script>
// 单文件上传
document.getElementById('single-file').addEventListener('change', function (event) {
const file = event.target.files[0];

console.log(file);

const formData = new FormData()
formData.append('file', file)
fetch('http://localhost:3000/upload', {
method: 'POST',
body: formData
}).then(res => {
console.log(res);
})
})
</script>
</body>

</html>

前后端的代码实现都非常的简单,接下来我们来实现分片上传。

分片上传文件

前端需要把文件切割成多个小片段,当前片段数小于总片段数就递归上传。input 标签选中的 File 文件实现了 Blob 的接口,也就是可以使用 slice 方法切割二进制数据。File 对象的 size 属性则可以实现切割计算总的片段数。

前端代码如下:

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
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Upload</title>
</head>

<body>
<p>upload file chunks</p>
<input type="file" id="fileInput">

<script>
// 分片上传
document.getElementById('fileInput').addEventListener('change', function (event) {
const file = event.target.files[0];
if (file) {
uploadFile(file);
}
});

function uploadFile(file) {
const chunkSize = 1 * 1024; // 1KB
const totalChunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;

let progress = 0;

function uploadChunk(start) {
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('filename', file.name);
formData.append('chunkNumber', currentChunk);
formData.append('totalChunks', totalChunks);

fetch('http://localhost:9000/upload', {
method: 'POST',
body: formData
}).then(response => {
if (response.ok) {
currentChunk++;

progress = ((currentChunk / totalChunks) * 100).toFixed(2) + '%';
console.log('progress:',progress);

if (currentChunk < totalChunks) {
uploadChunk(currentChunk * chunkSize);
} else {
console.log('Upload complete');
}
} else {
console.error('Upload failed');
}
}).catch(error => {
console.error('Upload error', error);
});
}

uploadChunk(0);
}
</script>
</body>

</html>

这里需要注意就是 totalChunks 的计算需要使用 Math.ceil 向上舍入, end 的取值使用 Math.min, 这样就可以避免超过文件的长度。

服务端代码:

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
// 断点续传的后端 node 实现
const express = require('express');
const multer = require('multer');
const cors = require('cors')

const fs = require('fs');
const path = require('path');

const app = express();

app.use(cors()) // 允许跨域

// 配置存储选项
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// 如果没有 uplaods 文件夹,则创建文件夹
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
}
});

const upload = multer({ storage });

// 解析 JSON 和 URL 编码数据
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 处理文件上传
app.post('/upload', upload.single('file'), (req, res) => {
const { filename, chunkNumber, totalChunks } = req.body;
if (!filename || chunkNumber === undefined || totalChunks === undefined) {
return res.status(400).send('Filename, chunkNumber or totalChunks is missing');
}

const tempFilePath = path.join(__dirname, 'uploads', `${filename}.part${chunkNumber}`);
// 在将磁盘中上传的 原生 chunk 名称更改为 filename.part0
// xxxx 变更为 xxx.part0
fs.renameSync(req.file.path, tempFilePath);

// 合并文件逻辑
if (Number(chunkNumber) + 1 === Number(totalChunks)) {
const finalFilePath = path.join(__dirname, 'uploads', filename);
const writeStream = fs.createWriteStream(finalFilePath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(__dirname, 'uploads', `${filename}.part${i}`);
const data = fs.readFileSync(chunkPath); // 读取 chunk 文件
writeStream.write(data);
fs.unlinkSync(chunkPath); // 逐一删除 chunk 文件
}
writeStream.end();
}

res.send('Chunk uploaded successfully');
});

const PORT = 9000
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});

服务端主要是 createWriteStream 将文件片段拼接,然后删除片段文件。你可以通过断点调试理解这个步骤。

介绍

Electron是一个使用 JavaScript、HTML 和 CSS 构建桌面应用程序的框架。 嵌入 Chromium 和 Node.js 到 二进制的 Electron 允许您保持一个 JavaScript 代码代码库并创建 在Windows上运行的跨平台应用 macOS和Linux——不需要原生桌面端开发经验。

安装

1
npm install --save-dev electron

由于众所周知的原因,你可能安装不上依赖,此时可以设置镜像。

首先查看你的镜像源。

1
nrm ls

确保你使用的镜像源是 npm 源。

打开npm的配置文件:

1
npm config edit

在里面新增如下配置:

1
2
3
registry=https://registry.npmmirror.com
electron_mirror=https://cdn.npmmirror.com/binaries/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/

修改之后的配置

然后关闭窗口,重新安装依赖。

创建第一个应用程序

在 Electron 中,每个窗口展示一个页面,后者可以来自本地的 HTML,也可以来自远程 URL。 在本例中,您将会装载本地的文件。 在您项目的根目录中创建一个 index.html 文件,并写入下面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
<title>Hello from Electron renderer!</title>
</head>
<body>
<h1>Hello from Electron renderer!</h1>
<p>👋</p>
</body>
</html>

在根目录下面新增 index.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
const { app, BrowserWindow } = require('electron')

// createWindow() 函数将您的页面加载到新的 BrowserWindow 实例中
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600
})

win.loadFile('index.html')
}

// 在应用准备就绪时调用函数
app.whenReady().then(() => {
createWindow()
// 如果没有窗口打开则打开一个窗口 (macOS)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})

// 关闭所有窗口时退出应用 (Windows & Linux)
// 监听 app 模块的 window-all-closed 事件,并调用 app.quit() 来退出您的应用程序。此方法不适用于 macOS
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
  • app: 管理应用程序的事件生命周期。
  • BrowserWindow: 负责创建和管理应用窗口。

配置 package.json 的文件:

1
2
3
4
5
6
{
"scripts": {
"main": "index.js",
"start": "electron ."
}
}

现在运行 npm run start 就可以启动项目了。

使用预加载脚本

Electron 的主进程是一个拥有着完全操作系统访问权限的 Node.js 环境。 除了 Electron 模组 之外,您也可以访问 Node.js 内置模块 和所有通过 npm 安装的包。 另一方面,出于安全原因,渲染进程默认跑在网页页面上,而并非 Node.js里。

BrowserWindow 的预加载脚本运行在具有 HTML DOM 和 Node.js、Electron API 的有限子集访问权限的环境中。

预加载脚本在渲染器加载网页之前注入。 如果你想为渲染器添加需要特殊权限的功能,可以通过 contextBridge 接口定义 全局对象。

在项目的根目录新建一个 preload.js 文件。该脚本通过 versions 这一全局变量,将 Electron 的 process.versions 对象暴露给渲染器。

1
2
3
4
5
6
7
8
9
// preload.js
const { contextBridge } = require('electron')

contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron
// 除函数之外,我们也可以暴露变量
})

为了将脚本附在渲染进程上,在 BrowserWindow 构造器中使用 webPreferences.preload 传入脚本的路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// index.js
const { app, BrowserWindow } = require('electron')
// 新增下面这行代码
const path = require('node:path')

const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
// 新增
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

win.loadFile('index.html')
}

app.whenReady().then(() => {
createWindow()
})

在项目的根目录新增一个 renderer.js 文件,内容如下:

1
2
const information = document.getElementById('info')
information.innerText = `本应用正在使用 Chrome (v${versions.chrome()}), Node.js (v${versions.node()}), 和 Electron (v${versions.electron()})`

然后把 renderer.js 脚本插入到 index.html 文件的 body 标签后面。

1
<script src="./renderer.js"></script>

总结一下:我们在 createWindow 的时候把 preload.js 插入到了 html 文件中,通过 contextBridge 设置了全局对象,在渲染进程就能访问到我们设置的特殊全局属性 versions。

在进程之间通信(双向)

单向 IPC 消息从渲染器进程发送到主进程,您可以使用 ipcRenderer.send API 发送消息,然后使用 ipcMain.on API 接收。比较简单,请查看官网。

Electron 的主进程和渲染进程有着清楚的分工并且不可互换。 这代表着无论是从渲染进程直接访问 Node.js 接口,亦或者是从主进程访问 HTML 文档对象模型 (DOM),都是不可能的。

解决这一问题的方法是使用进程间通信 (IPC)。可以使用 Electron 的 ipcMain 模块和 ipcRenderer 模块来进行进程间通信。 为了从你的网页向主进程发送消息,你可以使用 ipcMain.handle 设置一个主进程处理程序(handler),然后在预处理脚本中暴露一个被称为 ipcRenderer.invoke 的函数来触发该处理程序(handler)。

我们将向渲染器添加一个叫做 ping() 的全局函数,该函数返回一个字符串 ‘pong’。

修改 preload.js 文件:

1
2
3
4
5
6
7
8
9
10
// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('versions', {
node: () => process.versions.node,
chrome: () => process.versions.chrome,
electron: () => process.versions.electron,
ping: () => ipcRenderer.invoke('ping') // 新增一个全局方法 versions.ping()
// 除函数之外,我们也可以暴露变量
})

在主进程中设置 handle 监听器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.js
const { app, BrowserWindow, ipcMain } = require('electron/main')
const path = require('node:path')

const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
// 监听渲染进程触发的方法,并返回一个字符串
ipcMain.handle('ping', () => 'pong')
createWindow()
})

在 renderer.js 中新增下面的内容:

1
2
3
4
5
6
7
8
// renderer.js
const func = async () => {
// 调用 'ping' 方法,并等待返回值
const response = await window.versions.ping()
console.log(response) // 打印 'pong'
}

func()

我们已经知道如何使用进程间的通信,如此一来就能在渲染进程,点击按钮触发主进程的相关操作。根据官网案例,增加案例复杂程度,设计一个可以切换白天/黑暗模式的桌面软件。

运行结果

代码仓库

主进程到渲染器进程

  1. 使用 webContents 模块发送消息
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
// index.js 省略部分代码
const createWindow = () => {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js')
}
})

// 使用 webContents.send() 来向渲染进程发送消息
const menu = Menu.buildFromTemplate([
{
label: app.name,
submenu: [
{
click: () => win.webContents.send('update-counter', 1),
label: 'Increment'
},
{
click: () => win.webContents.send('update-counter', -1),
label: 'Decrement'
}
]
}
])
Menu.setApplicationMenu(menu)

win.loadFile('index.html')
// 打开开发工具
win.webContents.openDevTools()
}
  1. 通过预加载脚本暴露 ipcRenderer.on
1