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

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

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

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

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


Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом 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.map(Item).join('')}
  </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. По-моему, очень неплохо для одной статьи.

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