React 环境感知:在服务端组件与客户端组件间安全共享常量与配置的工程实践

各位听众,大家好,我是你们的老朋友,一个在 React 的代码海洋里摸爬滚打多年,头发日渐稀疏但依然热爱技术的资深工程师。

今天我们不聊那些虚头巴脑的“架构模式”,也不讲什么“设计模式六大原则”。今天我们要聊的是个硬骨头——React 环境感知

特别是当 Next.js App Router 这种“双城记”的架构横空出世后,我们不得不面对一个极其尴尬的现实:服务端组件(RSC)和客户端组件(CSC)正在打冷战

想象一下,你在服务端写了一行优雅的代码,正准备用 fs 读取文件,结果在客户端运行时,浏览器直接给你弹出一个红色的报错窗口:“Oh no! I cannot access ‘fs’”。这就像你跟女朋友约会,她在服务端说“我想吃火锅”,结果到了客户端,她变成了“我想吃沙拉”,还告诉你“火锅在浏览器里是非法的”。

如何在服务端和客户端之间,安全地共享常量、配置和工具函数?这不仅是技术问题,更是情商问题——你要学会跟这两位“性格迥异”的组件打好交道。

准备好了吗?让我们开始这场关于“共享”的冒险。


第一部分:冷战的开端——为什么我们不能共享?

首先,我们要搞清楚,为什么不能直接共享?这就像你不能把一个会飞的烤面包机放在一个不能飞的游泳池里。

1. 服务端的特权
服务端组件运行在 Node.js 环境中。那里有 fs(文件系统)、path(路径操作)、process(进程信息)、fetch(网络请求)等等。服务端组件是“上帝视角”,它知道服务器长什么样,知道数据库在哪。

2. 客户端的限制
客户端组件运行在浏览器中。那里只有 windowdocumentnavigator。它不知道什么是文件系统,不知道什么是服务器进程。如果你在客户端组件里写了 import { writeFile } from 'fs',恭喜你,你的构建过程会直接报错,或者你的页面会在运行时直接崩溃。

3. 常量的尴尬
看似简单的常量,也藏着深坑。

// ❌ 错误示范:如果这个文件被 import 到了客户端组件
const DEBUG_MODE = true; 
// 如果在服务端编译时,这段代码没问题。
// 但是如果这个文件被移动到客户端,或者被动态导入,它会变成运行时变量。
// 更糟糕的是,如果这个常量引用了服务端特有的对象,比如 require('crypto'),客户端就会炸。

export const API_ENDPOINTS = {
  login: '/api/login',
  logout: '/api/logout'
};

如果客户端组件需要访问这个配置,它必须知道怎么安全地拿到它。如果直接 import,服务端的逻辑可能会泄漏到客户端,导致 Bundle 体积爆炸,或者安全漏洞。

所以,我们需要一种机制,既能让服务端组件拿到配置,又能让客户端组件安全使用,且不破坏构建流程。


第二部分:最简单的解法——use client 指令

这是最粗暴,但也最有效的解法。如果你觉得“共享”太麻烦,那就干脆不要共享,把它们隔离。

策略:
把常量文件改写成客户端组件,或者把需要常量的组件标记为 use client

// app/constants.tsx
'use client'; // 👈 关键!告诉 Next.js:这是客户端代码,别往服务端送

import { useMemo } from 'react';

export const APP_FEATURE_FLAGS = {
  ENABLE_ANALYTICS: true,
  ENABLE_NEW_DASHBOARD: false,
} as const;

// 这里不能直接使用服务端的 API,比如 fetch
// 但可以使用浏览器 API,比如 localStorage
export const getTheme = (): 'light' | 'dark' => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
  }
  return 'light';
};
// app/dashboard/page.tsx
import { APP_FEATURE_FLAGS, getTheme } from './constants';

export default function Dashboard() {
  // 直接使用,无需任何转换
  const isAnalyticsEnabled = APP_FEATURE_FLAGS.ENABLE_ANALYTICS;

  return (
    <div>
      <h1>Dashboard</h1>
      <p>Analytics is {isAnalyticsEnabled ? 'ON' : 'OFF'}</p>
      <p>Current theme: {getTheme()}</p>
    </div>
  );
}

优点: 极其简单,不需要任何构建工具配置,类型安全。
缺点: 代码重复。如果我有 10 个页面都需要这个配置,我就得在 10 个地方写 import。而且,这把所有逻辑都推到了客户端,增加了 Bundle 体积。如果你只是想共享一个 URL 列表,为什么要把它打包到每个客户端组件里呢?

所以,use client 是最后的手段,不是首选。


第三部分:构建时魔法——生成文件

这是很多资深工程化专家的“杀手锏”。既然我们不能在运行时动态共享,那就在构建时就把它们生成好。

