Skip to content

第4章:开发环境搭建与工具链配置

4.1 开发环境概览

工具链架构图

环境要求清单

工具/框架最低版本推荐版本说明
Node.js16.14.018.17.0+支持 ES2022 特性
React18.0.018.2.0+必须支持 Concurrent Features
TypeScript4.9.05.0.0+更好的类型推断
Next.js13.0.014.0.0+App Router 支持
Webpack5.75.05.88.0+RSC 插件支持

4.2 Next.js 环境搭建

项目初始化

bash
# 1. 创建 Next.js 项目
npx create-next-app@latest my-rsc-app --typescript --tailwind --eslint --app

# 2. 进入项目目录
cd my-rsc-app

# 3. 安装额外依赖
npm install @types/node @types/react @types/react-dom

# 4. 开发依赖
npm install -D @typescript-eslint/eslint-plugin @typescript-eslint/parser

项目结构

my-rsc-app/
├── app/                          # App Router 目录
│   ├── globals.css              # 全局样式
│   ├── layout.tsx               # 根布局 (Server Component)
│   ├── page.tsx                 # 首页 (Server Component)
│   ├── loading.tsx              # 加载 UI
│   ├── error.tsx                # 错误 UI
│   ├── not-found.tsx            # 404 页面
│   └── api/                     # API 路由
│       └── hello/
│           └── route.ts
├── components/                   # 组件目录
│   ├── server/                  # Server Components
│   ├── client/                  # Client Components
│   └── shared/                  # 共享组件
├── lib/                         # 工具库
│   ├── db.ts                    # 数据库配置
│   ├── utils.ts                 # 工具函数
│   └── types.ts                 # 类型定义
├── public/                      # 静态资源
├── next.config.js               # Next.js 配置
├── tailwind.config.js           # Tailwind 配置
├── tsconfig.json                # TypeScript 配置
└── package.json

Next.js 配置文件

javascript
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 启用实验性功能
  experimental: {
    // 服务器组件
    serverComponents: true,
    // 服务器操作
    serverActions: true,
    // 部分预渲染
    ppr: true,
  },
  
  // 编译配置
  compiler: {
    // 移除 console.log(生产环境)
    removeConsole: process.env.NODE_ENV === 'production',
  },
  
  // 图片优化
  images: {
    domains: ['example.com', 'cdn.example.com'],
    formats: ['image/webp', 'image/avif'],
  },
  
  // 重定向配置
  async redirects() {
    return [
      {
        source: '/old-page',
        destination: '/new-page',
        permanent: true,
      },
    ];
  },
  
  // 重写配置
  async rewrites() {
    return [
      {
        source: '/api/proxy/:path*',
        destination: 'https://external-api.com/:path*',
      },
    ];
  },
  
  // 环境变量
  env: {
    CUSTOM_KEY: process.env.CUSTOM_KEY,
  },
  
  // Webpack 配置
  webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
    // 自定义 Webpack 配置
    if (!isServer) {
      config.resolve.fallback = {
        ...config.resolve.fallback,
        fs: false,
        net: false,
        tls: false,
      };
    }
    
    return config;
  },
};

module.exports = nextConfig;

TypeScript 配置

json
// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "ES2022"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@/components/*": ["./components/*"],
      "@/lib/*": ["./lib/*"],
      "@/app/*": ["./app/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    ".next/types/**/*.ts"
  ],
  "exclude": ["node_modules"]
}

ESLint 配置

