Привет, Хабр! Меня зовут Василий Беляев. Я руководитель группы разработки по направлению фронтенда в ИТ-компании «Криптонит». В этой статье хочу поделиться опытом и рассказать об оптимизации библиотек: о том, с какими проблемами мы столкнулись, и как их пошагово решили.

Для начала кратко опишу фронтовый стек у нас в «Криптоните»: мы те ребята, которые не пишут на React. У нас все проекты и библиотеки написаны на Vue + Composition API. Из дополнительных инструментов мы используем следующие:

  • Typescript – must have для типизации данных

  • Pinia – стейт-менеджер

  • Vite – сборщик

  • Vitest + Playwright – тестирование

  • Eslint + Stylelint – линтирование кода

  • Storybook – документация для компонентов

Всё это крутится на NodeJS + Yarn, но это сейчас! В момент, когда мы начинали оптимизацию библиотеки, стек у нас был немного другой...:

  • Вместо Vue3 был Vue2

  • Webpack вместо Vite

  • Vuex вместо pinia, но здесь роли стейт-менеджер не играет, так как в библиотеках он не использовался.

Проблемы, с которыми мы столкнулись

Вес бандла и скорость сборки проекта – общий вес бандлов достигал 68 МБ, что очень много для библиотек. Тут хочется обратить внимание, что все это тянулось в проекты при каждом старте pipeline – из-за этого хромало время сборки проекта для выкладки на dev-stand: в среднем около 15 минут на 1 проект.

Лишние зависимости, которые не используются, но зачем-то оставались «жить» в package.json. Если они находились в блоке dependencies, то затягивались ещё и в проект.

Конфликт версий! О, это было немного больно и случалось не раз. Изначально проблема была в том, что из-за прямых импортов копировался код компонента. Из-за этого в таблице компоненты могли быть ещё старые, в проекте — обновлённые, а в карточке — вообще могли забыть обновить. Именно по этой причине компонент кнопки мог вести себя по-разному на одном экране.

Долгое ожидание – сборка всех библиотек занимала около 45 минут.

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

Для понимания вот гифка, поясняющая как у нас строились связи:

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

Мы наметили следующие направления оптимизации:

  1. работа с импортами – разбираемся, как у нас работают импорты, и убираем дубликаты;

  2. перебираем зависимости и приводим в порядок package.json;

  3. исправляем конфликты версий;

  4. меняем и фиксируем workflow.

Последние два пункта мы подробнее разберём в следующий раз.

В этой статье я буду использовать ряд терминов в определённом значении:

  • Проект — продукт, который мы разрабатываем для заказчика или распространения.

  • Библиотека — наша самописная библиотека, которую мы поддерживаем и развиваем.

  • Компонент — единица библиотеки, которую можем вызвать и использовать.

  • Core-зависимость — библиотека, на которой строится наш проект (например, Vue, OpenLayers).

  • Зависимость — всё, что тянется из npmjs или nexus.

Работа с импортами

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

Менее очевидная проблема: могут случаться коллизии следующего рода:

  • Обновили компонент, например поменяли внешний вид кнопки.

  • Подтянули свежую версию компонентов в таблицы.

  • Подтянули свежую версию таблицы в проект.

  • В проекте не обновляли версию библиотек.

  • Итог: везде кнопки выглядят по-старому, но в таблице уже новые.

Теперь давайте рассмотрим следующую ситуацию:

  • Иконки тянутся в компоненты.

  • Иконки и компоненты (а именно селект) тянутся в datepicker.

  • Иконки, компоненты и datepicker тянутся в таблицу.

  • Иконки, компоненты, datepicker и таблицы тянутся в проект.

Итог: сколько раз в сборке может встретиться код компонента select?

В таблице получилось точно больше 20 раз.

Пример того, как у нас был реализован код:

<script setup>
import Component1 from './component1.vue';
import Component2 from './component2.vue';
import Component3 from 'dev-components';
</script>
<template>
  <Component1 />
  <Component2 />
  <Component3 />
</template>
// и это всё внутри самих компонентов

Решением стал переход на плагины.

Плагины вместо локальных импортов

В чём отличие прямого или глобального импорта от плагина? Импорт добавляет копию кода каждый раз. Плагины импортируют компонент один раз, а при наличии уже существующего плагина происходит проверка на его наличие в проекте (во Vue2 надо было писать обработку, во Vue3 уже встроили). То есть, грубо говоря, создаётся ссылка на компонент. Также плагины позволяют задать глобальные настройки использования компонентов для всего проекта. 

Пример реализации плагина:

import DevTable from './DevTable.vue';

export default {
  install: (app) => { 
    app.component('DevTable', DevTable);
  },
};
export { default as DevTable };

Как видно на листинге выше – мы взяли компонент таблицы и обернули в install-метод. Первым аргументом мы передаём служебную переменную app и используем его для вызова компонента, а вторым аргументом мы можем передать необходимые параметры, которые можем подцеплять в библиотеке. 