核心思想:
在构建阶段,运行一个脚本,读取配置文件,生成一个 constants.ts 文件。这个文件包含了所有的常量,但它是纯静态的,既可以在服务端用,也可以在客户端用。

步骤 1:定义配置源
我们通常使用 JSON 或 YAML 来定义配置,因为它们是纯文本,不包含服务端特定的逻辑。

// config/settings.json
{
  "api": {
    "baseUrl": "https://api.example.com",
    "timeout": 5000
  },
  "features": {
    "beta": true
  }
}

步骤 2:编写生成脚本
我们需要一个 Node.js 脚本来读取这个 JSON,并把它转换成 TypeScript 代码。

// scripts/generate-constants.js
const fs = require('fs');
const path = require('path');

const configPath = path.join(__dirname, '../config/settings.json');
const outputPath = path.join(__dirname, '../src/constants/generated-constants.ts');

const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

// 将 JSON 转换为 TS 的 interface 和 const
const tsContent = `
// This file is auto-generated by scripts/generate-constants.js
// DO NOT EDIT MANUALLY

export const CONFIG = ${JSON.stringify(config, null, 2)} as const;
`;

fs.writeFileSync(outputPath, tsContent, 'utf8');
console.log('Constants generated successfully!');

步骤 3:集成到构建流程
package.json 中添加脚本,并在 Next.js 的构建钩子中调用它。

// package.json
{
  "scripts": {
    "build": "node scripts/generate-constants.js && next build",
    "dev": "node scripts/generate-constants.js && next dev"
  }
}

步骤 4:使用生成的常量

现在,我们有了这个 src/constants/generated-constants.ts,它是一个纯数据文件,没有任何副作用。

// app/layout.tsx (服务端组件)
import { CONFIG } from '@/constants/generated-constants';

export const metadata = {
  title: `API: ${CONFIG.api.baseUrl}`,
};

export default function RootLayout({ children }) {
  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

// app/page.tsx (可以是服务端或客户端)
import { CONFIG } from '@/constants/generated-constants';

export default function Page() {
  // 服务端组件可以直接使用
  console.log(CONFIG.api.timeout); // 5000

  return (
    <main>
      <h1>Welcome to {CONFIG.api.baseUrl}</h1>
    </main>
  );
}

// components/ClientComponent.tsx (客户端组件)
'use client';

import { CONFIG } from '@/constants/generated-constants';

export default function ClientComponent() {
  // 客户端组件也可以直接使用
  const isEnabled = CONFIG.features.beta;

  return (
    <div>
      <p>Beta feature is {isEnabled ? 'Enabled' : 'Disabled'}</p>
    </div>
  );
}

为什么这招高明?

  1. 零运行时开销: 常量在构建时就变成了代码,不需要序列化/反序列化。
  2. 类型安全: TypeScript 能完美识别 CONFIG 的结构。
  3. 无副作用: 这个文件里没有任何服务端特有的 API 调用,也没有副作用,Next.js 可以放心地把它优化成静态代码。

第四部分:环境变量——上帝的私房钱

这是 Next.js 提供的官方解决方案,也是处理敏感配置(API Key、Secret Key)的最佳实践。

核心原则:

  • 服务端变量:NEXT_PUBLIC_ 开头的变量会被打包到客户端。千万不要把密钥放在这里!
  • 客户端变量:NEXT_PUBLIC_ 开头的变量。

使用场景:
如果你有一个配置,它是动态的(比如根据环境不同而不同),或者它是敏感的,请使用环境变量。

# .env.local
NEXT_PUBLIC_API_BASE_URL=https://api.production.com
NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX

代码中的使用:

// app/api/login/route.ts (服务端组件)
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  // 服务端可以直接读取环境变量,不暴露给前端
  const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL; // 这里的逻辑其实稍微有点绕,通常我们用服务端变量,但 Next.js 的设计是把所有变量都挂载到 process.env

  // 注意:在服务端,process.env.NEXT_PUBLIC_* 也可以读取,但通常我们建议使用服务端变量(不以下划线开头)配合 getServerSideProps 或 RSC 的 context
  // 但为了简单演示共享,我们假设这是配置

  return NextResponse.json({ message: 'Login success' });
}

等等,这里有个坑!Next.js 的 App Router 中,服务端组件读取环境变量有一个巨大的坑

在 Next.js App Router 中,如果你在服务端组件里写 process.env.MY_SECRET_KEY,这个变量在构建时会被替换成 undefined,除非你在 next.config.js 里配置了 serverRuntimeConfigpublicRuntimeConfig

正确的做法:

  1. 客户端组件: 直接用 process.env.NEXT_PUBLIC_*
  2. 服务端组件: 使用 getServerSidePropscontext,或者在 layout.tsxprops 中传递,或者使用 Next.js 的 getServerSideConfig(如果有的话)。

