发布于 2026-01-05 0 阅读
0

使用 Nest.js + Next.js 创建项目

使用 Nest.js + Next.js 创建项目

这是关于如何结合使用 nest.js 和 NEXT.js 的系列文章的第一部分。今天,您将学习如何设置项目并选择合适的 SSR 策略。在第二部分中,您将学习热模块替换、更多 SSR 技术以及子目录部署。

嘿,现在是 2022 年 10 月,Next.js 13刚刚发布!它带来了许多变化和新的app目录结构。我正在努力探索所有的新特性和用例,以便更新本教程(或许还有其他内容nest-next),同时,请务必在这个 GitHub issuenest-next中留下您对 Next.js 13 的反馈

本文是我在 Habr 上发表的原文的翻译。翻译过程中没有经过任何经验丰富的技术撰稿人或编辑的审阅。因此,如果您能提供任何关于纠正错误的反馈,我将不胜感激。

目录

介绍

在选择 2022 年的 Web 开发框架时,您不妨考虑一下Next.js。它是一个基于 React 的框架,支持服务器端渲染/静态站点生成 (SSR/SSG)、TypeScript、出色的代码分割以及开箱即用的预设路由。对于独立开发者和团队来说,它都是一个绝佳的选择。

Next.js 也提供了一些后端功能,例如无服务器函数。但是,如果您不习惯无服务器架构,而更倾向于使用自己熟悉的方案,那么Nest.js就是一个不错的选择——它遵循许多人熟悉的 MVC 架构,因此具有很强的可扩展性和易扩展性。

NEXT.js 和 nest.js 的命名非常相似,因此我将始终使用大写字母输入 NEXT.js(React 框架),使用小写字母输入 nest.js(MVC 后端框架),以便更容易地区分它们。

但如何将这两者整合起来呢?如何让它们协同工作?最简单的办法是创建一个复杂(或简单)的代理,将 API 请求转发到 nest.js,将所有其他请求转发到 NEXT.js。但如果您受限于微服务的数量,或者您不想使用真正的单体仓库(monorepo)来在服务之间共享代码,那么这可能就不是一个可行的方案了。

幸运的是,您可以轻松地将 Next.js 直接嵌入到 Nest.js 服务器中。Meetnest-next是一个 Nest.js 的视图渲染器,它使用 Next.js 通过单个装饰器直接从控制器渲染页面。然而,这会导致应用程序内部运行出现一些问题。让我们nest-next从头开始创建一个简单的应用程序,以便发现这些问题,我也会分享我和我的同事在使用这项技术时总结的一些最佳实践。

本文将尽量避免深入探讨各个框架的具体细节,主要关注两者之间的桥梁作用。当需要了解某个框架的特定知识时,我会提供官方文档的链接。

在我们开始之前

您或您的团队很可能在选择后端之前就选择了 Next.js。我建议您暂时停下来,认真考虑一下Next.js 的服务器端特性——对于很多应用场景,例如简单的前端渲染服务器后端,您可能并不需要真正的后端。使用两个框架并维护它们之间的桥接会增加额外的开销。

nest-next只有当您计划使用真正的 node.js 后端,或者您已经拥有某些 Express/fastify/nest.js 基础架构并计划采用时,才应该考虑使用它。

这篇文章篇幅较长,因为它涵盖了框架的大部分细节,因此分为两部分。在第一部分中,我们将从零开始创建一个简单的应用程序,并展示一些关于服务端渲染 (SSR) 的基本问题的解决方案。如果您认为自己是这方面经验丰富的开发者,那么从第二部分开始阅读可能更适合您,在第二部分中,我将讨论一些更高级的用例,例如热模块替换、SSR 技术以及在子目录中部署。

最后:对于那些更喜欢跳过文章直接查看代码的人——你可以在我的 GitHub 上找到本文的源代码——https: //github.com/yakovlev-alexey/nest-next-example——提交历史与文章基本一致。

创建 nest.js 应用程序

首先,我们需要一个 nest.js 应用作为基础。我们将使用 nest.js CLI 来生成模板。



npx @nestjs/cli new nest-next-example


Enter fullscreen mode Exit fullscreen mode

请按照说明操作。我选择使用 yarn 作为包管理器,文章中会提供 yarn 的命令示例,但我假设您熟悉包管理器,因此使用 npm 也不会有问题。

命令执行完毕后,我们将得到一个几乎为空的项目,该项目可能会立即启动。由于本文中我们不打算创建任何测试,因此我将从项目中删除所有测试文件(test目录和)。app.controller.spec.ts

