{"attributes":[{"trait_type":"xlog_slug","value":"nextjs-runtime-env-and-build-once-deploy-many"}],"content":"我们一般通过控制 env 的方式去做到 [\"Build once, deploy many\"](https://www.mikemcgarr.com/blog/build-once-deploy-many.html) 哲学。但是在 Next.js 中,环境变量分为两种,一个种是可被用于 Client 侧的 `NEXT_PUBLIC_` 开头的环境变量,另一个种是只能被用于 Server 侧的环境变量。前者会在 Next.js 构建时被注入到客户端代码中,导致原有代码被替换,那么也就意味着我们控制 env 并不能做到一次构建多处部署。一旦需要部署到不同的环境并且修改 env,我们就需要重新构建一次。\n\n![](https://object.innei.in/bed/2024/0329_1711711896670.png)\n\n今天的文章,我们将会探讨如何通过 Next.js 的 Runtime Env 来实现一次构建多处部署。\n\n## Next.js Runtime Env\n\n今天的主角是 `next-runtime-env` 这个库,它可以让我们在 Next.js 中使用 Runtime Env。我们可以通过它来实现一次构建多处部署。\n\n```bash\nnpm i next-runtime-env\n```\n\n更换 Client 侧的环境变量使用方式:\n\n```tsx\nimport { env } from 'next-runtime-env'\n\nconst API_URL = process.env.NEXT_PUBLIC_API_URL // [!code --]\nconst API_URL = env('NEXT_PUBLIC_API_URL') // [!code ++]\n\nexport const fetchJson = () => fetch(API_URL as string).then((r) => r.json())\n```\n\n然后在 `app/layout.tsx` 上增加环境变量注入 Script。\n\n```tsx filename=\"app/layout.tsx\"\nimport { PublicEnvScript } from 'next-runtime-env'\n\nexport default function RootLayout({\n children,\n}: Readonly<{\n children: React.ReactNode\n}>) {\n return (\n \n \n // [!code ++]\n \n {children}\n \n )\n}\n```\n\n那么这样就可以了。\n\n现在我们来试一试。我们有这样页面,直接渲染上述 `API_URL` 的响应数据。\n\n```tsx\n'use client'\n\nexport default function Home() {\n const [json, setJson] = useState(null)\n useEffect(() => {\n fetchJson().then((r) => setJson(r))\n }, [])\n return JSON.stringify(json)\n}\n```\n\n现在我们使用 `next build` 构建项目,然后在构建之后,修改 `.env` 中的 `NEXT_PUBLIC_API_URL`,然后使用 `next start` 启动项目,观察实际请求的接口是否随着 `.env` 的修改而变化。\n\n现在我们的 `NEXT_PUBLIC_API_URL=https://jsonplaceholder.typicode.com/todos/2`,启动项目之后,浏览器请求的是 `https://jsonplaceholder.typicode.com/todos/2`。\n\n![](https://object.innei.in/bed/2024/0329_1711712558782.png)\n\n当我们修改 `.env` 中的 `NEXT_PUBLIC_API_URL` 为 `https://jsonplaceholder.typicode.com/todos/3`,然后重启项目,浏览器请求的是 `https://jsonplaceholder.typicode.com/todos/3`。\n\n![](https://object.innei.in/bed/2024/0329_1711712596345.png)\n\n这样我们就实现了一次构建多处部署,只需要修改 env 即可。\n\n## 深入了解 Runtime Env\n\n其实 `next-runtime-env` 的实现原理非常简单,`` 实际就是在 `` 中注入了一个 `\n```\n\n由于 `` 中的 script 会在页面水合前被执行,所以我们可以在 Client 侧通过 `window['__ENV']` 来获取环境变量,而 `next-runtime-env` 提供 `env()`正是这样实现的。而这个环境变量在 Server Side 都是动态的,所以在 Server Side 的取值永远都是通过 `process.env[']`。\n\n下面的简略的代码展示了 `env()` 的实现。\n\n```tsx\nexport function env(key: string): string | undefined {\n if (isBrowser()) {\n if (!key.startsWith('NEXT_PUBLIC_')) {\n throw new Error(\n `Environment variable '${key}' is not public and cannot be accessed in the browser.`,\n );\n }\n\n return window['__ENV'][key];\n }\n\n return process.env[key];\n}\n```\n\n## 构建一个无环境变量依赖的产物\n\n一个项目中,一般都会存在大量的环境变量,有部分环境变量只会在 Client Side 使用,在项目 build 过程中,必须要正确的注入环境变量,否则会导致项目无法通过构建。\n\n例如常见的 `API_URL` 变量,是请求接口的地址,在构建中,如果没有值,就会导致预渲染中的接口请求错误导致构建失败。比如在 Route Handler 中,我们有这样一个函数。\n\n```ts filename=\"app/feed/route.ts\"\nimport { NextResponse } from 'next/server'\n\nimport { fetchJson } from '../../../lib/api'\n\nexport const GET = async () => {\n await fetchJson()\n return NextResponse.json({})\n}\n```\n\n当 `API_URL` 为空时,`fetchJson` 会报错,导致构建失败。\n\n```\n ✓ Collecting page data \n Generating static pages (0/6) [ ]\nError occurred prerendering page \"/feed\". Read more: https://nextjs.org/docs/messages/prerender-error\n\nTypeError: Failed to parse URL from \n```\n\n这是因为在 Next.js 中,默认对 Route handler 进行了预渲染,而在预渲染过程中,`fetchJson` 会被执行,而 `API_URL` 为空,导致请求失败。\n\n只需要使用 `noStore()` 或者改变 dynamic 的方式,就可以解决这个问题。\n\n```ts filename=\"app/feed/route.ts\"\nimport { unstable_noStore } from 'next/cache'\nimport { NextResponse } from 'next/server'\n\nimport { fetchJson } from '../../../lib/api'\n\nexport const dynamic = 'force-dynamic' // 方式 2\n\nexport const GET = async () => {\n unstable_noStore() // 方式 1\n await fetchJson()\n return NextResponse.json({})\n}\n```\n\n那么,在其他的页面构建中,如果也遇到类似的问题,也修改这个地方就可以了。\n\n构建的时候,我们没有注入任何的环境变量,在启动构建后的服务之前,记得一定要在当前目录下创建一个 `.env` 文件,并且正确填写变量值,这样才能保证项目正常运行。\n\n## 通过 Dockerfile 构建无环境变量依赖的镜像\n\n在上节的基础上,对整个构建过程进一步封装,使用 Docker 完成整个构建然后发布到 Docker Hub,真正意义上实现一次构建多处部署。\n\n创建一个 `Dockerfile` 文件。\n\n```dockerfile\nFROM node:18-alpine AS base\n\nRUN npm install -g --arch=x64 --platform=linux sharp\n\nFROM base AS deps\n\nRUN apk add --no-cache libc6-compat\nRUN apk add --no-cache python3 make g++\n\nWORKDIR /app\n\nCOPY . .\n\nRUN npm install -g pnpm\nRUN pnpm install\n\nFROM base AS builder\n\nRUN apk update && apk add --no-cache git\n\n\nWORKDIR /app\nCOPY --from=deps /app/ .\nRUN npm install -g pnpm\n\nENV NODE_ENV production\nRUN pnpm build\n\nFROM base AS runner\nWORKDIR /app\n\nENV NODE_ENV production\n\n# and other docker env inject\nCOPY --from=builder /app/public ./public\nCOPY --from=builder /app/.next/standalone ./\nCOPY --from=builder /app/.next/static ./.next/static\nCOPY --from=builder /app/.next/server ./.next/server\n\nEXPOSE 2323\n\nENV PORT 2323\nENV NEXT_SHARP_PATH=/usr/local/lib/node_modules/sharp\nCMD node server.js;\n```\n\n上面的 dockerfile 在官网版本的基础上做了修改,已在 [Shiro](https://github.com/Innei/Shiro) 中落地使用。\n\n由于 Next.js standalone build 中并不包含 sharp 依赖,所以在 Docker 构建中我们首先全局安装了 sharp,并且在后续注入了 sharp 的安装位置的环境变量。\n\n这样构建的 Docker 镜像也不依赖于环境变量,并且 standalone build 让 Docker image 的占用空间更小。\n\n通过 Docker 容器的路径映射,我们只需要把当前目录下的 `.env` 映射到容器内部的 `/app/.env` 即可。\n\n这里编写一个简单的 Docker compose 实例。\n\n```yaml\nversion: '3'\n\nservices:\n shiro:\n container_name: shiro\n image: innei/shiro:latest\n volumes:\n - ./.env:/app/.env # 映射 .env 文件\n restart: always\n ports:\n - 2323:2323\n```\n\n大功告成,后续任何人只需要通过 Docker pull 取得构建后的镜像然后再修改本地 `.env` 就能够运行属于自己环境的项目了。\n\n此文由 [Mix Space](https://github.com/mx-space) 同步更新至 xLog\n原始链接为

","date_published":"2024-03-29T12:27:33.810Z","external_urls":["https://innei-4525.xlog.app/nextjs-runtime-env-and-build-once-deploy-many"],"sources":["xlog"],"tags":["post"],"title":"一次构建多处部署 - Next.js Runtime Env"}