但是,对于常量(比如 API_URL),我们通常希望它在服务端和客户端都能用,或者至少在客户端能用。

// components/ClientButton.tsx
'use client';

export default function ClientButton() {
  const apiUrl = process.env.NEXT_PUBLIC_API_URL;

  const handleClick = () => {
    fetch(apiUrl).then(res => res.json());
  };

  return <button onClick={handleClick}>Fetch Data</button>;
}

注意: 环境变量在客户端是字符串。如果你需要数字,记得 parseInt(process.env.NUM, 10)


第五部分:类型共享——看不见的桥梁

有时候,我们共享的不是数据,而是类型。比如,我们在服务端定义了一个 API 响应的结构,客户端组件需要使用这个结构来渲染。

问题:
如果我在 app/types/api.ts 里定义了 interface User { name: string },然后在服务端组件里用了它,在客户端组件里也用了它,TypeScript 不会报错,因为类型在编译后会被擦除。

但是! 如果我有一个配置对象,它是一个复杂的类型,我需要确保客户端和服务端看到的类型定义是一致的。

策略 1:共享类型文件
直接把类型定义放在一个单独的文件里,不包含任何运行时逻辑。

// lib/types.ts
export interface AppConfig {
  version: string;
  features: {
    darkMode: boolean;
    notifications: boolean;
  };
}

export const defaultConfig: AppConfig = {
  version: '1.0.0',
  features: {
    darkMode: false,
    notifications: true,
  },
};

然后在服务端和客户端都 import 这个文件。

策略 2:使用 Zod 或 Yup(强烈推荐)
如果你需要在运行时验证配置,或者需要从 API 获取配置,Zod 是神器。Zod 的 Schema 既是类型定义,也是运行时验证器。

// lib/schema.ts
import { z } from 'zod';

// 定义 Schema
export const AppConfigSchema = z.object({
  version: z.string(),
  features: z.object({
    darkMode: z.boolean(),
  }),
});

// 获取类型
export type AppConfig = z.infer<typeof AppConfigSchema>;

// 在服务端解析
import { AppConfigSchema } from '@/lib/schema';

const rawConfig = { version: '2.0', features: { darkMode: true } };
const validatedConfig = AppConfigSchema.parse(rawConfig);

// 在客户端使用,类型也是 AppConfig

这完美解决了“类型不一致”的问题,因为 Schema 就在两边的代码里。


第六部分:实战演练——一个完整的案例

假设我们正在开发一个电商网站。我们需要共享以下配置:

  1. 全局常量: 货币符号(¥)、时区。
  2. API 配置: API 基础地址、超时时间。
  3. 功能开关: 是否开启新支付流程。

工程实践:

1. 创建配置源
我们使用 TypeScript 接口作为源,然后通过 Babel 插件或者脚本生成 JSON。

// config/default.ts
export interface AppConfig {
  currency: {
    symbol: string;
    locale: string;
  };
  api: {
    baseUrl: string;
    timeout: number;
  };
  features: {
    newCheckout: boolean;
  };
}

// 默认配置
export const DEFAULT_CONFIG: AppConfig = {
  currency: { symbol: '¥', locale: 'zh-CN' },
  api: { baseUrl: 'https://api.mysite.com', timeout: 3000 },
  features: { newCheckout: false },
};

2. 编写构建脚本
这里我们用一个简单的 Node 脚本来生成最终的常量文件。

// scripts/generate-config.js
const fs = require('fs');
const path = require('path');

const sourcePath = path.join(__dirname, '../config/default.ts');
const targetPath = path.join(__dirname, '../src/constants/config.ts');

// 简单的读取和替换(实际项目中可以用更复杂的工具,如 babel-plugin-transform-typescript-to-json)
// 这里为了演示,我们手动写死生成逻辑
const tsCode = `
// Auto-generated configuration
// Source: config/default.ts

export const CONFIG = ${JSON.stringify(require('../config/default.ts').DEFAULT_CONFIG, null, 2)} as const;

export type AppConfig = import('../config/default.ts').AppConfig;
`;

fs.writeFileSync(targetPath, tsCode, 'utf8');
console.log('Config file generated at', targetPath);

3. 在服务端使用
app/layout.tsx 中使用。

// app/layout.tsx
import { CONFIG, AppConfig } from '@/constants/config';

export const metadata: {
  title: string;
  description: string;
  openGraph: {
    type: string;
    locale: string;
    url: string;
    siteName: string;
  };
} = {
  title: `My Shop - v${CONFIG.version}`,
  description: 'Best shop in the world',
  openGraph: {
    type: 'website',
    locale: CONFIG.currency.locale,
    url: 'https://mysite.com',
    siteName: 'My Shop',
  },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang={CONFIG.currency.locale}>
      <body>{children}</body>
    </html>
  );
}

