我們一般通過控制 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。
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_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
就能夠運行屬於自己環境的項目了。
此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/nextjs-runtime-env-and-build-once-deploy-many