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


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


  1. webdevium
    04.08.2021 16:41
    +2

    Где тесты?


  1. Suvitruf
    04.08.2021 17:13

    Если в цифрах, то размер axios составляет 371 Кб, а размер very-simple-fetch — 9.33 Кб.
    Но ведь это всё gzip'ом отдают. В таком случае он вряд ли будет больше 10 kB.


    1. nin-jin
      04.08.2021 17:26
      +6

      1. aio350 Автор
        05.08.2021 09:20

        спасибо за уточнение


  1. Bigata
    04.08.2021 17:31
    +4

    Вопрос - fetch() сложный? Куда уж проще... Ладно колбеки в XMLHttpRequest не нравились, вот вам промис готовый, нет надо лишний траффик, чтобы обертку накатить.

    Ну ладно, а в чем преимущество перед наивным fetch ()? Вообще не понял


    1. PaulIsh
      04.08.2021 18:20
      +1

      Если говорить про axios, то преимуществ ощущается несколько:

      1. Единый код для node и browser

      2. Возможность установки настроек для instance. Вроде baseUrl, заголовков, которые должны передавать с каждым запросом (тот же Authorization)

      3. Есть interceptors.

      Можно это сделать самому вокруг того же fetch, что и сделал автор статьи для своего проекта.


      1. Bigata
        04.08.2021 19:57

        Моё мнение с Вашим совпало: "Можно это сделать самому вокруг того же fetch, что и сделал автор статьи для своего проекта."


  1. justboris
    04.08.2021 21:08
    +2

    simpleFetch.cancel() очень странно работает в случае нескольких параллельных запросов. Получается, что он отменит их все сразу. Правильнее было бы сделать какой-то более гранулярный контроль.

    И еще пара рационализаторских предложений:

    1. Для собирания baseUrl можно было взять нативное API: new URL(options.url, baseUrl)

    2. Для параметров тоже есть нативный URLSearchParams

    P.S. и совсем маленькое замечание – сократили вы бы пост в два раза, в ценности бы он совсем не потерял. Не обязательно комментировать каждую строку кода, можно только про самое важное рассказать.


    1. 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)


      1. webdevium
        05.08.2021 11:13

        Добавили возможность отмены запросы и сделали ошибку в коде.
        У вас есть подсветка в редакторе?


        1. aio350 Автор
          05.08.2021 12:03

          ?


          1. webdevium
            05.08.2021 12:06

            const 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?


            1. aio350 Автор
              05.08.2021 16:06

              спасибо


    1. Ustas4
      08.08.2021 11:12
      +1

      А мне, новичку, с опытом 0 и даже минус очень нужны такие комментарии.


  1. 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')


    1. aio350 Автор
      05.08.2021 16:08

      в чем состоит небезопасность?


      1. 1x1
        05.08.2021 18:00

        С небезопасностью ошибся, но в целом полагаться на неизменность currentRequestId не очень хорошо

        И бессмысленно с await simpleFetch(url)


  1. Andy_Francev
    05.08.2021 16:06
    +3

    Скажите, а рассматривали ли вы Redaxios? https://github.com/developit/redaxios


    1. aio350 Автор
      05.08.2021 18:05
      -2

      посмотрел, не впечатлен


      1. evgeniyPP
        06.08.2021 21:40
        +2

        "Сын маминой подруги". Полностью дублирует интерфейс Axios и весит меньше вашей библиотеки.


  1. 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? Серьёзно?


    Было бы интересно послушать противоположное мнение.


    1. kai3341
      06.08.2021 16:14

      Удваиваю предыдущего оратора. Принципиально выиграть ничего не удалось. Мы как работали с http, так и продолжаем работать с http. Просто через обёртку


  1. eshimischi
    06.08.2021 14:22

    Для сравнения ohmyfetch


  1. 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 на такой случай есть разные инстансы.