4. 在客户端使用
components/ProductList.tsx 中使用。

// components/ProductList.tsx
'use client';

import { CONFIG } from '@/constants/config';

export default function ProductList() {
  return (
    <div>
      <h1>Products</h1>
      <p>Price: {CONFIG.currency.symbol} 99.00</p>
      {/* 这是一个动态判断的例子 */}
      {CONFIG.features.newCheckout && <button>Checkout with New Flow</button>}
    </div>
  );
}

5. 处理环境差异
如果 api.baseUrl 需要根据环境变化,我们不要在代码里写死,而是使用环境变量。

// src/constants/config.ts
import { DEFAULT_CONFIG } from '../config/default';

// 环境变量覆盖默认配置
// 注意:在客户端必须用 NEXT_PUBLIC_ 前缀,否则服务端会读取不到
const envConfig = {
  ...DEFAULT_CONFIG,
  api: {
    ...DEFAULT_CONFIG.api,
    baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL || DEFAULT_CONFIG.api.baseUrl,
  },
};

export const CONFIG = envConfig;

第七部分:那些年我们踩过的坑(反模式)

在工程实践中,为了图省事,我们经常写出一些“毒瘤”代码。下面列出几个常见的反模式,请务必避坑。

1. JSON.parse(JSON.stringify(data))
这是最丑陋的共享数据方式。

// ❌ 反模式
const sharedData = { a: 1, b: 2 };
const clientData = JSON.parse(JSON.stringify(sharedData));
  • 后果:
    • 失去类型:clientData 变成了 any
    • 失去方法:如果对象是 Date,它会变成字符串。如果对象有自定义方法,它会被丢失。
    • 性能差:深拷贝非常慢。
    • 循环引用:会直接报错。
  • 正确做法: 使用构建时生成文件,或者直接把常量定义在两边的代码里。

2. 在服务端组件里读取客户端环境变量

// ❌ 反模式
export default function Page() {
  // 这里 process.env 会是 undefined
  console.log(process.env.NEXT_PUBLIC_CLIENT_VAR); 
  return <div>...</div>;
}
  • 后果: 构建时出错,或者运行时获取不到值。
  • 正确做法: 在客户端组件里读取,或者使用 getServerSidePropscontext

3. 在常量文件里写逻辑

// ❌ 反模式
// app/constants.ts
export const getFormattedDate = () => {
  return new Date().toISOString();
}
  • 后果: 这个函数在服务端每次渲染都会执行,产生副作用。在客户端,它会依赖服务端的时区(虽然现在浏览器大多统一了),而且每次渲染都会变,导致组件无限重渲染。
  • 正确做法: 常量应该是静态的、不可变的。如果要动态计算,把它放到组件内部。

第八部分:进阶技巧——共享工具函数

除了常量,我们经常需要共享一些工具函数,比如 formatCurrencydebounceclsx

原则:
工具函数必须是纯函数。没有副作用,不依赖外部状态,输入相同,输出必然相同。

// lib/utils.ts
export function formatCurrency(amount: number, currency: string = 'USD') {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

export function cn(...classes: (string | boolean | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

然后在服务端和客户端都可以 import。

// app/page.tsx
import { formatCurrency } from '@/lib/utils';

export default function Page() {
  return <div>Total: {formatCurrency(100)}</div>;
}

因为工具函数通常不包含浏览器特有的 API,它们天然兼容 RSC 和 CSC。


第九部分:总结与建议

好了,各位听众,我们已经走完了这段从“混乱”到“有序”的旅程。

在 React Server Components 和 Client Components 共存的今天,共享常量和配置的核心在于“隔离”与“生成”

  1. 如果只是简单共享: 使用构建时生成文件。这是最优雅、最安全、性能最好的方案。
  2. 如果是环境相关的: 使用 Next.js 的环境变量。记住,敏感信息不要暴露给客户端。
  3. 如果是类型定义: 使用 TypeScript 接口或 Zod Schema。
  4. 如果是工具函数: 编写纯函数,放在 lib 目录下。
  5. 最后手段: 使用 use client 指令。虽然它增加了客户端的负担,但它是解决复杂依赖关系的最终防线。

最后给各位的一句忠告:
不要试图把所有东西都塞进一个 constants.js 文件里。随着项目变大,那个文件会变成一个充满了 if (process.env.NODE_ENV === 'production') 的屎山。保持常量的纯粹性,保持配置的简单性。

好了,今天的讲座就到这里。希望大家在未来的开发中,不再被“服务端组件”和“客户端组件”的边界搞得头秃。记住,代码是写给人看的,偶尔也要给机器运行。Happy Coding!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注