一文讲透NestJS + Vite (React/Vue) + Nginx 下以虚拟目录方式部署应用

1,525 阅读8分钟

标题有点长,应用场景略显狭窄,但是也正因此,相关的参考资料比较少。因此要写这篇blog记录相关的要点。

收到一个需求:在现有的业务(系统)中加入一个临时的功能。那么摆在我们面前只有两种选择:要么在生产系统中修改补充,加入这个临时的业务,然后在业务结束之后移除相关的数据和代码,重新发布。要么通过创建一个虚拟目录的方式,让一个独立的应用附属在主系统的域名下,还能共享cookie和域的权限。

本文就后一个方案方式展开讨论,前后端分离开发的应用方案,如何从开发阶段开始,就运行在一个子目录中做好(配置)准备,少走弯路。前端程序假设用Vite构建(Webpack异曲同工),无论React或者VueJS或者TS均可。后端以NestJS为例,同样也兼容Express框架。

开发时

默认情况下,借助脚手架工具,默认创建的前端项目端口号为5713,后端项目端口号3000。而我们开发者,如果没有特别的约定,默认访问后端资源会依次从协议开始、主机、端口和目录,依次写。同时后端服务也要开启跨域调用,例如(step-01):

axios.get('http://localhost:3000/hello-world',{})
    .then(/** **/)

这样的话,虽然在开发时不会发生什么问题,但是系统发布就悲剧了,所有的资源(hard code)要全部替换掉。这个问题可以通过配置的方式来解决:写一个配置文件(或者干脆在代码中判断node_env),但还有更好的方案:

配置前端代理

一般我们部署应用程序会将前后段都放在一台主机上(很少有跨域调用的情景),所以最好能够从协议部分开始,到主路径(可能不是根)都采用默认的方式。这样可以省去在应用程序发布后替换资源地址和免去设置配置文件的步骤。更重要的是:还不容易出错。

主流的打包工具Vite或者Webpack,在支持HMR的时候,同时也引入了反向代理的支持。那么我们就可以为后端设置代理,那么在调用后端的资源时将url主目录之前的信息统统省略(step-02)。

axios.get('/hello-world',{})
.then(/** **/

vite的配置文件如下:

export default defineConfig({
  server: {
    proxy: {
      "/hello-world": "http://localhost:3000/"
    }
  },
  plugins: [react()]
});

以后端为主导的开发

到刚才那一步,我们已经基本满足了前后端开发的需求。如果开发者以后端程序为主(不需要一直开着前端hmr,节省系统消耗),那么还可以以后端为主(用后端的Web服务)来作为开发环境的支持。

我们先将前端应用程序构建出来npm run build,默认的打包输出目录在项目目录下的dist子目录中。以我们这个项目的目录为例,则是如下

.
├── backend
│   ├── dist
│   ├── nest-cli.json
│   ├── node_modules
│   ├── package.json
│   ├── package-lock.json
│   ├── README.md
│   ├── src
│   ├── test
│   ├── tsconfig.build.json
│   └── tsconfig.json
└── frontend
    ├── index.html
    ├── node_modules
    ├── package.json
    ├── package-lock.json
    ├── public
    ├── src
    ├── tsconfig.json
    ├── tsconfig.node.json
    └── vite.config.ts

此时需要在后端的服务中,设置静态资源的访问主目录(即,前端构建后的目标目录)frontend/dist

所以,我们要在后端项目NestJS的框架中设置指向前端构建目标为静态资源目录(step-03):

const staticPath = join(resolve(process.cwd(), '../', 'frontend', 'dist'));
async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);
  app.useStaticAssets(staticPath);
  await app.listen(3000);
}
bootstrap();

因为NestJS默认使用Express为内核,我们就用ExpressuseStaticAssets方法绑定静态资源目录。在调用工厂模式的时候,多给一个指定类型。另外,用ServeStaticModule可以实现同样的效果。

此时,我们直接访问后端的服务,已经可以打开前端页面,并且能够调用到后端的接口。

注意[1],我们没有设置任何配置,也就是说,这个环境基本可以无障碍部署了。

注意[2],目前我们还未在前端引入路由,后文会阐述前端路由的解决方案。

设置子目录

