![](https://habrastorage.org/webt/ig/mq/2d/igmq2dvqrh3pvqpohaicef0c3zi.png)
Доброго времени суток, друзья!
В данном туториале я покажу вам, как создать фуллстек-тудушку.
Наше приложение будет иметь стандартный функционал:
- добавление новой задачи в список
- обновление индикатора выполнения задачи
- обновление текста задачи
- удаление задачи из списка
- фильтрация задач: все, активные, завершенные
- сохранение задач на стороне клиента и в базе данных
Выглядеть наше приложение будет так:
![](https://habrastorage.org/webt/nd/st/7k/ndst7klmkvjcdjvlam_ewm9ylaa.png)
Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом 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;
}
Наше приложение будет состоять из следующих частей (основные компоненты выделены зеленым, дополнительные элементы — синим):
![](https://habrastorage.org/webt/le/ew/ev/leewevg6tfvo8pw-lduzvqfybio.png)
Основные компоненты: 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:
База данных
Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного. Алгоритм действий следующий:
- Создаем аккаунт в MongoDB Atlas
- Во вкладке Projects нажимаем на кнопку New Project
- Вводим название проекта, например, todos-db, и нажимаем Next
- Нажимаем Create Project
- Нажимаем Build a Cluster
- Нажимаем Create a cluster (FREE)
- Выбираем провайдера и регион, например, Azure и Hong Kong, и нажимаем Create Cluster
- Ждем завершения создания кластера и нажимаем connect
- В разделе Add a connection IP address выбираем либо Add Your Current IP Address, если у вас статический IP, либо Allow Access from Anywhere, если у вас, как в моем случае, динамический IP (если сомневаетесь, выбирайте второй вариант)
- Вводим имя пользователя и пароль, нажимаем Create Database User, затем нажимаем Choose a connection method
- Выбираем Connect your application
- Копируем строку из раздела Add your connection string into your application code
- Нажимаем Close
![](https://habrastorage.org/webt/rg/p6/mg/rgp6mg93mjgs58ry7krffru2_nq.png)
![](https://habrastorage.org/webt/v_/kn/2h/v_kn2hkkonkoiltuigwhmocpgds.png)
![](https://habrastorage.org/webt/qt/pa/ja/qtpajasnm1rhm5glqxhsegdbmxi.png)
![](https://habrastorage.org/webt/lq/hg/i4/lqhgi4bxzubt_3otxsokec8nmqs.png)
![](https://habrastorage.org/webt/oa/br/wd/oabrwdbbfxgk5nme07h0v5do7pk.png)
![](https://habrastorage.org/webt/ms/ca/0w/msca0wpfwlkkfo0fsnxv2zxe0su.png)
![](https://habrastorage.org/webt/51/5m/hl/515mhl7ioqxc6awb1k9cjjq5lp4.png)
![](https://habrastorage.org/webt/sf/0u/2h/sf0u2hbog4wkyuapoxphlclyloe.png)
![](https://habrastorage.org/webt/v7/om/py/v7ompytuxthndokiqsdbtcf23ho.png)
![](https://habrastorage.org/webt/xr/2x/jg/xr2xjgghvr0i6ecoafufuz_cwdu.png)
В корневой директории создаем файл .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
![](https://habrastorage.org/webt/ka/s8/mq/kas8mqhnmbisaopthb-qxtgwgmc.png)
Прекрасно, сервер работает. Теперь займемся маршрутизацией. Но перед этим определим схему данных, которые мы будем получать от клиента. Создаем директорию 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.
![](https://habrastorage.org/webt/jr/en/wh/jrenwhgghxnnmo-zix06kyfbkdo.png)
Заключение
Подведем краткие итоги.
Мы с вами реализовали полноценное клиент-серверное приложение для добавления, редактирования и удаления задач из списка, интегрированное с настоящей базой данных. На клиенте мы использовали самый современный (чистый) JavaScript, на сервере — Node.js сквозь призму Express.js, для взаимодействия с БД — Mongoose. Мы рассмотрели парочку вариантов хранения данных на стороне клиента (local storage, indexeddb — idb-keyval). Также мы увидели примеры реализации клиентской части на React (+TypeScript) и Vue. По-моему, очень неплохо для одной статьи.
Буду рад любой форме обратной связи. Благодарю за внимание и хорошего дня.