Полный стек на примере списка задач (React, Vue, TypeScript, Express, Mongoose)

от автора

Доброго времени суток, друзья!

В данном туториале я покажу вам, как создать фуллстек-тудушку.

Наше приложение будет иметь стандартный функционал:

  • добавление новой задачи в список
  • обновление индикатора выполнения задачи
  • обновление текста задачи
  • удаление задачи из списка
  • фильтрация задач: все, активные, завершенные
  • сохранение задач на стороне клиента и в базе данных

Выглядеть наше приложение будет так:


Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом 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-mongoose cd !$ 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> 

Стили (client/style.css):

@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:

База данных

Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного. Алгоритм действий следующий:

  1. Создаем аккаунт в MongoDB Atlas
  2. Во вкладке Projects нажимаем на кнопку New Project
  3. Вводим название проекта, например, todos-db, и нажимаем Next
  4. Нажимаем Create Project
  5. Нажимаем Build a Cluster
  6. Нажимаем Create a cluster (FREE)
  7. Выбираем провайдера и регион, например, Azure и Hong Kong, и нажимаем Create Cluster
  8. Ждем завершения создания кластера и нажимаем connect
  9. В разделе Add a connection IP address выбираем либо Add Your Current IP Address, если у вас статический IP, либо Allow Access from Anywhere, если у вас, как в моем случае, динамический IP (если сомневаетесь, выбирайте второй вариант)
  10. Вводим имя пользователя и пароль, нажимаем Create Database User, затем нажимаем Choose a connection method
  11. Выбираем Connect your application
  12. Копируем строку из раздела Add your connection string into your application code
  13. Нажимаем 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 || 1234 app.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.css server   Todo.js   router.js .env index.html index.js package.json yarn.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://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. По-моему, очень неплохо для одной статьи.

Буду рад любой форме обратной связи. Благодарю за внимание и хорошего дня.

ссылка на оригинал статьи https://habr.com/ru/post/534622/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *