Привет, друзья!
В этой статье я хочу поделиться с вами результатами небольшого эксперимента, суть которого заключается в создании обертки над Fetch API
для максимального упрощения работы с ним.
С чего все началось?
Прочитал статью Kent C. Dodds "Replace axios with a simple custom fetch wrapper", изучил несколько аналогичных утилит других разработчиков и решил, что могу сделать лучше. О том, насколько мне это удалось, судите сами.
Ссылки
Исходный код проекта находится здесь.
Обертка в виде npm-пакета — very-simple-fetch
(хотел назвать пакет просто simple-fetch
, но это название оказалось занято).
Нет предела совершенству и не ошибается только тот, кто ничего не делает, поэтому буду рад любой конструктивной критике, любым замечаниям и предложениям по улучшению инструмента: отправляйте реквесты или пишите в личку.
Что насчет Axios
?
Наша обертка будет сильно похожа на axios
. Это объясняется тем, что axios
— лучший из известных мне инструментов для отправки HTTP-запросов. Он одинаково хорошо работает как в браузере, так и в Node.js
. Безусловно, разработчики axios
проделали большую работу. Однако в большинстве случаев нам для решения повседневных задач, связанных с разработкой веб-приложений, не требуется весь функционал, предоставляемый этим инструментом. Если в цифрах, то размер axios
составляет 371 Кб, а размер very-simple-fetch
— 9.33 Кб.
Постановка задач и проектирование
Наш инструмент должен решать следующие задачи (как минимум):
- простая отправка
GET
,POST
,PUT
иDELETE
запросов; - возвращаемый ответ должен содержать разобранный
JSON
, текст или необработанный результат, включая кастомные ошибки, полученные от сервера, и исключения; - ответы на GET-запросы должны записываться в локальный кеш и извлекаться из него при выполнении аналогичного запроса без обращения к серверу. При этом запись в кеш должна быть дефолтной, но опциональной;
- настройки могут включать объект с параметрами, которые преобразуются в параметры строки запроса и добавляются к
URL
; -
URL
должен кодироваться с сохранением специальных символов; - запрос должен быть отменяемым. При этом отмена запроса не должна блокировать отправку последующих запросов;
- должна быть возможность определения базового
URL
и токена аутентификации.
Исходя из этого, сигнатура основной функции должна выглядеть так:
simpleFetch(options: string | object)
где string
— это URL
, а object
— объект с настройками.
Сигнатуры вспомогательных функций должны выглядеть так:
// GET-запрос
simpleFetch.get(url: string, options: object)
// или, если определен базовый URL
simpleFetch.get(options: object)
// POST
simpleFetch.post(url: string, body: any, options: object)
// baseUrl
simpleFetch.post(body: any, options: object)
// PUT
simpleFetch.update(url: string, body: any, options: object)
// baseUrl
simpleFetch.update(body: any, options: object)
// DELETE
simpleFetch.remove(url: string, options: object)
// baseUrl
simpleFetch.remove(options: object)
Сеттеры для установки основного пути и токена аутентификации должны быть такими:
// baseUrl
simpleFetch.baseUrl = 'https://example.com'
// authToken
simpleFetch.authToken = token
/*
{
Authorization: 'Bearer [token]'
}
*/
Выполнение запроса должно прекращаться после вызова соответствующего метода:
simpleFetch.cancel()
Настройки
Объект с настройками (options
) должен содержать следующие свойства:
- общие, из которых обязательным является только метод;
- кастомные:
-
customCache
: boolean — еслиtrue
, результат GET-запроса должен записываться в локальный кеш. Результат аналогичного запроса должен доставляться из кеша без обращения к серверу при условии, что настройкаcustomCache
не установлена в значениеfalse
. Значением по умолчанию являетсяtrue
. Настройка является обязательной; -
log
: boolean — еслиtrue
, настройки, содержимое локального кеша и результаты запроса должны выводиться в консоль инструментов разработчика браузера. Значением по умолчанию являетсяfalse
. Настройка является обязательной; -
params
: object — опциональный объект, который преобразуется в параметры строки запроса и добавляется кURL
: -
key
: string -
value
: string -
handlers
: object — опциональный объект с обработчиками успешной отправки запроса, возникшей ошибки и отмены выполнения запроса: -
onSuccess
: function -
onError
: function -
onAbort
: function
-
Настройки по умолчанию
{
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
referrerPolicy: 'no-referrer',
customCache: true,
log: false,
signal: new window.AbortController().signal
}
Ответ
Ответ на любой запрос должен содержать следующие свойства:
-
data
: any | null — результат запроса илиnull
, если возникла ошибка; -
error
: null | any —null
при успешном запросе, кастомная ошибка или исключение -
info
: object — объект, содержащий дополнительную информацию:-
headers
: object — заголовки ответа; -
status
: number — статус-код ответа; -
statusText
: string — сообщение; -
url
: string — адрес запроса.
-
Основной целью обертки должно быть выполнение запроса любой ценой, т.е. с любыми аргументами, за исключением отсутствующего URL
, и возврат любых результатов, будь то данные при успехе операции, кастомная ошибка или даже исключение. При этом исключение и прерывание запроса не должны приводить к поломке приложения.
Реализация обертки
Начнем с определения локального кеша и контролера для отмены запроса:
const simpleFetchCache = new Map()
let simpleFetchController = new window.AbortController()
Для локального кеша мы используем объект Map
, который в качестве значений позволяет хранить что угодно, включая объекты.
Для отмены запроса мы используем интерфейс AbortController
. Несмотря на то, что данная технология является экспериментальной, она поддерживается всеми современными браузерами.
Определяем основную функцию:
const simpleFetch = async (options) => {
// дальнейший код пишем здесь
}
Функция принимает единственный аргумент — объект с настройками.
URL
Внутри функции имеет смысл начать с определения URL
:
- создаем переменную
url
; - проверяем, является ли
options
строкой — функция может вызываться без настроек, в этом случае аргументoptions
будет строкой; - если является, записываем его значение в переменную
url
; - если не является, проверяем, содержится ли в настройках свойство
url
; - если содержится, записываем значение этого свойства в
url
let url = ''
if (typeof options === 'string') {
url = options
} else {
if (options?.url) {
url = options.url
}
}
Здесь мы используем оператор опциональной последовательности (?.
), который позволяет безопасно обращаться к свойствам несуществующего объекта, т.е. избежать проверки options && options.url
. Без этого оператора при попытке обращения к свойству url
несуществующего options
будет выброшено исключение.
- проверяем, установлен ли основной путь (
baseUrl
); - если установлен,
- проверяем, имеет ли переменная
url
какое-либо значение, кроме пустой строки и других ложных значений; - если не имеет, записываем в переменную значение
baseUrl
; - если имеет,
- проверяем, начинается ли значение переменной с символов
/
или?
; - если начинается, просто добавляем значение
url
кbaseUrl
; - иначе разделяем их с помощью
/
- проверяем, начинается ли значение переменной с символов
- проверяем, имеет ли переменная
if (simpleFetch.baseUrl) {
if (!url) {
url = simpleFetch.baseUrl
} else {
url =
url.startsWith('/') || url.startsWith('?')
? `${simpleFetch.baseUrl}${url}`
: `${simpleFetch.baseUrl}/${url}`
}
}
- проверяем, содержится ли в настройках объект
params
; - если содержится, преобразуем его в строку в формате
?key1=val1&key2=val2
с помощью методаreduce()
:
if (options?.params) {
url = Object.entries(options.params)
.reduce((a, [k, v]) => {
a += `&${k}=${v}`
return a
}, url)
// заменяем первый символ `&` на символ `?`
.replace('&', '?')
}
Таким образом, при вызове simpleFetch()
с baseUrl
, равным https://example.com
, и настройками url: 'todos'
и params: { limit: 3 }
:
simpleFetch.baseUrl = 'https://example.com'
simpleFetch({
url: 'todos',
params: {
limit: 3
}
})
Мы получим такой URL
:
https://example.com/todos?limit=3
Осталось закодировать URL
с сохранением специальных символов:
url = window.decodeURI(url)
При отсутствии URL
сообщаем об этом разработчику и прекращаем выполнение функции:
if (!url) {
return console.error('URL not provided!')
}
Настройки по умолчанию
Определяем настройки по умолчанию:
let _options = {
method: 'GET',
headers: {
'Content-Type': 'application/json'
},
referrerPolicy: 'no-referrer',
customCache: true,
log: false,
signal: simpleFetchController.signal
}
В качестве значения настройки signal
используется AbortController.signal
, позволяющий прерывать выполнение запроса.
Если options
— это объект, выполняем его объединение с объектом _options
:
if (typeof options === 'object') {
_options = {
..._options,
...options
}
}
Проверяем, содержится ли в options
тело запроса. Если содержится и заголовок Content-Type
имеет значение application/json
, выполняем его стрингификацию:
if (
_options.body &&
_options.headers['Content-Type'] === 'application/json'
) {
_options.body = JSON.stringify(_options.body)
}
Проверяем, установлен ли authToken
. Если установлен, добавляем соответствующий заголовок в _options.headers
:
if (simpleFetch.authToken) {
_options.headers['Authorization'] = `Bearer ${simpleFetch.authToken}`
}
Если включено логирование, выводим настройки в консоль:
if (_options.log) {
console.log(
`%c Options: ${JSON.stringify(_options, null, 2)}`,
'color: blue'
)
}
Определяем наличие тела запроса, выполняемого методом POST
или PUT
. Если тело отсутствует, предупреждаем об этом разработчика:
if (
(_options.method === 'POST' || _options.method === 'PUT') &&
!_options.body
) {
console.warn('Body not provided!')
}
Проверяем, содержит ли options
обработчики. Если в обработчиках имеется функция для обработки отмены запроса (onAbort
), выполняем ее однократную регистрацию:
const handlers = options?.handlers
if (handlers?.onAbort) {
simpleFetchController.signal.addEventListener('abort', handlers.onAbort, {
once: true
})
}
Проверяем, включен ли локальный кеш. Если включен и в кеше имеется ответ на запрос, извлекаем результат из кеша и либо вызываем обработчик успешного выполнения запроса (onSuccess
), передавая ему результат в качестве аргумента, либо просто возвращаем результат:
if (
_options.method === 'GET' &&
_options.customCache &&
simpleFetchCache.has(url)
) {
const cachedData = simpleFetchCache.get(url)
return handlers?.onSuccess ? handlers.onSuccess(cachedData) : cachedData
}
Выполнение запроса
Выполняем запрос, извлекаем статус-код и сообщение из ответа и формируем объект с дополнительной информацией:
try {
const response = await fetch(url, defaultOptions)
const { status, statusText } = response
const info = {
// заголовки в форме объекта
headers: [...response.headers.entries()].reduce((a, [k, v]) => {
a[k] = v
return a
}, {}),
status,
statusText,
url: response.url
}
// ...
Создаем переменную для данных data
. Если заголовок Content-Type
ответа содержит слово json
, преобразуем JSON
в объект. Иначе, если заголовок содержит слово text
(например, когда было выброшено исключение), преобразуем ответ в текст. Если в тексте встречается Error:
, извлекаем из ошибки сообщение и записываем его в data
. Иначе просто записываем ответ в data
:
const contentTypeHeader = response.headers.get('Content-Type')
if (contentTypeHeader) {
if (contentTypeHeader.includes('json')) {
data = await response.json()
} else if (contentTypeHeader.includes('text')) {
data = await response.text()
// если имеем дело с исключением,
if (data.includes('Error:')) {
const errorMessage = data
// извлекаем сообщение
.match(/Error:.[^<]+/)[0]
// удаляем `Error:`
.replace('Error:', '')
// удаляем лишние пробелы в начале и конце строки
.trim()
if (errorMessage) {
data = errorMessage
}
}
} else {
data = response
}
} else {
data = response
}
Обратите внимание: data
— это не ответ на запрос. Мы записываем в эту переменную все, что пришло в ответ: ответ на успешный запрос, кастомную ошибку или исключение.
Создаем переменную для результата выполнения запроса result
. В зависимости от статуса ответа (response.ok
) формируем и возвращаем результат запроса.
Если запрос был выполнен успешно:
- формируем результат;
- если методом запроса является
GET
, записываем в кеш его результат. Мы делаем это независимо от того, включен ли локальный кеш, поскольку в противном случае, кеш невозможно будет обновить; - если логирование включено, выводим результат в консоль;
- если имеется обработчик успешного выполнения запроса (
onSuccess
), вызываем его, передавая ему результат в качестве аргумента, либо просто возвращаем результат.
Если в процессе выполнения запроса произошла ошибка:
- формируем результат;
- если логирование включено, выводим результат в консоль;
- если имеется обработчик ошибок (
onError
), вызываем его, передавая ему результат в качестве аргумента, либо просто возвращаем результат.
В блоке catch
мы также проверяем, имеется ли обработчик ошибок (onError
), и либо вызываем его с ошибкой, либо выводим ошибку в консоль. Выполнение кода достигает этого блока только в случае прерывания запроса.
// ...
let result = null
// запрос был выполнен успешно
if (response.ok) {
result = { data, error: null, info }
if (_options.method === 'GET') {
simpleFetchCache.set(url, result)
if (_options.log) {
console.log(simpleFetchCache)
}
}
if (_options.log) {
console.log(
`%c Result: ${JSON.stringify(result, null, 2)}`,
'color: green'
)
}
return handlers?.onSuccess ? handlers.onSuccess(result) : result
}
result = {
data: null,
error: data,
info
}
if (_options.log) {
console.log(`%c Result: ${JSON.stringify(result, null, 2)}`, 'color: red')
}
return handlers?.onError ? handlers.onError(result) : result
} catch (err) {
if (handlers?.onError) {
handlers.onError(err)
}
console.error(err)
}
Последние штрихи
Определяем геттеры и сеттеры для baseUrl
и authToken
:
Object.defineProperties(simpleFetch, {
baseUrl: {
value: '',
writable: true,
enumerable: true
},
authToken: {
value: '',
writable: true,
enumerable: true
}
})
Определяем метод для отмены запроса:
simpleFetch.cancel = () => {
simpleFetchController.abort()
simpleFetchController = new window.AbortController()
}
Обратите внимание на необходимость создания нового экземпляра AbortController
. После вызова метода abort()
, старый экземпляр будет блокировать выполнения последующих запросов.
Наконец, определяем вспомогательные функции:
simpleFetch.get = (url, options) => {
// с `baseUrl` `simpleFetch.get()` может вызываться без `url`,
// т.е. только с настройками или вообще без параметров
if (typeof url === 'string') {
return simpleFetch({
url,
...options
})
}
return simpleFetch({
...url
})
}
simpleFetch.post = (url, body, options) => {
// с `baseUrl` `simpleFetch.post()` может вызываться без `url`,
// т.е. только с телом запроса и настройками
// или только с телом,
// или вообще без параметров,
// но в любом случае его методом должен быть `POST`
if (typeof url === 'string') {
return simpleFetch({
url,
method: 'POST',
body,
...options
})
}
return simpleFetch({
method: 'POST',
body: url,
...body
})
}
simpleFetch.update = (url, body, options) => {
// с `baseUrl` `simpleFetch.update()` может вызываться без `url`,
// т.е. только с телом запроса и настройками
// или только с телом,
// или вообще без параметров,
// но его методом должен быть `PUT`
if (typeof url === 'string') {
return simpleFetch({
url,
method: 'PUT',
body,
...options
})
}
return simpleFetch({
method: 'PUT',
body: url,
...body
})
}
simpleFetch.remove = (url, options) => {
// с `baseUrl` `simpleFetch.remove()` может вызываться без `url`,
// т.е. только с настройками
// или вообще без параметров,
// но его методом должен быть `DELETE`
if (typeof url === 'string') {
return simpleFetch({
url,
method: 'DELETE',
...options
})
}
return simpleFetch({
...url
})
}
Тестирование инструмента
Для тестирования инструмента нам потребуются два сервера: один для фиктивной базы данных и еще один для фронтенда, чтобы запросы к БД не блокировались CORS
, а также несколько вспомогательных утилит.
Создаем директорию проекта, переходим в нее, инициализируем проект и устанавливаем зависимости:
mkdir simple-fetch
cd !$
yarn init -y
# or
npm init -y
yarn add concurrently cors express json-server nodemon open-cli
# or
npm i ...
Зависимости
-
concurrently
— утилита для одновременного выполнения нескольких команд вpackage.json
; -
cors
— утилита для работы сCORS
. Здесь вы найдете шпаргалку по работе с этой утилитой; -
express
— Node.js-фреймворк, облегчающий создание серверов; -
json-server
— утилита для тестированияAPI
путем создания фиктивных БД; -
nodemon
— утилита для перезапуска сервера при внесении изменений в наблюдаемые файлы; -
open-cli
— утилита для автоматического открытия вкладки браузера по указанному адресу.
Определяем в файле package.json
команды для запуска сервера для БД, сервера для фронтенда и открытия вкладки браузера, а также команды для одновременного запуска серверов:
"scripts": {
"db": "json-server -w todos.json -p 5000 -m middleware.js",
"server": "open-cli http://localhost:3000 && nodemon server.js",
"dev": "concurrently \"yarn db\" \"yarn server\""
}
Строка json-server -w todos.json -p 5000 -m middleware.js
означает запуск сервера для БД todos.json
(и его автоматический перезапуск при изменении данного файла) на порту 5000
с использованием посредника (промежуточного программного обеспечения), определенного в файле middleware.js
.
Формируем структуру проекта:
- public - директория со статическими файлами
- scripts
- actions.js - операции
- index.js - обработчик
- utils.js - утилита для создания элемента списка
- index.html
- style.css
- middleware.js - посредник для `json-server`
- server.js - сервер для фронтенда
- simpleFetch.js - наша обертка
- todos.json - фиктивная БД
Наша фиктивная БД (todos.json
) будет содержать массив из 4 задач:
{
"todos": [
{
"id": "1",
"text": "Eat",
"done": true
},
{
"id": "2",
"text": "Code",
"done": true
},
{
"id": "3",
"text": "Sleep",
"done": false
},
{
"id": "4",
"text": "Repeat",
"done": false
}
]
}
В посреднике для json-server
мы делаем следующее:
- декодируем
URL
запроса с помощьюquerystring
; - проверяем, содержит ли
URL
словозадачи
. Если содержит, заменяем его наtodos
; - если
URL
имеет значениеtodos/private-request
, пытаемся получить токен авторизации. Если токен отсутствует, возвращаем статус-код403
. Если токен имеет значение, отличное отtoken
, возвращаем статус-код403
. Если токен имеет значениеtoken
, возвращаем ответ{ message: 'Private response' }
в форматеJSON
; - если
URL
имеет значение/todos/too-long
, запускаем таймер с отправкой статус-кода200
через3
секунды. Мы будем отменять запрос, который выполняется дольше2
секунд; - если
URL
имеет значение/todos/custom-error
, отправляем в ответ статус-код400
и{ message: 'Custom error' }
в форматеJSON
; - если
URL
имеет значение/todos/throw-exception
, выбрасываем исключение с сообщениемError!
; - если
URL
имеет другое значение, передаем запрос дальше
const querystring = require('querystring')
module.exports = (req, res, next) => {
req.url = querystring.unescape(req.url)
if (req.url.includes('задачи')) {
req.url = req.url.replace('задачи', 'todos')
}
switch (req.url) {
case '/todos/private-request':
const authToken = req.headers.authorization?.split('Bearer ')[1]
if (!authToken) {
return res.sendStatus(403)
}
if (authToken !== 'token') {
return res.sendStatus(403)
} else {
return res.status(200).json({ message: 'Private response' })
}
case '/todos/too-long':
const timerId = setTimeout(() => {
res.sendStatus(200)
clearTimeout(timerId)
}, 3000)
break
case '/todos/custom-error':
return res.status(400).json({ message: 'Custom error!' })
case '/todos/throw-exception':
throw new Error('Error!')
default:
next()
}
}
В server.js
мы настраиваем простой Express-сервер для фронтенда:
const express = require('express')
const cors = require('cors')
const app = express()
// отключаем `CORS`
app.use(cors())
// определяем директорию со статическими файлами
app.use(express.static('public'))
// этот роут нужен для доступа к `simpleFetch.js`
// который находится за пределами `public`
app.get('*', (req, res) => {
res.sendFile(`${__dirname}${req.url}`)
})
// определяем порт и запускаем сервер
const PORT = process.env.PORT || 3000
app.listen(PORT, () => {
console.log('Server ready ')
})
Переходим к фронтенду.
Начнем с index.html
. В head
подключаем Google-шрифт, Bootstrap
, стили и нашу обертку:
<link
href="https://fonts.googleapis.com/css2?family=Montserrat&display=swap"
rel="stylesheet"
/>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
crossorigin="anonymous"
/>
<link rel="stylesheet" href="style.css" />
<script src="simpleFetch.js"></script>
В body
создаем контейнер с кнопками для запуска различных операций и контейнер для результатов выполнения операций. Обратите внимание на атрибуты data-for
кнопок. С их помощью мы будем определять, какая кнопка была нажата.
<div class="container">
<h1 class="text-center mt-4">Simple Fetch</h1>
<div class="d-flex">
<!-- контейнер для кнопок -->
<div class="buttons">
<!-- кнопка для выполнения операции получения задач из кеша -->
<div class="d-flex">
<button class="btn btn-primary" data-for="get_cached_todos">
Get
</button>
<p>todos from cache</p>
</div>
<!-- ... получения задач от сервера -->
<div class="d-flex">
<button class="btn btn-primary" data-for="get_todos_from_server">
Get
</button>
<p>todos from server</p>
</div>
<!-- получения первых двух задач с сортировкой по убыванию -->
<div class="d-flex">
<button class="btn btn-primary" data-for="get_first_two_todos">
Get
</button>
<p>first two todos desc</p>
</div>
<!-- получения задачи по `id` -->
<div class="d-flex">
<button class="btn btn-primary" data-for="get_todo_by_id">
Get
</button>
<label>todo by ID</label>
<input type="text" class="todo_id form-control" value="1" />
</div>
<!-- добавления в БД новой задачи -->
<div class="d-flex">
<button class="btn btn-success" data-for="add_todo">Add</button>
<input
type="text"
class="todo_text form-control"
value="New todo"
/>
</div>
<!-- установки токена авторизации -->
<div class="d-flex">
<button class="btn btn-primary" data-for="set_auth_token">
Set
</button>
<p>auth token</p>
</div>
<!-- отправки запроса на получения защищенного ответа -->
<div class="d-flex">
<button class="btn btn-warning" data-for="send_private_request">
Send
</button>
<p>private request</p>
</div>
<!-- отправки слишком долгого запроса -->
<div class="d-flex">
<button class="btn btn-warning" data-for="send_long_request">
Send
</button>
<p>too long request</p>
</div>
<!-- получения кастомной ошибки -->
<div class="d-flex">
<button class="btn btn-danger" data-for="get_custom_error">
Get
</button>
<p>custom error</p>
</div>
<!-- отправки запроса, приводящего к выбрасыванию исключения -->
<div class="d-flex">
<button class="btn btn-danger" data-for="throw_exception">
Throw
</button>
<p>exception</p>
</div>
</div>
<!-- контейнер для результатов выполнения операций -->
<ul class="result list-group"></ul>
</div>
</div>
Определяем глобальные переменные и подключаем основной скрипт с типом module
:
<script>
const container = document.querySelector('.container')
const buttons = container.querySelector('.buttons')
const result = container.querySelector('.result')
const idInput = container.querySelector('.todo_id')
const textInput = container.querySelector('.todo_text')
</script>
<script src="scripts/index.js" type="module"></script>
Немного редактируем стили Boostsrap
в style.css
:
* {
font-family: 'Montserrat', sans-serif;
}
.container {
max-width: 760px;
}
.d-flex {
margin: 0.15rem 0;
padding: 0.15rem 0;
align-items: center;
gap: 1rem;
}
p {
margin: 0;
}
.form-control {
width: auto;
}
.list-group {
min-width: 280px;
}
.list-group-item {
align-items: center;
justify-content: space-between;
}
.list-group-item input {
margin-left: 0.5rem;
width: 15px;
height: 15px;
}
.list-group-item .btn {
margin-right: 0.5rem;
}
В scripts/utils.js
определяем утилиту для создания элемента списка и его вставки в соответствующий контейнер:
export const createTodo = (todo) => {
const template = /*html*/ `
<li
data-id="${todo.id}"
class="todo_item list-group-item d-flex"
>
<input
type="checkbox"
${todo.done ? 'checked' : ''}
class="btn"
data-for="update_todo"
/>
<p>${todo.text}</p>
<button
class="btn btn-sm"
data-for="remove_todo"
>
</button>
</li>
`
result.insertAdjacentHTML('beforeend', template)
}
Обратите внимание на атрибуты data-id
тега li
и data-for
тегов input
и button
.
В файле scripts/index.js
мы делаем следующее:
- импортируем операции из
actions.js
; - определяем массив с кнопками, после нажатия которых и выполнения соответствующих операций, необходимо обновить кэш;
- регистрируем на контейнере обработчик "клика", в котором сначала проверяем, что список CSS-классов цели клика содержит
.btn
; - если это не так, прекращаем выполнение кода;
- очищаем контейнер для результатов;
- в зависимости от того, какая кнопка была нажата, выполняем соответствующую операцию.
// импорт операций
import * as A from './actions.js'
// кнопки, после нажатия которых и выполнения соответствующих операций
// должен обновляться кеш
// Важно: мы ожидаем завершения соответствующих операций перед обновление кеша
const cacheRefresh = ['add_todo', 'update_todo', 'remove_todo']
container.addEventListener('click', async ({ target }) => {
if (!target.classList.contains('btn')) return
result.innerHTML = ''
switch (target.dataset.for) {
case 'get_cached_todos':
return A.getCachedTodos()
case 'get_todos_from_server':
return A.getTodosFromServer()
case 'get_todo_by_id':
// аргументом операции является значение соответствующего `input`
return A.getTodoById(idInput.value)
case 'get_first_two_todos':
return A.getFirstTwoTodosDesc()
case 'add_todo':
// аргументом операции является значение соответствующего `input`
await A.addTodo(textInput.value)
break
case 'update_todo': {
// получаем идентификатор обновляемой задачи
const { id } = target.closest('.todo_item').dataset
// и обновляем существующую задачу в БД
await A.updateTodo(id)
break
}
case 'remove_todo': {
// получаем идентификатор удаляемой задачи
const { id } = target.closest('.todo_item').dataset
// и удаляем ее из БД
await A.removeTodo(id)
break
}
case 'set_auth_token':
return A.setAuthToken()
case 'send_private_request':
return A.sendPrivateRequest()
case 'send_long_request':
return A.sendTooLongRequest()
case 'get_custom_error':
return A.getCustomError()
case 'throw_exception':
return A.throwException()
}
if (cacheRefresh.includes(target.dataset.for)) {
// обновляем кеш
A.getTodosFromServer()
}
})
Последнее, что осталось сделать, это реализовать операции.
Открываем scripts/actions.js
.
Импортируем утилиту для рендеринга задачи, а также определяем baseUrl
:
import { createTodo } from './utils.js'
simpleFetch.baseUrl = 'http://localhost:5000/задачи'
Начнем с операции получения задач от сервера (с кешированием и логированием) и рендеринга задач:
export const getCachedTodos = async () => {
// GET-запрос
const response = await simpleFetch.get({
// логирование
log: true
})
response.data.forEach((todo) => {
createTodo(todo)
})
}
Операция получения задач от сервера без кеширования, но с обработчиком успешного выполнения запроса:
export const getTodosFromServer = async () => {
// обработчик
const onSuccess = ({ data }) => {
data.forEach((todo) => {
createTodo(todo)
})
}
// `.get()` можно опустить
await simpleFetch({
// без кеширования,
customCache: false,
// но с обработчиком
handlers: { onSuccess }
})
}
Операция получения задачи по id
и ее рендеринга:
export const getTodoById = async (todoId) => {
const { data } = await simpleFetch.get(todoId)
createTodo(data)
}
Операция получения первых двух задач, отсортированных по id
и по убыванию:
export const getFirstTwoTodosDesc = async () => {
const onSuccess = ({ data }) => {
data.forEach((todo) => {
createTodo(todo)
})
}
await simpleFetch({
// параметры
params: {
_sort: 'id',
_order: 'desc',
_limit: 2
},
handlers: { onSuccess },
// логирование
log: true
})
}
Операция добавления новой задачи:
export const addTodo = async (text) => {
// генерируем `id`
const id = Math.random().toString(16).replace('0.', '')
// создаем задачу
const todo = {
id,
text,
done: false
}
await simpleFetch.post(todo)
}
Операция обновления задачи:
export const updateTodo = async (todoId) => {
// получаем существующую задачу по `id` без кеширования
const { data: existingTodo } = await simpleFetch.get(todoId, {
customCache: false
})
// создаем новую задачу на основе существующей -
// обновляем индикатор завершенности задачи
const newTodo = { ...existingTodo, done: !existingTodo.done }
await simpleFetch.update(todoId, newTodo)
}
Операция удаления задачи:
export const removeTodo = async (todoId) => {
await simpleFetch.remove(todoId)
}
Операция установки токена аутентификации:
export const setAuthToken = () => {
simpleFetch.authToken = 'token'
}
Операция отправки запроса на получение защищенного ответа:
export const sendPrivateRequest = async () => {
const { data, error } = await simpleFetch.get('/private-request', {
customCache: false,
log: true
})
if (error) {
console.error(error)
} else {
console.log(data)
}
}
Операция отправки слишком долгого запроса:
export const sendTooLongRequest = async () => {
// обработчик отмены запроса
const onAbort = () => {
console.log('Request aborted!')
}
// обработчик ошибки
const onError = (err) => {
console.error(err.message)
}
simpleFetch({
url: '/too-long',
handlers: {
onAbort,
onError
}
})
const timerId = setTimeout(() => {
// отменяем запрос через 2 сек после запуска
simpleFetch.cancel()
clearTimeout(timerId)
}, 2000)
}
Операция получения кастомной ошибки:
export const getCustomError = async () => {
const response = await simpleFetch('custom-error')
console.log(response.error)
}
Операция получения исключения:
export const throwException = async () => {
const { error } = await simpleFetch('throw-exception')
console.log(error)
}
Проверка работоспособности
Находясь в корневой директории проекта, выполняем команду yarn dev
или npm run dev
для запуска серверов для БД и фронтенда.
Получаем задачи от сервера с кешированием и логированием:
При повторном выполнении этой операции задачи доставляются из кеша — индикатором служит отсутствие сообщения об URL
запроса от json-server
в терминале.
Результат получения задач от сервера отличается от предыдущего тем, что задачи всегда запрашиваются от сервера.
Получаем первые две задачи, отсортированные по убыванию:
Получаем задачу по идентификатору:
Добавляем в список новую задачу:
Обновляем добавленную задачу:
И удаляем ее из списка.
Пробуем отправить защищенный запрос:
Получаем статус-код 403
и сообщение Forbidden
.
Устанавливаем токен аутентификации и пробуем снова:
Получаем { message: 'Private response' }
.
Отправляем слишком долгий запрос:
Через 2 секунды получаем сообщения об отмене запроса пользователем.
Получаем кастомную ошибку:
Наконец, получаем исключение:
Кажется, наш инструмент прекрасно справляется с поставленными перед ним задачами. Круто!
Надеюсь, что вы не зря потратили время и узнали что-то новое для себя. Благодарю за внимание и хорошего дня!
Комментарии (24)
Bigata
04.08.2021 17:31+4Вопрос - fetch() сложный? Куда уж проще... Ладно колбеки в XMLHttpRequest не нравились, вот вам промис готовый, нет надо лишний траффик, чтобы обертку накатить.
Ну ладно, а в чем преимущество перед наивным fetch ()? Вообще не понял
PaulIsh
04.08.2021 18:20+1Если говорить про axios, то преимуществ ощущается несколько:
Единый код для node и browser
Возможность установки настроек для instance. Вроде baseUrl, заголовков, которые должны передавать с каждым запросом (тот же Authorization)
Есть interceptors.
Можно это сделать самому вокруг того же fetch, что и сделал автор статьи для своего проекта.
Bigata
04.08.2021 19:57Моё мнение с Вашим совпало: "Можно это сделать самому вокруг того же fetch, что и сделал автор статьи для своего проекта."
justboris
04.08.2021 21:08+2simpleFetch.cancel()
очень странно работает в случае нескольких параллельных запросов. Получается, что он отменит их все сразу. Правильнее было бы сделать какой-то более гранулярный контроль.И еще пара рационализаторских предложений:
Для собирания baseUrl можно было взять нативное API:
new URL(options.url, baseUrl)
Для параметров тоже есть нативный URLSearchParams
P.S. и совсем маленькое замечание – сократили вы бы пост в два раза, в ценности бы он совсем не потерял. Не обязательно комментировать каждую строку кода, можно только про самое важное рассказать.
aio350 Автор
05.08.2021 09:30Спасибо за рационализаторские предложения.
2) Поправил
if (options?.params) { url = `${url}?${new URLSearchParams(options.params)}` }
1) new URL() не будет работать в случае передачи url без ведущего /, а также в случае передачи в качестве url параметров строки запроса.
3) добавил возможность безопасной отмены параллельных запросов:
// будет отменен последний отправленный запрос simpleFetch.cancel() // отправляем запрос simpleFetch(url) // извлекаем его id const { currentRequestId } = simpleFetch // отменяем этот запрос simpleFetch.cancel(currentRequestId)
webdevium
05.08.2021 11:13Добавили возможность отмены запросы и сделали ошибку в коде.
У вас есть подсветка в редакторе?aio350 Автор
05.08.2021 12:03?
webdevium
05.08.2021 12:06const generateRequestId = () => { const requestId = Math.random().toString(16).slice(2) if (simpleFetch.abortControllers[requestId]) { return getRequestId() } simpleFetch.abortControllers[requestId] = new AbortController() simpleFetch.currentRequestId = requestId return requestId }
Где в коде функция getRequestId?
1x1
05.08.2021 13:52Полезно (и просто) добавить нормальный chaining, вместо затычек вроде вашего authToken.
Патч выше с currentRequestId небезопасен, лучше уж добавить событие onRequest и создавать его там. Что-то вроде
const api = fetcher({...options, authToken, baseUrl}, request => { request.on(2000, request.cancel) }) await api.get('data')
Andy_Francev
05.08.2021 16:06+3Скажите, а рассматривали ли вы Redaxios? https://github.com/developit/redaxios
faiwer
06.08.2021 00:27+4Никогда не понимал зачем люди тащат в 2021г в свои проекты
axios
или подобные либы. В каждом проекте одна из первых задач, которую я решаю это создание двух слоёв архитектуры:- Слой
http
(название от балды), где зарыта вся специфика работы с конкретным бакендом. JSON | XML? JWT? Кастомные заголовки? Какой формат у ошибок? CORS? Есть ли специфические заголовки в response? И так далее. Ключевой момент — оно всегда одинаковое для адекватного сервера (если проект пишет не безумец). Так что это слой неизбежен и предельно очевиден - Слой
api
, который под капотом работает с вышеописаннымhttp
. Этот слой ничего не знает о вашей бизнеслогике. Просто служит прослойкой между сервером и вашим приложением. Здесь можно подключить нужные io-ts валидаторы. Здесь можно трансформировать данные из какого-нибудь неудобного формата в удобный и наоборот. Здесь можно запустить несколько запросов в параллели. Но самое главное что вся эта мишура лежит внутри этого слоя и не выглядывает наружу. Т.е. здесь будет условныйexport const apiLoginUser = (userName: string, password: string): Promise<AccessTokens/>
. Никакой сетевой требухи. Этот же слой можно mock-ать для тестов и обходится вообще без сервера, если нужно.
В итоге в приложении используются уже максимально упрощённые promise-based методы. Никаких fetch/axios/whatever обвязок. И тут возникает главный вопрос — а зачем при такой архитектуре
axios
? Чтобы не писать в единственном месте в приложении.then(response => response.json())
и сделать проверку на.statusCode
? Серьёзно?Было бы интересно послушать противоположное мнение.
kai3341
06.08.2021 16:14Удваиваю предыдущего оратора. Принципиально выиграть ничего не удалось. Мы как работали с http, так и продолжаем работать с http. Просто через обёртку
- Слой
rock
06.08.2021 18:38+1Начал смотреть код снизу - и сразу в первом же методе, видимо, пропущен
method: 'DELETE'
при передаче одного объекта опций.Задать методы вроде
.delete
и подобных можно чем-то вродеconst createMethod = (method, hasBody) => (...args) => simpleFetch({ method, url: typeof args[0] == 'string' ? args.shift() : undefined, body: hasBody && args.shift(), ...args.shift(), }) simpleFetch.get = createMethod('GET') simpleFetch.post = createMethod('POST', true) simpleFetch.update = createMethod('PUT', true) simpleFetch.delete = createMethod('DELETE')
и это сэкономит строк 40.
Использование конструктора и объектов
URL
сильно упростит и улучшит работу.Глобальные настройки - зло. Предположим, данную библиотеку используете не только вы, но и кто-то ещё, тоже задает
baseUrl
иauthToken
и все ломает. Уaxios
на такой случай есть разные инстансы.
webdevium
Где тесты?