一次构建多处部署 - 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 // [!code --]
const API_URL = env('NEXT_PUBLIC_API_URL') // [!code ++]
export const fetchJson = () => fetch(API_URL as string).then((r) => r.json())
然后在 app/layout.tsx
上增加环境变量注入 Script。
import { PublicEnvScript } from 'next-runtime-env'
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en">
<head>
<PublicEnvScript /> // [!code ++]
</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_URL
为 https://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 中,我们有这样一个函数。
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 的方式,就可以解决这个问题。
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
就能够运行属于自己环境的项目了。