javascript

Server side rendering на Vue.js

  • суббота, 14 октября 2017 г. в 03:14:10
https://habrahabr.ru/post/334952/
  • Системы сборки
  • Node.JS
  • JavaScript
  • HTML


Сравнительно недавно Vue.js обзавёлся полноценной поддержкой серверного рендеринга. В интернете довольно мало информации о том, как его правильно готовить, так что я решил подробно описать процесс создания необходимой среды для разработки приложения с SSR на Vue.js.


Всё, о чём пойдёт речь, реализовано в репозитории на github. Я буду часто ссылаться на его исходники и, собственно, попытаюсь объяснить, что происходит и зачем это нужно :)


В статье будут описаны достаточно общие для SSR подходы (если вам просто нужно что-то готовое для использования, то вы можете посмотреть в сторону Nuxt.js), так что вполне вероятно, что сказанное ниже можно будет частично или полностью применить и к другим фреймворкам/библиотекам типа Angular и React.


Ведение


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


Данные, которые подставляются в HTML, должны быть вытянуты по API, расположенному на том же или на другом сервере/домене. Настройка и разработка API-сервера выходит за рамки этой статьи, а вот в качестве клиента для него можно взять axios или любой другой изоморфный http-клиент.


Также нужно помнить о том, что на сервере нет DOM, так что все манипуляции с document, window и прочими navigator либо вообще не должны использоваться, либо должны быть запущены только на клиенте, то есть в хуках beforeMount, mounted и т.п.


Ниже будет много букв, где я пытаюсь разъяснить, что происходит в коде. Поэтому, если буквы покажутся вам сложно читаемыми, рекомендую сразу смотреть в код :) Ссылки на соответствующие части репозитория будут даны в каждом разделе.


Конфигурация Webpack


Код


Сборка делится на 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, который также будет заниматься сборкой проекта на лету во время разработки. Тут много кода, так что будет проще посмотреть примеры точки запуска сервера и конфигурации сервера для разработки.


Несколько тонкостей:


  • Нужно подготовить общий шаблон таким образом, чтобы плагин vue-meta на клиенте понял, что разметка уже готова и не продублировал meta теги. Для этого нужно просто вставить специальный атрибут data-vue-meta-server-rendered без значения в тег <html>. Название атрибута настраивается, так что в вашем проекте оно может быть другим (я, например, решил заменить его на data-meta-ssr, так как это короче).
  • Также в шаблон нужно подставить всё необходимое из плагина vue-meta: атрибуты для html и body, мета-теги, link, noscript и т.д… В простейшем варианте это происходит примерно так:

// ...
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()}>
    ...
`)
// ...

  • Для корректной обработки серверного бандла (который был предварительно собран с помощью webpack'а) нужно использовать vue-server-renderer, которому нужно указать файл с бандлом и его кодировку. Подробнее о параметрах можно почитать в официальной документации. Там есть как минимум один интересный параметр runInNewContext, который позволит довольно неплохо оптимизировать рендеринг, но при соблюдении определённых правил (о чём речь пойдёт ниже, в разделе про точки входа).
  • Так как все данные из API загружаются во время рендеринга, то нет необходимости загружать их повторно на клиенте. Но клиент, очевидно, не может просто вынуть их из разметки, поэтому необходимо передать ему данные вместе с разметкой. Решается эта задача максимально просто: в разметку добавляется script, где все нужные данные записываются в переменные. Сами данные обрабатываются 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, а приложение будет готово к отправке клиенту.
Порядок действий в этой функций может быть таким (код):


  1. Создаём приложение из app.js.
  2. Настраиваем baseUrl в axios таким образом, чтобы он мог обратиться к серверу API локально (при необходимости). Здесь просто нужно помнить, что браузера нет, а значит и нет объекта location, из которого можно хотя бы взять домен и протокол, так что это нужно будет прописать вручную.
  3. Задаём обработчик для vue-router ready (app.$router.onReady(...)), который будет выполнен при нахождении соответствия компонентов и URL:
    1. Берём все асинхронные компоненты для данной страницы и выполняем их функции для вытягивания асинхронных операций получения данных. Собираем возращённые промисы в массив.
    2. Ждём выполнения всех промисов в Promise.all.
    3. Резолвим, добавляя к контексту информацию из vue-meta, vuex state, а так же записываем в компоненты и в контекст данные, полученные в результате выполнения асинхронных операций.
  4. Говорим роутеру, что пришло время обработать URL из контекста (app.$router.push(context.url)).

Далее все полученные данные будут обработаны http-сервером, компоненты отдадут свою разметку, данные с разметкой будут записаны в шаблон, получившийся HTML отправится клиенту.


Компоненты и роутинг


Код для регистрации роутера и компонентов для него.


Для разработки приложения с SSR нужно исходить из того, что только root-компонент или компоненты, которые привязаны к роутам, имеют возможность асинхронно загрузить данные перед рендерингом. Для этих компонентов специальным образом нужно обрабатывать изменения роута и записывать данные, которые вернул сервер после рендеринга. Для этих целей хорошим решением будет создать mixin, который автоматически подключается к каждому компоненту при инициализации роутера. Пример кода подобного mixin'а.


В prefetch-mixin нужно добавить примерно следующее:


  • created-хук, который будет брать поле prefetchData (при инициализации приложения в это поле пишется data компонента, пришедшая с сервера после рендеринга или просто записанная напрямую во время рендеринга на сервере) и полностью заменять значения полей this.$data на значения из this.constructor.extendOptions.prefetchData, но только до того, как приложение уже полностью инициализировано, что мы можем выяснить из поля this.$root._isMounted.
  • beforeMount-хук будет вызывать prefetch только на клиенте уже после загрузки страницы в том случае, если произошёл переход на другой роут.
  • beforeRouteUpdate-хук будет вызывать prefetch только на клиенте при изменении параметров роута.

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).


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