一次构建多处部署 - Next.js Runtime Env

我们一般通过控制 env 的方式去做到 "Build once, deploy many" 哲学。但是在 Next.js 中,环境变量分为两种,一个种是可被用于 Client 侧的 NEXT_PUBLIC_ 开头的环境变量,另一个种是只能被用于 Server 侧的环境变量。前者会在 Next.js 构建时被注入到客户端代码中,导致原有代码被替换,那么也就意味着我们控制 env 并不能做到一次构建多处部署。一旦需要部署到不同的环境并且修改 env,我们就需要重新构建一次。

今天的文章,我们将会探讨如何通过 Next.js 的 Runtime Env 来实现一次构建多处部署。

Next.js Runtime Env

今天的主角是 next-runtime-env 这个库,它可以让我们在 Next.js 中使用 Runtime Env。我们可以通过它来实现一次构建多处部署。

npm i next-runtime-env

更换 Client 侧的环境变量使用方式:

import { env } from 'next-runtime-env'

const API_URL = process.env.NEXT_PUBLIC_API_URL
const API_URL = env('NEXT_PUBLIC_API_URL') 

export const fetchJson = () => fetch(API_URL as string).then((r) => r.json())

然后在 app/layout.tsx 上增加环境变量注入 Script。

app/layout.tsx
import { PublicEnvScript } from 'next-runtime-env'

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <head>
        <PublicEnvScript />
      </head>
      <body className={inter.className}>{children}</body>
    </html>
  )
}

那么这样就可以了。

现在我们来试一试。我们有这样页面,直接渲染上述 API_URL 的响应数据。

'use client'

export default function Home() {
  const [json, setJson] = useState(null)
  useEffect(() => {
    fetchJson().then((r) => setJson(r))
  }, [])
  return JSON.stringify(json)
}

现在我们使用 next build 构建项目,然后在构建之后,修改 .env 中的 NEXT_PUBLIC_API_URL,然后使用 next start 启动项目,观察实际请求的接口是否随着 .env 的修改而变化。

现在我们的 NEXT_PUBLIC_API_URL=https://jsonplaceholder.typicode.com/todos/2,启动项目之后,浏览器请求的是 https://jsonplaceholder.typicode.com/todos/2

当我们修改 .env 中的 NEXT_PUBLIC_API_URLhttps://jsonplaceholder.typicode.com/todos/3,然后重启项目,浏览器请求的是 https://jsonplaceholder.typicode.com/todos/3

这样我们就实现了一次构建多处部署,只需要修改 env 即可。

深入了解 Runtime Env

其实 next-runtime-env 的实现原理非常简单,<PublicEnvScript /> 实际就是在 <head> 中注入了一个 <script /> 类似这样。

<script data-testid="env-script">window['__ENV'] = {"NEXT_PUBLIC_API_URL":"https://jsonplaceholder.typicode.com/todos/3"}</script>

由于 <head /> 中的 script 会在页面水合前被执行,所以我们可以在 Client 侧通过 window['__ENV'] 来获取环境变量,而 next-runtime-env 提供 env()正是这样实现的。而这个环境变量在 Server Side 都是动态的,所以在 Server Side 的取值永远都是通过 process.env[']

下面的简略的代码展示了 env() 的实现。

export function env(key: string): string | undefined {
  if (isBrowser()) {
    if (!key.startsWith('NEXT_PUBLIC_')) {
      throw new Error(
        `Environment variable '${key}' is not public and cannot be accessed in the browser.`,
      );
    }

    return window['__ENV'][key];
  }

  return process.env[key];
}

构建一个无环境变量依赖的产物

一个项目中,一般都会存在大量的环境变量,有部分环境变量只会在 Client Side 使用,在项目 build 过程中,必须要正确的注入环境变量,否则会导致项目无法通过构建。

例如常见的 API_URL 变量,是请求接口的地址,在构建中,如果没有值,就会导致预渲染中的接口请求错误导致构建失败。比如在 Route Handler 中,我们有这样一个函数。

app/feed/route.ts
import { NextResponse } from 'next/server'

import { fetchJson } from '../../../lib/api'

export const GET = async () => {
  await fetchJson()
  return NextResponse.json({})
}

API_URL 为空时,fetchJson 会报错,导致构建失败。

 ✓ Collecting page data    
   Generating static pages (0/6)  [    ]
Error occurred prerendering page "/feed". Read more: https://nextjs.org/docs/messages/prerender-error

TypeError: Failed to parse URL from

这是因为在 Next.js 中,默认对 Route handler 进行了预渲染,而在预渲染过程中,fetchJson 会被执行,而 API_URL 为空,导致请求失败。

只需要使用 noStore() 或者改变 dynamic 的方式,就可以解决这个问题。

app/feed/route.ts
import { unstable_noStore } from 'next/cache'
import { NextResponse } from 'next/server'

import { fetchJson } from '../../../lib/api'

export const dynamic = 'force-dynamic' // 方式 2

export const GET = async () => {
  unstable_noStore() // 方式 1
  await fetchJson()
  return NextResponse.json({})
}

那么,在其他的页面构建中,如果也遇到类似的问题,也修改这个地方就可以了。

构建的时候,我们没有注入任何的环境变量,在启动构建后的服务之前,记得一定要在当前目录下创建一个 .env 文件,并且正确填写变量值,这样才能保证项目正常运行。

通过 Dockerfile 构建无环境变量依赖的镜像

在上节的基础上,对整个构建过程进一步封装,使用 Docker 完成整个构建然后发布到 Docker Hub,真正意义上实现一次构建多处部署。

创建一个 Dockerfile 文件。

FROM node:18-alpine AS base

RUN npm install -g --arch=x64 --platform=linux sharp

FROM base AS deps

RUN apk add --no-cache libc6-compat
RUN apk add --no-cache python3 make g++

WORKDIR /app

COPY . .

RUN npm install -g pnpm
RUN pnpm install

FROM base AS builder

RUN apk update && apk add --no-cache git


WORKDIR /app
COPY --from=deps /app/ .
RUN npm install -g pnpm

ENV NODE_ENV production
RUN pnpm build

FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

# and other docker env inject
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/server ./.next/server

EXPOSE 2323

ENV PORT 2323
ENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp
CMD node server.js;

上面的 dockerfile 在官网版本的基础上做了修改,已在 Shiro 中落地使用。

由于 Next.js standalone build 中并不包含 sharp 依赖,所以在 Docker 构建中我们首先全局安装了 sharp,并且在后续注入了 sharp 的安装位置的环境变量。

这样构建的 Docker 镜像也不依赖于环境变量,并且 standalone build 让 Docker image 的占用空间更小。

通过 Docker 容器的路径映射,我们只需要把当前目录下的 .env 映射到容器内部的 /app/.env 即可。

这里编写一个简单的 Docker compose 实例。

version: '3'

services:
  shiro:
    container_name: shiro
    image: innei/shiro:latest
    volumes:
      - ./.env:/app/.env # 映射 .env 文件
    restart: always
    ports:
      - 2323:2323

大功告成,后续任何人只需要通过 Docker pull 取得构建后的镜像然后再修改本地 .env 就能够运行属于自己环境的项目了。


最后更新于 2024/4/27 20:14:01

更新历史

本书还在编写中..

前往 https://innei.in/posts/tech/my-first-nextjs-book-here#comment 发表你的观点吧。