我还建议使用类似单体仓库的目录结构,如下所示。



└── src
    ├── client # client code: hooks, components, etc
    ├── pages # actual NEXT.js pages
    ├── server # nest.js server code
    └── shared # common types, utils, constants etc


Enter fullscreen mode Exit fullscreen mode

让我们对 nest.js 配置进行必要的更改,以支持我们的新布局。



// ./nest-cli.json
{
    "collection": "@nestjs/schematics",
    "sourceRoot": "src",
    "entryFile": "server/main"
}


Enter fullscreen mode Exit fullscreen mode

现在,如果我们启动该应用程序,在浏览器中访问时yarn start:dev应该可以看到。"Hello world"localhost:3000

由于 nest.js 构建管道存在一些缺陷,您可能会在终端中看到错误。错误信息可能类似于:'Error: Cannot find module '.../dist/server/main'。在这种情况下,您可以暂时将 `is_construction_name` 设置"entryFile"为 `--just` "main",这应该可以解决问题。

NEXT.js 安装

现在让我们把 NEXT.js 添加到我们的项目中。



# NEXT.js and its peers
yarn add next react react-dom
# required types and eslint preset
yarn add -D @types/react @types/react-dom eslint-config-next


Enter fullscreen mode Exit fullscreen mode

接下来,您应该使用 `next.js` 启动 NEXT.js 开发服务器yarn next dev。您的 tsconfig 文件将进行必要的更改,并添加一些新文件,包括next-env.d.ts`next.js`。NEXT.js 将成功启动。但是,如果我们现在启动 nest.js 服务器,我们会发现 NEXT.js 破坏了我们的 TypeScript 配置。让我们为 nest.js 创建一个单独的配置文件——我将重用现有的配置文件,tsconfig.build.json内容tsconfig.server.json如下。



// ./tsconfig.server.json
{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "noEmit": false
    },
    "include": [
        "./src/server/**/*.ts",
        "./src/shared/**/*.ts",
        "./@types/**/*.d.ts"
    ]
}


Enter fullscreen mode Exit fullscreen mode

现在 nest.js 又能正常工作了。让我们更新package.json文件中的 scripts 部分。



// ./package.json
"scripts": {
    "prebuild": "rimraf dist",
    "build": "yarn build:next && yarn build:nest",
    "build:next": "next build",
    "build:nest": "nest build --path ./tsconfig.server.json",
    "start": "node ./dist/server/main.js",
    "start:next": "next dev",
    "start:dev": "nest start --path ./tsconfig.server.json --watch",
    "start:debug": "nest start --path ./tsconfig.server.json --debug --watch",
    "start:prod": "node dist/main",
    // ... lint/format/test etc
},


Enter fullscreen mode Exit fullscreen mode

让我们向目录中添加一个index页面和一个App组件src/pages



// ./src/pages/app.tsx
import { FC } from 'react';
import { AppProps } from 'next/app';

const App: FC<AppProps> = ({ Component, pageProps }) => {
    return <Component {...pageProps} />;
};

export default App;


Enter fullscreen mode Exit fullscreen mode


// ./src/pages/index.tsx
import { FC } from 'react';

const Home: FC = () => {
    return <h1>Home</h1>;
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

现在,当您使用该应用程序启动时,yarn start:next您应该会看到此页面localhost:3000

你还应该.next在你的.gitignore目录中添加一个文件夹——NEXT.js 会将构建版本存储在那里。

建立框架之间的联系

现在我们有两个独立的服务器。但我们想要的是一个使用 nest.js 的单一服务器nest-next:所以让我们来安装它。



yarn add nest-next


Enter fullscreen mode Exit fullscreen mode

接下来我们应该初始化新安装的RenderModule程序app.module.ts



// ./src/server/app.module.ts
import { Module } from '@nestjs/common';
import { RenderModule } from 'nest-next';
import Next from 'next';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
    /* should pass a NEXT.js server instance
        as the argument to `forRootAsync` */
    imports: [RenderModule.forRootAsync(Next({}))],
    controllers: [AppController],
    providers: [AppService],
})
export class AppModule {}


Enter fullscreen mode Exit fullscreen mode

现在我们可以使用@Render从 Nest 导出的装饰器了。所以让我们在 . 中创建我们的第一个页面控制器app.controller.ts



// ./src/server/app.controller.ts
import { Controller, Get, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
    constructor(private readonly appService: AppService) {}

    @Get()
    @Render('index')
    home() {
        return {};
    }
}


