Как стать автором
Обновить
1965.21

JavaScript: как из Fetch сделать Axios?

Время на прочтение20 мин
Количество просмотров15K


Привет, друзья!


В этой статье я хочу поделиться с вами результатами небольшого эксперимента, суть которого заключается в создании обертки над 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 секунды получаем сообщения об отмене запроса пользователем.


Получаем кастомную ошибку:




Наконец, получаем исключение:




Кажется, наш инструмент прекрасно справляется с поставленными перед ним задачами. Круто!


Надеюсь, что вы не зря потратили время и узнали что-то новое для себя. Благодарю за внимание и хорошего дня!




Теги:
Хабы:
+8
Комментарии24

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud