← Назад к вопросам

Как сделать классический рендеринг при универсальном API для разных потребителей?

2.3 Middle🔥 141 комментариев
#JavaScript Core

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Универсальное API для разных клиентов

Это вопрос о том, как сделать один API, который подходит как для веб-браузеров (SSR, CSR), так и для нативных приложений, мобильных клиентов и других потребителей. Ключ — разделить логику рендеринга от логики доставки данных.

Архитектурный подход

┌─────────────────────────────────────────────────────┐
│                   Backend API                       │
│  (данные, логика, валидация, авторизация)          │
└──────────────────┬──────────────────────────────────┘
                   │
      ┌────────────┼────────────┬───────────┐
      ↓            ↓            ↓           ↓
   Web SSR      Web CSR      Mobile     Desktop
   (Next.js)   (React)    (React Native) (Electron)

1. REST/GraphQL API — чистые данные

Первое правило: API должен возвращать только данные, без информации о рендеринге.

// ✅ ХОРОШО — API возвращает данные
GET /api/v1/products/123
{
  "id": "123",
  "name": "Ноутбук",
  "price": 99999,
  "description": "Мощный ноутбук",
  "image_url": "https://cdn.example.com/product.jpg",
  "rating": 4.5,
  "in_stock": true,
  "category_id": "electronics"
}

// ❌ ПЛОХО — API содержит информацию о рендеринге
GET /api/v1/products/123?render=web
{
  "html": "<div class='product'>...</div>",
  "css": ".product { color: blue; }",
  "javascript": "console.log('loaded');"
}

2. Формирование ответа в зависимости от клиента

// Backend API
const express = require('express');
const app = express();

// Один endpoint, разные клиенты
app.get('/api/v1/products/:id', async (req, res) => {
  const clientType = req.headers['x-client-type']; // 'web', 'mobile', 'desktop'
  const data = await getProductData(req.params.id);
  
  // Базовые данные для всех
  let response = {
    id: data.id,
    name: data.name,
    price: data.price,
    description: data.description
  };
  
  // Дополнительные поля для разных клиентов
  if (clientType === 'web') {
    response.image_url = data.image_url;
    response.thumbnail_url = data.thumbnail_url;
    response.seo_title = data.seo_title;
    response.seo_description = data.seo_description;
  }
  
  if (clientType === 'mobile') {
    response.image_mobile = data.image_mobile;
    response.rating = data.rating; // важнее для мобильных
  }
  
  if (clientType === 'desktop') {
    response.detailed_images = data.detailed_images;
    response.specifications = data.specifications;
  }
  
  res.json(response);
});

3. Server-Side Rendering (SSR) в Next.js

// pages/products/[id].tsx
import { GetServerSideProps } from 'next';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  image_url: string;
}

interface Props {
  product: Product;
}

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  const { id } = context.params as { id: string };
  
  // Запрос к API с указанием типа клиента
  const res = await fetch(`https://api.example.com/api/v1/products/${id}`, {
    headers: {
      'x-client-type': 'web', // указываем, что это веб-клиент
    }
  });
  
  if (!res.ok) {
    return { notFound: true };
  }
  
  const product = await res.json();
  
  return {
    props: { product },
    revalidate: 3600 // ISR (Incremental Static Regeneration)
  };
};

export default function ProductPage({ product }: Props) {
  return (
    <div className="product-detail">
      <h1>{product.name}</h1>
      <img src={product.image_url} alt={product.name} />
      <p className="price">${product.price}</p>
      <p>{product.description}</p>
    </div>
  );
}

4. Client-Side Rendering (CSR) в React

// ProductPage.tsx
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  image_url: string;
}

