Перейти к содержанию

Серверные и клиентские компоненты: тема и композиция

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Выделить интерактивную границу и передать серверное содержимое через children.

Входное состояние: Рабочая оболочка содержит навигацию и параллельный слот. Выходное состояние: Клиентский провайдер темы окружает серверные страницы без переноса данных в браузер. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-10 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.

Интерфейсу нужна интерактивность, но не каждая его часть должна получать одинаковую среду выполнения. Навигация читает текущий путь, диалог управляет элементом браузера, а страница карточки получает данные на сервере. Добавим переключение темы и используем его как пример композиции. Главный вопрос: где проходит граница импортируемого клиентского кода и как серверное содержимое может оказаться внутри клиентской оболочки.

Директива use client в начале файла обозначает клиентскую границу. Модули, которые этот файл импортирует для своей реализации, входят в соответствующее дерево зависимостей. Однако переданный из серверного родителя children не становится клиентским исходником просто из-за положения внутри JSX. Такое различие позволяет оставить получение заявки на сервере и поместить её результат внутрь интерактивного провайдера.

Провайдер описывает общее состояние

ThemeProvider хранит значение light или dark и передаёт его вместе с функцией переключения через контекст. ThemeButton читает контекст и показывает понятный текст. Если кнопка оказалась вне провайдера, возникает явная ошибка. Так отсутствующая связь не превращается молча в неработающий элемент. Контекст предназначен для клиентского состояния оформления и не хранит серверные полномочия сотрудника.

Начальное значение всегда светлое. После монтирования эффект пытается прочитать localStorage. Это браузерный API, которого нет в серверной среде компонента. Не обращаемся к нему на уровне модуля или во время первого серверного построения. Одинаковое начальное состояние помогает согласовать первую разметку сервера и клиента; чтение сохранённого выбора происходит позже и может визуально изменить тему.

Первый эффект чтения окружён обработкой отказа хранилища. Пользователь может запретить доступ или среда может быть недоступной. В таком случае тема продолжает работать в памяти. Второй эффект меняет атрибут data-theme у body. CSS использует этот атрибут для оформления. Сервер не получает доказательство личности или роли из выбранной темы; такое значение относится только к пользовательскому предпочтению.

Функция переключения сразу меняет состояние и пытается сохранить новое значение. Это не серверная мутация: база заявок остаётся неизменной. В интерфейсе два действия могут выглядеть как кнопки, но иметь разные последствия. Кнопка темы работает локально, а будущая кнопка закрытия заявки должна вызвать проверенную серверную операцию и получить подтверждение записи.

Переданное содержимое и импортированные модули

Рабочая компоновка остаётся Server Component и импортирует провайдер как клиентскую оболочку. В неё передаётся children выбранной рабочей страницы; модальный слот создаётся корневой компоновкой отдельно. Поэтому серверная карточка не должна импортироваться непосредственно из клиентского файла провайдера. Серверный родитель собирает дерево, затем передаёт подготовленное содержимое. Этот способ композиции важнее механического правила «всё внутри клиента тоже клиент».

Данные, переданные клиентскому компоненту через props, должны соответствовать поддерживаемому договору сериализации React. Не передавайте соединение базы, функции обычного серверного модуля или секретный объект конфигурации. Позже таблица получит только разрешённые строки с простыми полями. Наличие серверного источника не означает, что все его поля можно безопасно включить в ответ клиенту.

Модуль с server-only помогает выявить ошибочный импорт в клиентское дерево. Он не шифрует уже отрисованный текст и не скрывает значения, которые вы сами передали в props. Аналогично client-only обозначает модуль, которому нужна браузерная среда, но не делает весь документ исключительно клиентским. Эти средства описывают допустимость среды кода, а правила доступа относятся к предметным данным.

Сторонние компоненты и глубина границы

Если сторонний компонент использует состояние или эффекты, но не задаёт нужную границу сам, можно обернуть его отдельным клиентским файлом. Не обязательно переводить всю страницу в клиентский режим. Чем ближе граница к реально интерактивному элементу, тем яснее остаётся серверная ответственность. В нашей теме провайдер нужен общим потомкам, поэтому находится на уровне рабочей области, а не корня всего публичного сайта.

Client Component может участвовать в первоначальном серверном представлении интерфейса; название не означает, что сервер никогда его не касается. Затем браузер получает код для интерактивности и согласует его с разметкой. Поэтому доступ к window во время построения без эффекта остаётся проблемой. SSR, гидратация и граница модулей являются связанными, но отдельными понятиями.

Ожидаемый результат: кнопка меняет оформление рабочей области, сохранённое значение восстанавливается после монтирования, а карточка продолжает получать данные серверным способом. Возможна краткая смена светлой темы на сохранённую тёмную: этот снимок не реализует предварительное определение темы до первого отображения. Такая граница честнее, чем обещание отсутствия визуального перехода без проверки.

Для самостоятельного разбора отметьте клиентские файлы, их импорты и серверные компоненты, переданные как содержимое. Затем объясните, почему server-only и ограничение владельцем решают разные задачи. Первая проверяет допустимость импорта, вторая определяет, какие данные вправе получить человек. Эти связи понадобятся при подключении базы и формы заявки.

Проверьте смысл границ

Сохранённая тема не включена в URL, потому что она относится к личному браузерному предпочтению, а не выбранной заявке. Поисковое q, напротив, описывает воспроизводимую выборку. Такой выбор источника состояния позволяет объяснить, почему два пользователя по одной ссылке могут видеть одинаковые данные в разном оформлении.

Если переместить провайдер в шаблон, его жизненный цикл изменится и локальное состояние может начинаться заново при переходе. Поэтому тема находится в сохраняемой рабочей компоновке. Это связывает текущую главу с устройством layout и template: граница среды выполнения и граница перемонтирования являются двумя отдельными решениями.

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

components/theme-provider.tsx

"use client";
import { createContext, useContext, useEffect, useState } from "react";
import type { ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");
  useEffect(() => { try { const saved = localStorage.getItem("smena-theme"); if (saved === "light" || saved === "dark") setTheme(saved); } catch { /* Пользователь может запрещать хранилище. */ } }, []);
  useEffect(() => { document.body.dataset.theme = theme; }, [theme]);
  const toggle = () => { const next = theme === "light" ? "dark" : "light"; setTheme(next); try { localStorage.setItem("smena-theme", next); } catch { /* Тема сохраняется только в памяти. */ } };
  return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}
export function ThemeButton() {
  const value = useContext(ThemeContext);
  if (!value) throw new Error("Нужен ThemeProvider");
  return <button onClick={value.toggle}>Тема: {value.theme === "light" ? "светлая" : "тёмная"}</button>;
}

app/(work)/layout.tsx

import Link from "next/link";
import type { ReactNode } from "react";
import { ThemeProvider, ThemeButton } from "@/components/theme-provider";
import { Nav } from "@/components/nav";
export default function WorkLayout({ children }: { children: ReactNode }) {
  return <ThemeProvider><header><Link href="/">Смена</Link><Nav /><ThemeButton /></header>{children}</ThemeProvider>;
}

components/ticket-dialog.tsx

"use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
export function TicketDialog({ children }: { children: ReactNode }) {
  const dialog = useRef<HTMLDialogElement>(null);
  const router = useRouter();
  useEffect(() => { dialog.current?.showModal(); return () => dialog.current?.close(); }, []);
  return <dialog ref={dialog} aria-label="Карточка заявки" onCancel={event => { event.preventDefault(); router.back(); }}><button onClick={() => router.back()}>Закрыть карточку</button>{children}</dialog>;
}

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.