Динамическая загрузка шаблона Vue компонента

Доброго времени суток, уважаемые Хабровчане! С недавнего времени, мы, в нашей команде начали использовать фреймворк Vue.js включая серверный рендеринг, после чего столкнулись с рядом проблем, в частности для меня как программиста.

Любое изменение в верстке сайта, происходило через меня. Мне скидывали часть html кода, будь то изменение заголовка, или смена мест блоков, далее было необходимо вставить эту часть в требуемый компонент, подставить необходимые переменные и методы, запустить webpack, залить код на сервер.

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

В качестве примера, рассмотрим упрощенный вариант такого подхода.

Структура следующая:

  • public/ — директория содержащая статические файлы
    • templates/ — директория содержащая шаблоны компонентов

  • server/ — код серверной части
  • app/ — код Vue приложения
  • client.js — точка входа клиентской части Vue приложения
  • serverEntry.js — точка входа серверной части Vue приложения

Что-бы использовать предзагрузку шаблона в компонент, необходимо дождаться получения этих данных с сервера, для чего прекрасно подойдут Promis'ы. В итоге у нас получилось две обертки над Vue компонентами.

wrapComponent — для глобальной регистрации Vue-компонета

// ./wrapComponent.js
import Vue from 'vue';
import axios from 'axios';

export default function wrapComponenet(name, template, component) {
    return () => {
        return new Promise((resolve, reject) => {
            axios.get(template).then((fetchData) => {
                const template = fetchData.data;

                Vue.component(name, {
                        ...component,
                        template,
                });
                resolve();
            });
        });
    };
}

wrapPageComponent — для возврата Vue-компонента.

// ./wrapPageComponent.js
import axios from 'axios';

export default function wrapPageComponent(name, template, component) {
    return () => {
        return new Promise((resolve, reject) => {
            axios.get(template).then((fetchData) => {
                const template = fetchData.data;

                resolve({
                        ...component,
                        template,
                });
            });
        });
    };
}

Большая часть кода используемая ниже в большей степени взята с официальной документации по серверному рендеру vue.js (ssr.vuejs.org), поэтому детально на этом останавливаться не стану.

// ./server/index.js
// Koa
import Koa from 'koa';
import staticFile from 'koa-static';
// Точка входа Vue-приложения
import createApp from '../serverEntry.js';
// Vue модуль
import { createRenderer } from 'vue-server-renderer';

const PORT = 4000;
const server = new Koa();

server.use(staticFile('public'));

server.use((ctx) => {
  // Дожидаемся создания приложения
  const app = await createApp();
  // Рендерим html - код
  const html = await renderer.renderToString(app);
  const page = `
<!DOCTYPE html>
<html lang="ru">
  <head>
    <title>Vue App</title>
    <base href="/">
    <meta charset="utf-8">
  </head>
  <body>
    <div id="root">${html}</div>
    <script src="js/app.js"></script>
  </body>
</html>
`);

  ctx.body = page;
});

server.listen(PORT, (err) => {
  if (err) console.log(err);
  console.log(`Server started on ${PORT} port`);
});

export default server;

В качестве серверного фреймворка используется KoaJS, хотя Вы можете использовать любые другие или вовсе без них.

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

// ./serverEntry.js
import { createApp } from './app';

export default async (context) => {
    const { app } = await createApp();

    return app;
};

Примерно тоже самое происходит и в клиентской точке входа

// ./client.js
import { createApp } from './app';

createApp()
    .then(({ app }) => {
        app.$mount('#app');
    });

И наконец мы подобрались к самому Vue-приложению.

// ./app/index.js
// Компоненты Vue
import Vue from 'vue';
import VueAxios from 'vue-axios';
// Дополнительные библиотеки
import axios from 'axios';
// Приложение Vue
import App from './App';

// Компоненты сайта
import mainMenu from './Components/MainMenu';
import mainContent from './Components/MainContent';

Vue.use(VueAxios, axios);
axios.defaults.baseURL = 'http://localhost:4000/';

export async function createApp( context ) {
    const appComponent = await App();
    
    const app = new Vue({
        render: (h) => h(App),
    });

    return new Promise((resolve, reject) => {
         // Загружаем все компоненты с помощью Promise
        const allComponents = [
            mainMenu(),
            mainContent(),
        ];
        Promise.all(allComponents)
            .then(() => {
                resolve({ app, router });
            });
    });
}

И не посредственно сами Vue-компоненты обернуты нашими обертками (извините за тавтологию).

// ./app/App.js
import wrapPageComponenets from '../wrapPageComponents';

export default wrapAppComponenets('App', '/template/App.html', {
  name: 'App',
});

// ./app/Components/MainMenu.js
import wrapComponenets from '../../wrapComponents';

export default wrapComponenets('main-menu', '/templates/MainMenu.html', { 
  data() {
    return { title: 'VueJS App'};
  }
})


// ./app/Components/MainContent.js
import wrapComponenets from '../../wrapComponents';

export default wrapComponenets('main-component', '/templates/MainContent.html', { 
  data() {
    return { name: 'Привет Хабра!'};
  },
  methods: {
    clickHandle() {
      alert('И еще раз привет');
    }
  }
});

И соответствующие данным компонентам шаблоны которые находятся в public/templates/

<!-- ./public/templates/App.html -->
<div>
  <main-menu></main-menu>
  <main-content></main-content>
