Skip to content

Управление потоками данных

В предыдущих главах мы сосредоточились на понимании фреймворка Vue 3 и создании контекста для создания веб-приложений. В этой главе мы сосредоточимся на том, как наши компоненты взаимодействуют друг с другом и обмениваются информацией для реализации нашего приложения.

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

  • Общение между родителями, детьми и братьями (siblings)
  • Реализация шины сообщений с использованием Singleton и Observer шаблонов
  • Реализация базового реактивного состояния с помощью композитных компонентов
  • Реализация централизованного хранилища данных с помощью мощного хранилища Pinia reactive store
  • Обзор предоставляемых браузерами альтернатив для обмена и хранения информации
  • Эксперименты с реактивностью, составными элементами и шаблонами прокси в действии

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

Некоторые из них больше подходят для небольших приложений, а другие - для больших и сложных. Вы сможете лучше подготовиться к управлению потоком информации для вашего приложения.

Технические требования

В этой главе мы рассмотрим концепции и применим шаблоны для управления связью и потоком информации между компонентами. Вы должны быть в состоянии выполнить код, представленный в этом тексте, но для лучшего понимания и контекстного опыта вам будет полезно ознакомиться с полным кодом приложения для этой главы, доступным в репозитории к этой книге: https://github.com/PacktPublishing/Vue.js-3-Design-Patterns-and-Best-Practices/tree/main/Chapter07.

Если вы начинаете новый проект, просто следуйте инструкциям, как показано в главе 3, Установка рабочего проекта.

Посмотрите следующее видео, чтобы увидеть код в действии.

Основное взаимодействие компонентов

Ранее мы видели, что родительский компонент и его дочерние компоненты имеют довольно простой и понятный способ взаимодействия. Родители передают данные в виде props своим дочерним компонентам, а те поднимают события (emits), чтобы привлечь внимание родителя.

Подобно сопоставимости параметров и аргументов в функциях, props получают простые данные по копии, а сложные типы (объекты, массивы и т.д.) - по ссылке.

Таким образом, мы можем передать от родителя к ребенку обычный объект с функциями-членами, а ребенок будет выполнять эти функции для доступа к данным родителя.

Несмотря на то, что это "работает", это своего рода антипаттерн, поскольку он скрывает отношения и затрудняет понимание потока данных. Правильным способом передачи данных вверх по дереву компонентов являются события (emits).

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

Для того чтобы управлять рабочим процессом данных чистым способом, у нас есть несколько альтернатив, которые следуют хорошей практике и шаблонам проектирования.

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

Для распространения информации мы можем использовать реактивную систему Vue. Ключевым моментом здесь является то, что манипулировать ею будет только родительский компонент. Посмотрим, как это работает на практике, на примере реализации небольшого тривиального приложения, как показано на Рисунке 7.1:

image

Рисунок 7.1 - Прямая базовая связь и реактивность

В данном приложении родительский компонент имеет три прямых дочерних компонента и разделяет с ними реактивный счетчик. Все компоненты отображают метку со значением счетчика и имеют кнопку для запуска инкремента, но только родительский компонент выполняет фактические манипуляции с данными. Vue обеспечивает реактивность, то есть когда родительский компонент изменяет значение, дочерние компоненты также получают его. Все достаточно просто - давайте посмотрим, как это реализовано:

/basic/ParentBasic.vue
vue
<script setup> 
import {ref} from "vue" 
import ChildComponent from "./Child.vue" 
const _counter = ref(0);                                  //1 
function incrementCounter() {                             //2 
   _counter.value++; 
} 
</script> 
<template> 
<div> 
   <strong>Counter</strong>
   <span>{{ _counter }}</span>
   <button @click="incrementCounter()">                   //3 
       Increment 
   </button> 
</div> 
<section> 
<ChildComponent title="Child component 1" 
  :counter="_counter" @increment="incrementCounter()">    //4 
</ChildComponent> 
<ChildComponent title="Child component 2" 
  :counter="_counter" @increment="incrementCounter()"> 
</ChildComponent> 
<ChildComponent title="Child component 3" 
  :counter="_counter" @increment="incrementCounter()">
