Доброго времени суток, друзья!
В данном туториале я покажу вам, как создать фуллстек-тудушку.
Наше приложение будет иметь стандартный функционал:
- добавление новой задачи в список
- обновление индикатора выполнения задачи
- обновление текста задачи
- удаление задачи из списка
- фильтрация задач: все, активные, завершенные
- сохранение задач на стороне клиента и в базе данных
Выглядеть наше приложение будет так:
Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом JavaScript, серверная на Node.js. В качестве абстракции для ноды будет использован Express.js, в качестве базы данных сначала локальное хранилище (Local Storage), затем индексированная база данных (IndexedDB) и, наконец, облачная MongoDB.
При разработке клиентской части будут использованы лучшие практики, предлагаемые такими фреймворками, как React и Vue: разделение кода на автономные переиспользуемые компоненты, повторный рендеринг только тех частей приложения, которые подверглись изменениям и т.д. При этом, необходимый функционал будет реализован настолько просто, насколько это возможно. Мы также воздержимся от смешивания HTML, CSS и JavaScript.
В статье будут приведены примеры реализации клиентской части на React и Vue, а также фуллстек-тудушки на React + TypeScript + Express + Mongoose.
Исходный код всех рассматриваемых в статье проектов находится здесь.
Код приложения, которое мы будет разрабатывать, находится здесь.
Демо нашего приложения:
Итак, поехали.
Клиент
Начнем с клиентской части.
Создаем рабочую директорию, например, javascript-express-mongoose:
mkdir javascript-express-mongoosecd !$code .
Создаем директорию client. В этой директории будет храниться весь клиентский код приложения, за исключением index.html. Создаем следующие папки и файлы:
client components Buttons.js Form.js Item.js List.js src helpers.js idb.js router.js storage.js script.js style.css
В корне проекта создаем index.html следующего содержания:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>JS Todos App</title> <!-- Подключаем стили --> <link rel="stylesheet" href="client/style.css" /> </head> <body> <div id="root"></div> <!-- Подключаем скрипт --> <script src="client/script.js" type="module"></script> </body></html>
@import url('https://fonts.googleapis.com/css2?family=Stylish&display=swap');* { margin: 0; padding: 0; box-sizing: border-box; font-family: stylish; font-size: 1rem; color: #222;}#root { max-width: 512px; margin: auto; text-align: center;}#title { font-size: 2.25rem; margin: 0.75rem;}#counter { font-size: 1.5rem; margin-bottom: 0.5rem;}#form { display: flex; margin-bottom: 0.25rem;}#input { flex-grow: 1; border: none; border-radius: 4px; box-shadow: 0 0 1px inset #222; text-align: center; font-size: 1.15rem; margin: 0.5rem 0.25rem;}#input:focus { outline-color: #5bc0de;}.btn { border: none; outline: none; background: #337ab7; padding: 0.5rem 1rem; border-radius: 4px; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); color: #eee; margin: 0.5rem 0.25rem; cursor: pointer; user-select: none; width: 102px; text-shadow: 0 0 1px rgba(0, 0, 0, 0.5);}.btn:active { box-shadow: 0 0 1px rgba(0, 0, 0, 0.5) inset;}.btn.info { background: #5bc0de;}.btn.success { background: #5cb85c;}.btn.warning { background: #f0ad4e;}.btn.danger { background: #d9534f;}.btn.filter { background: none; color: #222; text-shadow: none; border: 1px dashed #222; box-shadow: none;}.btn.filter.checked { border: 1px solid #222;}#list { list-style: none;}.item { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center;}.item + .item { border-top: 1px dashed rgba(0, 0, 0, 0.5);}.text { flex: 1; font-size: 1.15rem; margin: 0.5rem; padding: 0.5rem; background: #eee; border-radius: 4px;}.completed .text { text-decoration: line-through; color: #888;}.disabled { opacity: 0.8; position: relative; z-index: -1;}#modal { position: absolute; top: 10px; left: 10px; padding: 0.5em 1em; background: rgba(0, 0, 0, 0.5); border-radius: 4px; font-size: 1.2em; color: #eee;}
Наше приложение будет состоять из следующих частей (основные компоненты выделены зеленым, дополнительные элементы синим):
Основные компоненты: 1) форма, включающая поле для ввода текста задачи и кнопку для добавления задачи в список; 2) контейнер с кнопками для фильтрации задач; 3) список задач. Также в качестве основного компонента мы дополнительно выделим элемент списка для обеспечения возможности рендеринга отдельных частей приложения.
Дополнительные элементы: 1) заголовок; 2) счетчик количества невыполненных задач.
Приступаем к созданию компонентов (сверху вниз). Компоненты Form и Buttons являются статическими, а List и Item динамическими. В целях дифференциации статические компоненты экспортируются/импортируются по умолчанию, а в отношении динамических компонентов применяется именованный экспорт/импорт.
client/Form.js:
export default /*html*/ `<div id="form"> <input type="text" autocomplete="off" autofocus id="input" > <button class="btn" data-btn="add" > Add </button></div>`
/*html*/ обеспечивает подсветку синтаксиса, предоставляемую расширением для VSCode es6-string-html. Атрибут data-btn позволит идентифицировать кнопку в скрипте.
Обратите внимание, что глобальные атрибуты id позволяют обращаться к DOM-элементам напрямую. Дело в том, что такие элементы (с идентификаторами), при разборе и отрисовке документа становятся глобальными переменными (свойствами глобального объекта window). Разумеется, значения идентификаторов должны быть уникальными для документа.
client/Buttons.js:
export default /*html*/ `<div id="buttons"> <button class="btn filter checked" data-btn="all" > All </button> <button class="btn filter" data-btn="active" > Active </button> <button class="btn filter" data-btn="completed" > Completed </button></div>`
Кнопки для фильтрации тудушек позволят отображать все, активные (невыполненные) и завершенные (выполненные) задачи.
client/Item.js (самый сложный компонент с точки зрения структуры):
/** * функция принимает на вход задачу, * которая представляет собой объект, * включающий идентификатор, текст и индикатор выполнения * * индикатор выполнения управляет дополнительными классами * и текстом кнопки завершения задачи * * текст завершенной задачи должен быть перечеркнут, * а кнопка для изменения (обновления) текста такой задачи - отключена * * завершенную задачу можно сделать активной*/export const Item = ({ id, text, done }) => /*html*/ `<li class="item ${done ? 'completed' : ''}" data-id="${id}"> <button class="btn ${done ? 'warning' : 'success'}" data-btn="complete" > ${done ? 'Cancel' : 'Complete'} </button> <span class="text"> ${text} </span> <button class="btn info ${done ? 'disabled' : ''}" data-btn="update" > Update </button> <button class="btn danger" data-btn="delete" > Delete </button></li>`
client/List.js:
/** * для формирования списка используется компонент Item * * функция принимает на вход список задач * * если вам не очень понятен принцип работы reduce * https://developer.mozilla.org/ru/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce*/import { Item } from "./Item.js"export const List = (todos) => /*html*/ ` <ul id="list"> ${todos.reduce( (html, todo) => (html += ` ${Item(todo)} `), '' )} </ul>`
С компонентами закончили.
Прежде чем переходить к основному скрипту, реализуем вспомогательную функцию переключения класса и составим примерный список задач, который мы будем использовать для тестирования работоспособности приложения.
src/helpers.js:
/** * данная функция будет использоваться * для визуализации нажатия одной из кнопок * для фильтрации задач * * она принимает элемент - нажатую кнопку и класс - в нашем случае checked * * основной контейнер имеет идентификатор root, * поэтому мы можем обращаться к нему напрямую * из любой части кода, в том числе, из модулей*/export const toggleClass = (element, className) => { root.querySelector(`.${className}`).classList.remove(className) element.classList.add(className)}// примерные задачиexport const todosExample = [ { id: '1', text: 'Learn HTML', done: true }, { id: '2', text: 'Learn CSS', done: true }, { id: '3', text: 'Learn JavaScript', done: false }, { id: '4', text: 'Stay Alive', done: false }]
Создадим базу данных (пока в форме локального хранилища).
src/storage.js:
/** * база данных имеет два метода * get - для получения тудушек * set - для записи (сохранения) тудушек*/export default (() => ({ get: () => JSON.parse(localStorage.getItem('todos')), set: (todos) => { localStorage.setItem('todos', JSON.stringify(todos)) }}))()
Побаловались и хватит. Приступаем к делу.
src/script.js:
// импортируем компоненты, вспомогательную функцию, примерные задачи и хранилищеimport Form from './components/Form.js'import Buttons from './components/Buttons.js'import { List } from './components/List.js'import { Item } from './components/Item.js'import { toggleClass, todosExample } from './src/helpers.js'import storage from './src/storage.js'// функция принимает контейнер и список задачconst App = (root, todos) => { // формируем разметку с помощью компонентов и дополнительных элементов root.innerHTML = ` <h1 id="title"> JS Todos App </h1> ${Form} <h3 id="counter"></h3> ${Buttons} ${List(todos)} ` // обновляем счетчик updateCounter() // получаем кнопку добавления задачи в список const $addBtn = root.querySelector('[data-btn="add"]') // основной функционал приложения // функция добавления задачи в список function addTodo() { if (!input.value.trim()) return const todo = { // такой способ генерации идентификатора гарантирует его уникальность и соответствие спецификации id: Date.now().toString(16).slice(-4).padStart(5, 'x'), text: input.value, done: false } list.insertAdjacentHTML('beforeend', Item(todo)) todos.push(todo) // очищаем поле и устанавливаем фокус clearInput() updateCounter() } // функция завершения задачи // принимает DOM-элемент списка function completeTodo(item) { const todo = findTodo(item) todo.done = !todo.done // рендерим только изменившийся элемент renderItem(item, todo) updateCounter() } // функция обновления задачи function updateTodo(item) { item.classList.add('disabled') const todo = findTodo(item) const oldValue = todo.text input.value = oldValue // тонкий момент: мы используем одну и ту же кнопку // для добавления задачи в список и обновления текста задачи $addBtn.textContent = 'Update' // добавляем разовый обработчик $addBtn.addEventListener( 'click', (e) => { // останавливаем распространение события для того, // чтобы нажатие кнопки не вызвало функцию добавления задачи в список e.stopPropagation() const newValue = input.value.trim() if (newValue && newValue !== oldValue) { todo.text = newValue } renderItem(item, todo) clearInput() $addBtn.textContent = 'Add' }, { once: true } ) } // функция удаления задачи function deleteTodo(item) { const todo = findTodo(item) item.remove() todos.splice(todos.indexOf(todo), 1) updateCounter() } // функция поиска задачи function findTodo(item) { const { id } = item.dataset const todo = todos.find((todo) => todo.id === id) return todo } // дополнительный функционал // функция фильтрации задач // принимает значение кнопки function filterTodos(value) { const $items = [...root.querySelectorAll('.item')] switch (value) { // отобразить все задачи case 'all': $items.forEach((todo) => (todo.style.display = '')) break // активные задачи case 'active': // отобразить все и отключить завершенные filterTodos('all') $items .filter((todo) => todo.classList.contains('completed')) .forEach((todo) => (todo.style.display = 'none')) break // завершенные задачи case 'completed': // отобразить все и отключить активные filterTodos('all') $items .filter((todo) => !todo.classList.contains('completed')) .forEach((todo) => (todo.style.display = 'none')) break } } // функция обновления счетчика function updateCounter() { // считаем количество невыполненных задач const count = todos.filter((todo) => !todo.done).length counter.textContent = ` ${count > 0 ? `${count} todo(s) left` : 'All todos completed'} ` if (!todos.length) { counter.textContent = 'There are no todos' buttons.style.display = 'none' } else { buttons.style.display = '' } } // функция повторного рендеринга изменившегося элемента function renderItem(item, todo) { item.outerHTML = Item(todo) } // функция очистки инпута function clearInput() { input.value = '' input.focus() } // делегируем обработку событий корневому узлу root.onclick = ({ target }) => { if (target.tagName !== 'BUTTON') return const { btn } = target.dataset if (target.classList.contains('filter')) { filterTodos(btn) toggleClass(target, 'checked') } const item = target.parentElement switch (btn) { case 'add': addTodo() break case 'complete': completeTodo(item) break case 'update': updateTodo(item) break case 'delete': deleteTodo(item) break } } // обрабатываем нажатие Enter document.onkeypress = ({ key }) => { if (key === 'Enter') addTodo() } // оптимизация работы с хранилищем window.onbeforeunload = () => { storage.set(todos) }}// инициализируем приложения;(() => { // получаем задачи из хранилища let todos = storage.get('todos') // если в хранилище пусто if (!todos || !todos.length) todos = todosExample App(root, todos)})()
В принципе, на данном этапе мы имеем вполне работоспособное приложение, позволяющее добавлять, редактировать и удалять задачи из списка. Задачи записываются в локальное хранилище, так что сохранности данных ничего не угрожает (вроде бы).
Однако, с использованием локального хранилища в качестве базы данных сопряжено несколько проблем: 1) ограниченный размер около 5 Мб, зависит от браузера; 2) потенциальная возможность потери данных при очистке хранилищ браузера, например, при очистке истории просмотра страниц, нажатии кнопки Clear site data вкладки Application Chrome DevTools и т.д.; 3) привязка к браузеру невозможность использовать приложение на нескольких устройствах.
Первую проблему (ограниченность размера хранилища) можно решить с помощью IndexedDB.
Индексированная база данных имеет довольно сложный интерфейс, поэтому воспользуемся абстракцией Jake Archibald idb-keyval. Копируем этот код и записываем его в файл src/idb.js.
Вносим в src/script.js следующие изменения:
// import storage from './src/storage.js'import { get, set } from './src/idb.js'window.onbeforeunload = () => { // storage.set(todos) set('todos', todos)}// обратите внимание, что функция инициализации приложения стала асинхронной;(async () => { // let todos = storage.get('todos') let todos = await get('todos') if (!todos || !todos.length) todos = todosExample App(root, todos)})()
Вторую и третью проблемы можно решить только с помощью удаленной базы данных. В качестве таковой мы будем использовать облачную MongoDB. Преимущества ее использования заключаются в отсутствии необходимости предварительной установки и настройки, а также в возможности доступа к данным из любого места. Из недостатков можно отметить отсутствие гарантии конфиденциальности данных. Однако, при желании, данные можно шифровать на клиенте перед отправкой на сервер или на сервере перед отправкой в БД.
React, Vue
Ниже приводятся примеры реализации клиентской части тудушки на React и Vue.
React:
Vue:
База данных
Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного. Алгоритм действий следующий:
- Создаем аккаунт в MongoDB Atlas
- Во вкладке Projects нажимаем на кнопку New Project
- Вводим название проекта, например, todos-db, и нажимаем Next
- Нажимаем Create Project
- Нажимаем Build a Cluster
- Нажимаем Create a cluster (FREE)
- Выбираем провайдера и регион, например, Azure и Hong Kong, и нажимаем Create Cluster
- Ждем завершения создания кластера и нажимаем connect
- В разделе Add a connection IP address выбираем либо Add Your Current IP Address, если у вас статический IP, либо Allow Access from Anywhere, если у вас, как в моем случае, динамический IP (если сомневаетесь, выбирайте второй вариант)
- Вводим имя пользователя и пароль, нажимаем Create Database User, затем нажимаем Choose a connection method
- Выбираем Connect your application
- Копируем строку из раздела Add your connection string into your application code
- Нажимаем Close
В корневой директории создаем файл .env и вставляем в него скопированную строку (меняем <username>, <password> и <dbname> на свои данные):
MONGO_URI=mongodb+srv://<username>:<password>@cluster0.hfvcf.mongodb.net/<dbname>?retryWrites=true&w=majority
Сервер
Находясь в корневой директории, инициализируем проект:
npm init -y// илиyarn init -yp
Устанавливаем основные зависимости:
yarn add cors dotenv express express-validator mongoose
- cors отключает политику общего происхождения (одного источника)
- dotenv предоставляет доступ к переменным среды в файле .env
- express облегчает создание сервера на Node.js
- express-validator служит для проверки (валидации) данных
- mongoose облегчает работу с MongoDB
Устанавливаем зависимости для разработки:
yarn add -D nodemon open-cli morgan
- nodemon запускает сервер и автоматически перезагружает его при внесении изменений в файл
- open-cli открывает вкладку браузера по адресу, на котором запущен сервер
- morgan логгер HTTP-запросов
Далее добавляем в package.json скрипты для запуска сервера (dev для запуска сервера для разработки и start для продакшн-сервера):
"scripts": { "start": "node index.js", "dev": "open-cli http://localhost:1234 && nodemon index.js"},
Отлично. Создаем файл index.js следующего содержания:
// подключаем библиотекиconst express = require('express')const mongoose = require('mongoose')const cors = require('cors')const morgan = require('morgan')require('dotenv/config')// инициализируем приложение и получаем роутерconst app = express()const router = require('./server/router')// подключаем промежуточное ПОapp.use(express.json())app.use(express.urlencoded({ extended: false }))app.use(cors())app.use(morgan('dev'))// указываем, где хранятся статические файлыapp.use(express.static(__dirname))// подлючаемся к БДmongoose.connect( process.env.MONGO_URI, { useNewUrlParser: true, useUnifiedTopology: true, useFindAndModify: false, useCreateIndex: true }, () => console.log('Connected to database'))// возвращаем index.html в ответ на запрос к корневому узлуapp.get('/', (_, res) => { res.sendFile(__dirname + '/index.html')})// при запросе к api передаем управление роутеруapp.use('/api', router)// определяем порт и запускаем серверconst PORT = process.env.PORT || 1234app.listen(PORT, () => console.log(`Server is running`))
Тестируем сервер:
yarn dev// илиnpm run dev
Прекрасно, сервер работает. Теперь займемся маршрутизацией. Но перед этим определим схему данных, которые мы будем получать от клиента. Создаем директорию server для хранения серверных файлов. В этой директории создаем файлы Todo.js и router.js.
Структура проекта на данном этапе:
client components Buttons.js Form.js Item.js List.js src helpers.js idb.js storage.js script.js style.cssserver Todo.js router.js.envindex.htmlindex.jspackage.jsonyarn.lock (либо package-lock.json)
Определяем схему в src/Todo.js:
const { Schema, model } = require('mongoose')const todoSchema = new Schema({ id: { type: String, required: true, unique: true }, text: { type: String, required: true }, done: { type: Boolean, required: true }})// экспорт модели данныхmodule.exports = model('Todo', todoSchema)
Настраиваем маршрутизацию в src/router.js:
// инициализируем роутерconst router = require('express').Router()// модель данныхconst Todo = require('./Todo')// средства валидацииconst { body, validationResult } = require('express-validator')/** * наш интерфейс (http://personeltest.ru/away/localhost:1234/api) * будет принимать и обрабатывать 4 запроса * GET-запрос /get - получение всех задач из БД * POST /add - добавление в БД новой задачи * DELETE /delete/:id - удаление задачи с указанным идентификатором * PUT /update - обновление текста или индикатора выполнения задачи * * для работы с БД используется модель Todo и методы * find() - для получения всех задач * save() - для добавления задачи * deleteOne() - для удаления задачи * updateOne() - для обновления задачи * * ответ на запрос - объект, в свойстве message которого * содержится сообщение либо об успехе операции, либо об ошибке*/// получение всех задачrouter.get('/get', async (_, res) => { const todos = (await Todo.find()) || [] return res.json(todos)})// добавление задачиrouter.post( '/add', // пример валидации [ body('id').exists(), body('text').notEmpty().trim().escape(), body('done').toBoolean() ], async (req, res) => { // ошибки - это результат валидации const errors = validationResult(req) if (!errors.isEmpty()) { return res.status(400).json({ message: errors.array()[0].msg }) } const { id, text, done } = req.body const todo = new Todo({ id, text, done }) try { await todo.save() return res.status(201).json({ message: 'Todo created' }) } catch (error) { return res.status(500).json({ message: `Error: ${error}` }) } })// удаление задачиrouter.delete('/delete/:id', async (req, res) => { try { await Todo.deleteOne({ id: req.params.id }) res.status(201).json({ message: 'Todo deleted' }) } catch (error) { return res.status(500).json({ message: `Error: ${error}` }) }})// обновление задачиrouter.put( '/update', [ body('text').notEmpty().trim().escape(), body('done').toBoolean() ], async (req, res) => { const errors = validationResult(req) if (!errors.isEmpty()) { return res.status(400).json({ message: errors.array()[0].msg }) } const { id, text, done } = req.body try { await Todo.updateOne( { id }, { text, done } ) return res.status(201).json({ message: 'Todo updated' }) } catch (error) { return res.status(500).json({ message: `Error: ${error}` }) }})// экспорт роутераmodule.exports = router
Интеграция
Возвращаемся к клиентской части. Для того, чтобы абстрагировать отправляемые клиентом запросы мы также прибегнем к помощи роутера. Создаем файл client/src/router.js:
/** * наш роутер - это обычная функция, * принимающая адрес конечной точки в качестве параметра (url) * * функция возвращает объект с методами: * get() - для получения всех задач из БД * set() - для добавления в БД новой задачи * update() - для обновления текста или индикатора выполнения задачи * delete() - для удаления задачи с указанным идентификатором * * все методы, кроме get(), принимают на вход задачу * * методы возвращают ответ от сервера в формате json * (объект со свойством message)*/export const Router = (url) => ({ // получение всех задач get: async () => { const response = await fetch(`${url}/get`) return response.json() }, // добавление задачи set: async (todo) => { const response = await fetch(`${url}/add`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(todo) }) return response.json() }, // обновление задачи update: async (todo) => { const response = await fetch(`${url}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(todo) }) return response.json() }, // удаление задачи delete: async ({ id }) => { const response = await fetch(`${url}/delete/${id}`, { method: 'DELETE' }) return response.json() }})
Для того, чтобы сообщать пользователю о результате выполнения CRUD-операции (create, read, update, delete создание, чтение, обновление, удаление), добавим в src/helpers.js еще одну вспомогательную функцию:
// функция создает модальное окно с сообщением о результате операции// и удаляет его через две секундыexport const createModal = ({ message }) => { root.innerHTML += `<div data-id="modal">${message}</div>` const timer = setTimeout(() => { root.querySelector('[data-id="modal"]').remove() clearTimeout(timer) }, 2000)}
Вот как выглядит итоговый вариант client/script.js:
import Form from './components/Form.js'import Buttons from './components/Buttons.js'import { List } from './components/List.js'import { Item } from './components/Item.js'import { toggleClass, createModal, todosExample } from './src/helpers.js'// импортируем роутер и передаем ему адрес конечной точкиimport { Router } from './src/router.js'const router = Router('http://localhost:1234/api')const App = (root, todos) => { root.innerHTML = ` <h1 id="title"> JS Todos App </h1> ${Form} <h3 id="counter"></h3> ${Buttons} ${List(todos)} ` updateCounter() const $addBtn = root.querySelector('[data-btn="add"]') // основной функционал async function addTodo() { if (!input.value.trim()) return const todo = { id: Date.now().toString(16).slice(-4).padStart(5, 'x'), text: input.value, done: false } list.insertAdjacentHTML('beforeend', Item(todo)) todos.push(todo) // добавляем в БД новую задачу и сообщаем о результате операции пользователю createModal(await router.set(todo)) clearInput() updateCounter() } async function completeTodo(item) { const todo = findTodo(item) todo.done = !todo.done renderItem(item, todo) // обновляем индикатор выполнения задачи createModal(await router.update(todo)) updateCounter() } function updateTodo(item) { item.classList.add('disabled') const todo = findTodo(item) const oldValue = todo.text input.value = oldValue $addBtn.textContent = 'Update' $addBtn.addEventListener( 'click', async (e) => { e.stopPropagation() const newValue = input.value.trim() if (newValue && newValue !== oldValue) { todo.text = newValue } renderItem(item, todo) // обновляем текст задачи createModal(await router.update(todo)) clearInput() $addBtn.textContent = 'Add' }, { once: true } ) } async function deleteTodo(item) { const todo = findTodo(item) item.remove() todos.splice(todos.indexOf(todo), 1) // удаляем задачу createModal(await router.delete(todo)) updateCounter() } function findTodo(item) { const { id } = item.dataset const todo = todos.find((todo) => todo.id === id) return todo } // дальше все тоже самое // за исключением window.onbeforeunload function filterTodos(value) { const $items = [...root.querySelectorAll('.item')] switch (value) { case 'all': $items.forEach((todo) => (todo.style.display = '')) break case 'active': filterTodos('all') $items .filter((todo) => todo.classList.contains('completed')) .forEach((todo) => (todo.style.display = 'none')) break case 'completed': filterTodos('all') $items .filter((todo) => !todo.classList.contains('completed')) .forEach((todo) => (todo.style.display = 'none')) break } } function updateCounter() { const count = todos.filter((todo) => !todo.done).length counter.textContent = ` ${count > 0 ? `${count} todo(s) left` : 'All todos completed'} ` if (!todos.length) { counter.textContent = 'There are no todos' buttons.style.display = 'none' } else { buttons.style.display = '' } } function renderItem(item, todo) { item.outerHTML = Item(todo) } function clearInput() { input.value = '' input.focus() } root.onclick = ({ target }) => { if (target.tagName !== 'BUTTON') return const { btn } = target.dataset if (target.classList.contains('filter')) { filterTodos(btn) toggleClass(target, 'checked') } const item = target.parentElement switch (btn) { case 'add': addTodo() break case 'complete': completeTodo(item) break case 'update': updateTodo(item) break case 'delete': deleteTodo(item) break } } document.onkeypress = ({ key }) => { if (key === 'Enter') addTodo() }};(async () => { // получаем задачи из БД let todos = await router.get() if (!todos || !todos.length) todos = todosExample App(root, todos)})()
Поздравляю, вы только что создали полноценную фуллстек-тудушку.
TypeScript
Для тех, кто считает, что использовать слаботипизированный язык для создания современных приложений не комильфо, предлагаю взглянуть на этот код. Там вы найдете фуллстек-тудушку на React и TypeScript.
Заключение
Подведем краткие итоги.
Мы с вами реализовали полноценное клиент-серверное приложение для добавления, редактирования и удаления задач из списка, интегрированное с настоящей базой данных. На клиенте мы использовали самый современный (чистый) JavaScript, на сервере Node.js сквозь призму Express.js, для взаимодействия с БД Mongoose. Мы рассмотрели парочку вариантов хранения данных на стороне клиента (local storage, indexeddb idb-keyval). Также мы увидели примеры реализации клиентской части на React (+TypeScript) и Vue. По-моему, очень неплохо для одной статьи.
Буду рад любой форме обратной связи. Благодарю за внимание и хорошего дня.