当前位置: 首页 > 工具软件 > T3.js > 使用案例 >

使用Next.js(React)作为Nest.js的模板引擎的踩坑之路

云洋
2023-12-01

本人平时做项目一般都基于Nest.js + React的前后端分离,之所以用这两个框架,

  • Nest.js的好处:写法类似Java的Spring, 对TypeScript的支持非常好,一方面可以一定程度规范代码,另一方面代码提示很友好,结合Typeorm,用起来很顺手,维护起来也比较方便;
  • React的好处,对TypeScript支持非常好,antd等大厂做的组件库很多都是针对React的,做项目开箱即用又不失灵活,减少做轮子成本,提升开发效率。

但是,前后端分离针对规模不大的项目来说,缺点也比较明显,就是效率不那么高, 那么问题来了,如果不前后端分离…

  • 使用传统的后端渲染模板的方式呢?No, 模板语法并不智能(或者说我还没发现很智能的)。
  • 如果使用React的服务端渲染框架Next.js呢?No,我还是比较喜欢Nest.js的代码组织方式。

针对规模不大的项目,怎么在保留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

参考:https://stackoverflow.com/questions/57973867/next-js-custom-server-app-render-do-not-pass-query-to-a-component

更多注意事项
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"
    ]
  ]
}

未完待续…

 类似资料: