Предзагрузка данных и состояния

Хранение данных

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

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

Чтобы решить эту проблему, полученные данные должны находиться вне компонентов представления, в специальном хранилище данных или в «контейнере состояния». На сервере мы можем предзагрузить и заполнить данные в хранилище перед рендерингом. Кроме того, мы будем сериализовывать и встраивать состояние в HTML. Хранилище на клиентской стороне сможет непосредственно получать вложенное состояние перед монтированием приложения.

Для этой цели мы будем использовать официальную библиотеку управления состоянием — Vuex. Давайте создадим файл store.js, с некоторой симуляцией логики получения элемента на основе id:

// store.js
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

// Предположим, что у нас есть универсальный API,
// который возвращает Promises и опустим детали реализации
import { fetchItem } from './api'

export function createStore () {
  return new Vuex.Store({
    state: {
      items: {}
    },
    actions: {
      fetchItem ({ commit }, id) {
        // возвращаем Promise через store.dispatch()
        // чтобы мы могли понять когда данные будут загружены
        return fetchItem(id).then(item => {
          commit('setItem', { id, item })
        })
      }
    },
    mutations: {
      setItem (state, { id, item }) {
        Vue.set(state.items, id, item)
      }
    }
  })
}

И обновляем app.js:

// app.js
import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router'
import { createStore } from './store'
import { sync } from 'vuex-router-sync'

export function createApp () {
  // Создаём экземпляры маршрутизатора и хранилища
  const router = createRouter()
  const store = createStore()

  // Синхронизируем чтобы состояние маршрута было доступно как часть хранилища
  sync(store, router)

  // Создадим экземпляр приложения, внедряя и маршрутизатор и хранилище
  const app = new Vue({
    router,
    store,
    render: h => h(App)
  })

  // Возвращаем приложение, маршрутизатор и хранилище.
  return { app, router, store }
}

Размещение логики для компонентов

Итак, где мы должны размещать код, который вызывает действия по предзагрузке данных?

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

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

<!-- Item.vue -->
<template>
  <div>{{ item.title }}</div>
</template>

<script>
export default {
  asyncData ({ store, route }) {
    // возвращаем Promise из действия
    return store.dispatch('fetchItem', route.params.id)
  },

  computed: {
    // отображаем элемент из состояния хранилища.
    items () {
      return this.$store.state.items[this.$route.params.id]
    }
  }
}
</script>

Загрузка данных на серверной части

В entry-server.js мы можем получить компоненты, соответствующие маршруту, с помощью router.getMatchedComponents(), и вызвать asyncData если компонент предоставляет её. Затем нужно присоединить разрешённое состояние к контексту рендера.

// entry-server.js
import { createApp } from './app'

export default context => {
  return new Promise((resolve, reject) => {
    const { app, router, store } = createApp()

    router.push(context.url)

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents()
      if (!matchedComponents.length) {
        reject({ code: 404 })
      }

      // вызов asyncData() на всех соответствующих компонентах
      Promise.all(matchedComponents.map(Component => {
        if (Component.asyncData) {
          return Component.asyncData({
            store,
            route: router.currentRoute
          })
        }
      })).then(() => {
        // После разрешения всех preFetch хуков, наше хранилище теперь
        // заполнено состоянием, необходимым для рендеринга приложения.
        // Когда мы присоединяем состояние к контексту, и есть опция `template`
        // используемая для рендерера, состояние будет автоматически
        // сериализовано и внедрено в HTML как window.__INITIAL_STATE__.
        context.state = store.state

        resolve(app)
      }).catch(reject)
    }, reject)
  })
}

При использовании template, context.state будет автоматически встроен в финальный HTML как window.__INITIAL_STATE__. На клиенте хранилище должно получить состояние перед монтированием приложения:

// entry-client.js

const { app, router, store } = createApp()

if (window.__INITIAL_STATE__) {
  store.replaceState(window.__INITIAL_STATE__)
}

Загрузка данных на клиентской части

На клиенте существует два разных подхода к получению данных:

  1. Разрешить данные перед навигацией по маршруту:

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

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

    // entry-client.js
    
    // ...опустим лишний код
    
    router.onReady(() => {
     // Добавляем хук маршрута для обработки asyncData.
     // Выполняем его после разрешения первоначального маршрута,
     // чтобы дважды не загружать данные, которые у нас уже есть.
     // Используем router.beforeResolve(), чтобы все асинхронные компоненты были разрешены.
     router.beforeResolve((to, from, next) => {
       const matched = router.getMatchedComponents(to)
       const prevMatched = router.getMatchedComponents(from)
    
       // мы заботимся только об отсутствующих ранее компонентах,
       // поэтому мы сравниваем два списка, пока не найдём отличия
       let diffed = false
       const activated = matched.filter((c, i) => {
         return diffed || (diffed = (prevMatched[i] !== c))
       })
    
       if (!activated.length) {
         return next()
       }
    
       // здесь мы должны вызвать индикатор загрузки, если используем его
    
       Promise.all(activated.map(c => {
         if (c.asyncData) {
           return c.asyncData({ store, route: to })
         }
       })).then(() => {
    
         // останавливаем индикатор загрузки
    
         next()
       }).catch(next)
     })
    
     app.$mount('#app')
    })
    
  2. Загружать данные после отображения нового представления:

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

    Этого можно достичь с помощью глобальной примеси на клиенте:

    Vue.mixin({
     beforeMount () {
       const { asyncData } = this.$options
       if (asyncData) {
         // присваиваем операцию загрузки к Promise
         // чтобы в компонентах мы могли делать так `this.dataPromise.then(...)`
         // для выполнения других задач после готовности данных
         this.dataPromise = asyncData({
           store: this.$store,
           route: this.$route
         })
       }
     }
    })
    

Эти две стратегии в конечном счёте являются различными решениями UX и должны выбираться на основе фактического сценария разрабатываемого приложения. Но, независимо от выбранной вами стратегии, функция asyncData также должна вызываться при повторном использовании компонента маршрута (тот же маршрут, но параметры изменились, например с user/1 на user/2). Мы также можем обрабатывать это с помощью глобальной примеси для клиентской части:

Vue.mixin({
  beforeRouteUpdate (to, from, next) {
    const { asyncData } = this.$options
    if (asyncData) {
      asyncData({
        store: this.$store,
        route: to
      }).then(next).catch(next)
    } else {
      next()
    }
  }
})

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

results matching ""

    No results matching ""