前言
前后端分离在目前的开发中大行其道。
传统的Java后端与Typescript前端的开发中,往往需要对后端返回的数据再创建一遍TS的类型,再封装对应的请求方法发送请求获取数据。
虽然现在有很多工具可以根据APi自动生成类型与请求方法,例如Antd Pro中的open-api插件,但还是不够完美,之前就在想,如果后端一样使用TS进行开发,是否可以将对后端的请求处理为一个函数呢?
例如后端创建一个create数据的函数。
export const createUser = (user: User) => {
// 数据库创建用户
return database.user.create(user)
}
前端不用再查看请求的URL、参数、返回值,而是直接调用后端创建的函数createUser
import { createUser } from '@server/userModel'
....
const Component = () => {
return (
<button onClick={() => createUser({...})>创建用户</button>
)
}
只要前后端类型是一致的那就可以直接看到函数的入参与返回值,字段的注释也可以直接使用JsDoc,不用通过注解(装饰器)来指定,比去翻api文档方便太多了。
Next.js
Getting Started: Project Structure | Next.js (nextjs.org)
Next.js被许多大型公司使用,通过扩展最新的React功能,您可以创建全栈Web应用程序,并集成强大的基于Rust的JavaScript工具以实现最快的构建。
首先锁定的技术就NextJs,因为不止一次在Next.js听到【全栈开发】这个词。
服务器组件与客户端组件
在Next13中,React组件分为客户端组件与服务器组件
- 客户端组件(client component):与普通的React组件一致可以使用基本的state等功能,运行在浏览器。
- 服务器组件(server component):没有state或者生命周期等功能只能输出基本的JSX,但是是在服务器的Node环境中运行。
例子:通过服务器组件的引入Node的方法,读取当前开发目录与内存使用率
'use server'
import fs from 'fs'
import { cwd } from 'process';
import React from 'react';
import os from 'os'
const ServerComponent: React.FC<React.PropsWithChildren> = ({ children }) =>{
const paths = fs.readdirSync(cwd())
return (
<>
<div>内存使用率{ os.freemem()/os.totalmem() }</div>
<div>
{paths.map(_ => <div key={_}>{ _ }</div>)}
</div>
{children}
</>
)
}
export default ServerComponent
当我们右键点击查看源代码可以看到,这些数据已经在返回的html中而不是通过前端代码渲染出来的,这就是常说的服务端渲染(Server Side Render)。
这种写法让我想起了以前学习JavaWeb时学习的JSP或者SpringBoot的Thymeleaf模板语法,这也是前后端分离出现之前Web最常规的开发方式。
那你可能会问了,这样和JSP有什么区别呢,服务端组件也只是一个基于JSX的模板语法罢了。
首先当然是同一种语言方便维护,使用JSP稍微复杂一点的页面就需要操作Dom或者Jquery来实现,增加维护成本。其次,服务端组件和客户端组件是可以进行嵌套的!虽然有一定的限制,但是相比传统模板语法灵活度非常的高,
JSP如果需要对数据进行更新删改就需要刷新页面,体验极差。ajax异步请求是前后端能够分离的重要因素。
Server Action
还记得开头提到的问题吗?Next.js中有一个完美的解决方案:Server Action
Server Functions:函数在服务器运行,但是可以在客户端调用
不过该方案目前还是实验性质的,只能简单先玩一玩。
将一个async函数中标记 'use server' 这个函数即为 server action
export const getServerMemo = async (str: string) => {
'use server'
return `${str}${os.totalmem()}`
}
客户端组件可以直接调用这个函数获取数据
const Button: FC<PropsWithChildren> = ({ children }) => {
const [memo, setMemo] = useState<string>()
useEffect(() => {
getServerMemo('内存为:').then(res => {
setMemo(res);
})
})
return <div>
<h1>{ memo }</h1>
</div>;
};
}
打开网络调试可以看到实现原理其实就是发送了一次HTTP请求,这也就意味了这个函数的入参与返回值必须是能被序列化的值,其他的例如函数就没办法作为入参,还是有一定的局限性。
tRPC
前端的同学可能对RPC比较陌生,在后端与大数据分布式架构中RPC起到了重要的作用
RPC【Remote Procedure Call】是指远程过程调用,是一种进程间通信的方式,它是一种技术的思想,而不是规范。它允许程序调用另一个地址空间(通常是共享网络的另一台机器上)的过程或函数,而不是程序员显式编码这个远程调用的细节。即程序员无论是调用本地的还是远程的函数,本质上编写的调用代码基本相同。
tRPC官网:trpc.io/
tRPC允许您轻松构建和使用完全类型安全的API,无需模式或代码生成。随着TypeScript和静态类型成为Web开发的最佳实践,API契约成为一个主要问题。我们需要更好的方式来静态类型化我们的API端点,并在客户端和服务器(或服务器之间)之间共享这些类型。我们致力于构建一个简单的库,用于构建类型安全的API,利用现代TypeScript的完整功能。
通过介绍可以看出,他的思想和NextJs新推出的Server Action差不多。如果我们直接使用Server Action为了项目的维护肯定也是要做一些封装的,那不如直接就用tRPC为我们定义好的功能与规范。
在github中的Star历史可以看到,2022年开始一年半的时间涨了超过20K的stars。
写一个简单的Demo
还是实现之前的读取服务器内存的功能
定义procedure
import { TRPCError, initTRPC } from "@trpc/server";
import os from "os";
// 初始化tRPC
const { procedure, router } = initTRPC.create();
// 创建路由
export const appRouter = router({
// 定义一个路由/过程
getServerMemo: procedure
// 可以在这里对输入进行效验,返回值类型就是客户端调用的入参类型
.input((value: unknown): string => {
if (typeof value === "string" && value.includes("内存")) {
// 返回类型为string
return value;
} else {
throw new TRPCError({
code: "BAD_REQUEST",
message: "输入内容包含 [内存] 的字符串!",
});
}
})
.query(async (opt) => {
// 这里的opt.input的类型被自动推断为string
return `${opt.input}${os.totalmem()}`;
}),
});
// 导出类型给客户端使用
export type AppRouter = typeof appRouter;
client调用
首先在客户端创建tRPC实例,指定数据传输使用 http://localhost:3000/api/trpc 这个URL
"use client";
import React, { FC, PropsWithChildren, useEffect, useState } from "react";
import "./index.css";
import { createTRPCNext } from "@trpc/next";
import { httpBatchLink } from "@trpc/client";
/**
* 这里我们可以看到导入的只有类型,并没有appRouter的实例
* 我们知道在TS编译为JS时这种类型是不会被编译的
*/
import type { AppRouter } from "../../server/demo";
// 创建客户端tRPC实例
export const trpc = createTRPCNext<AppRouter>({
config(opts) {
return {
links: [
// 我们使用http作为传输的载体
httpBatchLink({
url: `http://localhost:3000/api/trpc`,
}),
],
};
},
});
const Button: FC<PropsWithChildren> = ({ children }) => {
// tRPC/Next中集成了 react-query 帮我们封装好了hooks 方便调用
const { data } = trpc.getServerMemo.useQuery("内存为:");
return (
<div>
<h1>{data}</h1>
</div>
);
};
// 通过tRPC提供的HOC对组件进行包裹
export default trpc.withTRPC(Button);
前后端桥梁
此时我们访问 http://localhost:3000/api/trpc 其实还什么都没有,需要借助Next的api能力进行创建并对tRPC进行处理。
tRPC本质上还是发送的网络请求,所以需要一种协议来支持网络的传输,目前可以有websocket和http,这个是框架无关的,你可以自己通过node的http相关api来创建,也可以通过Next提供的api功能来创建。如果使用websocket,因为Next没有提供websocket能力所以需要自己启一个websocket服务器。
注意:这里创建api使用的是App文件目录,如果是老版本请参考pages的文件目录
在 app/api/trpc/[trpc] 中创建route.ts文件,定义api
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "../../../../server/demo";
// 处理请求
const handler = (request: Request, context: any) => {
return fetchRequestHandler({
endpoint: "/api/trpc",
req: request,
router: appRouter,
createContext: (opts) => ({}),
});
};
// 导出为POST和GET方法
// 详情请参考Next官方文档 https://nextjs.org/docs/app/building-your-application/routing/router-handlers
export const GET = handler;
export const POST = handler;
测试
首先看看正常的效果
完美!
再看看能不能正常校验类型,我们入参传入一个number看看TS是否会报错
如果入参内容不包含【内存】是否会报错
其他
除了procedure中除了可以定义query(查询数据)外,还可以定义mutation(用来改变数据,例如数据库的增删改操作)与subscription(类似websocket的长连接,但是传输协议也必须是websocket)。
同时tRPC也并不是和Next.js强绑定的,纯客户端渲染的项目也可以使用tRPC,只要前后端使用monorepo就可以使用。此外,tRPC也可以直接创建为Restful api服务器。
总结与扩展
对于小型应用来说这种开发方法是很不错的,相比于php等开发方法也更符合前端开发人员的习惯。
前端程序猿有时候也可以不仅限于对浏览器的钻研,Node的出现让我们可以把视野放的更长远了解更多领域的知识,在前端这些卷王的努力下,Node的地位相信也会越来越高,只有不断的学习才不会被时代给淘汰。
说到后端开发,就不得不提CRUD,现在Node的ORM框架生态也很丰富,例如Prisma,它对Typescript的支持也非常的好,Next.js+tRPC+Prisma 尝试了一下开发起来非常舒服。不用再手写sql语句,甚至不用定义实例类,Prisma都帮你搞定。 www.prisma.io/