json
// .eslintrc.json
{
  "extends": [
    "next/core-web-vitals",
    "@typescript-eslint/recommended"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {
    // React Server Components 相关规则
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn",
    
    // TypeScript 规则
    "@typescript-eslint/no-unused-vars": "error",
    "@typescript-eslint/no-explicit-any": "warn",
    "@typescript-eslint/prefer-const": "error",
    
    // 通用规则
    "no-console": "warn",
    "prefer-const": "error",
    "no-var": "error"
  },
  "overrides": [
    {
      // Server Components 特殊规则
      "files": ["app/**/*.tsx", "components/server/**/*.tsx"],
      "rules": {
        "react-hooks/rules-of-hooks": "off"
      }
    }
  ]
}

4.3 基础组件示例

根布局组件

typescript
// app/layout.tsx - Root Layout (Server Component)
import './globals.css';
import { Inter } from 'next/font/google';
import { Metadata } from 'next';
import { Analytics } from '@/components/analytics';
import { ThemeProvider } from '@/components/theme-provider';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: {
    default: 'React Server Components Demo',
    template: '%s | RSC Demo'
  },
  description: 'React Server Components 演示应用',
  keywords: ['React', 'Server Components', 'Next.js'],
  authors: [{ name: 'Your Name' }],
  openGraph: {
    title: 'React Server Components Demo',
    description: 'React Server Components 演示应用',
    url: 'https://your-domain.com',
    siteName: 'RSC Demo',
    images: [
      {
        url: 'https://your-domain.com/og-image.jpg',
        width: 1200,
        height: 630,
      },
    ],
    locale: 'zh_CN',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'React Server Components Demo',
    description: 'React Server Components 演示应用',
    images: ['https://your-domain.com/twitter-image.jpg'],
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
};

interface RootLayoutProps {
  children: React.ReactNode;
}

export default function RootLayout({ children }: RootLayoutProps) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <body className={inter.className}>
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <div className="min-h-screen bg-background font-sans antialiased">
            <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
              <div className="container flex h-14 items-center">
                <nav className="flex items-center space-x-6 text-sm font-medium">
                  <a href="/" className="transition-colors hover:text-foreground/80">
                    首页
                  </a>
                  <a href="/posts" className="transition-colors hover:text-foreground/80">
                    文章
                  </a>
                  <a href="/about" className="transition-colors hover:text-foreground/80">
                    关于
                  </a>
                </nav>
              </div>
            </header>
            
            <main className="flex-1">
              {children}
            </main>
            
            <footer className="border-t py-6 md:py-0">
              <div className="container flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
                <p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
                  Built with React Server Components and Next.js
                </p>
              </div>
            </footer>
          </div>
        </ThemeProvider>
        <Analytics />
      </body>
    </html>
  );
}

首页组件

typescript
// app/page.tsx - Home Page (Server Component)
import { Suspense } from 'react';
import { PostList } from '@/components/server/post-list';
import { UserStats } from '@/components/server/user-stats';
import { InteractiveDemo } from '@/components/client/interactive-demo';
import { LoadingSpinner } from '@/components/shared/loading-spinner';
import { getRecentPosts, getUserStats } from '@/lib/data';

// 这个组件在服务器端运行
export default async function HomePage() {
  // 并行获取数据
  const [recentPosts, stats] = await Promise.all([
    getRecentPosts(6),
    getUserStats()
  ]);

  return (
    <div className="container mx-auto px-4 py-8">
      {/* Hero Section */}
      <section className="text-center py-12">
        <h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-6xl">
          React Server Components
        </h1>
        <p className="mt-6 text-lg leading-8 text-gray-600">
          体验零 Bundle 影响的服务器端渲染
        </p>
      </section>

      {/* Stats Section */}
      <section className="py-8">
        <Suspense fallback={<LoadingSpinner />}>
          <UserStats stats={stats} />
        </Suspense>
      </section>

      {/* Posts Section */}
      <section className="py-8">
        <h2 className="text-2xl font-bold mb-6">最新文章</h2>
        <Suspense fallback={<PostListSkeleton />}>
          <PostList posts={recentPosts} />
        </Suspense>
      </section>

      {/* Interactive Demo */}
      <section className="py-8">
        <h2 className="text-2xl font-bold mb-6">交互演示</h2>
        <InteractiveDemo />
      </section>
    </div>
  );
}

