Доброго времени суток, друзья!
В данном туториале я покажу вам, как создать фуллстек-тудушку.
Наше приложение будет иметь стандартный функционал:
- добавление новой задачи в список
- обновление индикатора выполнения задачи
- обновление текста задачи
- удаление задачи из списка
- фильтрация задач: все, активные, завершенные
- сохранение задач на стороне клиента и в базе данных
Выглядеть наше приложение будет так:
Для более широкого охвата аудитории клиентская часть приложения будет реализована на чистом 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>
@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:
База данных
Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного. Алгоритм действий следующий:
- Создаем аккаунт в MongoDB Atlas
- Во вкладке Projects нажимаем на кнопку New Project
- Вводим название проекта, например, todos-db, и нажимаем Next
- Нажимаем Create Project
- Нажимаем Build a Cluster
- Нажимаем Create a cluster (FREE)
- Выбираем провайдера и регион, например, Azure и Hong Kong, и нажимаем Create Cluster
- Ждем завершения создания кластера и нажимаем connect
- В разделе Add a connection IP address выбираем либо Add Your Current IP Address, если у вас статический IP, либо Allow Access from Anywhere, если у вас, как в моем случае, динамический IP (если сомневаетесь, выбирайте второй вариант)
- Вводим имя пользователя и пароль, нажимаем Create Database User, затем нажимаем Choose a connection method
- Выбираем Connect your application
- Копируем строку из раздела Add your connection string into your application code
- Нажимаем Close
В корневой директории создаем файл .env и вставляем в него скопированную строку (меняем <username>, <password> и <dbname> на свои данные):
MONGO_URI=mongodb+srv://<username>:<password>@cluster0.hfvcf.mongodb.net/<dbname>?retryWrites=true&w=majority
Сервер
Находясь в корневой директории, инициализируем проект:
npm init -y
// или
yarn init -yp
Устанавливаем основные зависимости:
yarn add cors dotenv express express-validator mongoose
- cors — отключает политику общего происхождения (одного источника)
- dotenv — предоставляет доступ к переменным среды в файле .env
- express — облегчает создание сервера на Node.js
- express-validator — служит для проверки (валидации) данных
- mongoose — облегчает работу с MongoDB
Устанавливаем зависимости для разработки:
yarn add -D nodemon open-cli morgan
- nodemon — запускает сервер и автоматически перезагружает его при внесении изменений в файл
- open-cli — открывает вкладку браузера по адресу, на котором запущен сервер
- morgan — логгер HTTP-запросов
Далее добавляем в package.json скрипты для запуска сервера (dev — для запуска сервера для разработки и start — для продакшн-сервера):
"scripts": {
"start": "node index.js",
"dev": "open-cli http://localhost:1234 && nodemon index.js"
},
Отлично. Создаем файл index.js следующего содержания:
// подключаем библиотеки
const express = require('express')
const mongoose = require('mongoose')
const cors = require('cors')
const morgan = require('morgan')
require('dotenv/config')
// инициализируем приложение и получаем роутер
const app = express()
const router = require('./server/router')
// подключаем промежуточное ПО
app.use(express.json())
app.use(express.urlencoded({ extended: false }))
app.use(cors())
app.use(morgan('dev'))
// указываем, где хранятся статические файлы
app.use(express.static(__dirname))
// подлючаемся к БД
mongoose.connect(
process.env.MONGO_URI,
{
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
useCreateIndex: true
},
() => console.log('Connected to database')
)
// возвращаем index.html в ответ на запрос к корневому узлу
app.get('/', (_, res) => {
res.sendFile(__dirname + '/index.html')
})
// при запросе к api передаем управление роутеру
app.use('/api', router)
// определяем порт и запускаем сервер
const PORT = process.env.PORT || 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. По-моему, очень неплохо для одной статьи.
Буду рад любой форме обратной связи. Благодарю за внимание и хорошего дня.
granvi
Боже, какой же огород! Из простого приложения на действительно "чистом js" нагенерить килотонны кода.
Прогресс бьёт ключом.
"Перед тем, как создавать сервер, имеет смысл настроить базу данных. Тем более, что в этом нет ничего сложного." — эта фраза очень пугает.
Если то, что написано в данной статье действительно считается фуллстек разработкой, то я, наверное, стал очень стар для всего этого…