Enter fullscreen mode Exit fullscreen mode

但是,当我们启动应用yarn start:dev并在浏览器中打开所需页面时,会在 Next.js 中看到一个错误——找不到构建版本。原来,渲染服务器以生产模式启动,并且期望看到的是前端应用的已构建版本。要解决这个问题,我们需要dev: true在初始化服务器实例时提供一个参数。



// ./src/server/app.module.ts
imports: [
    RenderModule.forRootAsync(Next({ dev: true }))
],


Enter fullscreen mode Exit fullscreen mode

我们再试一次。localhost:3000在浏览器中打开,你会看到 404 错误。这很奇怪,因为我们既有控制器页面,也有 Next.js 页面。原来nest-next它在错误的文件夹中查找。默认情况下,它使用文件夹views下的子目录,而不是文件夹本身。我个人不喜欢 Next.js 的这种不一致,所以我们需要在我们的实例中pages指定路径viewsDir: nullRenderModule



// ./src/server/app.module.ts
imports: [
    RenderModule.forRootAsync(
        Next({ dev: true }),
        /* null means that nest-next 
            should look for pages in root dir */
        { viewsDir: null }
    )
],


Enter fullscreen mode Exit fullscreen mode

旁边viewsDir还有一个dev选项——这次是用于nest-next启用更具体的错误序列化。我个人觉得这个选项没什么用,但如果你需要的话,它就在那里。

最后,当我们localhost:3000在浏览器中打开它时,我们看到了我们之前描述的页面index.tsx

SSR数据准备

Next.js 的主要优势之一是能够轻松获取静态或动态渲染页面所需的数据。用户有多种方式可以实现这一点。我们将使用getServerSidePropsGSSP(全局共享服务协议)——这是 Next.js 中获取动态数据的最新方法。不过,Next.jsnest-next也完全支持其他方法。

是时候添加另一个页面了。假设我们这里index是一个博客页面。而我们要创建的页面是按 ID 分类的博客文章。

添加必要的类型和控制器:



// ./src/shared/types/blog-post.ts
export type BlogPost = {
    title: string;
    id: number;
};

// ./src/server/app.controller.ts
import { Controller, Get, Param, Render } from '@nestjs/common';

// ...

@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
 return {};
}


Enter fullscreen mode Exit fullscreen mode

添加新页面:



// ./src/pages/[id].tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';

type TBlogProps = {
    post: BlogPost;
};

const Blog: FC<TBlogProps> = ({ post = {} }) => {
    return (
        <div>
            <Link href={'/'}>Home</Link>
            <h1>Blog {post.title}</h1>
        </div>
    );
};

export const getServerSideProps: GetServerSideProps<TBlogProps> = async (
    ctx,
) => {
    return { props: {} };
};

export default Blog;


Enter fullscreen mode Exit fullscreen mode

刷新我们的主页:



// ./src/pages/index.tsx
import { GetServerSideProps } from 'next';
import Link from 'next/link';
import { FC } from 'react';
import { BlogPost } from 'src/shared/types/blog-post';

type THomeProps = {
    blogPosts: BlogPost[];
};

const Home: FC<THomeProps> = ({ blogPosts = [] }) => {
    return (
        <div>
            <h1>Home</h1>
            {blogPosts.map(({ title, id }) => (
                <div key={id}>
                    <Link href={`/${id}`}>{title}</Link>
                </div>
            ))}
        </div>
    );
};