// 骨架屏组件
function PostListSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="bg-gray-200 h-48 rounded-lg mb-4"></div>
          <div className="bg-gray-200 h-4 rounded mb-2"></div>
          <div className="bg-gray-200 h-4 rounded w-3/4"></div>
        </div>
      ))}
    </div>
  );
}

Server Component 示例

typescript
// components/server/post-list.tsx
import { Post } from '@/lib/types';
import { PostCard } from './post-card';

interface PostListProps {
  posts: Post[];
}

// Server Component - 在服务器端渲染
export async function PostList({ posts }: PostListProps) {
  // 可以在这里进行额外的数据处理
  const processedPosts = posts.map(post => ({
    ...post,
    readingTime: calculateReadingTime(post.content)
  }));

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
      {processedPosts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

function calculateReadingTime(content: string): number {
  const wordsPerMinute = 200;
  const words = content.split(' ').length;
  return Math.ceil(words / wordsPerMinute);
}
typescript
// components/server/post-card.tsx
import { Post } from '@/lib/types';
import { formatDate } from '@/lib/utils';
import Link from 'next/link';

interface PostCardProps {
  post: Post & { readingTime: number };
}

export function PostCard({ post }: PostCardProps) {
  return (
    <article className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
      {post.coverImage && (
        <img 
          src={post.coverImage} 
          alt={post.title}
          className="w-full h-48 object-cover"
        />
      )}
      
      <div className="p-6">
        <h3 className="text-xl font-semibold mb-2">
          <Link 
            href={`/posts/${post.slug}`}
            className="hover:text-blue-600 transition-colors"
          >
            {post.title}
          </Link>
        </h3>
        
        <p className="text-gray-600 mb-4 line-clamp-3">
          {post.excerpt}
        </p>
        
        <div className="flex items-center justify-between text-sm text-gray-500">
          <span>{formatDate(post.publishedAt)}</span>
          <span>{post.readingTime} 分钟阅读</span>
        </div>
        
        <div className="flex flex-wrap gap-2 mt-4">
          {post.tags.map(tag => (
            <span 
              key={tag}
              className="px-2 py-1 bg-gray-100 text-gray-700 text-xs rounded"
            >
              {tag}
            </span>
          ))}
        </div>
      </div>
    </article>
  );
}

Client Component 示例

typescript
// components/client/interactive-demo.tsx
'use client';

import { useState, useEffect } from 'react';
import { Button } from '@/components/shared/button';

export function InteractiveDemo() {
  const [count, setCount] = useState(0);
  const [mounted, setMounted] = useState(false);
  const [data, setData] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);

  // 防止 hydration 不匹配
  useEffect(() => {
    setMounted(true);
  }, []);

  const fetchData = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/demo-data');
      const result = await response.json();
      setData(result);
    } catch (error) {
      console.error('获取数据失败:', error);
    } finally {
      setLoading(false);
    }
  };

  const handleIncrement = () => {
    setCount(prev => prev + 1);
    
    // 保存到本地存储
    if (typeof window !== 'undefined') {
      localStorage.setItem('demo-count', (count + 1).toString());
    }
  };

  // 从本地存储恢复状态
  useEffect(() => {
    if (typeof window !== 'undefined') {
      const savedCount = localStorage.getItem('demo-count');
      if (savedCount) {
        setCount(parseInt(savedCount, 10));
      }
    }
  }, []);

  if (!mounted) {
    return (
      <div className="bg-gray-50 p-6 rounded-lg">
        <div className="animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
          <div className="h-10 bg-gray-200 rounded w-32"></div>
        </div>
      </div>
    );
  }

  return (
    <div className="bg-gray-50 p-6 rounded-lg">
      <h3 className="text-lg font-semibold mb-4">客户端交互演示</h3>
      
      <div className="space-y-4">
        <div className="flex items-center space-x-4">
          <span className="text-2xl font-bold">{count}</span>
          <Button onClick={handleIncrement}>
            增加计数
          </Button>
        </div>
        
        <div className="flex items-center space-x-4">
          <Button 
            onClick={fetchData} 
            disabled={loading}
            variant="outline"
          >
            {loading ? '加载中...' : '获取数据'}
          </Button>
        </div>
        
        {data.length > 0 && (
          <div className="mt-4">
            <h4 className="font-medium mb-2">获取的数据:</h4>
            <ul className="space-y-1">
              {data.slice(0, 5).map((item, index) => (
                <li key={index} className="text-sm text-gray-600">
                  {JSON.stringify(item)}
                </li>
              ))}
            </ul>
          </div>
        )}
      </div>
    </div>
  );
}

