Полный стек на примере списка задач (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.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. По-моему, очень неплохо для одной статьи.

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

    Similar posts

    Ads
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More

    Comments 6

      +2

      Боже, какой же огород! Из простого приложения на действительно "чистом js" нагенерить килотонны кода.
      Прогресс бьёт ключом.
      "Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного." — эта фраза очень пугает.
      Если то, что написано в данной статье действительно считается фуллстек разработкой, то я, наверное, стал очень стар для всего этого…

        +2
        Вот это жесткач, так на реакте писать категорически нельзя!
        Вот по быстренькому исправил откровенную дичь и props hell.

        Вариант вашего to-do листа с исправлениями откровенного ада:
        codesandbox.io/s/react-todos-app-forked-4g3m4
          +2

          Новогоднее оливье подвезли

            +2

            Тяжел этап вступления в начинающие миддлы — но как себя вспомню, и похуже писал… createModal(await router.delete(todo)) — "дорогой роутер, удали объект который я в тебя передал, и верни мне такой ответ, который сразу отображу в модалке". Полная бессмыслица.


            А в самописном "реакте", кажется, лишние кавычки...


            export const List = (todos) => /*html*/ `
              <ul id="list">
                ${todos.reduce(
                  (html, todo) =>
                    (html += `
                        ${Item(todo)}
                    `),
                  ''
                )}
              </ul>
            `
            
            // Походу подразумевался один из этих вариантов
            
            export const List = (todos) => /*html*/ `
              <ul id="list">${todos.reduce((html, todo) => (html += Item(todo)), '')}</ul>
            `
            export const List = (todos) => /*html*/ `
              <ul id="list">${todos.map(Item).join('')}</ul>
            `

            Но в целом вроде смех, но и грех, что-то еще комментить — себя не уважать, как автор статьи не уважает читателей. Буквально все состоит из плохих практик, чистый пример того, что "не надо копировать то, что пишут в интернетах".

              +1

              По-моему, тудушка идёт первым примером в доках какой-то либы? Не?
              (во всяком случае — лет неск. назад)

                +3
                Это еще один пример писанины ради писанины.

                Если задача обучить — надо взять 1 стэк, вылизать все, и объяснить почему хорошо именно так.
                Если задача сравнить — должна быть аналитика, где проще, где быстрее работает (что не исключает устранение говнокода)

                То что у автора — радость «ух ты в одной статье куча всего!» В результате куча, но бесполезного.

                Only users with full accounts can post comments. Log in, please.