本人平时做项目一般都基于Nest.js + React的前后端分离,之所以用这两个框架,
但是,前后端分离针对规模不大的项目来说,缺点也比较明显,就是效率不那么高, 那么问题来了,如果不前后端分离…
针对规模不大的项目,怎么在保留Nest.js和React优点情况下,做到前后端不那么分离呢?在Nest.js中使用React?没错!理论上,可以借助Next.js 把react当作Nest.js的渲染引擎。
希望最终控制器部分代码形如:
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@NextRender()
//函数名对应pages下的页面路径
index() {
//返回值为query参数
return {
hello :1
}
}
@NextRender()
['a/[...t]/[[...test]]'](@NextParam("t") t, @NextParam("test") test, @NextParam() allParam) {
return {
t,
test,
allParam
}
}
@NextRender()
['test2/abc']() {
return {
aa: 11
}
}
}
接下来,开始为实现这一目标踩坑吧~
先创建一个nest项目:
nest n nest-with-next
完成后,进到项目中:
cd nest-with-next
安装next、react、react-dom:
yarn add next react react-dom
在项目根目录创建pages文件夹(next默认页面文件夹,详情查看next文档),在pages中创建index.tsx
function Home() {
return (
<div>
Home Page
</div>
)
}
export default Home;
不出意外,vs code会提示tsconfig没配置支持jsx语法,这里注意了!!
next在dev模式下,如果tsconfig不满足next要求,会重写tsconfig,而重写的tsconfig又不满足nest的要求。
所以,先创建一个tsconfig.next.json文件, 再创建一个next.config.js,并进行一下配置:
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: false,
// distDir: "./public/next",
// basePath: "/home", //node
typescript: {
tsconfigPath: "./tsconfig.next.json"
}
}
之后再运行的话,next会自动填充tsconfig.next.json文件。
注意!!由于项目是以nest为主体,所以tsconfig.json本身也要支持下jsx语法,以免报错,在tsconfig.json补充以下配置:
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
nest怎么引入next呢?为规范代码,我们可以把next做成个nest模块,再引入这个模块,先创建next lib:
nest g lib next
此时,多了个libs/next文件夹
先做个next服务的provider,以便后面中间件引入(可参考nest官方文档了解更多关于provider、middleware的等知识):
next.provider.ts 核心代码如下:
export const createNextServer = (
nextServerOptions: NextServerOptions
): FactoryProvider<Promise<NextServer>> => ({
provide: NextServerToken,
useFactory: async () => {
//创建Next实例
const nextServer = Next(nextServerOptions);
await nextServer.prepare();
return nextServer;
},
})
next.module.ts 核心代码:
@Module({})
export class NextModule implements NestModule {
static forRoot(nextServerOptions: NextServerOptions): DynamicModule {
const nextServer = createNextServer(nextServerOptions)
return {
module: NextModule,
providers: [nextServer],
//NextHandlerController后续会讲
controllers: [NextHandlerConrtoller],
exports: [nextServer],
}
}
configure(consumer: MiddlewareConsumer): void {
consumer.apply(NextMiddleware).forRoutes('*')
}
}
next.middleware.ts 核心代码
@Injectable()
export class NextMiddleware
implements NestMiddleware<NextRequest, NextResponse> {
constructor(@Inject(NextServerToken) private nextServer: NextServer) {}
//方便上下文使用到nextServer
use(req: NextRequest, res: NextResponse, next: () => void): void {
res.nextServer = this.nextServer
res.nextRender = this.nextServer.render.bind(this.nextServer, req, res);
res.nextRequestHandler = this.nextServer.getRequestHandler();
next()
}
}
要注意的来了!!next除了一般的页面路由(和pages文件夹有关)外,还有些请求和.next文件夹有关,开始我还想过通过静态服务的方式开放这个文件夹,实验后发现实现困难,总会有些坑,后面想到可以通过nest支持的*通配符,把与页面url、api url等不匹配的都交给Next控制器处理,所以需要个NextHandlerController控制器:
next-handler.controller.ts核心代码:
@Controller("_next")
export class NextHandlerConrtoller {
@Get("*")
allHandler(@Req() req: NextRequest, @Res() res: NextResponse) {
console.log("next handler", req.url);
return res.nextRequestHandler(req, res)
}
}
之后可以在AppModule中引入NextModule
@Module({
imports: [
NextModule.forRoot({
dev: true,
}),
]
})
为了实现:
@NextRender()
['a/[...t]/[[...test]]'](@NextParam("t") t, @NextParam("test") test, @NextParam() allParam) {
return {
t,
test,
allParam
}
}
这种使用效果,要对next请求的url,结合pages/下的文件名解析,进行转换, 其中解析文件名算法使用了逆波兰的思想,代码如下:
/**
* 解析nextUrl
* @param nextUrl
* @returns
*/
export const nextUrlAnalysis = (nextUrl: string): {
key: string,
isParam: boolean,
optional: boolean
}[] => {
const splits = nextUrl.split('/');
return splits.map((item: string) => {
if (item[item.length - 1] !== "]") {
return {
key: item,
optional: false,
isParam: false,
}
}
let arr = item.split('');
let stack = [];
let isParam = false;
let optional = false;
let hasClose = false;
let key = "";
while (arr.length) {
if (arr[arr.length - 1] !== "[") {
if (arr[arr.length - 1] !== "]") {
stack.push(arr[arr.length - 1]);
}
} else {
while (stack.length) {
const temp = stack[stack.length - 1];
if (('0' <= temp && temp <= '9') ||
('a' <= temp && temp <= 'z') ||
('A' <= temp && temp <= 'Z') ||
['_', '$'].includes(temp)) {
key += temp;
} else {
if (!isParam) {
isParam = true;
}
}
stack.pop();
}
if (!hasClose) {
hasClose = true;
} else {
optional = true;
}
}
arr.pop();
}
return {
key,
optional,
isParam,
}
})
}
接下来根据文件名解析结果,转换成nest能接受的path,这一步需要做一个NextRender装饰器,用于处理Get,转换path,代码如下:
next-render.decorator.ts
export function NextRender() {
return applyDecorators(
function (target, key: string, descriptor) {
const items = nextUrlAnalysis(key);
let path: string = "/";
//生成nest Get请求支持的路径
for (let i = 0; i < items.length; i++) {
const item = items[i];
if(path.length === 0 || path[path.length - 1] !== "/") {
path += "/";
}
if(item.key === "index") {
if(i !== items.length - 1) {
path += item.key
}
}else if(item.optional) {
if(i !== items.length - 1) {
//可选参数只能放在最后一个/之后
throw new Error("next optional param must be at the tail of url")
}
path += "*";
}else if(item.isParam) {
path += `:${item.key}`;
}else {
path += item.key;
}
}
const requestMethod = RequestMethod.GET;
//Get请求相关
Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
Reflect.defineMetadata(METHOD_METADATA, requestMethod, descriptor.value);
//为后续如需参数解析,把先前文件名解析结果存下来
Reflect.defineMetadata(NEXT_URL_ITEMS_METADATA, items, descriptor.value);
return descriptor;
}
)
}
再做一个next参数解析的装饰器NextParam, 代码如下:
export const NextParam = createParamDecorator((key: string, ctx: ExecutionContext): any => {
const request: Request = ctx.switchToHttp().getRequest();
const root = Reflect.getMetadata(PATH_METADATA, ctx.getClass());
const path = Reflect.getMetadata(PATH_METADATA, ctx.getHandler());
const items = Reflect.getMetadata(NEXT_URL_ITEMS_METADATA, ctx.getHandler());
//简单处理下完整的nest url
const _url = root === "/" ? path : `/${root.replace('/', '')}${path}`;
//因为可以保证只有一个*,所以只需做如下处理
const regexp = pathToRegexp(_url.replace("*", "(.*)"));
const results = regexp.exec(request.url);
let paramIndex = 0;
const params: {[key: string]: string | string[]} = {};
//根据之前文件名解析的结果,解析url所带的参数
for (let i = 0; i < items.length; i++) {
const item = items[i];
if(item.isParam) {
paramIndex++;
}
if(item.isParam) {
if(item.optional) {
params[item.key] = results[paramIndex].split('/')
}else {
params[item.key] = results[paramIndex];
}
}
}
return key ? params[key] : params;
}
);
接下来做个interceptor,用于渲染next,代码如下:
next-render.interceptor.ts
@Injectable()
export class NextRenderInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> | Observable<any> {
const req: NextRequest = context.switchToHttp().getRequest();
const res: NextResponse = context.switchToHttp().getResponse();
return next.handle().pipe(map((data: any, message) => {
if(Reflect.getMetadata(NEXT_HANDLER, context.getClass())) {
//重要!! next非页面请求走这里
return data;
}else if(Reflect.hasMetadata(NEXT_URL_ITEMS_METADATA, context.getHandler())) {
//重要!! next页面请求走这里
const url = req.url?.includes("?") ?
req.url + '&' + json2url(data) :
req.url + '?' + json2url(data);
//组和了
let parsedUrl = parse(url, true);
let { pathname, query } = parsedUrl;
req.url = url;
return res.nextServer.render(req, res, pathname, query);
}else {
if(typeof data === "object" && "error" in data) {
return data["error"];
}
return {
code: 1,
msg: message || 'success',
data: data,
}
}
}));
}
}
注意, 对应的前端组件应该设置getInitialProps才能正常接收query参数
import { useRouter } from 'next/router'
const Index = () => {
const router = useRouter();
const { query } = router;
return (
<div>
Query: {JSON.stringify(query)}
</div>
);
};
Index.getInitialProps = async () => {
return {};
};
export default Index
更多注意事项
next build时的注意事项
tsconfig.build要添加:
"experimentalDecorators": true
虽然next build时不会把src里面的文件build进去,但是在build前的类型检查时会过不去(尝试过在exclude里加src,但不起作用)
按需加载相关:
LESS相关
网上搜的@zeit/less这个库已经不支持如今的next了,大家还是用sass/scss吧
可以引入antd的css文件,再自己写sass
按需加载相关:
安装babel-plugin-import
yarn add babel-plugin-import
在项目根目录创建.bashrc,可参考
{
"presets": ["next/babel"],
"plugins": [
[
"import",
{ "libraryName": "antd", "style": false }
],
[
"import",
{ "libraryName": "@ant-design/charts", "libraryDirectory": "es" },
//second name, 不然会报错, 可能import也是名字 不是关键字?这个没细究,待我后面学习这个插件
"import-ant-design-charts"
]
]
}
未完待续…