(Иллюстрация)
Senior web developer’ы Антон и Алексей продолжают рассказ о непростой борьбе с Nuxt. В предыдущем раунде битвы с этим фреймворком они показали, как запустить проект на Nuxt так, чтобы все были счастливы. В новой статье поговорим о реальном применении фреймворка.
Мы начали переписывать проект с огромным техническим долгом. Месячная аудитория составляла 6-7 млн. уникальных посетителей, но существующая платформа доставляла слишком много проблем. Поэтому было решено отправить ее на пенсию. Само собой, производительность была нашим наибольшим опасением, но также не хотелось просесть по SEO.
После пары раундов обсуждения решили не полагаться на традиционный подход с только серверным рендерингом — но и не загонять себя в ловушку клиентского рендеринга. Как итог мы начали строить решение на базе Nuxt.js.
Старый добрый Nuxt.js
Берем уже известный нам по прошлой статье «фреймворк для фреймворка» на базе Vue.js для построения универсальных клиент-серверных приложений. В нашем случае приложение работает в связке с довольно-таки сложным API (хитросплетение микросервисов, но об этом как-нибудь в другой раз) и несколькими слоями кеширования, рендерит изменяемый редакторами контент и возвращает уже статический контент для молниеносной производительности. Здорово, правда?
На самом деле, ничего нового тут нет. Но что делает Nuxt.js интересным, так это возможность быстрого старта проекта с клиент-серверным рендерингом. Иногда нужно пойти против установленных рамок фреймворка. Именно это мы и сделали.
Некогда объяснять, build once, deploy many!
Как-то к нам подошел техлид и озадачил: всякий раз, когда мы пушим изменения в репозиторий, нам нужно делать билд для каждого из окружений (dev-, stage- и prod-среды) отдельно. Это было медленно. Но в чем отличие этих билдов? Да только в переменных окружения! И то, что он попросил сделать, звучало логично и обоснованно. Но наша первая реакция была: О_о
Стратегия «Build once, deploy many» имеет смысл в мире разработки ПО. Но в мире Javascript… У нас целая батарея компиляторов, транспиляторов, пре- и пост-процессоров, а еще тестов и линтеров. Все это требует времени, чтобы настроить их для каждого из окружений. Кроме того, существует множество потенциальных проблем утечки конфиденциальных данных (секреты, ключи API и прочее, что может храниться в конфигурациях).
И мы начали
Конечно, начали с поиска в Google. Потом пообщались с мейнтейнерами Nuxt.js, но без особого успеха. Что поделать — пришлось придумывать решение самостоятельно, а не копировать со StackOverflow (это ведь основа нашей деятельности, не так ли?).
Разберемся, как Nuxt.js это делает
У Nuxt.js есть конфигурационный файл с ожидаемым названием nuxt.config.js. Он используется для программной передачи конфигураций в приложение:
const config = require('nuxt.config.js')
const nuxt = new Nuxt(config)
Есть возможность установить окружение через env-переменные. В общем-то, довольно распространенная практика — подключить конфигурационный файл динамически. Дальше все это передается в вебпак definePlugin и может использоваться на клиенте и сервере, примерно так:
process.env.propertyName
//или
context.env.propertyName.
Эти переменные запекаются при сборке, больше информации здесь: Nuxt.js env page.
Обратили внимание на вебпак? Да, это означает компиляцию, и это не то, чего мы хотим.
Попробуем иначе
Понимание того, как Nuxt.js работает, означает для нас:
- мы больше не можем использовать env внутри nuxt.config.js;
- любые другие динамические переменные (например, внутри head.meta) должны быть переданы в nuxt.config.js-объект в рантайме.
Код в server/index.js:
const config = require('../nuxt.config.js')
Меняем на:
// Импорт расширенных конфигураций Nuxt.js
const config = require('./utils/extendedNuxtConfig.js').default
Где utils/extendedNuxtConfig.js:
import config from 'config'
import get from 'lodash/get'
// Импорт конфигураций Nuxt.js
const defaultConfig = require('../../nuxt.config.js')
// Расширенные настройки
const extendedConfig = {}
// Смержим конфигурации Nuxt.js
const nuxtConfig = {
...defaultConfig,
...extendedConfig
}
// Финальные манипуляции для мест
// где нам не нужны расширенные настройки
if (get(nuxtConfig, 'head.meta')) {
nuxtConfig.head.meta.push({
hid: 'og:url',
property: 'og:url',
content: config.get('app.canonical_domain')
})
}
export default nuxtConfig
Слона-то мы и не приметили
Хорошо, мы решили проблему получения динамических переменных извне env-свойства объекта конфигураций в nuxt.config.js. Но изначальная проблема по-прежнему не решена.
Было предположение, что некий абстрактный sharedEnv.js будет использоваться для:
- клиента — создадим файл env.js, который будет загружен глобально (window.env.envKey),
- сервера — импортируется в модули, где это необходимо,
- изоморфного кода, что-то вроде
context.isClient? window.env[key]: global.sharedEnv[key].
Как-то не здорово. Эта абстракция решила бы наиболее серьезную проблему — утечку конфиденциальных данных в клиентское приложение, так как потребовалось бы добавлять значение осознанно.
Vuex нам поможет
Во время исследования проблемы мы обратили внимание, что Vuex store экспортируется в window-объект. Это решение вынужденное, для поддержки изоморфности Nuxt,js. Vuex — это хранилище данных, вдохновленное Flux, специально разработанное для Vue.js-приложений.
Ну а почему бы не использовать его для наших общих переменных? Это более органичный подход — данные в глобальном хранилище, нам подходит.
Начнем с server/utils/sharedEnv.js:
import config from 'config'
/**
* Настройка объекта, доступного для клиента и сервера
* Не усложняйте, объект должен быть плоским
* Будьте внимательны, чтобы не произошло утечки конфиденциальной информации
*
* @type {Object}
*/
const sharedEnv = {
// ...
canonicalDomain: config.get('app.canonical_domain'),
}
export default sharedEnv
Код, что выше, выполнится во время запуска сервера. Затем добавим его в хранилище Vuex:
/**
* Получить объект конфигураций.
* Документация подразумевает только выполнение на стороне сервера
* см. больше здесь
* https://nuxtjs.org/guide/vuex-store/#the-nuxtserverinit-action
*
* @return {Object} Shared environment variables.
*/
const getSharedEnv = () =>
process.server
? require('~/server/utils/sharedEnv').default || {}
: {}
// ...
export const state = () => ({
// ...
sharedEnv: {}
})
export const mutations = {
// ...
setSharedEnv (state, content) {
state.sharedEnv = content
}
}
export const actions = {
nuxtServerInit ({ commit }) {
if (process.server) {
commit('setSharedEnv', getSharedEnv())
}
}
}
Будем опираться на факт, что nuxtServerInit запускается во время, хм, серверной инициализации. Есть некоторая сложность: обратите внимание на метод getSharedEnv, здесь добавлена проверка на повторное выполнения на сервере.
Что вышло
Теперь мы получили общие переменные, которые могут быть извлечены в компонентах примерно так:
this.$store.state.sharedEnv.canonicalDomain
Победа!
А, нет. Что насчет плагинов?
Для конфигурации некоторых плагинов нужны переменные окружения. И когда мы хотим их использовать:
Vue.use(MyPlugin, { someEnvOption: 'Здесь нет доступа к vuex store' })
Великолепно, состояние гонки, Vue.js пытается инициализировать себя, прежде чем Nuxt.js зарегистрирует sharedEnvobject в хранилище Vuex.
Хотя функция, которая регистрирует плагины, предоставляет доступ к объекту Context, содержащему ссылку на хранилище, sharedEnv по-прежнему пуст. Решается это довольно просто — сделаем плагин async-функцией и будем ждать выполнения nuxtServerInit:
import Vue from 'vue'
import MyPlugin from 'my-plugin'
/**
* Конфигурация плагина MyPlugin асинхронно.
*/
export default async (context) => {
// выполнить вручную, чтобы иметь доступ к объекту sharedEnv
await context.store.dispatch('nuxtServerInit', context)
const env = { ...context.store.state.sharedEnv }
Vue.use(MyPlugin, { option: env.someKey })
}
Вот теперь победа.