</ChildComponent> 
</section> 
</template>

В этом компоненте, мы объявляем реактивную переменную _counter (строка //1) и функцию incrementCounter() для работы с ее значением (строка //2). Мы запускаем эту функцию в родительской кнопке по событию click, как показано в строке //3.

Теперь, чтобы увидеть реализацию этого шаблона, мы просто передаем нашу реактивную переменную _counter в качестве prop каждому дочернему компоненту, и связываем нашу функцию incrementCounter() с событием инкремента каждого дочернего компонента (строка //4). Все достаточно просто - давайте посмотрим, как каждый ребенок реализует свою часть:

/basic/Child.vue
vue
<script setup> 
const 
    $props=defineProps(['counter', 'title']),           //1 
    $emit=defineEmits(['increment']) 
function incrementCounter(){$emit("increment")}         //2 
</script> 
<template> 
<h3>{{$props.title}}</h3> 
<span class="badge">{{$props.counter}}</span>           //3 
<button @click="incrementCounter()">                    //4 
    Increment 
</button> 
</template>

Наша дочерняя реализация также проста. Начнем с определения пропсов для получения переменной counter в строке //1, а также нашего пользовательского события increment, чтобы мы могли уведомить родителя. Для этого мы создаем функцию в строке //2.

В нашем шаблоне мы отображаем наш пропс в строке //3 и запускаем нашу функцию инкремента в строке //4. Обратите внимание, что наш дочерний компонент не изменяет счетчик. За это отвечает компонент-отец, поэтому мы соблюдаем шаблон.

Этот шаблон мы будем использовать довольно часто, но у него есть некоторые ограничения. Например, что произойдет, если данные должны попасть к родителю, брату или внуку? Будем ли мы передавать данные вверх и вниз по дереву, даже если компоненты их не используют? Можно, но, опять же, это грязно, многословно и не самый лучший способ. У нас есть более эффективные средства для этого.

В главе 4, Композиция пользовательского интерфейса с помощью компонентов, мы видели, что родитель может передавать данные и функциональность любому из своих дочерних элементов по дереву, используя инъекцию зависимостей (DI) с provide и inject.

Поскольку приведенный там пример был достаточно полным, мы не будем повторять его здесь. Я рекомендую вам пересмотреть, как создавалось и вводился функционал provide/inject. Вместо того чтобы повторяться, давайте перейдем к следующему пункту нашей программы по обмену информацией в любом месте дерева компонентов: реализуем шину сообщений message bus (также называемую event bus).

Реализация шины событий с помощью шаблонов Singleton и Observer

Шина сообщений - это реализация шаблона Observer, который мы рассматривали в главе 2, Принципы и шаблоны проектирования программного обеспечения.

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

image

Рисунок 7.2 - Упрощенный вид отношений шины сообщений с компонентами

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

Давайте сведем эти понятия к коду на примере реализации. Начнем с создания сервиса, использующего шаблон Singleton, который предоставит нам шину сообщений. В нашем случае мы просто обернем пакет mitt, который предоставляет нам эту функциональность (см. https://github.com/developit/mitt#usage).

Пакет mitt может быть установлен в наше приложение с помощью следующей команды в терминале:

sh
 $ npm install mitt

Тогда наш сервис будет выглядеть например так:

/services/MessageBus.js
js
import mitt from "mitt" 
const messageBus = mitt() 
export default messageBus

Это даст нам синглтон для эмиттера событий и диспетчера, то есть нашей шины сообщений. В нашем примере мы будем отправлять через нее текстовые сообщения, а каждый принимающий компонент будет их отображать. Наши компоненты будут выглядеть следующим образом:

/bus/Child.vue
vue
 
<script setup> 
import messageBus from '../services/MessageBus';                //1 
import {ref, onMounted, onBeforeUnmount} from 'vue'; 
const 
   $props=defineProps(['title']), 
   message=ref("")                                              //2 
    onMounted(()=>{ 
        messageBus.on("message", showMessage)})                 //3 
    onBeforeUnmount(()=>{ 
        messageBus.off("message",showMessage)}) 
    function showMessage(value){                                //4 
        message.value=value;} 
    function sendMessage(){                                     //5 
        messageBus.emit("message",`Sent by ${$props.title}`)} 
</script> 
<template> 
    <h4>{{$props.title}}</h4> 
    <strong>Received: </strong> 
    <div>{{message}}</div> 
    <button @click="sendMessage()">Send message</button>        //6 
</template>

В этом примере мы начинаем со строки //1, импортируя наш объект messageBus (проверьте правильность пути в вашей реализации) и объявляя реактивную переменную message, инициализированную пустой строкой.

Обратите внимание, что мы также импортируем и используем методы onMounted() и onBeforeUnmount() из жизненного цикла компонента для подписки и отписки на событие message, начиная со строки //3.

Функция, которую мы регистрируем, находится в строке //4, и она получает от события значение, которое мы передаем нашей внутренней переменной для отображения в шаблоне. Также нам необходима функция для публикации события с целью оповещения других пользователей, которая находится в строке //5. В данном случае мы публикуем заголовок компонента. Эта функция вызывается кнопкой, как показано в строке //6.

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

image

Фигура 7.3 - Простая реализация совместного использования данных с помощью шаблона Observer

Этот подход к управлению потоком данных достаточно эффективен, но имеет и свои ограничения. События - это хороший способ одновременного оповещения нескольких компонентов для запуска действий, независимо от их места в дереве организации. Если в приложении имеется несколько подсистем, которые должны реагировать на изменение состояния приложения, то такой шаблон будет вполне уместен.

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

Есть случаи, когда это необходимо или желательно, но, конечно, не для каждого случая. Если у нас 50, 100 или 1000 компонентов, подписанных на одно и то же событие, будут ли все они иметь одну и ту же копию данных? Если каждый компонент должен обрабатывать и, возможно, модифицировать данные независимо от других, то это работает хорошо. Но если мы хотим лучше использовать реактивность Vue и улучшить работу с памятью, то нам нужно использовать другой подход. Именно это мы и увидим на примере базового реактивного состояния приложения.

Реализация базового реактивного состояния

Как уже упоминалось, недостатком использования шины сообщений для обмена данными является множественность копий одних и тех же данных, включая накладные расходы на обработку событий. Вместо этого мы можем использовать механизм реактивности Vue и, в частности, вспомогательный конструктор reactive() для создания единой сущности, хранящей состояние нашего приложения.

Как и раньше, мы можем обернуть этот реактивный объект в шаблон Singleton, чтобы использовать его совместно с компонентами и обычными JavaScript-функциями, объектами и классами. Стоит отметить, что это одно из главных преимуществ Vue 3 и нового Composition API.

В завершение мы рассмотрим базовый код примерно так:

image

Рисунок 7.4 - Общий реактивный объект для управления состоянием

Как видно на предыдущем снимке экрана, состояние в данном случае является общим (или доступным) для всех компонентов данного примера. Любой из дочерних компонентов может изменить любое из его значений, и это изменение немедленно отражается на всем приложении. В отличие от предыдущих примеров, реализация данного шаблона проста и понятна. Давайте погрузимся в нее, создав сначала сервис с нашим реактивным состоянием:

/service/SimpleState.js
js
import {reactive} from "vue"                                   //1
const _state = reactive({counter: 0})                          //2
function useState() {return _state;}                           //3
export default useState;

Если этот код кажется простым, то это так и есть. Мы создаем JavaScript-файл и импортируем конструктор reactive из Vue (строка //1). Затем мы объявляем реактивную константу с начальным объектом (строка //2).

Это будет состояние приложения, которое мы возвращаем через функцию useState(), названную по модели композитных компонентов (строка //3). Эта функция является нашим экспортом модуля.

Использование этого централизованного состояния также очень просто, как мы можем увидеть здесь:

#####/simple/ChildSimple.vue

vue
<script setup>
    import useState from "../../services/SimpleState"          //1
    const $state = useState()
</script>
<template>
    <strong>State: </strong><br>
    <pre>{{$state}}</pre>                                      //2
    <div>
        <button @click="$state.counter++">Increment</button>   //3
        <button @click="$state.counter--">Decrement</button>
    </div>
</template>

Начнем наш компонент с импорта фабричной функции useState и объявим с ее помощью реактивную константу (строка //1). Мы используем эту реактивную переменную в нашем шаблоне так же, как и любую другую (строка //2), и точно так же можем напрямую обращаться к полям-членам объекта для их модификации, как и в случае с любым другим объектом, как это показано в строке //3.

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

Этот простой подход очень полезен и подходит для небольших и даже средних приложений. Он имеет много преимуществ, таких как следующие:

  • Простота реализации и понимания.
  • Используется реактивная система Vue.
  • Она гибкая, поскольку мы можем добавлять новые реактивные члены после инициализации.
  • Она создает единый источник истины, то есть наше состояние является централизованным хранилищем данных приложения. Нет необходимости синхронизировать внутренние или приватные переменные.

Если рассматривать рассмотренные до сих пор варианты, то это гигантский скачок вперед. Однако есть ситуации, когда эти недостатки:

  • Что происходит, когда функция изменяет свое значение асинхронным способом, если другие компоненты внесли изменения до того, как функция закончила работу?
  • Этот подход не позволяет нам работать с вычисляемыми (computed) данными, которые должны быть реализованы в каждом компоненте
  • Отладка может быть затруднена, поскольку нет специальной поддержки инструментов разработчика

Как уже было сказано, этот подход подходит для простых нужд. Для более надежного подхода мы обратимся к официальному решению централизованного управления состоянием от проекта Vue: Pinia.

Реализация мощного реактивного стора с помощью Pinia

Центральное управление состоянием не является концепцией, присущей только Vue, и аналогичный шаблон можно встретить в других библиотеках и фреймворках.

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

Это состояние разделяется между компонентами приложения и дает нам доступ ко всему спектру реактивных инструментов, предоставляемых Vue, через четко определенный интерфейс. Понять суть Pinia будет проще, если сначала мы построим пример, демонстрирующий результаты его использования. Запуск кода примера даст нам нечто подобное этому:

image

Рисунок 7.5 - Центральное управление состоянием с помощью Pinia

В этом примере мы строим стор, который не только отображает реактивное состояние, но и реализует вычисляемые значения. Как официально поддерживаемый проект, Pinia также предоставляет реализации Options API и Composition API. Чтобы использовать Pinia, нам необходимо включить его в наш проект с помощью следующей команды в корневом каталоге проекта:

sh
 $ npm install pinia

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

Стор - это как наш реактивный синглтон из предыдущего раздела, то есть объект, который будет содержать реактивные поля, разделяемые в нашем приложении, а также соответствующую бизнес-логику.

Итак, каждый стор будет содержать следующие элементы: data, вычисляемые свойства, известные как getters, и методы, известные как actions. Каждый стор мы определяем в отдельном файле как модуль, определяя каждый элемент. При использовании Options API стор будет выглядеть как здесь:

Options API basic store
js
import { defineStore } from 'pinia';                    //1
const useCounterStore = defineStore('counter', {        //2
  state: () => {return {count: 0, in_range: false}},    //3
  getters: {
    doubleCount: (state) => {                           //4
      if(state.count>=0){
            return state.count *2;
      }else{
       return 0
      }
  }, inRange: (state)=> state.count>=0},
  actions: {                                            //5
    increment(){this.count++},
    decrement(){this.count--}
  },
})
export {useCounterStore}

В этом сторе, мы начинаем с импорта конструктора defineStore из пакета Pinia (строка //1) и используем его для создания стора в строке //2. Этот конструктор получает два аргумента:

  • Имя стора в виде строки. Оно должно быть уникальным среди сторов, так как используется внутри стора в качестве идентификатора.
  • Объект, содержащий определение стора со следующими членами:
    • state (строка //3): Это функция, возвращающая объект. Обратите внимание, что мы не объявляем ее реактивной. Pinia позаботится об этом.
    • getters (строка //4): Это объект, члены которого станут вычисляемыми свойствами. Каждый член получает в качестве первого аргумента состояние стора, как реактивный объект.
    • actions (строка //5): Это опять же объект, членами которого являются функции, которые могут получать доступ к состоянию и изменять его, но должны делать это через ключевое слово this.

Использование API Options для определения стора - хороший способ понять, из каких частей он состоит. Однако изменение синтаксиса между getters и actions может запутать и привести к непроизвольным ошибкам, поскольку в одном случае доступ к состоянию осуществляется через аргумент, а в другом - с помощью ссылки this.

Однако если мы немного посмотрим на конструктор, то увидим, что getters и actions являются аналогами вычисляемых свойств и методов компонента (функций). Учитывая это, давайте посмотрим, как переписать этот стор с помощью Composition API, и именно его мы будем использовать в нашем примере кода:

/stores/counter.js
js
//Composition API
import {ref,computed} from 'vue'                            //1
import {defineStore} from 'pinia'
export const useCounterStore = defineStore('counter',()=>{  //2
    const
        count = ref(0),                                     //3
        in_range=ref(true),
        doubleCount = computed(() => {                      //4
             if(count.value>=0){
                 return count.value *2;
             }else{
                 return 0
        }}),
        inRange = computed(()=>return count.value>=0);
    function increment() {count.value++}                    //5
    function decrement(){count.value--;}
    return {                                                //6
        count, doubleCount, inRange,
        increment, decrement
    }
})

Использование Composition API делает стор более похожим на остальное приложение, поскольку мы применяем один и тот же подход.

Мы начинаем с импорта из Vue необходимых нам конструкторов в строке //1, как и в случае с компонентами, использующими тот же API. На этот раз, когда мы используем конструктор defineStore, вместо объекта мы передаем функцию (или функцию-стрелку), которая вернет реактивные свойства и методы, составляющие стор. Это можно увидеть в строке //2, а затем объект return в строке //6.

Как и следовало ожидать, внутри этой функции мы объявляем наши реактивные свойства (строка //3) и вычисляемые свойства (строка //4), а также методы (строка //5). Реактивные свойства станут просто реактивными свойствами. Вычисляемые свойства станут нашими геттерами, а функции - actions.

Пока в этом синтаксисе нет того синтаксического сахара, к которому мы привыкли, используя тег <script setup>, но тело функции - это тот же подход, который мы используем с компонентами.

Когда у нас есть стор (а их может быть много), прежде чем мы сможем реально его использовать, нам нужно реализовать Pinia в нашем приложении. Для этого в наш файл main.js включите следующие выделенные строки:

./main.js
js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')

Этот шаг необходим для включения движка Pinia для всего приложения. Теперь осталось импортировать стор, который мы хотим использовать в компонентах, нуждающихся в нем. Например, если заглянуть в репозиторий примеров, то можно найти этот файл:

/pinia/ChildPinia.vue
vue
<script setup>
import { useCounterStore } from '../../stores/counter';   //1
const $store=useCounterStore()                            //2
</script>
<template>
    <h4>Child component</h4>
    <code :class="{'red': !$store.in_range}">             //3
        {{$store}}
    </code>
    <button @click="$store.increment()">                  //4
        Increment</button>
    <button @click="$store.decrement()"
        :disabled="!$store.in_range">Decrement
    </button>
</template>
<style scoped>
.red{color: red;}
</style>

В строке //1 мы импортируем конструктор стора, а в строке //2 создаем наш реактивный объект. Чтобы использовать их значения или выполнить их методы, мы используем их напрямую, как если бы это были обычные объекты, используя нотацию точки (.).

Обратите внимание, как в строке //3 мы обращаемся к значению in_range, а позже, в строке //4, выполняем функцию increment(). Как и следовало ожидать, любое изменение значений хранилища будет автоматически синхронизировано по всему нашему приложению.

В отличие от предыдущих методов, хранилища и состояния Pinia отслеживаются и отображаются в инструментах разработчика. Для приложений среднего размера и выше использование Pinia является практически обязательным условием, когда необходимо центральное состояние.

Pinia является официальным решением Vue 3 для центрального управления состояниями, заменив Vuex из версии Vue 2. На практике они реализуют одинаковую функциональность, но у первого есть ряд преимуществ, которые заставили команду Vue выбрать его и спонсировать. Глубокий обзор - не тема для наших целей, но вот краткий список изменений или преимуществ Pinia:

  • Другой подход к сторам. В Pinia каждый стор - это отдельный модуль, и все они динамические. В Vuex вместо этого используется один единственный стор с разделами в модулях.
  • Синтаксис и API для Pinia проще и менее многословны по сравнению с Vuex.
  • Улучшена поддержка TypeScript и улучшена открываемость автозаполнения в IDE.
  • Поддержка как Options API, так и Composition API.
  • Более эффективное внутреннее использование новых реактивных моделей Vue.
  • Поддержка инструментов разработчика.
  • Архитектура плагинов для расширения Pinia.

Переход от Vuex к Pinia затрудняет одномоментное обновление проектов, которые его использовали. Однако команда Pinia опубликовала на официальном сайте хорошее руководство по миграции, которое можно найти здесь. Для полного ознакомления со всеми возможностями, доступными в Pinia, я рекомендую прочитать официальную документацию по адресу https://pinia.vuejs.org.

С помощью Pinia мы рассмотрели наиболее распространенные и актуальные шаблоны для управления потоком данных между компонентами (и сервисами!), но это не единственные доступные нам. Далее мы рассмотрим хранилища, предоставляемые по умолчанию в современных веб-браузерах, и то, как использовать их.

Хранилища данных браузера - сессионные, локальные и IndexedDB

Браузеры предоставляют и другие возможности для локального хранения данных, которые могут быть прочитаны не только любым другим компонентом, но и любым скриптом, выполняющимся на той же странице. Мы будем говорить не о cookies, а о новых методах, предоставляемых в качестве хранилищ ключевых значений: SessionStore и LocalStore.

Но это не единственные варианты, поскольку браузеры также предоставляют базу данных IndexedDB, которая предлагает гораздо больше места для хранения данных и может быть доступна также за пределами окна нашего приложения в другом потоке. Более подробно мы рассмотрим это в главе 8, Многопоточность с Web Workers, а здесь мы сосредоточимся на понимании основной концепции и ограничений каждого из них.

SessionStorage - это объект, доступный только для чтения, создаваемый для каждого origin (комбинация протокола, домена и порта).

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

Ярким примером такого использования является сохранение данных формы. Объект привязывается к объекту window (window.sessionStorage) и может быть доступен любому скрипту на странице.

LocalStorage по возможностям и хранению данных похож на SessionStorage. Он имеет тот же интерфейс и ограничен тем же происхождением страницы (origin). Основное отличие заключается в том, что он сохраняется за пределами жизни страницы и является общей для всех открытых страниц одного происхождения. Веб-сайты и приложения могут использовать его для хранения данных и их извлечения в течение нескольких сеансов в одном и том же браузере.

SessionStorage и LocalStorage имеют одинаковый интерфейс:

  • .setItem(item_name, item_data): Здесь item_name - это строка, которая однозначно идентифицирует item_data, которая также является строкой
  • .getItem(item_name): Получает строковые данные, хранящиеся под именем item-_name, или null, если не найдено
  • .removeItem(item_name): Удаляет данные по имени_элемента из стора
  • .clear(): Удаляет все данные из хранилища

Вышеперечисленные методы представляют собой совокупность конечных точек API для обоих хранилищ. Все достаточно просто - мы можем сериализовать данные, чтобы записать их в эти хранилища. Например, для хранения JSON-объекта мы используем следующее (ссылку на объект window можно опустить, так как он считается глобальным объектом):

js
localStorage.setItem("MyData", JSON.stringify({...});

А затем, чтобы получить его, мы используем следующее:

js
let data=localStorage.getItem("MyData") 
if(data) { 
    data=JSON.parse(data); 
}

У обоих хранилищ есть некоторые ограничения и несколько предостережений:

  • В браузерах не существует стандартного ограничения на количество символов, которое может содержать каждое хранилище. Строки хранятся в формате UTF-16, поэтому каждый символ может занимать от 2 байт и более (см. https://en.wikipedia.org/wiki/UTF-16), что затрудняет расчеты. В спецификациях рекомендуется не менее 5 МБ для каждого хранилища.
  • Когда в этих хранилищах заканчивается место, некоторые браузеры аварийно завершают работу страницы, другие запрашивают у пользователя согласие на расширение хранилища.
  • Доступ к хранению и получению данных осуществляется последовательно, что может блокировать процесс рендеринга и сделать страницу/приложение неотзывчивым. Но это происходит только при длительных операциях.
  • Для sessionStorage дублирование вкладок приведет и к дублированию хранилища. Вместо этого для localStorage обе вкладки будут обращаться к одной и той же информации.
  • Ни localStorage, ни sessionStorage не являются реактивными и не предоставляют слушателей для отслеживания изменения значения.

Вышеперечисленные ограничения ни в коем случае не являются угрозой или предложением не использовать их. Наоборот, это границы и пределы их использования, поскольку все данные хранятся локально в браузере пользователя и ничего не отсылается обратно на сервер (как это делают cookies).

В отличие от этих объектов веб-хранилища, IndexedDB представляет собой совершенно другую систему. Это полноценная реализация транзакционной базы данных, которая хранит объекты JavaScript под уникальным ключом.

Мы можем открывать несколько баз данных, создавать соединения с ними и определять схемы, причем все операции выполняются асинхронно, так что блокировка приложения исключена.

Также расширено ограничение на размер базы данных: мягкое ограничение составляет 50 МБ. Если база данных вырастает больше этого значения, пользователю предлагается дать согласие на ее расширение, и место предоставляется.

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

Замечание

В движке Chrome предусмотрен флаг, позволяющий работать без ограничений для IndexedDB, используя всё доступное дисковое пространство. Этот флаг также может быть активирован в гибридных фреймворках, таких как NW.js, или при сборке браузера из исходного кода.

Существует серьезная проблема с IndexedDB, которая заключается в том, что ее API является сложным и громоздким, поэтому очень редко приложения обращаются к ней напрямую. Вместо этого, поскольку IndexedDB настолько гибкая и быстрая, существует достаточно большое количество библиотек, которые создают собственную реализацию базы данных на ее основе или обеспечивают более простой интерфейс (например, с использованием шаблона Façade).

Список этих библиотек и фреймворков можно найти в документации Mozilla Developer Network.

В наших примерах реализации главы 8, Многопоточность с Web Workers, мы будем использовать одну из этих библиотек. Для целей этой главы просто помните, что каждый браузер предоставляет вам мощную базу данных для вашего приложения, и вы можете обращаться к ней с помощью различных шаблонов и подходов.

Экспериментируем с шаблонами reactivity и Proxies

Настало время применить на практике то, что мы узнали в этой главе, в свете шаблонов, которые мы рассматривали в главе 2, Принципы и шаблоны проектирования программного обеспечения, с помощью небольшого экспериментального проекта.

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

Поскольку SessionStorage не предоставляет API, к которому мы могли бы обращаться, наш подход будет заключаться в создании обработчика Proxy с использованием шаблона Decorator, чтобы сопоставить и синхронизировать значения в хранилище с внутренним и частным реактивным свойством. Мы обернем его в синглтон и используем подход Central State manager для его совместного использования в нашем приложении. Начнем с создания нашего основного сервиса:

/services/sessionStorage.js
js
import { reactive } from 'vue';
let handler = {                                                  //1
    props: reactive({}),                                         //2
    get(target, prop, receiver) {                                //3
        let value = target[prop]
        if (value instanceof Function) {
            return (...args) => {
                return target[prop](...args)
            }
        } else {
            value = target.getItem(prop)
            if (value) {
                this.props[prop] = value;
            }
            return this.props[prop]
        }
    },
    set(target, prop, value) {                                   //4
        target.setItem(prop, value)
        this.props[prop] = value
        return true;
    }
}
const Decorator= new Proxy(window.sessionStorage, handler);      //5
function useSessionStorage(){                                    //6
     return Decorator;
}
export { useSessionStorage }

В этом модуле service мы будем использовать собственную JavaScript-реализацию объекта Proxy для перехвата конкретных обращений к API объекта window.sessionStorage.

Использование объектов Proxy является достаточно сложным в JavaScript, поэтому я рекомендую ознакомиться с документацией на MDN.

Начнем с импорта конструктора reactive() из Vue, а затем создадим простой объект с именем handler (строка //1), который будет выступать в роли нашего прокси/декоратора.

Этот объект будет размещен для перехвата обращений к исходному sessionStorage. Внутри него мы объявляем свойство prop как реактивное (строка //2)), инициализируя его пустым объектом. Этот объект будет синхронизирован с хранилищем.

Затем мы создаем две ловушки (или перехватчики): одну для операций получения или чтения (строка //3), другую для операций установки или записи (строка //4).

Функция get() принимает три аргумента, из которых мы будем использовать только два. Target ссылается на sessionStorage, а prop - это имя запрашиваемого метода или атрибута.

Поскольку prop может быть любым из них, мы проверяем, является ли это функцией, с помощью оператора if, и если да, то возвращаем функцию, которая принимает все аргументы и возвращает исходный вызов функции с ними.

Если это не функция, то мы получаем элемент из стора, проверяем, является ли он частью нашего внутреннего реактивного свойства, и, наконец, возвращаем его значение. Это гарантирует, что наш внутренний объект props синхронизирован со значениями, созданными до того, как декоратор был реализован.

Функция set() проще, поскольку мы просто берем переданное значение и храним его в обоих местах: в нашем внутреннем props и в сторе.

Когда наш обработчик готов, в строке //5, мы создаем прокси-объект Decorator с помощью собственного конструктора JavaScript и предоставляем функцию useSessionStorage() в строке //6, чтобы мы могли экспортировать его как синглтон.

Создав наш декоратор, теперь мы можем использовать его в наших компонентах, используя тот же подход, который является стандартным в Vue 3:

/session_storage/ChildSession.vue
vue
<script setup>
    import {useSessionStorage} from "../../services/SessionStorage"
    const $sessionStorage = useSessionStorage()
</script>
<template>
    <strong>Child Component</strong>
    Counter: {{ $sessionStorage.counter }}
</template>

Заметьте, что теперь мы можем использовать этот объект как хранилище Pinia или простой реактивный объект, и значение sessionStorage всегда будет синхронизировано и сохранится, даже если мы обновим страницу. Для просмотра полного примера ознакомьтесь с реализацией примера кода в репозитории GitHub. При его запуске вы увидите секцию, подобную этой:

image

Рисунок 7.6 - Пример нашего реактивного объекта $sessionStorage

В этом примере мы также реализовали родительский компонент с элементом ввода. При изменении значения оно автоматически синхронизируется и отражается в дочернем компоненте, а также в sessionStorage. Если открыть инструменты разработчика браузера и перейти в раздел Web Storage, то можно увидеть это отражение. Вот скриншот того, как это выглядит в Chrome на системе Ubuntu:

image

Рисунок 7.7 - Сессионное хранилище, показывающее элемент из примера

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

Подведение итогов

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

Мы также уделили время интеграции наших знаний, экспериментируя с хранением сессий и шаблоном Decorator для создания реактивного/постоянного центрального состояния. Мы уделили время разграничению подходов и посмотрели код для реализации каждого метода. Все эти новые навыки ежедневно используются при разработке приложений Vue 3.

В следующей главе мы рассмотрим повышение производительности нашего приложения с помощью современных инструментов JavaScript: web workers.

Вопросы для проверки

Используйте эти вопросы для обзора того, что вы узнали в этой главе:

  • Какие методы доступны нам для обмена данными между родственными компонентами?
  • Что такое шина сообщений/событий, и когда она наиболее полезна?
  • Что такое централизованное управление состоянием, и как его можно реализовать?
  • В чем разница между sessionStorage и localStorage?
  • Как мы можем увидеть, какая информация хранится в sessionStorage или localStorage?