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

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

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

Для решения этой проблемы, полученные данные должны находиться вне компонентов представления, в специальном хранилище данных или в «контейнере состояния». На сервере мы можем предзагрузить и заполнить данные в хранилище перед рендерингом. Кроме того, мы будем сериализовывать и встраивать состояние в 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)
      }
    }
  })
}

ВНИМАНИЕ

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

И обновляем 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 }
}

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

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

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

Мы будем использовать опцию serverPrefetch (добавлена в версии 2.6.0+) в компонентах. Эта опция распознаётся рендерингом на стороне сервера и приостанавливает отрисовку до тех пор, пока Promise не разрешится. Это позволяет нам «дожидаться» асинхронных данных в процессе отрисовки.

Совет

Можно использовать serverPrefetch в любом компоненте, а не только в компонентах указываемых в маршрутах.

Вот пример компонента Item.vue, который отображается по маршруту '/item/:id'. Поскольку экземпляр компонента уже создан на этом этапе, он имеет доступ к this:

<!-- Item.vue -->
<template>
  <div v-if="item">{{ item.title }}</div>
  <div v-else>...</div>
</template>

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

  // Только на стороне сервера
  // Будет автоматически вызвано рендером сервера
  serverPrefetch () {
    // возвращает Promise из действия, поэтому
    // компонент ждёт данные перед рендерингом
    return this.fetchItem()
  },

  // Только на стороне клиента
  mounted () {
    // Если мы не сделали ещё это на сервере,
    // то получаем элемент (сначала показав текст загрузки)
    if (!this.item) {
      this.fetchItem()
    }
  },

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

ВНИМАНИЕ

Необходимо проверять рендерился ли компонент на стороне сервера в хуке mounted во избежание выполнения логики загрузки дважды.

Совет

Можно увидеть одинаковую логику fetchItem(), повторяющуюся несколько раз (в коллбэках serverPrefetch, mounted и watch) в каждом компоненте — рекомендуется создать собственную абстракцию (например, примесь или плагин) для упрощения подобного кода.

Инъекция финального состояния

Теперь мы знаем что процесс отрисовки будет дожидаться получения данных в наших компонентах, но как же узнавать когда всё «готово»? Для этого потребуется использовать коллбэк rendered в контексте рендера (также добавлено в версии 2.6), который будет вызывать серверный рендер после завершения всего процесса рендеринга. В этот момент хранилище должно быть заполнено данными своего финального состояния. Затем мы можем внедрить его в контекст в этом коллбэке:

// 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(() => {
      // Хук `rendered` будет вызван, когда приложение завершит рендеринг
      context.rendered = () => {
        // После рендеринга приложение, наше хранилище теперь
        // заполнено финальным состоянием из наших компонентов.
        // Когда мы присоединяем состояние к контексту, и есть опция `template`
        // используемая для рендерера, состояние будет автоматически
        // сериализовано и внедрено в HTML как `window.__INITIAL_STATE__`.
        context.state = store.state
      }

      resolve(app)
    }, reject)
  })
}

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

// entry-client.js

import { createApp } from './app'

const { app, store } = createApp()

if (window.__INITIAL_STATE__) {
  // Инициализируем состояние хранилища данными, внедрёнными на сервере
  store.replaceState(window.__INITIAL_STATE__)
}

app.$mount('#app')

Разделение кода хранилища

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

// store/modules/foo.js
export default {
  namespaced: true,

  // ВАЖНО: state должен быть функцией, чтобы
  // модуль мог инстанцироваться несколько раз
  state: () => ({
    count: 0
  }),

  actions: {
    inc: ({ commit }) => commit('inc')
  },

  mutations: {
    inc: state => state.count++
  }
}

Мы можем использовать store.registerModule для ленивой регистрации этого модуля в хуке serverPrefetch компонента маршрута:

// внутри компонента маршрута
<template>
  <div>{{ fooCount }}</div>
</template>

<script>
// импортируем модуль здесь, а не в `store/index.js`
import fooStoreModule from '../store/modules/foo'

export default {
  computed: {
    fooCount () {
      return this.$store.state.foo.count
    }
  },

  // Только на стороне сервера
  serverPrefetch () {
    this.registerFoo()
    return this.fooInc()
  },

  // Только на стороне клиента
  mounted () {
    // Мы уже увеличили значение 'count' на сервере
    // Определяем это, проверив что 'foo' уже существует
    const alreadyIncremented = !!this.$store.state.foo
    
    // Регистрируем модуль foo
    this.registerFoo()
    
    if (!alreadyIncremented) {
      this.fooInc()
    }
  },

  // ВАЖНО: избегайте дублирования регистрации модуля на клиенте
  // когда маршрут посещается несколько раз.
  destroyed () {
    this.$store.unregisterModule('foo')
  },

  methods: {
    registerFoo () {
      // Сохраняем предыдущее состояние, если оно внедрялось на стороне сервера
      this.$store.registerModule('foo', fooStoreModule, { preserveState: true })
    },

    fooInc () {
      return this.$store.dispatch('foo/inc')
    }
  }
}
</script>

Поскольку модуль теперь является зависимостью компонента маршрута, он будет перемещён в асинхронный фрагмент компонента маршрута с помощью Webpack.

ВНИМАНИЕ

Не забывайте использовать опцию preserveState: true для registerModule чтобы сохранять состояние, внедрённое сервером.