假设,我们的应用程序会被部署到/eventA/*下面,首先我们在前端的配置中设置base字段(step-04):

export default defineConfig({
  server: {
    proxy: {
      "/hello-world": "http://localhost:3000/"
    }
  },
  base: "/eventA",
  plugins: [react()]
});

此时前端默认的页面打开请求都会被定位到/eventA/*,但并不会打开任何页面,因为还没修改路由绑定的路径。

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/eventA" element={<Home />} />
        <Route path="/eventA/sub1" element={<Sub1 />} />
      </Routes>
    </BrowserRouter>
  );
}

修改后可以打开前端的所有页面。接下来修改后端的默认根目录,用setGlobalPrefix方法设置。最后,修改修改前端的请求地址以及代理。这里,如果用ServeStaticModule,那么要用serveRoot参数定义默认根目录。

这里有一个细节问题:在开发前端业务的时候,是不是还能够再省略请求路径?答案是可以的,请求的资源不要从/开始,即,以相对路径的方式访问。例如,当前网页地址是/eventA/sub1/下,那么请求后端服务的可以用../hello-world

不过我并不推荐用这个方法,这样做会让整个应用对路径的依赖性比较强,在解耦和抽象时容易出现相对位置变化而导致的路径不一致问题。还要注意前端的路由方式路由方式浏览器路由(BrowserRouter)还是哈希路由(HashRouter)。

以后端为服务的

到上一步,其实并未完美,因为以后端为主服务的方式你会发现,前端SPA应用,除非进入主地址,否则一定会爆无法找到网页的404错误。原因也很简单,因为SPA应用在请求(跳转)地址时,会先查找前端映射的地址,如果有,则拦截请求并加载内容再渲染网页,没有产生实质性的前后段通信(这也是SPA加载速度和用户体验很好的关键原因)。而后端为主的服务,则会直接查询那个静态文件,如果文件不存在,则直接抛出404。例如/eventA/sub1/1这个路径只存在于物理路径/frontend/dist/index.html这个地址上,因为加入了前缀,所以不论请求/eventA/*,都应该将其定位到/eventA/index.html才行。所以,此时不能简单的靠useStaticAssets方法来处理静态文件的请求了。根据NestJS的特性,适当地调整:

  1. 当所有Controller中都没有符合条件的资源,NestJS自动抛出404异常;
  2. 捕获(拦截)404,判断是否为get方式,如果不是,继续抛出404;
  3. 判断请求的url是否是已经存在在frontend/dist/assets中的文件,如果是,则返回文件;
  4. 如果打包的时候还包含其他资源,根据特性判断其他资源的地址并返回;
  5. 读取fontend/dist/index.html,返回给前端;

经过上述的改造,无论时直接请求地址还是通过nav亦或是前端a标签跳转,都可以完成(step-04-1)。

部署时可用方案

以上,我们在前端和后端两个服务中,分别就SPA和REST应用做了相关的配置,让程序可以实现在某个虚拟目录中独立运行。以前端为主时,适合前后端开发;以后端为主时,仅适合后端开发;

但是当面对应用程序部署时,可以选择的空间又扩大了……

此时需要考虑将两个项目是部署在一台主机上(或一个容器内),还是多处。如果是部署在一处,那么服务提供方式只能用后端的服务,前端程序构建后存放在特定位置,并确保后端服务可以读取到。为了演示项目如是如何加入到先有的业务中的,为此特地在此之外启动一个Nginx的服务,将根目录和子目录(虚拟目录)分别代理不同的location,代码见step-05-1分支中的Dockerfiledocker-compose.yml文件。

提示:分支切换到step-05-1后,在dockers目录中启动compose

docker compose up -d

启动后访问localhost,以及localhost/eventA,可以看到配置生效。

如果部署在两处(或多处),那么两者在路径(配置)上需要做比较明确的切割,例如将后端的API归入到/eventA/API/*,前端则继续用/eventA。构建过程也需要稍作调整。可以选择在主机中构建前端项目,然后复制到nginx的托管目录中,然后前后端分别用不同的前缀路径。配置代码见step-05-2。

提示:一定要在主机中先构建前端项目npm run build

源码分支说明

示例代码地址:gitee.com/anuoxiang/v…

  • step-01 / master 前后端可以运行,可以调用联动;
  • step-02 用 Vite 实现前端代理,以前端为主的开发
  • step-03 后端静态目录,以后端为主的开发
  • step-04 前后端应用虚拟(子)目录配置,并尝试相对路径的方式访问
  • step-04-1 修改静态资源访问方式,以后端为主导的服务来适应SPA应用
  • step-05-1 用Nginx在生产环境中部署单体应用
  • step-05-2 用Nginx在生产环境中部署前后端分离