Затем мы сделали глобальный файл экспорта. С ним разработчику не нужно думать, импортирован компонент или нет. Основное, что надо сделать – это собрать все необходимые конфиги и прокинуть их дальше в вызов плагинов самих компонентов. Также мы дополнительно делаем экспорт функций-хелперов:

import DevTablePlugin from '@/components';
export default {
  install: (app) => {
    app.use(DevTablePlugin);
  },
}
export { cellFactory } from './helper/cells';
export { DevTablePlugin }
export { default as DevTable }

Теперь всё это дело мы используем в проекте или другой библиотеке:

import DevDatepickerPlugin from 'dev-datepicker';
import DevComponentsPlugin from 'dev-components';
import { createApp } from 'vue';
import DevTablePlugin from '@/components';
import App from './App.vue';

(async () => {
  const app = createApp(App);
  app.use(DevComponentsPlugin, {}); // <== Пример использования 
с глобальными настройками
  app.use(DevDatepickerPlugin);
  app.use(DevTablePlugin);
  app.mount('#app');
})();

Убрав прямые импорты, мы сократили размер бандлов с 68,3 МБ до 17,3 МБ (экономия составила 74%).

Уже хорошо, но хочется ещё лучше! Следующим шагом стала оптимизация конфигурационного файла package.json.

Как устроен package.json

По своей сути package.json — это конфигурационный файл. Его можно рассматривать как объект с определённым набором блоков. Нас интересуют в нём блоки scripts, dependencies, devDependencies и peerDependencies:

  1. В блоке scripts мы указываем те наборы команд, которые мы планируем запускать в проекте с помощью пакетного менеджера.

  2. В блоке dependencies мы указываем список пакетов, которые требуются для работы библиотеки/проекта (например, dayjs).

  3. В devDependencies мы указываем список зависимостей, необходимых для разработки, например typescript.

  4. В peerDependencies указываем список тех библиотек, которые по идее должны быть установлены на проекте для корректной работы нашего пакета.

Наводим порядок в package.json

Рассмотрим основную проблему более детально.

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

Дальше надо все используемые пакеты распределить по двум основным группам: зависимости для работы библиотеки и зависимости для разработки. 

Зависимости, необходимые для работы нашей библиотеки, назовём core-зависимости. Это те, на основе которых работает библиотека, например — openlayers для библиотеки карт. 

Зависимости, необходимые для разработки, — это инструменты, с помощью которых мы упрощаем себе жизнь. Например, это typescript для типизации. 

В данной ситуации у нас может оказаться несовместимость версий, поэтому определяем те версии зависимостей, которые нам нужно зафиксировать, и указываем их в peerDependencies. Теперь при установке наших библиотек как зависимостей ставятся только необходимые библиотеки, например для карт OpenLayers.

Итого мы получили дополнительную оптимизацию:

  1. Вынесли управление версиями библиотек в единое место (пока ещё в проекте)

  2. Убрали конфликт версий при установке в проект.

  3. Убрали прямые связи.

4.Уменьшили среднее время сборки проекта почти на 50%, а если быть точнее, то с 12,6 минут до 6,63 — с учётом тестов, подготовки образа к релизам и т. д.

Выводы

Мы систематизировали подход к управлению зависимостями во фронтенд-библиотеках и проектах. Обратите внимание, что отказ от прямых импортов в пользу плагинов позволил не только сократить финальный размер бандла на 74% (с 68,3 МБ до 17,3 МБ), но и решить проблему конфликта версий компонентов. Плагины действуют как единая точка регистрации, гарантируя, что в проекте будет использоваться только одна версия компонента, а его глобальные настройки будут применены ко всем потребителям. 

Чистота package.json — залог предсказуемости результата и скорости сборки. Разделение зависимостей на dependencies, devDependencies и peerDependencies должно быть осознанным. В очередной раз мы убедились, что нужно строже следить за неиспользуемыми пакетами, и стоит их удалять сразу, иначе потом будут тянуться и тратить время сборки. А core-зависимости (библиотеки, от которых напрямую зависит работа кода) лучше выносить в peerDependencies, чтобы явно обозначить требования к окружению проекта и сделать сборку предсказуемой. 

Оптимизация — процесс итеративный. Сначала мы убрали дублирование кода на уровне импортов (переход на плагины), затем занялись чисткой зависимостей. Каждый из этих шагов дал ощутимый прирост к производительности сборки и предсказуемости работы приложения.  

Надеюсь, наш опыт поможет и вам навести порядок в своих проектах. Попробуйте начать с аудита ваших импортов — возможно, вы удивитесь, насколько можно сократить вес бандлов ваших библиотек без потери функциональности. Ведь библиотеки – это внутренний продукт, которым вы пользуетесь у себя в разработке.