export const getServerSideProps: GetServerSideProps<THomeProps> = async (
    ctx,
) => {
    return { props: {} };
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

太好了!现在我们有几个页面需要一些数据。剩下的就是提供这些数据了。让我们来看看有哪些不同的方法可以做到这一点。

使用 nest.js 控制器

我们的home控制器app.controller.ts返回一个空对象。事实证明,该对象中的所有内容都可以在ctx.queryGSSP 中访问。

让我们添加一些占位数据app.service.ts



// ./src/server/app.service.ts
import { Injectable } from '@nestjs/common';
import { from } from 'rxjs';

const BLOG_POSTS = [
    { title: 'Lorem Ipsum', id: 1 },
    { title: 'Dolore Sit', id: 2 },
    { title: 'Ame', id: 3 },
];

@Injectable()
export class AppService {
    getBlogPosts() {
        return from(BLOG_POSTS);
    }
}


Enter fullscreen mode Exit fullscreen mode

在控制器中,我们可以访问此服务并返回数据。



// ./src/server/app.controller.ts
import { map, toArray } from 'rxjs';

// ...

@Get('/')
@Render('index')
home() {
    return this.appService.getBlogPosts().pipe(
        toArray(),
        map((blogPosts) => ({ blogPosts })),
    );
}


Enter fullscreen mode Exit fullscreen mode

现在我们可以在 GSSP 中访问blogPosts属性ctx.query。然而,这种实现似乎不太可靠:TypeScript 应该警告我们,实际上并不存在blogPosts该属性ctx.query。它的类型被定义为ParsedUrlQuery

肯定是 TypeScript 出错了?让我们在GSSP 中保留一些console.logs 。然后打开。查看终端(日志会显示在那里——GSSP 只在服务器端运行)。我们确实看到日志在那里。那问题出在哪里呢?ctx.queryindex.tsxlocalhost:3000blogPosts

我们打开链接localhost:3000/1并点击它Home。突然,终端输出了一个空对象。但这怎么可能呢?我们明明已经blogPosts从控制器返回了属性啊!

在客户端进行导航时,NEXT.js 会获取一个内部端点,该端点执行GSSP 函数并返回序列化的 JSON。因此,我们的home控制器根本不会被调用,ctx.query它只包含路径参数和搜索查询。

使用直接服务访问

如前所述,GSSP 仅在服务器端执行。因此,理论上我们可以直接从 GSSP 内部使用 nest.js 服务。

这实际上是个非常糟糕的想法。要么你必须自己构建每个服务(这样就会有很多重复代码,并且失去依赖注入带来的所有好处),要么就得用 Nest.js 的get方法暴露应用程序。

即使你能够容忍从不同上下文进行全局应用程序访问所带来的混乱局面,你在调用服务时最终还是会缺少 HTTP 上下文。

通过向自身发送请求

实际上,我们完全可以使用fetchGSSP 发起异步请求。不过,我们需要编写一个包装器fetch来选择要调用的地址。但在继续之前,我们必须获取代码执行位置以及服务器订阅的端口信息。



// ./src/shared/constants/env.ts
export const isServer = typeof window === 'undefined';

export const isClient = !isServer;

export const NODE_ENV = process.env.NODE_ENV;

export const PORT = process.env.PORT || 3000;


Enter fullscreen mode Exit fullscreen mode

现在更新main.ts( await app.listen(PORT)) 中的端口订阅,并根据环境选择 NEXT.js 模式。



// ./src/server/app.module.ts
RenderModule.forRootAsync(
    Next({ dev: NODE_ENV === 'development' }),
    { viewsDir: null }
)

// ./package.json
"start:dev": "NODE_ENV=development nest start --path ./tsconfig.server.json --watch"


Enter fullscreen mode Exit fullscreen mode

现在服务器从src/shared编译后的 nest.js 文件结构中导入模块,服务器结构与之前有所不同。如果您之前更改过entryFilenest-cli.json请将其恢复为旧值(server/main.ts),清理dist文件夹并重启服务器。

fetch 的包装器

现在我们可以添加一个包装器,fetch根据执行环境选择主机名。



// ./src/shared/utils/fetch.ts
import { isServer, PORT } from '../constants/env';

const envAwareFetch = (url: string, options?: Record<string, unknown>) => {
    const fetchUrl =
        isServer && url.startsWith('/') ? `http://localhost:${PORT}${url}` : url;

    return fetch(fetchUrl, options).then((res) => res.json());
};

export { envAwareFetch as fetch };


Enter fullscreen mode Exit fullscreen mode

并更新app.service.ts



// ./src/server/app.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { from, of, toArray } from 'rxjs';

const BLOG_POSTS = [
  { title: 'Lorem Ipsum', id: 1 },
  { title: 'Dolore Sit', id: 2 },
  { title: 'Ame', id: 3 },
];

@Injectable()
export class AppService {
  getBlogPosts() {
    return from(BLOG_POSTS).pipe(toArray());
  }

  getBlogPost(postId: number) {
    const blogPost = BLOG_POSTS.find(({ id }) => id === postId);

    if (!blogPost) {
      throw new NotFoundException();
    }

    return of(blogPost);
  }
}


Enter fullscreen mode Exit fullscreen mode

添加新的 API 端点app.controller.ts



// ./src/server/app.controller.ts
import { Controller, Get, Param, ParseIntPipe, Render } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('/')
  @Render('index')
  home() {
    return {};
  }

  @Get(':id')
  @Render('[id]')
  public blogPost(@Param('id') id: string) {
    return {};
  }

  @Get('/api/blog-posts')
  public listBlogPosts() {
    return this.appService.getBlogPosts();
  }

  @Get('/api/blog-posts/:id')
  public getBlogPostById(@Param('id', new ParseIntPipe()) id: number) {
    return this.appService.getBlogPost(id);
  }
}


