各位听众,大家好,我是你们的老朋友,一个在 React 的代码海洋里摸爬滚打多年,头发日渐稀疏但依然热爱技术的资深工程师。
今天我们不聊那些虚头巴脑的“架构模式”,也不讲什么“设计模式六大原则”。今天我们要聊的是个硬骨头——React 环境感知。
特别是当 Next.js App Router 这种“双城记”的架构横空出世后,我们不得不面对一个极其尴尬的现实:服务端组件(RSC)和客户端组件(CSC)正在打冷战。
想象一下,你在服务端写了一行优雅的代码,正准备用 fs 读取文件,结果在客户端运行时,浏览器直接给你弹出一个红色的报错窗口:“Oh no! I cannot access ‘fs’”。这就像你跟女朋友约会,她在服务端说“我想吃火锅”,结果到了客户端,她变成了“我想吃沙拉”,还告诉你“火锅在浏览器里是非法的”。
如何在服务端和客户端之间,安全地共享常量、配置和工具函数?这不仅是技术问题,更是情商问题——你要学会跟这两位“性格迥异”的组件打好交道。
准备好了吗?让我们开始这场关于“共享”的冒险。
第一部分:冷战的开端——为什么我们不能共享?
首先,我们要搞清楚,为什么不能直接共享?这就像你不能把一个会飞的烤面包机放在一个不能飞的游泳池里。
1. 服务端的特权
服务端组件运行在 Node.js 环境中。那里有 fs(文件系统)、path(路径操作)、process(进程信息)、fetch(网络请求)等等。服务端组件是“上帝视角”,它知道服务器长什么样,知道数据库在哪。
2. 客户端的限制
客户端组件运行在浏览器中。那里只有 window、document、navigator。它不知道什么是文件系统,不知道什么是服务器进程。如果你在客户端组件里写了 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>
);
}
为什么这招高明?
- 零运行时开销: 常量在构建时就变成了代码,不需要序列化/反序列化。
- 类型安全: TypeScript 能完美识别
CONFIG的结构。 - 无副作用: 这个文件里没有任何服务端特有的 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 里配置了 serverRuntimeConfig 和 publicRuntimeConfig。
正确的做法:
- 客户端组件: 直接用
process.env.NEXT_PUBLIC_*。 - 服务端组件: 使用
getServerSideProps的context,或者在layout.tsx的props中传递,或者使用 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 就在两边的代码里。
第六部分:实战演练——一个完整的案例
假设我们正在开发一个电商网站。我们需要共享以下配置:
- 全局常量: 货币符号(¥)、时区。
- API 配置: API 基础地址、超时时间。
- 功能开关: 是否开启新支付流程。
工程实践:
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>;
}
- 后果: 构建时出错,或者运行时获取不到值。
- 正确做法: 在客户端组件里读取,或者使用
getServerSideProps的context。
3. 在常量文件里写逻辑
// ❌ 反模式
// app/constants.ts
export const getFormattedDate = () => {
return new Date().toISOString();
}
- 后果: 这个函数在服务端每次渲染都会执行,产生副作用。在客户端,它会依赖服务端的时区(虽然现在浏览器大多统一了),而且每次渲染都会变,导致组件无限重渲染。
- 正确做法: 常量应该是静态的、不可变的。如果要动态计算,把它放到组件内部。
第八部分:进阶技巧——共享工具函数
除了常量,我们经常需要共享一些工具函数,比如 formatCurrency、debounce、clsx。
原则:
工具函数必须是纯函数。没有副作用,不依赖外部状态,输入相同,输出必然相同。
// 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 共存的今天,共享常量和配置的核心在于“隔离”与“生成”。
- 如果只是简单共享: 使用构建时生成文件。这是最优雅、最安全、性能最好的方案。
- 如果是环境相关的: 使用 Next.js 的环境变量。记住,敏感信息不要暴露给客户端。
- 如果是类型定义: 使用 TypeScript 接口或 Zod Schema。
- 如果是工具函数: 编写纯函数,放在
lib目录下。 - 最后手段: 使用
use client指令。虽然它增加了客户端的负担,但它是解决复杂依赖关系的最终防线。
最后给各位的一句忠告:
不要试图把所有东西都塞进一个 constants.js 文件里。随着项目变大,那个文件会变成一个充满了 if (process.env.NODE_ENV === 'production') 的屎山。保持常量的纯粹性,保持配置的简单性。
好了,今天的讲座就到这里。希望大家在未来的开发中,不再被“服务端组件”和“客户端组件”的边界搞得头秃。记住,代码是写给人看的,偶尔也要给机器运行。Happy Coding!