export function ProductPage() {
  const { id } = useParams<{ id: string }>();
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetchProduct();
  }, [id]);

  const fetchProduct = async () => {
    try {
      setLoading(true);
      const res = await fetch(`/api/v1/products/${id}`, {
        headers: {
          'x-client-type': 'web'
        }
      });
      
      if (!res.ok) throw new Error('Failed to load');
      
      const data = await res.json();
      setProduct(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error');
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!product) return <div>Not found</div>;

  return (
    <div className="product-detail">
      <h1>{product.name}</h1>
      <img src={product.image_url} alt={product.name} />
      <p className="price">${product.price}</p>
      <p>{product.description}</p>
    </div>
  );
}

5. Мобильный клиент (React Native)

// screens/ProductScreen.tsx
import React, { useEffect, useState } from 'react';
import { View, Text, Image, ScrollView, ActivityIndicator } from 'react-native';

interface Product {
  id: string;
  name: string;
  price: number;
  description: string;
  image_mobile: string; // мобильная версия изображения
  rating: number;
}

export function ProductScreen({ route }) {
  const { id } = route.params;
  const [product, setProduct] = useState<Product | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchProduct();
  }, [id]);

  const fetchProduct = async () => {
    try {
      const res = await fetch(`https://api.example.com/api/v1/products/${id}`, {
        headers: {
          'x-client-type': 'mobile' // указываем мобильный клиент
        }
      });
      const data = await res.json();
      setProduct(data);
    } finally {
      setLoading(false);
    }
  };

  if (loading) return <ActivityIndicator size="large" />;
  if (!product) return <Text>Not found</Text>;

  return (
    <ScrollView>
      <Image
        source={{ uri: product.image_mobile }}
        style={{ width: '100%', height: 300 }}
      />
      <View style={{ padding: 16 }}>
        <Text style={{ fontSize: 24, fontWeight: 'bold' }}>
          {product.name}
        </Text>
        <Text style={{ fontSize: 18, color: '#27ae60', marginVertical: 10 }}>
          ${product.price}
        </Text>
        <Text>{product.description}</Text>
        <Text style={{ marginTop: 10 }}>Rating: {product.rating}⭐</Text>
      </View>
    </ScrollView>
  );
}

6. Кэширование и оптимизация

// Frontend кэширование
const cache = new Map();

async function fetchWithCache(id: string, clientType: string) {
  const cacheKey = `${id}-${clientType}`;
  
  if (cache.has(cacheKey)) {
    return cache.get(cacheKey);
  }
  
  const res = await fetch(`/api/v1/products/${id}`, {
    headers: { 'x-client-type': clientType }
  });
  
  const data = await res.json();
  cache.set(cacheKey, data);
  
  return data;
}

// Backend кэширование
const Redis = require('redis');
const redis = Redis.createClient();

app.get('/api/v1/products/:id', async (req, res) => {
  const clientType = req.headers['x-client-type'];
  const cacheKey = `product:${req.params.id}:${clientType}`;
  
  // Проверяем кэш
  const cached = await redis.get(cacheKey);
  if (cached) {
    return res.json(JSON.parse(cached));
  }
  
  // Получаем из БД
  const data = await getProductData(req.params.id);
  const response = formatResponse(data, clientType);
  
  // Кэшируем на час
  await redis.setex(cacheKey, 3600, JSON.stringify(response));
  
  res.json(response);
});

7. Версионирование API

// Если нужны значительные изменения, создаём новую версию
GET /api/v1/products/:id      // старая версия
GET /api/v2/products/:id      // новая версия

// Или используем Accept header
GET /api/products/:id
Accept: application/vnd.example.v1+json
Accept: application/vnd.example.v2+json

Выводы

  1. Разделение ответственности — API отвечает за данные, клиент за рендеринг
  2. Content-negotiation — API возвращает разные данные для разных клиентов
  3. Единый источник истины — одна БД, один бэкенд для всех
  4. Кэширование — на обоих уровнях (frontend и backend)
  5. Версионирование — развивайте API без ломания старых клиентов
  6. SSR/CSR гибридность — Next.js и подобные фреймворки позволяют переключаться между подходами

Это позволяет масштабировать приложение и поддерживать разные типы клиентов эффективно.