我知道这事有点画蛇添足,甚至有点蠢也有点蛋疼,但是我就是想试试。
用Nestjs做自己服务器的后端有点年头了,但是网页渲染这块也有些年头没有更新过了。虽说之前也是利用 react-dom/server 做React SSR, 但只是用到 renderToString。感觉比起Nextjs那种用Streaming 将网页一部分一部分地渲染出来的效果差点意思。正好最近突然有点时间折腾这玩意,所以就动手试试吧。
首先要说明的是,由于我自己用Nestjs写的后端程序使用多年,已经比较稳定,我不打算进行大规模重构。有鉴于此我打算只替换其中网页渲染的部分,所以我没有使用 create-next-app 来创建一个新的项目,而是直接安装依赖:
npm install next@14 react@18 react-dom@18
这里为什么只用nextjs 14,主要是因为我自己原来的网页渲染有依赖react-virtualized 而它使用的findDOMNode 方法要在React 19废弃,而且可爱的nextjs 15强制要求使用最新的React 19,所以这里只好暂时放弃最新版本,等以后再说吧。
安装好之后先创建一个目录用于Nextjs的相关的内容,我起名叫 Site ,但是由于Nextjs和Nestjs的typescript配置并不相同,所以先要在Nestjs的 tsconfig.json 上面动点手
{
"compilerOptions": {
.....
},
"exclude": ["node_modules", "dist", "**/*spec.ts", "site"]
}
这里将新创建的目录加到exclude,这样Nestjs编译时就不会和Nextjs工程搅到一起了。
接下来创建用于Handle Nextjs的module SiteModule.ts
@Module({
imports: [
... //其他需要的Module
],
providers: [
... //其他需要的Service
SiteService //用于管理Nextjs的Service
],
controllers: [
SiteController //用于管理Nextjs的Controller
]
})
export class SiteModule {}
管理Nextjs实例的服务SiteService.ts
import { parse } from 'node:url';
import { join } from 'node:path';
import { createReadStream } from 'node:fs';
import mime from 'mime-types';
import { FastifyRequest, FastifyReply } from 'fastify';
import next from 'next';
import type { NextServer } from 'next/dist/server/next';
export class SiteService {
#siteInst!: NextServer;
#sitePath!: string;
/**
* 启动一个Nextjs服务
* 需要注意的是,这个服务与本Nestjs是在同一个js context里面,并不是以一个子进程的方式存在
* @param sitePath Nextjs工程的根目录
*/
public async init(sitePath: string) {
const app = next({
customServer: true, // 由于不是以Nextjs cli的方式启动,所以这里设置为true
dev: process.env.NODE_ENV === 'development', // dev模式随环境变量而改变
dir: sitePath,
});
await app.prepare(); // 等待Nextjs就绪
this.#siteInst = app;
this.#sitePath = sitePath;
}
/**
* 让Nextjs 响应一个页面
* @param req FastifyRequest
* @param res FastifyReply
* @param extraData 额外需要从Nestjs交给Nextjs的数据
* @returns Promise 这里并不会返回任何数据, Nextjs会自己调用ServerResponse.end()方法
*/
public handlePage(req: FastifyRequest, res: FastifyReply, extraData?: Record) {
// 注意,这里不能从fastify层面设置header
// 因为后面Nextjs接管后续的渲染是使用ServerResponse
// 所以要直接给最终的ServerResponse对象设置header
res.raw.setHeader('Content-Security-Policy', `你的CSP设置,也可以不设置`);
const parsedUrl = parse(req.url, true); // 解析url
if (extraData) {
parsedUrl.query._extra = JSON.stringify(extraData);
}
//将解析后的url, 原始的ServerResponse, 原始的IncomingMessage 交给Nextjs进行渲染
//到这里基本没有Nestjs什么事了
return this.#siteInst.getRequestHandler()(req.raw, res.raw, parsedUrl);
}
/**
* 响应一个Nextjs里面页面资源(例如 _next目录下的那些)
* @param req FastifyRequest
* @param res FastifyReply
* @returns void 这里并不会返回任何数据, 根据请求路径直接获取对应文件并压缩后返回
*/
public handleAssets(req: FastifyRequest, res: FastifyReply) {
const parsedUrl = parse(req.url);
const { pathname } = parsedUrl; // 解析url
//如果需要压缩assets(例如:css, js) 就用这一段, 我这里使用的时fastify, 也可用express,不过貌似对http2的兼容性不太好
const filePath = join(this.#sitePath, pathname.replace('/_next', '/.next'));
const cType = mime.lookup(pathname);
cType && res.header('Content-Type', cType);
res.compress(createReadStream(filePath));
// 如果不需要压缩,或者httpServer不支持http2的可以直接交给Nextjs处理
// this.#siteInst.render(req.raw, res.raw, pathname!);
}
}
对应的控制器SiteController.ts
import {
Controller,
Get,
Res,
Req,
} from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { SiteService } from '../services/site.service';
/**
* 主控制器
* 这里选择这样写, 是为了让Nestjs有选择地将请求交由Nextjs处理, 非SSR请求(常规API call)由Nestjs会更高效和安全(Nestjs有自己地guard机制).
*/
@Controller('/')
export class SiteController {
constructor(
private readonly site: SiteService,
) {}
@Get('')
public index(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
// 这里选择交给Nextjs处理
this.site.handlePage(req, res);
}
@Get('blog')
public blog(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
// 这里选择交给Nextjs处理
this.site.handlePage(req, res);
}
@Get('_next*')
public nextAssets(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
//这里处理Nextjs相关的assets
this.site.handleAssets(req, res);
}
@Get('other-page')
public blog(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
// 这里依然可以选择由Nestjs处理请求
return { xx: 'xx' }
}
}
将SiteModule准备好后,记得要加到AppModule里面,我们在Nestjs的启动环节加点东西,然Nextjs服务在主服务启动前准备好,以响应主服务的请求。
const adapter = new FastifyAdapter({
http2: true,
https: {
allowHTTP1: true,
key, cert
},
});
const app = await NestFactory.create(
AppModule,
adapter
);
// ...挂载各种插件
const sitePath = resolve(__dirname, '../site'); //设定好Nextjs工程所在的位置,我这里是和Nestjs src平级
const siteService: SiteService = app.select(SiteModule).get(SiteService); //找到SiteService
await siteService.init(sitePath); //启动Nextjs服务
app.listen(443, '0.0.0.0'); //最后启动server
接下来要公开一个服务供Nextjs获取数据
我们先创建一个入口文件 GetDataService.ts
import app from '你的Nestjs服务实例';
import { DataService } from '你管理数据的服务';
import { SthModule } from 'DataService所在的Module'
export const GetDataServiceFromNestjs = () => app.select(SthModule).get(DataService);
之后我们为这个入口加一个declare GetDataService.d.ts
import type { DataService } from "你管理数据的服务";
declare global {
interface GetDataService {
GetDataServiceFromNestjs(): DataService
}
}
到这里Nestjs这边的准备工作就差不多了,接下来我们看看Nextjs这边
首先是工程基础的配置,由于我们没有使用create-next-app, 所以得自己来。
先将这三个文件拷到新建的site目录下
git ignore配置也可以参考官方的来。
https://github.com/vercel/next.js/blob/v14.2.18/packages/create-next-app/templates/app/ts/gitignore
搞定之后我们先将数据获取相关的函数配好
我们在site目录这里新建一个data.ts 用于调用从Nestjs公开的GetDataService.ts
/// <reference types="刚才创建的GetDataService.d.ts" />
async function getService() {
const entrance: GetDataService = await import(/* webpackIgnore: true */ `file://${process.cwd()}/dist/GetDataService.ts文件的位置`);
return entrance.GetDataServiceFromNestjs();
}
// Nextjs获取数据的方法
export async function getSomeData() {
const dataService = await getService();
return dataService.xxxxx();
}
选择这样写,是因为要将两个工程的代码严格隔离开来。因为双方的typescript配置不一致,很容易在类型检查的环节出问题(比如Nextjs这边是不怎么支持装饰器写法的)。再一个则是因为Nextjs编译后的包本身体积就不小,要是不小心将Nestjs那边的代码弄进去,最后包的体积会大的完全没道理,而且Nestjs也会编译自己的代码,没必要重复编译浪费资源。
ok, 页面所需的基础内容大体搞定,接下来可以参考官方教程写页面了
一般来说按照常规地Nestjs调试就可以了,比如
nest start --watch
但是这样每次有Nestjs代码修改,Nextjs的服务都会重启,所以如果只是调试Nextjs代码,只需要
nest start
就够了。
如果需要部署Nextjs,建议先把.next文件删除了再重新编译,这样生成出来的文件会小很多。