diff --git a/lessons/lesson22/lesson.md b/lessons/lesson22/lesson.md index 00a9bec..b94aa07 100644 --- a/lessons/lesson22/lesson.md +++ b/lessons/lesson22/lesson.md @@ -407,4 +407,4 @@ alert(s(3)(4)(5)()); // 12 - [Debug Visualizer](https://marketplace.visualstudio.com/items?itemName=hediet.debug-visualizer) - [Visualize JavaScript code execution](http://www.pythontutor.com/javascript.html#mode=edit) - [JavaScript Visualizer (ES5)](https://ui.dev/javascript-visualizer/) -- [Code to graph](https://crubier.github.io/code-to-graph/) \ No newline at end of file +- [Code to graph](https://crubier.github.io/code-to-graph/) diff --git a/lessons/lesson23/lesson.md b/lessons/lesson23/lesson.md index fbaba91..fd4955f 100644 --- a/lessons/lesson23/lesson.md +++ b/lessons/lesson23/lesson.md @@ -1,4 +1,5 @@ # Lesson 23 + ## OTUS Javascript Basic ### Разделение логики и представления @@ -9,25 +10,26 @@ #### Цели занятия -* Разобрать, как разделение кода на составляющие помогает с переиспользованием кода и его поддержкой (на простых примерах с запросами и DOM). -* Узнать подходы: принцип единственной ответственности, представление, шаблонизация, сервисный слой, MVC и увидеть, как они выражаются в коде (используя то, что уже известно: функции, async/await, fetch, DOM, события). -* На практике: маленькие примеры и рефакторинг (без классов — только функции). +- Разобрать, как разделение кода на составляющие помогает с переиспользованием кода и его поддержкой (на простых примерах с запросами и DOM). +- Узнать подходы: принцип единственной ответственности, представление, шаблонизация, сервисный слой, MVC и увидеть, как они выражаются в коде (используя то, что уже известно: функции, async/await, fetch, DOM, события). +- На практике: маленькие примеры и рефакторинг (без классов — только функции). #### **Компетенции:** -* Применение метанавыков для обработки информации и принятия решений. -* Умение структурировать программы. + +- Применение метанавыков для обработки информации и принятия решений. +- Умение структурировать программы. ### План занятия -* Введение: Зачем разделять — как "сортировка" в коде -* Теория: Определения и подходы. -* Маленькие примеры с запросами и DOM: "до/после" -* Лайвкодинг: Плохой код → шаг за шагом в MVC -* Итоги +- Введение: Зачем разделять — как "сортировка" в коде +- Теория: Определения и подходы. +- Маленькие примеры с запросами и DOM: "до/после" +- Лайвкодинг: Плохой код → шаг за шагом в MVC +- Итоги @@ -35,7 +37,6 @@ - ### Почему разделение — это легко и выгодно? Вы уже знаете DOM, async/await и fetch, функции и объекты. Но в простых скриптах часто всё смешивается: fetch в обработчике клика + innerHTML там же. Результат — дубли, ошибки при изменении (сломал UI — сломал запрос). @@ -55,16 +56,17 @@ ### fetch в обработчике + DOM ```javascript -document.getElementById('btn').addEventListener('click', async () => { - const response = await fetch('https://jsonplaceholder.typicode.com/users/1'); +document.getElementById("btn").addEventListener("click", async () => { + const response = await fetch("https://jsonplaceholder.typicode.com/users/1"); const data = await response.json(); - document.getElementById('output').innerHTML = `
${JSON.stringify(data)}
`; + document.getElementById("output").innerHTML = `
${JSON.stringify( + data + )}
`; }); ``` Плохо: данные и отображение смешаны, нет переиспользования, любая ошибка в fetch ломает UI - ### Представление (View) @@ -79,10 +81,14 @@ document.getElementById('btn').addEventListener('click', async () => { ```javascript function renderDataAndFetch() { - fetch('https://jsonplaceholder.typicode.com/users/1') - .then(res => res.json()) - .then(data => { - document.getElementById('output').innerHTML = `
${JSON.stringify(data, null, 2)}
`; + fetch("https://jsonplaceholder.typicode.com/users/1") + .then((res) => res.json()) + .then((data) => { + document.getElementById("output").innerHTML = `
${JSON.stringify(
+        data,
+        null,
+        2
+      )}
`; }); } renderDataAndFetch(); @@ -90,20 +96,19 @@ renderDataAndFetch(); Плохо: функция одновременно делает fetch и вставку в DOM, нарушается принцип единственной ответственности - ### Шаблонизация Определение: генерация HTML из заготовки и данных. -Пример: +Пример: ```js for (let i = 0; i < data.length; i++) { - html += '

' + data[i] + '

'; + html += "

" + data[i] + "

"; } ``` @@ -113,15 +118,15 @@ for (let i = 0; i < data.length; i++) { Функции для запросов и расчётов. -Пример: +Пример: ### сервисный fetch встроен в обработчик ```javascript -document.getElementById('btn').addEventListener('click', async () => { - const res = await fetch('https://jsonplaceholder.typicode.com/users/1'); +document.getElementById("btn").addEventListener("click", async () => { + const res = await fetch("https://jsonplaceholder.typicode.com/users/1"); const data = await res.json(); console.log(data); }); @@ -129,7 +134,6 @@ document.getElementById('btn').addEventListener('click', async () => { Плохо: нет переиспользования, каждый обработчик дублирует fetch, тестировать сложно - ### Теория: MVC @@ -142,12 +146,11 @@ Controller — связывает (addEventListener → model → view). Преимущества: структура и масштабируемость. - ### Примеры: "До" и "После" -Используем fetch, addEventListener, innerHTML. +Используем fetch, addEventListener, innerHTML. Каждый пример: "плохой" (смешанный) и "хороший" (разделённый). @@ -159,11 +162,11 @@ Controller — связывает (addEventListener → model → view).
``` @@ -174,13 +177,17 @@ document.getElementById('btn').addEventListener('click', showName); ### Пример 1: "После" ```javascript -function getName() { return 'Алекс'; } +function getName() { + return "Алекс"; +} function renderName(name) { - document.getElementById('name').innerHTML = `Имя: ${name}`; + document.getElementById("name").innerHTML = `Имя: ${name}`; } -document.getElementById('btn').addEventListener('click', () => renderName(getName())); +document + .getElementById("btn") + .addEventListener("click", () => renderName(getName())); ``` Разделение: данные и представление отделены. @@ -192,21 +199,20 @@ document.getElementById('btn').addEventListener('click', () => renderName(getNam ### Пример 2: "До" — список имён ```javascript -const names = ['Маша', 'Петя']; -let html = '"; // Здесь тоже уязвимость: innerHTML вставляет HTML напрямую -document.getElementById('list').innerHTML = html; - +document.getElementById("list").innerHTML = html; ``` Недостаток: небезопасно. @@ -216,16 +222,16 @@ document.getElementById('list').innerHTML = html; ### Пример 2: "После" — шаблон ```javascript -const names = ['Маша', 'Петя']; +const names = ["Маша", "Петя"]; function renderListSafe(items, containerId) { const container = document.getElementById(containerId); // создаём новый ul - const ul = document.createElement('ul'); + const ul = document.createElement("ul"); - items.map(name => { - const li = document.createElement('li'); + items.map((name) => { + const li = document.createElement("li"); li.textContent = name; // безопасно ul.appendChild(li); }); @@ -234,16 +240,13 @@ function renderListSafe(items, containerId) { container.replaceChildren(ul); } -renderListSafe(names, 'list'); - - +renderListSafe(names, "list"); ``` безопаснее - ### Пример 3: "До" — расчёт суммы ```javascript @@ -252,12 +255,13 @@ let sum = 0; for (let i = 0; i < prices.length; i++) { sum += prices[i]; } -document.getElementById('total').innerText = 'Сумма: ' + sum; +document.getElementById("total").innerText = "Сумма: " + sum; ``` Недостаток: логика и DOM смешаны. + ### Пример 3: "После" — сервис ```javascript @@ -265,7 +269,7 @@ function calculateSum(prices) { return prices.reduce((total, price) => total + price, 0); } function renderTotal(sum) { - document.getElementById('total').innerText = `Сумма: ${sum}`; + document.getElementById("total").innerText = `Сумма: ${sum}`; } renderTotal(calculateSum([100, 200])); ``` @@ -280,26 +284,27 @@ renderTotal(calculateSum([100, 200]));
``` Недостаток: асинхронный код и DOM в обработчике. + ### Пример 4: "После" — сервис + View ```javascript // Сервис: получает данные пользователя async function getUser(id) { const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`); - if (!res.ok) throw new Error('Ошибка запроса'); + if (!res.ok) throw new Error("Ошибка запроса"); return await res.json(); } @@ -319,14 +324,15 @@ async function handleClick(id, containerId) { } // Пример привязки к кнопке -document.getElementById('btn').addEventListener('click', () => handleClick(1, 'user')); +document + .getElementById("btn") + .addEventListener("click", () => handleClick(1, "user")); ``` Код становится чище и надёжнее. - ### Практика @@ -335,22 +341,20 @@ document.getElementById('btn').addEventListener('click', () => handleClick(1, 'u Основные выводы: -* SRP, View, шаблоны, сервисы и MVC разделяют fetch и DOM. -* Переиспользование: общие функции для разных мест. +- SRP, View, шаблоны, сервисы и MVC разделяют fetch и DOM. +- Переиспользование: общие функции для разных мест. -### Домашнее задание: Смотрите на портале - +### Домашнее задание: Смотрите на портале ### Дополнительные материалы -* SRP и SOLID в JavaScript -* MVC без фреймворков -* Шаблоны в JavaScript - +- SRP и SOLID в JavaScript +- MVC без фреймворков +- Шаблоны в JavaScript diff --git a/lessons/lesson24/lesson.md b/lessons/lesson24/lesson.md index ae23b01..6b4afa1 100644 --- a/lessons/lesson24/lesson.md +++ b/lessons/lesson24/lesson.md @@ -8,25 +8,25 @@ #### Цели занятия -* Узнать подходы к проектированию частей приложения, которые упрощают поддержку и развитие. -* Разобраться, почему важна низкая связанность и высокая связанность внутри модуля (cohesion), и как этого достичь. -* Научиться выделять чистые функции и выносить побочные эффекты. -* Освоить внедрение зависимостей (Dependency Injection) на функциях и модулях. +- Узнать подходы к проектированию частей приложения, которые упрощают поддержку и развитие. +- Разобраться, почему важна низкая связанность и высокая связанность внутри модуля (cohesion), и как этого достичь. +- Научиться выделять чистые функции и выносить побочные эффекты. +- Освоить внедрение зависимостей (Dependency Injection) на функциях и модулях. #### Компетенции -* Владение синтаксисом JavaScript (модули `import`/`export`). -* Применение метанавыков для обработки информации и принятия решений в разработке. -* Умение структурировать программы и проектировать API модулей. +- Владение синтаксисом JavaScript (модули `import`/`export`). +- Применение метанавыков для обработки информации и принятия решений в разработке. +- Умение структурировать программы и проектировать API модулей. #### Формат и результаты -* Конспект занятия с примерами. -* Длительность: 90 минут. +- Конспект занятия с примерами. +- Длительность: 90 минут. @@ -47,10 +47,10 @@ Модуль — это логически связанный кусок кода с чёткой зоной ответственности и внешним API (экспортами). Делим код, чтобы: -* уменьшить когнитивную нагрузку (проще понимать части); -* переиспользовать и тестировать; -* изолировать изменения (правка внутри модуля не ломает остальные); -* управлять зависимостями явно. +- уменьшить когнитивную нагрузку (проще понимать части); +- переиспользовать и тестировать; +- изолировать изменения (правка внутри модуля не ломает остальные); +- управлять зависимостями явно. @@ -58,16 +58,16 @@ ```js // app.js -const form = document.querySelector('#form'); -const list = document.querySelector('#list'); +const form = document.querySelector("#form"); +const list = document.querySelector("#list"); const items = []; -form.addEventListener('submit', (e) => { +form.addEventListener("submit", (e) => { e.preventDefault(); - const value = form.elements.namedItem('title').value; + const value = form.elements.namedItem("title").value; items.push(value); - list.innerHTML = items.map((x) => `
  • ${x}
  • `).join(''); + list.innerHTML = items.map((x) => `
  • ${x}
  • `).join(""); }); ``` @@ -81,25 +81,27 @@ form.addEventListener('submit', (e) => { ```js // math.js — экспортируем ИМЕНОВАННО -export function add(a, b) { return a + b; } +export function add(a, b) { + return a + b; +} // app.js — импортируем ИМЕНОВАННО -import { add } from './math.js'; +import { add } from "./math.js"; console.log(add(2, 3)); ``` - Разделим по ролям (после): +Разделим по ролям (после): ```js // view.js — модуль представления // export: функция renderList — отвечает только за отображение списка export function renderList(container, items) { container.replaceChildren( - Object.assign(document.createElement('ul'), { - innerHTML: items.map((x) => `
  • ${x}
  • `).join(''), - }), + Object.assign(document.createElement("ul"), { + innerHTML: items.map((x) => `
  • ${x}
  • `).join(""), + }) ); } ``` @@ -109,7 +111,9 @@ export function renderList(container, items) { ```js // model.js — модуль чистой логики (без DOM и побочных эффектов) // export: addItem — ЧИСТАЯ функция, возвращает новый массив -export function addItem(model, value) { return [...model, value]; } +export function addItem(model, value) { + return [...model, value]; +} ``` @@ -117,23 +121,23 @@ export function addItem(model, value) { return [...model, value]; } ```js // app.js — композиция модулей и работа с DOM-событиями // import: берём renderList из view.js и addItem из model.js -import { renderList } from './view.js'; -import { addItem } from './model.js'; +import { renderList } from "./view.js"; +import { addItem } from "./model.js"; // ссылки на DOM-элементы и локальное состояние -const form = document.querySelector('#form'); -const list = document.querySelector('#list'); +const form = document.querySelector("#form"); +const list = document.querySelector("#list"); let items = []; // первый рендер пустого списка renderList(list, items); // обработчик: читаем ввод → обновляем модель → перерисовываем -form.addEventListener('submit', (e) => { +form.addEventListener("submit", (e) => { e.preventDefault(); - const value = form.elements.namedItem('title').value; // читаем значение из формы - items = addItem(items, value); // чистая логика - renderList(list, items); // отображение + const value = form.elements.namedItem("title").value; // читаем значение из формы + items = addItem(items, value); // чистая логика + renderList(list, items); // отображение }); ``` @@ -161,11 +165,11 @@ export function renderName(container, text) { } // app.js — импортируем и «склеиваем» -import { fullName } from './logic.js'; -import { renderName } from './view.js'; +import { fullName } from "./logic.js"; +import { renderName } from "./view.js"; -const name = fullName('Иван', 'Иванов'); // чистая логика -renderName(document.getElementById('out'), name); // отображение +const name = fullName("Иван", "Иванов"); // чистая логика +renderName(document.getElementById("out"), name); // отображение ``` @@ -174,9 +178,9 @@ renderName(document.getElementById('out'), name); // отображение ```js function submitFormAndRenderAndTrack(form, container, analytics) { - const value = form.elements.namedItem('email').value; - analytics.track('submit', { value }); - fetch('https://jsonplaceholder.typicode.com/users/1') + const value = form.elements.namedItem("email").value; + analytics.track("submit", { value }); + fetch("https://jsonplaceholder.typicode.com/users/1") .then((r) => r.json()) .then((data) => { container.innerHTML = `

    ${data.name}

    `; @@ -193,14 +197,14 @@ function submitFormAndRenderAndTrack(form, container, analytics) { ```js // domain.js (чистая логика) export function getEmail(form) { - return form.elements.namedItem('email').value.trim(); + return form.elements.namedItem("email").value.trim(); } // api.js (эффект) // загрузка пользователя по id (GET) export async function loadUser(id, http) { const res = await http(`https://jsonplaceholder.typicode.com/users/${id}`); - if (!res.ok) throw new Error('Request failed'); + if (!res.ok) throw new Error("Request failed"); return res.json(); } @@ -210,15 +214,15 @@ export function renderStatus(container, status) { } // app.js (склейка) -import { getEmail } from './domain.js'; -import { loadUser } from './api.js'; -import { renderStatus } from './view.js'; +import { getEmail } from "./domain.js"; +import { loadUser } from "./api.js"; +import { renderStatus } from "./view.js"; const http = window.fetch.bind(window); // внедряем зависимость async function onSubmit(form, container, analytics) { const email = getEmail(form); - analytics.track('submit', { email }); + analytics.track("submit", { email }); const user = await loadUser(1, http); // загрузка пользователя renderStatus(container, user.name); } @@ -230,29 +234,35 @@ async function onSubmit(form, container, analytics) { Чистая функция: -* зависит только от входных параметров; -* не меняет внешнее состояние (нет побочных эффектов); -* при одинаковом входе — одинаковый выход. +- зависит только от входных параметров; +- не меняет внешнее состояние (нет побочных эффектов); +- при одинаковом входе — одинаковый выход. Примеры чистых функций: ```js -function sum(a, b) { return a + b; } +function sum(a, b) { + return a + b; +} -function addItem(items, v) { return [...items, v]; } +function addItem(items, v) { + return [...items, v]; +} -function formatName(user) { return `${user.last} ${user.first}`.trim(); } +function formatName(user) { + return `${user.last} ${user.first}`.trim(); +} ``` Побочные эффекты (IO): -* DOM-операции (`innerHTML`, `addEventListener`), -* сеть (`fetch`, `WebSocket`), -* логирование (`console.log`). +- DOM-операции (`innerHTML`, `addEventListener`), +- сеть (`fetch`, `WebSocket`), +- логирование (`console.log`). Подход: вынести эффекты на «края» приложения, оставить «ядро» чистым. @@ -264,12 +274,14 @@ function formatName(user) { return `${user.last} ${user.first}`.trim(); } // до — смешение расчёта и эффекта (лог) function addAndLog(items, v) { const next = [...items, v]; - console.log('Updated:', next); + console.log("Updated:", next); return next; } // после — чистое ядро + эффект отдельно -function add(items, v) { return [...items, v]; } // чистая +function add(items, v) { + return [...items, v]; +} // чистая function logItems(prefix, items) { console.log(prefix, items); @@ -305,9 +317,12 @@ export function formatCurrency(value) { ```js // app.js — используем связный модуль как единое целое -import { subtotal, applyDiscount, formatCurrency } from './price.js'; +import { subtotal, applyDiscount, formatCurrency } from "./price.js"; -const items = [{ price: 100, qty: 2 }, { price: 50, qty: 1 }]; +const items = [ + { price: 100, qty: 2 }, + { price: 50, qty: 1 }, +]; const sum = subtotal(items); const discounted = applyDiscount(sum, 10); console.log(formatCurrency(discounted)); @@ -321,7 +336,7 @@ console.log(formatCurrency(discounted)); // api/users.js — функция зависит только от интерфейса http(url, init) export async function getUser(http, id) { const res = await http(`https://jsonplaceholder.typicode.com/users/${id}`); - if (!res.ok) throw new Error('HTTP error'); + if (!res.ok) throw new Error("HTTP error"); return res.json(); } @@ -355,7 +370,7 @@ loadProfile(http, 1); В тесте можно подменить `http` стабом: ```js -const fakeHttp = async () => ({ json: async () => ({ id: 1, name: 'Test' }) }); +const fakeHttp = async () => ({ json: async () => ({ id: 1, name: "Test" }) }); loadProfile(fakeHttp, 1).then((p) => console.log(p)); ``` @@ -368,14 +383,22 @@ DI на модуле (фабрика): export function makeCounter(start = 0) { let value = start; return { - inc() { value += 1; return value; }, - dec() { value -= 1; return value; }, - get() { return value; }, + inc() { + value += 1; + return value; + }, + dec() { + value -= 1; + return value; + }, + get() { + return value; + }, }; } // app.js -import { makeCounter } from './counter.js'; +import { makeCounter } from "./counter.js"; const counter = makeCounter(0); counter.inc(); ``` @@ -388,13 +411,13 @@ counter.inc(); ```js // запись строки -localStorage.setItem('greeting', 'hello'); +localStorage.setItem("greeting", "hello"); // чтение строки -const text = localStorage.getItem('greeting'); // 'hello' или null +const text = localStorage.getItem("greeting"); // 'hello' или null // удаление -localStorage.removeItem('greeting'); +localStorage.removeItem("greeting"); ``` @@ -403,11 +426,11 @@ localStorage.removeItem('greeting'); ```js // сохраняем объект — сначала сериализуем -const user = { id: 1, name: 'Alex' }; -localStorage.setItem('user', JSON.stringify(user)); +const user = { id: 1, name: "Alex" }; +localStorage.setItem("user", JSON.stringify(user)); // читаем и парсим -const raw = localStorage.getItem('user'); +const raw = localStorage.getItem("user"); const parsed = raw ? JSON.parse(raw) : null; ``` @@ -423,8 +446,11 @@ export function saveJSON(key, value) { export function loadJSON(key, fallback = null) { const raw = localStorage.getItem(key); - try { return raw ? JSON.parse(raw) : fallback; } - catch { return fallback; } + try { + return raw ? JSON.parse(raw) : fallback; + } catch { + return fallback; + } } ``` @@ -437,10 +463,14 @@ export function loadJSON(key, fallback = null) { ```js // model.js — чистые функции для списка (без id) // addTodo — не меняет исходный массив, возвращает новый -export function addTodo(list, text) { return [...list, text]; } +export function addTodo(list, text) { + return [...list, text]; +} // clearTodos — возвращает новый пустой список -export function clearTodos() { return []; } +export function clearTodos() { + return []; +} ``` @@ -448,15 +478,21 @@ export function clearTodos() { return []; } ```js // storage.js — обёртки над localStorage // ключ, под которым храним список задач -const KEY = 'todos'; +const KEY = "todos"; // save — сохраняет список как JSON-строку -export function save(list) { localStorage.setItem(KEY, JSON.stringify(list)); } +export function save(list) { + localStorage.setItem(KEY, JSON.stringify(list)); +} // load — читает список и парсит JSON, при ошибке вернёт [] export function load() { const raw = localStorage.getItem(KEY); - try { return raw ? JSON.parse(raw) : []; } catch { return []; } + try { + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } } ``` @@ -469,7 +505,6 @@ export function load() { - @@ -483,13 +518,15 @@ export function load() { // простая отрисовка элементов списка в виде
  • // export: функция, которая принимает контейнер и массив строк export function renderList(container, list) { - container.innerHTML = list.map((t) => `
  • ${t}
  • `).join(''); + container.innerHTML = list.map((t) => `
  • ${t}
  • `).join(""); } // renderListWithIndex — отрисовка списка с индексами // export: функция, которая принимает контейнер и массив строк export function renderListWithIndex(container, list) { - container.innerHTML = list.map((t, i) => `
  • ${t}
  • `).join(''); + container.innerHTML = list + .map((t, i) => `
  • ${t}
  • `) + .join(""); } ``` @@ -498,33 +535,33 @@ export function renderListWithIndex(container, list) { ```js // app.js — склейка // import: берём чистые функции модели, обёртки для storage и функцию отображения -import { addTodo, clearTodos } from './model.js'; -import { save, load } from './storage.js'; -import { renderList } from './view.js'; +import { addTodo, clearTodos } from "./model.js"; +import { save, load } from "./storage.js"; +import { renderList } from "./view.js"; // ссылки на элементы интерфейса -const form = document.querySelector('#todo-form'); -const input = document.querySelector('#todo-input'); -const listEl = document.querySelector('#todo-list'); -const clearBtn = document.querySelector('#todo-clear'); +const form = document.querySelector("#todo-form"); +const input = document.querySelector("#todo-input"); +const listEl = document.querySelector("#todo-list"); +const clearBtn = document.querySelector("#todo-clear"); // инициализация состояния из localStorage let todos = load(); renderList(listEl, todos); // обработка добавления: берём текст → новая версия списка → сохраняем → перерисовываем -form.addEventListener('submit', (e) => { +form.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; todos = addTodo(todos, text); save(todos); renderList(listEl, todos); - input.value = ''; + input.value = ""; }); // очистка списка по кнопке -clearBtn.addEventListener('click', () => { +clearBtn.addEventListener("click", () => { todos = clearTodos(); save(todos); renderList(listEl, todos); @@ -551,7 +588,9 @@ const addWithNow = makeAddWithNow(() => Date.now()); ```js // Дополнительно: удаление последнего элемента // model.popLast — возвращает новый список без последнего элемента -export function popLast(list) { return list.slice(0, -1); } +export function popLast(list) { + return list.slice(0, -1); +} // app.js — добавляем обработчик для кнопки #todo-pop // const popBtn = document.querySelector('#todo-pop'); @@ -573,7 +612,9 @@ export function removeAt(list, index) { // view.js — добавляем data-index для каждого
  • (пример альтернативной отрисовки) export function renderListWithIndex(container, list) { - container.innerHTML = list.map((t, i) => `
  • ${t}
  • `).join(''); + container.innerHTML = list + .map((t, i) => `
  • ${t}
  • `) + .join(""); } // app.js — делегирование событий: удаляем пункт, по которому кликнули @@ -586,6 +627,7 @@ export function renderListWithIndex(container, list) { // renderListWithIndex(listEl, todos); // }); ``` + ### 7. Мини-рефакторинг: «до/после» @@ -595,7 +637,7 @@ export function renderListWithIndex(container, list) { ```js async function onClick(btn, container) { btn.disabled = true; - const res = await fetch('https://jsonplaceholder.typicode.com/users/1'); + const res = await fetch("https://jsonplaceholder.typicode.com/users/1"); const user = await res.json(); container.innerHTML = `

    ${user.name}

    `; btn.disabled = false; @@ -627,13 +669,17 @@ export function makeShowUserFlow({ http, toVM, render }) { } // app.js -import { makeShowUserFlow } from './app/flows.js'; -import { toUserViewModel } from './domain/userViewModel.js'; -import { renderUser } from './ui/render.js'; +import { makeShowUserFlow } from "./app/flows.js"; +import { toUserViewModel } from "./domain/userViewModel.js"; +import { renderUser } from "./ui/render.js"; const http = (url, init) => fetch(url, init); -const showUser = makeShowUserFlow({ http, toVM: toUserViewModel, render: renderUser }); +const showUser = makeShowUserFlow({ + http, + toVM: toUserViewModel, + render: renderUser, +}); ``` Результат: проще тестировать и менять отдельно представление и сеть. @@ -642,15 +688,17 @@ const showUser = makeShowUserFlow({ http, toVM: toUserViewModel, render: renderU ### 8. Частые анти-паттерны и как их исправить - #### (плохо) → SRP и разбиение (хорошо) ```js // плохо — функция делает и валидацию, и запрос, и рендер async function handle(form, container) { - const email = form.elements.namedItem('email').value; - if (!email.includes('@')) { container.textContent = 'bad email'; return; } - const res = await fetch('https://jsonplaceholder.typicode.com/users/1'); + const email = form.elements.namedItem("email").value; + if (!email.includes("@")) { + container.textContent = "bad email"; + return; + } + const res = await fetch("https://jsonplaceholder.typicode.com/users/1"); const data = await res.json(); container.textContent = data.name; } @@ -658,13 +706,24 @@ async function handle(form, container) { ```js // хорошо — разделение обязанностей -function validateEmail(email) { return email.includes('@'); } -function renderStatus(container, text) { container.textContent = text; } -async function loadUser(http, id) { return (await http(`https://jsonplaceholder.typicode.com/users/${id}`)).json(); } +function validateEmail(email) { + return email.includes("@"); +} +function renderStatus(container, text) { + container.textContent = text; +} +async function loadUser(http, id) { + return ( + await http(`https://jsonplaceholder.typicode.com/users/${id}`) + ).json(); +} async function onSubmit(http, form, container) { - const email = form.elements.namedItem('email').value; - if (!validateEmail(email)) { renderStatus(container, 'bad email'); return; } + const email = form.elements.namedItem("email").value; + if (!validateEmail(email)) { + renderStatus(container, "bad email"); + return; + } const data = await loadUser(http, 1); renderStatus(container, data.name); } @@ -695,7 +754,9 @@ function addTag(user, tag) { ```js // плохо -function isExpired(expAt) { return Date.now() > expAt; } +function isExpired(expAt) { + return Date.now() > expAt; +} // хорошо (DI времени) function makeIsExpired(now) { @@ -716,10 +777,10 @@ const isExpired = makeIsExpired(() => Date.now()); Основные выводы: -* SRP упрощает сопровождение: одна ответственность — одна причина менять код. -* Чистые функции — ядро, эффекты — по краям. -* Низкая связанность достигается через явные интерфейсы и DI. -* Модули `import/export` помогают структурировать код и API. +- SRP упрощает сопровождение: одна ответственность — одна причина менять код. +- Чистые функции — ядро, эффекты — по краям. +- Низкая связанность достигается через явные интерфейсы и DI. +- Модули `import/export` помогают структурировать код и API. @@ -732,10 +793,10 @@ const isExpired = makeIsExpired(() => Date.now()); ### Дополнительные материалы -* MDN: Modules — `import`/`export`. -* Статья: Принцип единственной ответственности (SRP). -* Pure functions and side effects (FP intro). -* Внедрение зависимостей без фреймворков (на функциях и фабриках). +- MDN: Modules — `import`/`export`. +- Статья: Принцип единственной ответственности (SRP). +- Pure functions and side effects (FP intro). +- Внедрение зависимостей без фреймворков (на функциях и фабриках). diff --git a/lessons/lesson27/lesson.md b/lessons/lesson27/lesson.md index 60841ec..cea9310 100644 --- a/lessons/lesson27/lesson.md +++ b/lessons/lesson27/lesson.md @@ -1 +1,365 @@ -# Lesson 27 +--- +title: Занятие 27 +description: Многостраничные и одностраничные приложения - работа с URL +--- + +# OTUS + +## Javascript Basic + + + +## Клиентский роутинг + +Как строится одностраничное приложение (SPA) + + + +### Проверка + +- Хорошо ли видно и слышно? +- Проверить идёт ли запись + + + +## Клиентский роутинг + +Как строится одностраничное приложение (SPA) + + + +### Цели занятия + +- Разобраться какие API можно использовать для организации SPA + +- Научиться создавать клиентский роутинг + + + +### Маршрут вебинара + +- Введение +- Hash API +- History API +- Router +- Практика +- Итоги + + + +## Введение + + + +**Что такое URL** + + + +> Единый указатель ресурса (англ. [**Uniform Resource Locator**](https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_is_a_URL), URL) — единообразный локатор (определитель местонахождения) ресурса. + +> Ранее назывался Universal Resource Locator — универсальный указатель ресурса. URL служит стандартизированным способом записи **адреса ресурса в сети Интернет**. + + + +``` +scheme:[//[user[:password]@]host[:port]][/path][?query][#fragment] +``` + +``` +https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_is_a_URL#anchor +``` + + + +[**Что происходит, когда вы вводите URL в браузере**](http://wsvincent.com/what-happens-when-url/) + + + +**Что такое клиентский роутинг** + + + +> Клиентский роутинг (**client-side routing**) это, когда пользователь перемещается по приложению/веб-сайту, и при этом **не происходит полной перезагрузки страницы**, даже если URL-адрес страницы изменяется. Вместо этого **используется JavaScript для обновления URL-адреса**, а также для извлечения и отображения нового содержимого. + + + +**Способы управления URL на клиенте** + + + +- Hash API +- History API + + + +## `Hash API` + + + +\*Старый способ. До появления HTML5. + + + +> Способ управления состояниям фрагмента URL + +``` +scheme:[//[user[:password]@]host[:port]][/path][?query][#fragment] +``` + + + +- window.location.hash +- `window.onhashchange` / `"hashchange"` event + + + +[Пример](https://codesandbox.io/s/vigorous-black-vzbit?file=/index.html) + +```js +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + let url = event.target.getAttribute("href"); + location.hash = url; // <= set only hash or URL +}); + +window.addEventListener("hashchange", () => { + // <= handle/catch hash changes + console.log(`hashchange: ${location.hash}`); +}); +``` + + + +## `History API` + + + +[Новое API](https://caniuse.com/?search=history) HTML5 + + + +> [**History API**](https://developer.mozilla.org/en-US/docs/Web/API/History_API) опирается на один DOM интерфейс — объект **History** + +> Каждая вкладка браузера имеет уникальный объект History, который находится в `window.history` + + + +> [**History**](https://developer.mozilla.org/en-US/docs/Web/API/History) имеет несколько методов, событий и свойств, которыми мы можем **управлять из JavaScript**. + + + + + +```js +/* Количество записей в текущей сессии истории */ +window.history.length + +/* Возвращает текущий объект состояния истории */ +window.history.state + +/* Метод, позволяющий гулять по истории. + * В качестве аргумента передается смещение, относительно текущей позиции. + * Если передан 0, то будет обновлена текущая страница. + * Если индекс выходит за пределы истории, то ничего не произойдет. */ +window.history.go(n) + +/* Метод, идентичный вызову go(-1) */ +window.history.back() + +/* Метод, идентичный вызову go(1) */ +window.history.forward() + +/* Добавляет элемент истории */ +window.history.pushState(data, title [, url]) + +/* Обновляет текущий элемент истории */ +window.history.replaceState(data, title [, url]) +``` + + + +```js +/* Триггерится при `history.go/back/forward` или при браузерных кликах */ +window.addEventListener("popstate", (event) => console.log(event.state)); +``` + + + +[Пример](https://codesandbox.io/s/vigorous-black-vzbit?file=/index.html) + +```js +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + let url = event.target.getAttribute("href"); + history.pushState({}, url, url); // <-- +}); + +/* Триггерится при `history.go/back/forward` или при браузерных кликах */ +window.addEventListener("popstate", (event) => { + console.log( + "location: " + document.location + ", state: " + JSON.stringify(event.state) + ); +}); +``` + + + +> \*Нужна настройка сервера, т.к. при обновлении / передаче ссылки должна загрузиться начальная страница + + + +## `Router` + + + +> Обработчик URL - называется роутером (**Router**) + +> Router определяет какой код должен выполняться в зависимости от адреса. +> Логика `router`'а может быть завязана на параметры. + + + +\*Бывает серверный и **браузерный** роутинг/роутер. + + + +\*Router **не встроенные API**, а скорее общепринятый термин. + + + +Очень много готовых библиотек/статей: + +- [Pilot: многофункциональный JavaScript роутер](https://habrahabr.ru/company/mailru/blog/172333/) +- [Роутер на JavaScript](http://blog.byndyu.ru/2009/09/javascript.html) +- [A modern JavaScript router in 100 lines](http://krasimirtsonev.com/blog/article/A-modern-JavaScript-router-in-100-lines-history-api-pushState-hash-url) +- [A simple minimalistic JavaScript router with a fallback for older browsers.](https://github.com/krasimir/navigo) +- [router.js](https://github.com/tildeio/router.js/) + + + +Example. Simple Route Interface + +```sh +# Interface of Route +IRoute { + match # String | RegExp | function + onEnter([data]) # function +} +``` + +```js +// Example +const route = { + match: "/", + onEnter: () => console.log("onEnter index"), +}; +``` + + + +Example. Advanced Route Interface + +```sh +# Interface of Route +IRoute { + match # String | RegExp | function + onEnter([data]) # function + onLeave([data]) # function + onBeforeEnter([data]) # function +} +``` + +```js +// Example +const route = { + match: "/", + onEnter: () => console.log("onEnter index"), + onLeave: () => console.log("onLeave index"), +}; +``` + + + +Example. Router Interface 1 + +```sh +# Interface of Route +IRouter { + add(route) # function + remove(route) # function + go(url, [param]) # function +} +``` + + + +Example. Router Interface 2 + +```sh +# Interface of Route +IRouter { + on(match, onEnter) # function + go(url, [params]) # function +} +``` + + + +[Пример](https://codesandbox.io/s/vigorous-black-vzbit?file=/index.html) + + + +## Практика + + + +To-do: + +1. Fork sandbox +1. Implement unsubscribe/remove functionality +1. Add support for "onLeave" callback + +```sh +# Interface of Route +IRouter { + on(match, onEnter, [onLeave]) # function -> function + go(url, [params]) # function +} +``` + + + +[codesandbox](https://codesandbox.io/s/vigorous-black-vzbit?file=/examples/practice.js) + + + +## Итоги + +> 1. **Клиентский роутинг** - навигацию по приложению/веб-сайту без **перезагрузки страницы** + +> 2. Способы управления URL на клиенте: +> (old) **Hash API** и (new) **History API** + +> 3. **Router** (термин)- обработчик URL, определяет какой код должен выполняться в зависимости от адреса. **\*Не встроённое API** + + + +### Вопросы? + + + +## Спасибо за внимание! + +[Ссылка на опрос]() + + + +### Ссылки + +- [Understanding client side routing by implementing a router in Vanilla JS](https://www.willtaylor.blog/client-side-routing-in-vanilla-js/#:~:text=What%20is%20client%20side%20routing,fetch%20and%20display%20new%20content.) diff --git a/lessons/lesson27/routing-examples/README.md b/lessons/lesson27/routing-examples/README.md new file mode 100644 index 0000000..000d747 --- /dev/null +++ b/lessons/lesson27/routing-examples/README.md @@ -0,0 +1,3 @@ +# vanilla-client-side-routing-examples + +Created with CodeSandbox diff --git a/lessons/lesson27/routing-examples/examples/hash-api.js b/lessons/lesson27/routing-examples/examples/hash-api.js new file mode 100644 index 0000000..939ad80 --- /dev/null +++ b/lessons/lesson27/routing-examples/examples/hash-api.js @@ -0,0 +1,28 @@ +/* eslint-disable */ + +const render = () => { + const route = location.hash.replace("#", "") || "/"; + document.getElementById("root").innerHTML = `

    "${route}" page

    `; +}; + +// 1. Handle initial page load +window.addEventListener("load", () => { + render(); // 👈 +}); + +// 2. Handle hash changes +window.addEventListener("hashchange", () => { + render(); // 👈 +}); + +// 3. Catch tag clicks +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + const url = event.target.getAttribute("href"); + location.hash = url; // doesn't reload page + // location.href = url; // reloads page + // location.replace(url); // reloads page +}); diff --git a/lessons/lesson27/routing-examples/examples/history-api.js b/lessons/lesson27/routing-examples/examples/history-api.js new file mode 100644 index 0000000..7be0621 --- /dev/null +++ b/lessons/lesson27/routing-examples/examples/history-api.js @@ -0,0 +1,28 @@ +/* eslint-disable */ + +const render = () => { + const route = location.pathname; + document.getElementById("root").innerHTML = `

    "${route}" page

    `; +}; + +// 1. Handle initial page load +window.addEventListener("load", () => { + render(); // 👈 +}); + +// 2. Handle history navigations. alternative "window.onpopstate" +window.addEventListener("popstate", (event) => { + render(); +}); + +// 3. Catch
    tag clicks + trigger change handler +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + let url = event.target.getAttribute("href"); + history.pushState({ foo: "bar" }, document.title, url); + // history.replaceState({ foo: "bar" }, url, url); + render(); // 👈 +}); diff --git a/lessons/lesson27/routing-examples/examples/practice.js b/lessons/lesson27/routing-examples/examples/practice.js new file mode 100644 index 0000000..a7ebba4 --- /dev/null +++ b/lessons/lesson27/routing-examples/examples/practice.js @@ -0,0 +1,44 @@ +/* eslint-disable */ + +/** + * TODO: modify router.js to support + * 1. unsubscribe function. + * Hint: inside Router.go function return unsubscribe function, + * which will remove listener by id + * 2. onLeave callback + * Hint: Add 3rd 'onLeave' parameter to Router.on + save in listener object + * Check in Router.handleListener if previousPath matches listener + */ + +const render = (content) => + (document.getElementById("root").innerHTML = `

    ${content}

    `); + +const createLogger = + (content, shouldRender = true) => + (...args) => { + console.log(`LOGGER: ${content} args=${JSON.stringify(args)}`); + if (shouldRender) { + render(content); + } + }; + +const router = Router(); + +const unsubscribe = router.on(/.*/, createLogger("/.*")); +router.on( + (path) => path === "/contacts", + createLogger("/contacts"), // onEnter + createLogger("[leaving] /contacts", false) // onLeave +); +router.on("/about", createLogger("/about")); +router.on("/about/us", createLogger("/about/us")); + +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + let url = event.target.getAttribute("href"); + router.go(url); + unsubscribe(); +}); diff --git a/lessons/lesson27/routing-examples/examples/router.js b/lessons/lesson27/routing-examples/examples/router.js new file mode 100644 index 0000000..edd14ea --- /dev/null +++ b/lessons/lesson27/routing-examples/examples/router.js @@ -0,0 +1,92 @@ +/* eslint-disable */ + +// IMPLEMENTATION +function Router() { + let listeners = []; + let currentPath = location.pathname; + let previousPath = null; + + const isMatch = (match, path) => + (match instanceof RegExp && match.test(path)) || + (typeof match === "function" && match(path)) || + (typeof match === "string" && match === path); + + const handleListener = ({ match, onEnter, onLeave }) => { + const args = { currentPath, previousPath, state: history.state }; + + isMatch(match, currentPath) && onEnter(args); + onLeave && isMatch(match, previousPath) && onLeave(args); + }; + + const handleAllListeners = () => listeners.forEach(handleListener); + + const generateId = () => { + const getRandomNumber = () => + Math.floor(Math.random() * listeners.length * 1000); + const doesExist = (id) => listeners.find((listener) => listener.id === id); + + let id = getRandomNumber(); + while (doesExist(id)) { + id = getRandomNumber(); + } + return id; + }; + + const on = (match, onEnter, onLeave) => { + const id = generateId(); + + const listener = { id, match, onEnter, onLeave }; + listeners.push(listener); + handleListener(listener); + + return () => { + listeners = listeners.filter((listeners) => listeners.id !== id); + }; + }; + + const go = (url, state) => { + previousPath = currentPath; + history.pushState(state, url, url); + currentPath = location.pathname; + + handleAllListeners(); + }; + + window.addEventListener("popstate", handleAllListeners); + + return { on, go }; +} + +// USAGE +const render = (content) => + (document.getElementById("root").innerHTML = `

    ${content}

    `); + +const createLogger = + (content, shouldRender = true) => + (...args) => { + console.log(`LOGGER: ${content} args=${JSON.stringify(args)}`); + if (shouldRender) { + render(content); + } + }; + +const router = Router(); + +const unsubscribe = router.on(/.*/, createLogger("/.*")); +router.on( + (path) => path === "/contacts", + createLogger("/contacts"), // onEnter + createLogger("[leaving] /contacts", false) // onLeave +); +router.on("/about", createLogger("/about")); +router.on("/about/us", createLogger("/about/us")); + +document.body.addEventListener("click", (event) => { + if (!event.target.matches("a")) { + return; + } + event.preventDefault(); + let url = event.target.getAttribute("href"); + router.go(url); + unsubscribe(); +}); diff --git a/lessons/lesson27/routing-examples/index.html b/lessons/lesson27/routing-examples/index.html new file mode 100644 index 0000000..06129c6 --- /dev/null +++ b/lessons/lesson27/routing-examples/index.html @@ -0,0 +1,32 @@ + + + + + + + Client-Side URL change examples + + +
    +

    Client-side URL change examples

    +
    +
    +
    + + + + + + + + diff --git a/lessons/lesson27/routing-examples/package.json b/lessons/lesson27/routing-examples/package.json new file mode 100644 index 0000000..eb165e0 --- /dev/null +++ b/lessons/lesson27/routing-examples/package.json @@ -0,0 +1,24 @@ +{ + "name": "vanilla-client-side-routing-examples", + "version": "1.0.0", + "description": "", + "main": "index.html", + "scripts": { + "start": "serve", + "build": "echo This is a static template, there is no bundler or bundling involved!" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/codesandbox-app/static-template.git" + }, + "keywords": [], + "author": "Ives van Hoorne", + "license": "MIT", + "bugs": { + "url": "https://github.com/codesandbox-app/static-template/issues" + }, + "homepage": "https://github.com/codesandbox-app/static-template#readme", + "devDependencies": { + "serve": "^11.2.0" + } +} diff --git a/lessons/lesson27/routing-examples/sandbox.config.json b/lessons/lesson27/routing-examples/sandbox.config.json new file mode 100644 index 0000000..5866ed7 --- /dev/null +++ b/lessons/lesson27/routing-examples/sandbox.config.json @@ -0,0 +1,3 @@ +{ + "template": "static" +}