4.4 数据层配置

数据库配置

typescript
// lib/db.ts
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';

// 数据库连接池
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// Drizzle ORM 实例
export const db = drizzle(pool);

// 数据库迁移
export async function runMigrations() {
  await migrate(db, { migrationsFolder: './drizzle' });
}

// 连接测试
export async function testConnection() {
  try {
    const client = await pool.connect();
    const result = await client.query('SELECT NOW()');
    client.release();
    console.log('数据库连接成功:', result.rows[0]);
    return true;
  } catch (error) {
    console.error('数据库连接失败:', error);
    return false;
  }
}

// 优雅关闭
process.on('SIGINT', async () => {
  await pool.end();
  process.exit(0);
});

数据模型定义

typescript
// lib/schema.ts
import { pgTable, serial, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

// 用户表
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  avatar: text('avatar'),
  bio: text('bio'),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

// 文章表
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  excerpt: text('excerpt'),
  coverImage: text('cover_image'),
  published: boolean('published').default(false),
  authorId: integer('author_id').references(() => users.id),
  publishedAt: timestamp('published_at'),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

// 标签表
export const tags = pgTable('tags', {
  id: serial('id').primaryKey(),
  name: text('name').notNull().unique(),
  slug: text('slug').notNull().unique(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow(),
});

// 文章标签关联表
export const postTags = pgTable('post_tags', {
  id: serial('id').primaryKey(),
  postId: integer('post_id').references(() => posts.id),
  tagId: integer('tag_id').references(() => tags.id),
});

// 关系定义
export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, {
    fields: [posts.authorId],
    references: [users.id],
  }),
  postTags: many(postTags),
}));

export const tagsRelations = relations(tags, ({ many }) => ({
  postTags: many(postTags),
}));

export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, {
    fields: [postTags.postId],
    references: [posts.id],
  }),
  tag: one(tags, {
    fields: [postTags.tagId],
    references: [tags.id],
  }),
}));

数据访问层

typescript
// lib/data.ts
import { db } from './db';
import { users, posts, tags, postTags } from './schema';
import { eq, desc, and, like, sql } from 'drizzle-orm';
import { cache } from 'react';

// React 缓存装饰器
export const getUser = cache(async (id: number) => {
  const user = await db.select().from(users).where(eq(users.id, id)).limit(1);
  return user[0] || null;
});

export const getUserByEmail = cache(async (email: string) => {
  const user = await db.select().from(users).where(eq(users.email, email)).limit(1);
  return user[0] || null;
});

export const getRecentPosts = cache(async (limit: number = 10) => {
  return await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      excerpt: posts.excerpt,
      coverImage: posts.coverImage,
      publishedAt: posts.publishedAt,
      author: {
        id: users.id,
        name: users.name,
        avatar: users.avatar,
      },
    })
    .from(posts)
    .leftJoin(users, eq(posts.authorId, users.id))
    .where(eq(posts.published, true))
    .orderBy(desc(posts.publishedAt))
    .limit(limit);
});

export const getPostBySlug = cache(async (slug: string) => {
  const result = await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      content: posts.content,
      excerpt: posts.excerpt,
      coverImage: posts.coverImage,
      publishedAt: posts.publishedAt,
      author: {
        id: users.id,
        name: users.name,
        avatar: users.avatar,
        bio: users.bio,
      },
    })
    .from(posts)
    .leftJoin(users, eq(posts.authorId, users.id))
    .where(and(eq(posts.slug, slug), eq(posts.published, true)))
    .limit(1);
    
  return result[0] || null;
});