Enter fullscreen mode Exit fullscreen mode

最后,我们来更新GSSP方法。



// ./src/pages/index.tsx
import { fetch } from 'src/shared/utils/fetch';

export const getServerSideProps: GetServerSideProps<THomeProps> = async () => {
    const blogPosts = await fetch('/api/blog-posts');
    return { props: { blogPosts } };
};

// ./src/pages/[id].tsx
import { fetch } from 'src/shared/utils/fetch';

export const getServerSideProps: GetServerSideProps<TBlogProps> = async () => {
    const id = ctx.query.id;
    const post = await fetch(`/api/blog-posts/${id}`);

    return { props: { post } };
};


Enter fullscreen mode Exit fullscreen mode

访问一下localhost:3000。博客列表确实可以访问。我们点击其中一个链接查看文章——这里一切应该都能正常工作,客户端导航也没问题。

但是,当我们在文章页面更新页面时,却出现错误——找不到该博客文章。客户端导航一切正常。

正如我们已经发现的那样,nest-next控制器返回值被放入了ctx.query。这意味着实际的查询并不存在,用户需要自行准备查询。

为了解决这个问题,我们将从blogPost控制器返回该 id。



// ./src/server/app.controller.ts
@Get(':id')
@Render('[id]')
public blogPost(@Param('id') id: string) {
    return { id };
}


Enter fullscreen mode Exit fullscreen mode

API 端点将参数强制转换为整数。在这种情况下,为了与 NEXT.js 保持一致,最好不要解析参数,而是将其保留为字符串。

现在让我们刷新一下浏览器页面——这应该可以解决我们的问题。

传递路径参数

显然,我们需要手动在控制器中传递所有参数,这让我们陷入了非常糟糕的境地。如果我们需要使用搜索参数呢?肯定有办法解决这个问题吧?

我们将使用 AOP (面向切面编程)代码片段以及 nest.js 中的一种机制:拦截器



// ./src/server/params.interceptor.ts
import {
    Injectable,
    NestInterceptor,
    ExecutionContext,
    CallHandler,
} from '@nestjs/common';
import { Request } from 'express';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ParamsInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
        const request = context.switchToHttp().getRequest() as Request;

      /* after executing the handler add missing request query and params */
        return next.handle().pipe(
            map((data) => {
                return {
                    ...request.query,
                    ...request.params,
                    ...data,
                };
            }),
        );
    }
}


Enter fullscreen mode Exit fullscreen mode

根据 Next.js 文档,路径参数的优先级高于搜索参数。我们也不会覆盖处理程序中的数据。

使用新的拦截器装饰页面控制器处理程序。



// ./src/server/app.controller.ts
import { UseInterceptors } from '@nestjs/common';
import { ParamsInterceptor } from './params.interceptor';

// ...

@Get('/')
@Render('index')
@UseInterceptors(ParamsInterceptor)
public home() {
    return {};
}

@Get(':id')
@Render('[id]')
@UseInterceptors(ParamsInterceptor)
public blogPost() {
    return {};
}


Enter fullscreen mode Exit fullscreen mode

确保 nest.js 和 NEXT.js 中的路径参数名称一致非常重要。换句话说,两者@Get中的路径参数@Render应该相同。API 端点不能包含此拦截器——我们不希望在调用 API 时返回路径参数。

将 API 控制器和页面控制器分开是明智之举。这样我们就可以@UseInterceptors在整个控制器类上添加装饰器。为了简化起见,本文将 API 控制器和页面控制器合并在一起。

让我们刷新浏览器页面来验证更改。我们应该仍然能够正确看到按 ID 分类的博客文章。

下一步

目前我们已经拥有一个nest-next能够渲染页面并向其提供数据的基本应用程序。然而,我们尚未充分利用这种架构的一些真正优势。此外,尤其是在将此组合用于企业开发时,您可能会遇到一些其他问题。

简短而略带艺术性的概述

要学习更多高级主题,例如 HMR、更多 SSR 技术和代理,请nest-next阅读本文的第二部分

我希望这篇文章能帮助你最终成功地将这些框架结合起来使用,尽管nest-next文档中关于实际使用的信息很少。

文章来源:https://dev.to/yakovlev_alexey/creating-a-project-with-nestjs-nextjs-3i1i