Server side rendering на Vue.js
- суббота, 14 октября 2017 г. в 03:14:10
Сравнительно недавно Vue.js обзавёлся полноценной поддержкой серверного рендеринга. В интернете довольно мало информации о том, как его правильно готовить, так что я решил подробно описать процесс создания необходимой среды для разработки приложения с SSR на Vue.js.
Всё, о чём пойдёт речь, реализовано в репозитории на github. Я буду часто ссылаться на его исходники и, собственно, попытаюсь объяснить, что происходит и зачем это нужно :)
В статье будут описаны достаточно общие для SSR подходы (если вам просто нужно что-то готовое для использования, то вы можете посмотреть в сторону Nuxt.js), так что вполне вероятно, что сказанное ниже можно будет частично или полностью применить и к другим фреймворкам/библиотекам типа Angular и React.
Основная идея любого приложения с SSR в том, что оно должно генерировать одинаковую HTML-разметку при выполнении на сервере и на клиенте.
Данные, которые подставляются в HTML, должны быть вытянуты по API, расположенному на том же или на другом сервере/домене. Настройка и разработка API-сервера выходит за рамки этой статьи, а вот в качестве клиента для него можно взять axios или любой другой изоморфный http-клиент.
Также нужно помнить о том, что на сервере нет DOM, так что все манипуляции с document, window и прочими navigator либо вообще не должны использоваться, либо должны быть запущены только на клиенте, то есть в хуках beforeMount, mounted и т.п.
Ниже будет много букв, где я пытаюсь разъяснить, что происходит в коде. Поэтому, если буквы покажутся вам сложно читаемыми, рекомендую сразу смотреть в код :) Ссылки на соответствующие части репозитория будут даны в каждом разделе.
Сборка делится на 3 основных конфигурации webpack — общая, сборка для сервера и сборка для клиента. После сборки мы должны получать 2 независимых бандла с набором файлов для клиента и лишь одним js файлом для сервера.
Для каждого бандла, очевидно, нужно будет создать отдельные entry, но об этом чуть позже.
Общая сборка (base.js) включает в себя загрузчики для всей статики, шаблонов, исходников JavaScript и vue-компонентов. Стили сюда включить теоретически тоже можно, но на сервере по очевидным причинам они не нужны, поэтому они будут прописаны только для клиента.
Клиентская сборка (client.js) добавляет к общей то, что необходимо нам в браузере. В rules прописываются загрузчики для css, stylus, sass, postcss и т.п.
Так же сюда можно добавить output для разделения бандла на несколько файлов, extract css, uglify и т.д. В общем, всё как обычно :)
Сюда же добавляем генерацию общего HTML шаблона с помощью html-webpack-plugin. На нём я отдельно остановлюсь чуть ниже.
Сборка для SSR (server.js) должна создавать единственный js-файл для отработки на сервере. Нас не заботит размер файла, так как его никто не будет загружать по http, поэтому всё, что обычно прописывается в конфигах для оптимизации, здесь не имеет смысла.
Нужно также указать target: node
, null-loader для стилей и externals. В externals указываются все пакеты из package.json, чтобы webpack не включал установленные пакеты в сборку, так как на сервере они будут подключены из node_modules.
{
target: 'node',
externals: Object.keys(require('../../package.json').dependencies)
}
Общий шаблон — это просто общая HTML-разметка, в которую будет вставлен отрендеренный код Vue приложения. Тут важно понимать, что сервер без специально обученных библиотек ничего не знает о DOM. Поэтому в шаблон нужно вписать некую строку, которая будет простой заменой подстроки заменена на разметку приложения. В примере это просто <!--APP-->
(или //APP
в pug), но она может быть любой другой.
Со скриптами, стилями и тегами в head немного проще — их мы с помощью той же замены будем вставлять перед </body>
/</head>
.
Для работы SSR необходим сервер (express в примере) на Node.js, который также будет заниматься сборкой проекта на лету во время разработки. Тут много кода, так что будет проще посмотреть примеры точки запуска сервера и конфигурации сервера для разработки.
Несколько тонкостей:
data-vue-meta-server-rendered
без значения в тег <html>
. Название атрибута настраивается, так что в вашем проекте оно может быть другим (я, например, решил заменить его на data-meta-ssr
, так как это короче).// ...
const {
title, htmlAttrs, bodyAttrs, link, style, script, noscript, meta
} = context.meta.inject()
res.write(`
<!doctype html>
<html data-vue-meta-server-rendered ${htmlAttrs.text()}>
<head>
${meta.text()}
${title.text()}
${link.text()}
${style.text()}
${script.text()}
${noscript.text()}
</head>
<body ${bodyAttrs.text()}>
...
`)
// ...
JSON.stringify
или, ещё лучше, с помощью serialize-javascript.const serialize = require('serialize-javascript')
// ...
res.write(`<script>
window.__INITIAL_VUEX_STATE__=${serialize(context.initialVuexState)}
</script>`);
res.write(`<script>
window.__INITIAL_COMP_STATE__=${serialize(context.initialComponentStates)}
</script>`);
В случае запуска сервера в режиме разработки сам сервер будет работать примерно так же. Отличаются лишь 2 момента — по-другому обрабатываются ошибки, возникшие при рендеринге, а так же подменяется renderer и разметка общего шаблона на новые при изменении кода приложения.
Помимо самого сервера нужно запустить webpack(clientConfig).watch
для генерации сборки на лету при изменении исходников. Перед этим инициализируется webpack со всеми нужными для разработки плагинами типа HotModuleReplacementPlugin.
Также нужно сообщать клиенту о новых сборках бандла. Для этого понадобятся webpack-dev-middleware и webpack-hot-middleware. Они отвечают за доставку изменившегося кода клиенту при появлении новых сборок (то есть каждый раз, когда изменяется исходный код приложения).
Отдельно запускается webpack(serverConfig).watch
и подменяется серверный бандл на новый при его изменении. В моём случае сообщаем о том, что он изменился, с помощью простого коллбэка (строка 50 в build/setup-dev-server.js
, строка 73 в index.js
).
Как я упоминал выше, необходимо создать 2 отдельные входные точки (entry в webpack) приложения для SSR и для клиента. Собственно, здесь так же, как и в конфигах webpack — 3 файла с общим, серверным и клиентским кодом.
Общий код (app.js) включает общую инициализацию приложения, то есть подключает Vue-плагины, создаёт vuex store, router и новый root-компонент. Также здесь регистрируются глобальные компоненты, фильтры и директивы.
Здесь же root-компоненту нужно подмешать vue-файл с шаблоном и логикой уже самого приложения, чтобы главный компонент приложения и root-компонент стали одним целым.
Важно, что для vue-server-renderer есть опция runInNewContext, которую можно отключить, получив при этом неплохой прирост производительности. Но для его использования необходимо каждый раз снова инициализировать приложение, поэтому в app.js я возвращаю функцию, производящую инициализацию, а не готовый объект Vue-компонента. Код же, исполняемый непосредственно в этом файле, будет выполнен только один раз при запуске сервера, о чём необходимо помнить. Здесь можно регистрировать общие моменты, не зависящие от данных, получаемых в рантайме — регистрировать компоненты, фильтры, директивы, извлекать переменные окружения и т.д. и т.п.
Точка входа для клиента (client.js). Здесь создаётся приложение с помощью функции из app.js, затем грузится и выполняется всё необходимое для корректной работы в браузере.
Здесь же производится замена объекта data для компонента, который должен быть показан на данной странице и состояния vuex store.
if (window.__INITIAL_VUEX_STATE__) {
// полностью заменяем state на возвращённый сервером
app.$store.replaceState(window.__INITIAL_VUEX_STATE__);
delete window.__INITIAL_VUEX_STATE__;
}
if (window.__INITIAL_COMP_STATE__) {
app.$router.onReady(() => {
// берём все компоненты, которые показываются на данной странице
// (почти всегда это единственный компонент, но мало ли)...
const comps = app.$router.getMatchedComponents()
// ...забираем только те, у которых есть данные для предзагрузки
.filter(comp => typeof comp.prefetch === 'function');
for (let i in comps)
if (window.__INITIAL_COMP_STATE__[i])
// собственно, записываем данные для data
// (сама подмена $data будет происходить в специальном миксине, о нём речь пойдёт ниже)
comps[i].prefetchedData = window.__INITIAL_COMP_STATE__[i];
delete window.__INITIAL_COMP_STATE__;
});
}
Завершаем код тем, что берём root-компонент и вызываем у него $mount
в корневой элемент приложения. Этому элементу будет автоматически дан атрибут data-server-rendered
, поэтому можно сделать так: app.$mount(document.body.querySelector('[data-server-rendered]'))
.
Точка входа для SSR (server.js). Здесь просто создаётся функция, которая будет принимать контекст запроса (то есть объект request из express) и инициализировать приложение. Функция должна вернуть promise, который будет выполнен в тот момент, когда все необходимые данные загружены из API, а приложение будет готово к отправке клиенту.
Порядок действий в этой функций может быть таким (код):
app.$router.onReady(...)
), который будет выполнен при нахождении соответствия компонентов и URL:Promise.all
.app.$router.push(context.url)
).Далее все полученные данные будут обработаны http-сервером, компоненты отдадут свою разметку, данные с разметкой будут записаны в шаблон, получившийся HTML отправится клиенту.
Код для регистрации роутера и компонентов для него.
Для разработки приложения с SSR нужно исходить из того, что только root-компонент или компоненты, которые привязаны к роутам, имеют возможность асинхронно загрузить данные перед рендерингом. Для этих компонентов специальным образом нужно обрабатывать изменения роута и записывать данные, которые вернул сервер после рендеринга. Для этих целей хорошим решением будет создать mixin, который автоматически подключается к каждому компоненту при инициализации роутера. Пример кода подобного mixin'а.
В prefetch-mixin нужно добавить примерно следующее:
this.$data
на значения из this.constructor.extendOptions.prefetchData
, но только до того, как приложение уже полностью инициализировано, что мы можем выяснить из поля this.$root._isMounted
.function update(vm, next, route) {
if (!route) route = vm.$route;
const promise = vm.$options.prefetch({
store: vm.$store,
props: route.params,
route
});
if (!promise) return next ? next() : undefined;
promise
.then(data => {
Object.assign(vm.$data, data);
if (next) next();
})
.catch(err => next && next(err));
}
const mixin = {
// подмешиваем данные компонента при первичной загрузке страницы
created() {
if (this.$root._isMounted || !this.constructor.extendOptions.prefetchedData) return;
Object.assign(this.$data, this.constructor.extendOptions.prefetchedData);
},
// вызываем prefetch при загрузке компонента (но не делаем ничего, если данные уже подмешаны в created)
beforeMount() {
if (this.$root._isMounted && this.$options.prefetch) update(this);
}
// вызываем prefetch, если изменился параметр роута
// в этом случае компонент не будет проинициализирован заново, так что beforeMount вызван не будет
beforeRouteUpdate(to, from, next) {
if (this.$options.prefetch && to.path !== from.path) update(this, next, to);
else next();
},
};
SSR не накладывает почти никаких ограничений на разработку приложений. Достаточно просто помнить о том, что нельзя использовать браузерное API там, где код выполняется на сервере, в остальных же случаях выносить код в клиентские хуки beforeMount/mounted.
Также приложение, созданное для работы с SSR, будет корректно работать и без SSR, так что подобный подход можно использовать для обычных SPA, чтобы при внезапном появлении требований к SEO не ломать голову и не писать костыли для оптимизации ваших сайтов.
Могут быть проблемы с директивами, роль которых часто сводится к манипуляции с DOM, но их легко решить, отдавая альтернативную реализацию (пустую?) вместо самой директивы на сервере (docs).
В общем и целом это всё, что необходимо учесть перед началом разработки самого приложения. Дальше вы просто создаёте компоненты, подключаете компоненты-страницы к соответствующим роутам и, если всё сделано правильно, будете получать срендеренную страницу с сервера и корректно работающее приложение на клиенте.