export const getPostTags = cache(async (postId: number) => {
  return await db
    .select({
      id: tags.id,
      name: tags.name,
      slug: tags.slug,
    })
    .from(tags)
    .leftJoin(postTags, eq(tags.id, postTags.tagId))
    .where(eq(postTags.postId, postId));
});

export const searchPosts = cache(async (query: string, limit: number = 10) => {
  return await db
    .select({
      id: posts.id,
      title: posts.title,
      slug: posts.slug,
      excerpt: posts.excerpt,
      publishedAt: posts.publishedAt,
    })
    .from(posts)
    .where(
      and(
        eq(posts.published, true),
        like(posts.title, `%${query}%`)
      )
    )
    .orderBy(desc(posts.publishedAt))
    .limit(limit);
});

export const getUserStats = cache(async () => {
  const [totalUsers, totalPosts, totalTags] = await Promise.all([
    db.select({ count: sql<number>`count(*)` }).from(users),
    db.select({ count: sql<number>`count(*)` }).from(posts).where(eq(posts.published, true)),
    db.select({ count: sql<number>`count(*)` }).from(tags),
  ]);
  
  return {
    users: totalUsers[0].count,
    posts: totalPosts[0].count,
    tags: totalTags[0].count,
  };
});

4.5 开发工具配置

VS Code 配置

json
// .vscode/settings.json
{
  "typescript.preferences.includePackageJsonAutoImports": "on",
  "typescript.suggest.autoImports": true,
  "editor.formatOnSave": true,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true,
    "source.organizeImports": true
  },
  "files.associations": {
    "*.css": "tailwindcss"
  },
  "emmet.includeLanguages": {
    "javascript": "javascriptreact",
    "typescript": "typescriptreact"
  },
  "tailwindCSS.experimental.classRegex": [
    ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
    ["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
    ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
  ]
}
json
// .vscode/extensions.json
{
  "recommendations": [
    "bradlc.vscode-tailwindcss",
    "esbenp.prettier-vscode",
    "dbaeumer.vscode-eslint",
    "ms-vscode.vscode-typescript-next",
    "formulahendry.auto-rename-tag",
    "christian-kohler.path-intellisense",
    "ms-vscode.vscode-json"
  ]
}

调试配置

json
// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Next.js: debug server-side",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "skipFiles": ["<node_internals>/**"]
    },
    {
      "name": "Next.js: debug client-side",
      "type": "chrome",
      "request": "launch",
      "url": "http://localhost:3000",
      "webRoot": "${workspaceFolder}"
    },
    {
      "name": "Next.js: debug full stack",
      "type": "node",
      "request": "launch",
      "program": "${workspaceFolder}/node_modules/.bin/next",
      "args": ["dev"],
      "env": {
        "NODE_OPTIONS": "--inspect"
      },
      "skipFiles": ["<node_internals>/**"],
      "console": "integratedTerminal"
    }
  ]
}

脚本配置

json
// package.json scripts
{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "lint:fix": "next lint --fix",
    "type-check": "tsc --noEmit",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "db:generate": "drizzle-kit generate:pg",
    "db:migrate": "drizzle-kit up:pg",
    "db:studio": "drizzle-kit studio",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "analyze": "ANALYZE=true next build",
    "clean": "rm -rf .next out dist"
  }
}

小结

本章详细介绍了 React Server Components 开发环境的搭建:

  1. 工具链配置:Node.js、TypeScript、ESLint 等基础工具的配置
  2. Next.js 设置:项目结构、配置文件和基础组件示例
  3. 数据层配置:数据库连接、模型定义和数据访问层
  4. 开发工具:VS Code 配置、调试设置和脚本配置

下一章我们将深入探讨传统 SSR 的实现原理,为理解 RSC 的优势做好准备。


微信公众号二维码