From e3ca927359b8f741411f0a27129cac1c440c5131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D1=81=D0=B8=D0=BF=D1=83=D0=BA?= Date: Mon, 29 Sep 2025 18:49:23 +0300 Subject: [PATCH] Add lesson 32 --- lessons/lesson32/code/package.json | 36 ++ lessons/lesson32/code/public/index.html | 42 +++ lessons/lesson32/code/src/App.tsx | 10 + .../code/src/hooks/useDebounce.test.tsx | 48 +++ .../lesson32/code/src/hooks/useDebounce.tsx | 21 ++ .../code/src/hooks/useDocumentTitle.test.tsx | 38 ++ .../code/src/hooks/useDocumentTitle.tsx | 14 + .../lesson32/code/src/hooks/useInput.test.tsx | 20 ++ lessons/lesson32/code/src/hooks/useInput.tsx | 12 + .../code/src/hooks/useLocalStorage.test.tsx | 36 ++ .../code/src/hooks/useLocalStorage.tsx | 18 + lessons/lesson32/code/src/index.tsx | 12 + lessons/lesson32/code/src/styles.css | 4 + lessons/lesson32/code/tsconfig.json | 9 + lessons/lesson32/lesson.md | 324 +++++++++++++++++- 15 files changed, 643 insertions(+), 1 deletion(-) create mode 100644 lessons/lesson32/code/package.json create mode 100644 lessons/lesson32/code/public/index.html create mode 100644 lessons/lesson32/code/src/App.tsx create mode 100644 lessons/lesson32/code/src/hooks/useDebounce.test.tsx create mode 100644 lessons/lesson32/code/src/hooks/useDebounce.tsx create mode 100644 lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx create mode 100644 lessons/lesson32/code/src/hooks/useDocumentTitle.tsx create mode 100644 lessons/lesson32/code/src/hooks/useInput.test.tsx create mode 100644 lessons/lesson32/code/src/hooks/useInput.tsx create mode 100644 lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx create mode 100644 lessons/lesson32/code/src/hooks/useLocalStorage.tsx create mode 100644 lessons/lesson32/code/src/index.tsx create mode 100644 lessons/lesson32/code/src/styles.css create mode 100644 lessons/lesson32/code/tsconfig.json diff --git a/lessons/lesson32/code/package.json b/lessons/lesson32/code/package.json new file mode 100644 index 0000000..b685e29 --- /dev/null +++ b/lessons/lesson32/code/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-typescript", + "version": "1.0.0", + "description": "React and TypeScript example starter project", + "keywords": [ + "typescript", + "react", + "starter" + ], + "main": "src/index.tsx", + "dependencies": { + "loader-utils": "3.2.1", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-scripts": "5.0.1", + "@testing-library/react": "16.3.0", + "@testing-library/dom": "10.4.0" + }, + "devDependencies": { + "@types/react": "18.2.38", + "@types/react-dom": "18.2.15", + "typescript": "4.4.4" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test --env=jsdom", + "eject": "react-scripts eject" + }, + "browserslist": [ + ">0.2%", + "not dead", + "not ie <= 11", + "not op_mini all" + ] +} diff --git a/lessons/lesson32/code/public/index.html b/lessons/lesson32/code/public/index.html new file mode 100644 index 0000000..475209a --- /dev/null +++ b/lessons/lesson32/code/public/index.html @@ -0,0 +1,42 @@ + + + + + + + + + + + React App + + + + +
+ + + diff --git a/lessons/lesson32/code/src/App.tsx b/lessons/lesson32/code/src/App.tsx new file mode 100644 index 0000000..dbc5c59 --- /dev/null +++ b/lessons/lesson32/code/src/App.tsx @@ -0,0 +1,10 @@ +import "./styles.css"; + +export default function App() { + return ( +
+

Hello CodeSandbox

+

Start editing to see some magic happen!

+
+ ); +} diff --git a/lessons/lesson32/code/src/hooks/useDebounce.test.tsx b/lessons/lesson32/code/src/hooks/useDebounce.test.tsx new file mode 100644 index 0000000..8559dab --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useDebounce.test.tsx @@ -0,0 +1,48 @@ +import { renderHook, act } from "@testing-library/react"; +import { useDebounce } from "./useDebounce"; + +// Используем фейковые таймеры Jest для управления временем в тестах +jest.useFakeTimers(); + +describe("useDebounce", () => { + it("должен возвращать начальное значение немедленно", () => { + const { result } = renderHook(() => useDebounce("initial", 500)); + expect(result.current).toBe("initial"); + }); + + it("не должен обновлять значение до истечения задержки", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { + initialProps: { value: "a", delay: 500 }, + } + ); + + rerender({ value: "b", delay: 500 }); + + expect(result.current).toBe("a"); + + act(() => { + jest.advanceTimersByTime(499); + }); + + expect(result.current).toBe("a"); + }); + + it("должен обновить значение после истечения задержки", () => { + const { result, rerender } = renderHook( + ({ value, delay }) => useDebounce(value, delay), + { + initialProps: { value: "a", delay: 500 }, + } + ); + + rerender({ value: "b", delay: 500 }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(result.current).toBe("b"); + }); +}); diff --git a/lessons/lesson32/code/src/hooks/useDebounce.tsx b/lessons/lesson32/code/src/hooks/useDebounce.tsx new file mode 100644 index 0000000..cfdb327 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useDebounce.tsx @@ -0,0 +1,21 @@ +import { useState, useEffect } from "react"; + +/** + * Хук, который возвращает значение с задержкой. + * @param {*} value - Значение, которое нужно "отложить". + * @param {number} delay - Задержка в миллисекундах. + * @returns {*} - "Отложенное" значение. + */ +export function useDebounce(value, delay) { + // TODO: Реализуйте хук. + // 1. Создайте состояние `debouncedValue` для хранения "отложенного" значения. + // 2. Используйте `useEffect` для установки таймера (`setTimeout`). + // - Эффект должен срабатывать, когда `value` или `delay` изменяются. + // - Внутри таймера, по истечении `delay`, обновите `debouncedValue` на текущее `value`. + // 3. Не забудьте про функцию очистки в `useEffect`! Она должна отменять предыдущий + // таймер (`clearTimeout`), если `value` изменилось до того, как таймер сработал. + // 4. Верните `debouncedValue`. + + // Заглушка + return value; +} diff --git a/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx b/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx new file mode 100644 index 0000000..5266c13 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useDocumentTitle.test.tsx @@ -0,0 +1,38 @@ +import { renderHook } from "@testing-library/react"; +import { useDocumentTitle } from "./useDocumentTitle"; + +describe("useDocumentTitle", () => { + const originalTitle = document.title; + + afterEach(() => { + document.title = originalTitle; + }); + + it("должен устанавливать заголовок документа", () => { + renderHook(() => useDocumentTitle("Новый заголовок")); + expect(document.title).toBe("Новый заголовок"); + }); + + it("должен обновлять заголовок документа при изменении", () => { + const { rerender } = renderHook(({ title }) => useDocumentTitle(title), { + initialProps: { title: "Первый заголовок" }, + }); + + expect(document.title).toBe("Первый заголовок"); + + rerender({ title: "Второй заголовок" }); + expect(document.title).toBe("Второй заголовок"); + }); + + it("должен (опционально) восстанавливать исходный заголовок при размонтировании", () => { + const { unmount } = renderHook(() => + useDocumentTitle("Временный заголовок") + ); + + expect(document.title).toBe("Временный заголовок"); + + unmount(); + + expect(document.title).toBe(originalTitle); + }); +}); diff --git a/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx b/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx new file mode 100644 index 0000000..8289d03 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useDocumentTitle.tsx @@ -0,0 +1,14 @@ +import { useEffect } from "react"; + +/** + * Хук для динамического изменения заголовка документа (вкладки браузера). + * @param {string} title - Новый заголовок. + */ +export function useDocumentTitle(title) { + // TODO: Реализуйте хук. + // 1. Используйте `useEffect` для обновления `document.title`. + // - Эффект должен применяться каждый раз, когда изменяется `title`. + // 2. (Опционально, для лучшей практики) Добавьте функцию очистки, + // которая будет возвращать исходный заголовок документа, + // когда компонент, использующий хук, размонтируется. +} diff --git a/lessons/lesson32/code/src/hooks/useInput.test.tsx b/lessons/lesson32/code/src/hooks/useInput.test.tsx new file mode 100644 index 0000000..c74f2d1 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useInput.test.tsx @@ -0,0 +1,20 @@ +import { renderHook, act } from "@testing-library/react"; +import { useInput } from "./useInput"; + +describe("useInput", () => { + it("должен устанавливать начальное значение", () => { + const { result } = renderHook(() => useInput("начальное значение")); + expect(result.current.value).toBe("начальное значение"); + }); + + it("должен обновлять значение при вызове onChange", () => { + const { result } = renderHook(() => useInput("")); + + // act() гарантирует, что все обновления состояния будут обработаны + act(() => { + result.current.onChange({ target: { value: "новое значение" } }); + }); + + expect(result.current.value).toBe("новое значение"); + }); +}); diff --git a/lessons/lesson32/code/src/hooks/useInput.tsx b/lessons/lesson32/code/src/hooks/useInput.tsx new file mode 100644 index 0000000..c90fab1 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useInput.tsx @@ -0,0 +1,12 @@ +import { useState } from "react"; + +/** + * @param {string} initialValue - Начальное значение поля. + * @returns {{value: string, onChange: function}} - Объект со значением и обработчиком. + */ +export function useInput(initialValue = "") { + // TODO: Реализуйте хук. + // 1. Создайте состояние для хранения значения поля с помощью useState. + // 2. Создайте функцию-обработчик onChange, которая будет обновлять это состояние, получая значение из event.target.value. + // 3. Верните объект, содержащий текущее значение (value) и обработчик (onChange). +} diff --git a/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx b/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx new file mode 100644 index 0000000..84935f2 --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useLocalStorage.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, act } from "@testing-library/react"; +import { useLocalStorage } from "./useLocalStorage"; + +const TEST_KEY = "test-key"; + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe("useLocalStorage", () => { + it("должен возвращать initialValue, если в localStorage пусто", () => { + const { result } = renderHook(() => useLocalStorage(TEST_KEY, "default")); + expect(result.current[0]).toBe("default"); + }); + + it("должен читать существующее значение из localStorage", () => { + window.localStorage.setItem(TEST_KEY, JSON.stringify("stored value")); + + const { result } = renderHook(() => useLocalStorage(TEST_KEY, "default")); + expect(result.current[0]).toBe("stored value"); + }); + + it("должен обновлять значение и записывать его в localStorage", () => { + const { result } = renderHook(() => useLocalStorage(TEST_KEY, "")); + + act(() => { + const setValue = result.current[1]; + setValue("new value"); + }); + + expect(result.current[0]).toBe("new value"); + + const storedValue = window.localStorage.getItem(TEST_KEY); + expect(JSON.parse(storedValue)).toBe("new value"); + }); +}); diff --git a/lessons/lesson32/code/src/hooks/useLocalStorage.tsx b/lessons/lesson32/code/src/hooks/useLocalStorage.tsx new file mode 100644 index 0000000..f91697d --- /dev/null +++ b/lessons/lesson32/code/src/hooks/useLocalStorage.tsx @@ -0,0 +1,18 @@ +import { useState, useEffect } from "react"; + +/** + * @param {string} key - Ключ для хранения в localStorage. + * @param {*} initialValue - Начальное значение. + * @returns {[*, function]} - Массив, аналогичный результату useState. + */ +export function useLocalStorage(key, initialValue) { + // TODO: Реализуйте хук. + // 1. В useState используйте функцию для ленивой инициализации, чтобы чтение из localStorage происходило только один раз. + // - Внутри этой функции попробуйте прочитать значение из localStorage по ключу (`window.localStorage.getItem(key)`). + // - Если там что-то есть, используйте `JSON.parse()` для этого значения и верните его. + // - Если там пусто или произошла ошибка (используйте try...catch), верните `initialValue`. + // 2. Используйте `useEffect` для сохранения нового значения в localStorage при его изменении. + // - Эффект должен срабатывать каждый раз, когда меняется `key` или `value`. + // - Внутри эффекта используйте `JSON.stringify()` для сохранения значения в localStorage. + // 3. Верните массив `[value, setValue]`, как это делает `useState`. +} diff --git a/lessons/lesson32/code/src/index.tsx b/lessons/lesson32/code/src/index.tsx new file mode 100644 index 0000000..80f59d4 --- /dev/null +++ b/lessons/lesson32/code/src/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +const rootElement = document.getElementById("root")!; +const root = ReactDOM.createRoot(rootElement); + +root.render( + + + +); diff --git a/lessons/lesson32/code/src/styles.css b/lessons/lesson32/code/src/styles.css new file mode 100644 index 0000000..59b0604 --- /dev/null +++ b/lessons/lesson32/code/src/styles.css @@ -0,0 +1,4 @@ +.App { + font-family: sans-serif; + text-align: center; +} diff --git a/lessons/lesson32/code/tsconfig.json b/lessons/lesson32/code/tsconfig.json new file mode 100644 index 0000000..41b91bb --- /dev/null +++ b/lessons/lesson32/code/tsconfig.json @@ -0,0 +1,9 @@ +{ + "include": ["./src/**/*"], + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "lib": ["dom", "es2015"], + "jsx": "react-jsx" + } +} diff --git a/lessons/lesson32/lesson.md b/lessons/lesson32/lesson.md index acdc2f5..eb66b0d 100644 --- a/lessons/lesson32/lesson.md +++ b/lessons/lesson32/lesson.md @@ -1 +1,323 @@ -# Lesson 32 +--- +title: Занятие 32 +description: Переиспользование кода с кастомными хуками +--- + +# OTUS + +## Javascript Basic + + + +### Вопросы? + + + +### Переиспользование кода с кастомными хуками + + + +Проблема дублирования логики + +```jsx +// ComponentOne.jsx +const [isHovered, setIsHovered] = useState(false); +const handleMouseEnter = () => setIsHovered(true); +const handleMouseLeave = () => setIsHovered(false); +
+ ... +
; +``` + +```jsx +// ComponentTwo.jsx +const [isHovered, setIsHovered] = useState(false); +const handleMouseEnter = () => setIsHovered(true); +const handleMouseLeave = () => setIsHovered(false); +
+ ... +
; +``` + + + +Логика полностью идентична. Как ее переиспользовать? + + + +Раньше для этого использовали: + +- HOC (Higher-Order Components) - Компоненты высшего порядка. +- Render Props. + + + +HOC + +```jsx +function withHover(WrappedComponent) { + return function (props) { + const [isHovered, setIsHovered] = useState(false); + const handleMouseEnter = () => setIsHovered(true); + const handleMouseLeave = () => setIsHovered(false); + + return ( +
+ +
+ ); + }; +} + +function MyComponent({ isHovered }) { + return
{isHovered ? "На меня навели курсор!" : "Наведите курсор"}
; +} + +export default withHover(MyComponent); +``` + + + +Render Props + +```jsx +function Hover({ children }) { + const [isHovered, setIsHovered] = useState(false); + const handleMouseEnter = () => setIsHovered(true); + const handleMouseLeave = () => setIsHovered(false); + + return ( +
+ {children(isHovered)} +
+ ); +} + +function App() { + return ( + + {(isHovered) => ( +
{isHovered ? "На меня навели курсор!" : "Наведите курсор"}
+ )} +
+ ); +} +``` + + + +Оба подхода рабочие, но имеют недостатки: + +- "Wrapper Hell": Дерево компонентов разрастается за счет оберток. +- Неявные зависимости: Сложно понять, откуда приходят props. +- Конфликты имен: Props от разных HOC могут перезаписывать друг друга. + + + +Хуки предложили более элегантное решение. + + + +Что такое кастомные хуки? + +Кастомный хук — это JavaScript-функция, имя которой начинается с use, и которая может вызывать другие хуки. + +Это не часть React API. Это соглашение, которое позволяет извлекать логику компонента в переиспользуемые функции. + + + +Зачем нужны кастомные хуки? + +- Убираем дублирование логики +- Разделяем бизнес-логику и UI +- Повышаем читаемость и тестируемость + + + +Правила хуков все еще действуют! + +Вызывайте хуки только на верхнем уровне вашей React-функции. + +Вызывайте хуки только из React-компонентов или из других кастомных хуков. + + + +хук useToggle + + + +Компонент с логикой внутри: + +```jsx +import { useState } from "react"; + +function ToggleComponent() { + const [isOn, setIsOn] = useState(false); + const toggle = () => setIsOn((prevIsOn) => !prevIsOn); + + return ; +} +``` + + + +Извлекаем логику в хук: + +```jsx +import { useState, useCallback } from "react"; + +export function useToggle(initialState = false) { + const [state, setState] = useState(initialState); + const toggle = useCallback(() => setState((prevState) => !prevState), []); + + return [state, toggle]; +} +``` + + + +Используем наш новый хук: + +```jsx +import { useToggle } from "../hooks/useToggle"; + +function ToggleComponent() { + const [isOn, toggle] = useToggle(false); + + return ; +} +``` + + + +Теперь useToggle можно использовать в любом компоненте! + + + +хук useRequest + +```jsx +function UserProfile() { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch("[https://api.example.com/user/1](https://api.example.com/user/1)") + .then((res) => res.json()) + .then((data) => setUser(data)) + .catch((err) => setError(err)) + .finally(() => setIsLoading(false)); + }, []); + // ... рендер состояний +} +``` + + + +Создаем хук useRequest + +```jsx +Копировать код +import { useState, useEffect } from 'react'; + +export function useRequest(url) { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!url) return; + + setIsLoading(true); + fetch(url) + .then(res => res.json()) + .then(data => setData(data)) + .catch(err => setError(err)) + .finally(() => setIsLoading(false)); + }, [url]); + + return { data, isLoading, error }; +} +``` + + + +Используем useRequest в компоненте: + +```jsx +import { useRequest } from "../hooks/useRequest"; + +function UserProfile({ userId }) { + const { + data: user, + isLoading, + error, + } = useRequest(`https://api.example.com/user/${userId}`); + + if (isLoading) return
Загрузка...
; + if (error) return
Ошибка: {error.message}
; + + return
Имя пользователя: {user?.name}
; +} +``` + + + +хук useInput + +```jsx +Копировать код +function useInput(initial = "") { + const [value, setValue] = useState(initial); + const onChange = e => setValue(e.target.value); + return { value, onChange }; +} +``` + +```jsx +function MyForm() { + const props = useInput(); + return ; +} +``` + + + +хук useLocalStorage + +```jsx +function useLocalStorage(key, initialValue) { + const [value, setValue] = useState( + () => JSON.parse(localStorage.getItem(key)) ?? initialValue + ); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} +``` + + + +Лучшие практики + +- Имя должно начинаться с use +- Фокус на одной задаче. Не делайте один хук, который делает всё. Лучше несколько маленьких, которые можно комбинировать. +- Хук — чистая функция (без сайд-эффектов вне React API) +- Логику можно комбинировать: хуки вызывают хуки +- Выносите только повторяющийся код + + + +[Практика](https://codesandbox.io/p/sandbox/t6st27) + + + +Дополнительные материалы + +- [Официальная документация по хукам](https://react.dev/reference/react/hooks) +- [Создание своего хука](https://react.dev/learn/reusing-logic-with-custom-hooks) +- [Коллекция полезных кастомных хуков](https://usehooks.com/)