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

Комментарии 7

LocalStorage ограничен по размерам, IndexedDB ненадежен, он привязан к объему свободного дискового пространства и внезапно может быть потерт (на лисе сталкивался с таким в реальной жизни) Ничего не советую, просто обращаю на это внимание. А так еще cacheStorage есть, можно и его использовать https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage

LocalStorage ограничен по размерам

Совершенно верно. Но объём в ≈ 5 МБ во многих случаях не такой уж и маленький для Store.

IndexedDB ненадежен

IndexedDB удобно использовать для работы оффлайн. При этом, как и при работе с любой базой данных, требуется организация резервного копирования. Для этого в Dexie есть родной пакет dexie-export-import. Если веб приложение запускается под какой-то системной обёрткой вроде Electron, резервное копирование можно организовать и в фоновом режиме, чтобы не полагаться в этом вопросе на пользователя. Тем более, что с IndexedDB можно работать и из-под веб воркера. Тогда проблема перестанет быть фатальной. Просто некоторые неудобства для пользователя, которому при крахе базы придётся подождать восстановления и, возможно, пережить потерю нескольких самых последний изменений в базе. Такие ситуации, всё же бывают не часто.

Спасибо! Интересная публикация.

Заметил, что почти в каждой статье, где упоминается local storage, обращают внимание на небольшой лимит в 5 мб. Если у разработчика нет соблазна хранить таблицы БД в LS, то мне кажется, что это довольно редкий кейс, когда лимит можно превысить.

Плюс работа с LS хорошо себя зарекомендовала для кеширования ответов вызовов API (конечно, если ответы не развесистые JSON). Довольно хорошо себя показывает на минутных интервалах кеширования.

Если не против, поделюсь своим решением, может быть кому-то пригодится. Можно использовать как для хранения стейта, так и для кеша. https://www.npmjs.com/package/@supercat1337/ls-cache.

Пример

import { CacheStorage } from '@supercat1337/ls-cache';

const cache = new CacheStorage();
const url = "https://example.com/api?123";

// Write a value to the cache
cache.write(
  url, // key name
  "Hello, world!", // value 
  1 // seconds
);

// Read a value from the cache
const value = cache.read(url);
console.log(value);
// Output: "Hello, world!"

// Wait for the cache to expire
await sleep(2000);
console.log(value);
// Output: null

Фишка решения в том, что в LS сопоставляется не только урл и ключ, а также хранятся данные о времени создания ключа и времени жизни. Плюс есть методы, с помощью которых можно удалять старые ненужные ключи по дате создания. И еще, ключ в LS - это не урл, а его хэш. Это сделано, чтобы справляться со слишком длинными урлами.

Могу дать некоторый фидбэк. В основном по форме.

Все нюансы, которые меня озадачили трогать не буду. Просто выборочно пробегусь.

  1. В самой статье огромное количество материала, который в общем-то не относится к целям статьи. Т.е. я бы скорее обратил внимание не на количество, а на качество материала.

  2. Несколько "вольное" обращение с ts. Для примера:

let theme = 'light' as Theme // тут все ясно, поэтому утверждение о типе лишнее. let theme: Theme = 'light'
...
theme = newTheme ?? (theme === 'light' ? 'dark' : 'light') // theme = newTheme ?? theme
...
const sdbDefaultSettings = { id: 1, theme: 'light' } as ISettings // такая же фигня
...
const theme = ref('light' as Theme) // ref<Theme>('light')
...
return useObservable(liveQuery(query) as any) as T as Readonly<Ref<T>> // Это прям ой.

3. Почему не стали делать тесты на традиционный подход? Понятно, что у вас тут учебный материал, но если бы вы лучше продумали ui/ux, то в последствии понимали, что и как вы тестируете. По сути, вы имеете лэйбл, две кнопки, и он-офф переключатель. Понять, с первого взгляда, что вы изобразили мне не удалось. По сути, переключатель вам не нужен. Достаточно кнопок с индикатором активности. А лучше, вообще взять готовый элемент формы и посмотреть подходит он или нет, что-то типа:

describe('Test radio', () => {
  const user = userEvent.setup()

  it('Test default', async () => {
    type Theme = 'dark' | 'light'
    const TestComponent = () => {
      const [theme, setTheme] = useState<Theme>('light')
      const changeHandler: ChangeEventHandler<HTMLInputElement> = e => setTheme(e.currentTarget.value as Theme)

      return (
        <fieldset>
          <legend>Тема:</legend>

          <div>
            <input
              onChange={changeHandler}
              type="radio"
              id="theme_dark"
              name="theme"
              value="dark"
              checked={theme === 'dark'}
            />
            <label htmlFor="theme_dark">Темная</label>
          </div>

          <div>
            <input
              onChange={changeHandler}
              type="radio"
              id="theme_light"
              name="theme"
              value="light"
              checked={theme === 'light'}
            />
            <label htmlFor="theme_light">Светлая</label>
          </div>
        </fieldset>
      )
    }

    render(<TestComponent />)

    const dark = byLabelText('Темная').get()
    const light = byLabelText('Светлая').get()

    expect(dark).toBeInTheDocument()
    expect(light).toBeInTheDocument()

    expect(light).toBeChecked()

    await user.click(dark)

    expect(dark).toBeChecked()
    expect(light).not.toBeChecked()
  })
})

Это реакт, нет сейчас развернутого вью. Тут смысл в том, что все имеет ярко выраженную семантику. На вью все это тоже есть в том же самом виде.

Тогда все эти выборы по тексту, проверка наличия классов, прямая работа с дом и т.п. вам в целом не нужна.

Теперь напишем модульный тест для компонента src/components/SettingsForm.vue

Если я правильно понял, вы тестируете то, что видит пользователь, а значит вам не нужно проверять корректность данных в локалсторэдж. Данные из стора у вас являются одним источником данных, а значит корректность работы проверяется просто проверив данные на экране, которые взаимно однозначно соответствуют данным из стора. Иными словами тесты формы отличаются только конфигурированием поставщика данных, но сами тесты одинаковые для любого из 3х подходов. При желании модульно можно протестировать каждый поставщик.

Спасибо за фидбек. Попробую пройтись по пунктам:

(1)

В самой статье огромное количество материала, который в общем-то не относится к целям статьи.

Вообще-то, я старался максимально убрать то, что к статье не относится. Допускаю, что что-то упустил, но не знаю, как из вашего комментария понять, что именно.

(2)

Несколько "вольное" обращение с ts.

Мой вариант: let theme = 'light' as Theme

Ваш вариант: let theme: Theme = 'light'

В вашем варианте theme получает тип декларативно, в моём — по типу присвоенного значения. В чём принципиальная разница, и в чём "вольность"?

Мой вариант: theme = newTheme ?? (theme === 'light' ? 'dark' : 'light')
Ваш вариант: theme = newTheme ?? theme
В вашем варианте ошибка в логике. Значение theme должно инвертироваться.

Сейчас уже не актуально, т.к. я, следуя вашему совету, переключатель убрал, соответственно, убрал и этот код.

Ваш вариант: ref<Theme>('light')
Мой вариант: const theme = ref('light' as Theme)
То же, что и для первого случая. На мой взгляд, это не вопрос "вольности" обращения с ts, а вопрос стиля кода. Согласен, что Ваш вариант более наглядный и общепринятый. Поправил в статье. Спасибо.

return useObservable(liveQuery(query) as any) as T as Readonly> // Это прям ой.

Спасибо, ...) as T as Readonly<Ref<T>> — опечатка. Должно быть:
return useObservable(liveQuery(query) as any) as Readonly<Ref<T>>
Поправил в статье.

Что касается any, то причина его появления следующая:
Тип liveQuery(query) — liveQuery(querier: () => T | Promise): Observable<T>
Тип первого аргумента useObservable — observable: Observable<T>
При этом, эти Observable берутся из разных пакетов и оказываются разными типами:

«Аргумент типа "import(".../node_modules/dexie/dist/dexie").Observable<T>" нельзя назначить параметру типа "import(".../node_modules/rxjs/dist/types/internal/Observable").Observable<T>".
В типе "Observable<T>" отсутствуют следующие свойства из типа "Observable<T>": source, operator, lift, forEach и еще 2.ts(2345)»

Я не стал заморачиваться тем, чтобы их подружить, поскольку всё работает и тип запроса query() правильно передаётся в результат выполнения Readonly>, а проблема нестыковки локализована исключительно в этом одном этом месте.

Но если Вы знаете, как подружить эти типы, буду благодарен за разъяснение.

(3)

По сути, переключатель вам не нужен.

Да. Согласен — для статьи так нагляднее. Упростил до некуда. Радиокнопки с состоянием тоже не нужны. Само изменение темы вполне заметно пользователю. Везде поправил. Спасибо.

Если я правильно понял, вы тестируете то, что видит пользователь, а значит вам не нужно проверять корректность данных в локалсторэдж