</div>

<!-- ./public/templates/MainMenu.html -->
<nav>
  <ul>
    <li class="logo">{{title}}</li>
  </ul>
</nav>

<!-- ./public/templates/MainContent.html -->
<div>
  <h1 @click="clickHandle()">{{name}}</h1>
</div>

Вот и все. Теперь все шаблоны подгружаются с сервера, и для своих коллег я могу дать список переменных и методов которые они могут подставлять в тот или иной шаблон и мое участие сводится лишь к добавлению новых методов и переменных и минимум работы с html — шаблонами. Так же оказалось гораздо проще объяснить использование директив v-show,v-if,v-for.

Спасибо за внимание!
Support the author
Share post

Comments 11

    0
    Смотрели ли на Nuxt, и если да, то почему выбрали свой вариант SSR?
      0
      Да смотрели, но честно сказать, не могу вспомнить по какой причине от него отказались… если не изменяет память что то с роутингом не понравилось и/или не подходило
      0
      Правильные ли в таком случае юз-кейсы?

      1. Копирайтер хочет изменить шаблон краткого описания товара: сместить картинку, вёрстку текста, добавить название производителя. Копирайтер делает файл с новым шаблоном, загружает на сервер, и приложение использует новый шаблон уже так прямо на лету?

      2. Копирайтер хочет добавить функцию сравнения цен товаров. Делает шаблон сравнения, прикладывает к техническому заданию, отправляет программисту. Программист реализует функцию сравнения, прикручивает шаблон, вводит переменную, по которой модуль можно запросить (к примеру из другого шаблона), и теперь тэгом {price-compare} копирайтер может вставить нужный модуль куда надо.
        0
        Да, получается в обоих вариантах да.
          0
          Логично, удобно, спасибо, закладка.
        0
        Постойте ка, но ведь это не «динамическая загрузка», а раздельная загрузка (по частям).
        По моему скромному мнению, «динамическая загрузка» — это когда я шаблон на лету могу загружать и менять у существующего компонента
          0
          Учту Ваше замечание в будущем, долго думал над названием но кроме «динамической загрузки» в голову так ничего не пришло.
          Хотя с другой стороны если посмотреть то тут происходит именно загрузка шаблона в компонент, а не собрано внутри js'ника, отсюда и «динамическая загрузка»
          +1
          Я вижу две, на мой взгляд, серьезные проблемы в данном подходе, которые делают его нежелательным для использования на продакшене:
          1. Сборка шаблонов в рантайме. Это черевато проблемами с производительностью. Если правильно понял принятое решение, на клиент грузится несжатый исходник шаблона, что приводит к передаче лишних данных по сети. После исходник компилируется, что тратит ресурсы клиента.
          2. Отсутствует проверка шаблона на ошибки. Любой незакрытый тег, отсутствующая переменная или метод просто уронят компонент.

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

          Так же вангую еще и третью проблему: системы контроля версий не используются, на сколько понял файлы шаблонов просто заливаются на сервер (ftp, scp, samba, rsync — не важно, они все ненадежны для выкладывания на продакшн). Нет разрешения конфликтов, нет возможности быстро откатится, нет истории правок, сложно автоматизировать сборку.
            0
            Да не без недостатков и смотрю дальше как это можно оптимизировать.
            По первому пункту если компонент написан в стиле:
            Vue.component({
              name: 'some-component',
              template: '<h1>I am component</h1>'
            });
            

            то тут происходит ровно тоже что и с загрузкой через обертку, при этом растет вес JS файла. При использовании .vue — компонента, конечно там уже все собирается в JS и не происходит лишней нагрузке на компонент.
            По второму пункту, да…
            И опять же, данный способ стал использоваться по причине, что приходится очень часто отвлекаться если мои коллеги решили сменить местами порядок 2х компонентов (а это действительно у нас к сожалению происходит часто), или ввели всего пару отступов в каком либо компоненте, то приходится тормозить работу с текущими задачами чтобы им срочно поменять пару блоков, после чего может вновь произойти тоже самое, потому что им не понравилось как выглядит)

            Системы контроля версий самая беда, поскольку использую исключительно я.

            Так же есть недостаток в данном подходе что происходит загрузка всех компонентов, даже которые могут быть не использованы, а так же лишний запрос к серверу на получение данных, что несколько снижает производительность/скорость загрузки сайта.
            Если не изменяет память в Angular 1.x как раз происходила подгрузка шаблонов в момент когда они были необходимы, после чего шаблон кэшировался и не запрашивался вновь, как раз смотрим в эту сторону, чтобы загружались только необходимые шаблоны в нужный момент времени, при этом без лишних Promis'ов/async/await

            Может в последствии когда все таки мы придем к одному варианту и количество изменений сведется к минимуму, то возможно и уберем обертку, но пока уже год в таком плавании, и остается только надеятся)
            0
            У вас излишне многословный код для оберток, создавать промисы там, где функции axios уже их возвращают — излишество. Можно написать каждую обертку буквально в две строки:
            const wrapComponent = (name, template, component) => () => axios.get(template)
                .then(({ data:template }) => Vue.component(name, { ...component, template }));
            
            const wrapPageComponent = (name, template, component) => () => axios.get(template)
                .then(({ data:template }) => ({ ...component,  template }));
            
              0
              Спасибо, не обратил на это внимание)

            Only users with full accounts can post comments. Log in, please.