Ваш подход реализован в интеграционном тесте компонента src/App.vue.

Для модульного теста такой подход не подходит. При модульном тестировании компонент тестируется атомарно, отдельно от остальных модулей. Компонент src/components/SettingsForm.vue непосредственно не реализует смену темы в DOM. Он лишь передаёт значение в хранилище, из которого уже его берёт родительский компонент и... Но это уже другая история, и src/components/SettingsForm.vue она не касается. Поэтому в модульном тесте проверяется localStorage, но не далее.

Я не пытался организовать тестирование на проекте. Просто показал, как написать как интеграционный, так и модульный тест.

Вообще-то, я старался максимально убрать то, что к статье не относится. Допускаю, что что-то упустил, но не знаю, как из вашего комментария понять, что именно.

Все что относится к настройке проекта, тестирования и в тоже время являются базовыми вещами я бы опустил.

В вашем варианте theme получает тип декларативно, в моём — по типу присвоенного значения. В чём принципиальная разница, и в чём "вольность"?

ТС он с одной стороны про создание своего дизайна типов, а с другой стороны представляет взгляд разработчиков ТС о том какие ситуации могут привести к ошибкам. Последнее поддерживается самим ТС. Например, в данном случае вы отключили избыточную проверку свойств у литералов. И, тип в обоих случаях задается декларативно.

Правилом хорошего тона будет то, что такие вещи, заложенные в ТС, отключаете только если явно обдумали и понимаете зачем это нужно. Т.к. вы по тексту ничего такого не предполагали я решил, что просто "вольно" обошлись с ТС

В вашем варианте ошибка в логике. Значение theme должно инвертироваться.

Я действительно не обратил внимание на инвертирование. Я решил, что вы либо используете значение по умолчанию, либо из переменной. Поэтому и не стал учитывать, что там что-то меняется.

То же, что и для первого случая. На мой взгляд, это не вопрос "вольности" обращения с ts, а вопрос стиля кода. Согласен, что Ваш вариант более наглядный и общепринятый. Поправил в статье.

Он не то, что бы общепринятый, просто as это утверждение, а не объявление. По сути, вы объявляете, но пользуетесь для этого утверждением. Тут как бы вопрос, зачем утверждать то, что можно просто объявить?

Но если Вы знаете, как подружить эти типы, буду благодарен за разъяснение.

Я тоже не стал вдаваться в подробности. Там просто одно из утверждений было лишним. Как его лучше использовать сам не знаю, не смотрел.

Поэтому в модульном тесте проверяется localStorage, но не далее.

Смотрите, у вас компонент называется SettingsForm.vue и вы в нем тестируете сторэдж.

Как бы я на все это дело посмотрел. Сделал бы компонент формочки в который бы инжектировал сервис хранилища. Т.е. работа со стором идет через интерфейс. Инжектируя различные сервисы, которые имплементируют один интерфейс вы используя одну и туже форму проверяете работу этих сервисов.

Внутри тестов describe('SettingsForm.vue', вы проверяете корректность работы этой формы. Вот такие вот штуки

expect(window.localStorage.getItem('settings-storage')).toBe(
      JSON.stringify({ theme: 'dark' })
    )

Тут смысла не имеют. Потому что у вас в зависимости от этого значения должно быть определенное состояние на экране и вы просто на него смотрите. По сути, вам не важно в контексте SettingsForm.vue какой сервис хранит данные.

А у вас получается, что вы и форму тестируете и детали сервиса тестируете. Меня вот этот момент смущает.

Я не пытался организовать тестирование на проекте. Просто показал, как написать как интеграционный, так и модульный тест.

Это вот как раз к вопросу организации материала. Можно было сделать формочку работы с данными с одной стороны и сервис, который эти данные предоставляет с другой стороны. Так как цель вашей статьи показать сразу три реализации сервиса работы с данными, можно было сделать единый интерфейс с которым работает форма и корневой элемент, который вешает класс темы. А потом этот интерфейс имплементировать в каждом сервисе. Тогда, что бы протестировать модуль каждого сервиса вам не нужна форма вообще. Вы бы могли показать и тестирование формы с одной стороны, и тестирование модулей сервисов с другой стороны. А так у вас получилось все слишком сумбурно и перемешано.

Один момент показался странным: или pinia, или localStorage. В pinia можно установить pinia-plugin-persistedstate и сохранять состояние в localStorage/sessionStorage прямо в store. В остальном очень познавательная статья, спасибо!

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации