Всем привет! Меня зовут Игорь Савин, я frontend-разработчик в компании Домклик. На текущий момент у нас около 100 различных команд разработки, из которых большая часть создает какой-либо фронтенд на HTML, CSS и Javascript. Но когда так много команд, непременно возникают ситуации, при которых в проект одной команды нужно встроить какую-то функциональность, разрабатываемую другой. И не просто встроить, но и потом поддерживать её работу, исправлять ошибки и внедрять новые фичи.
Например, есть проект с калькулятором ипотеки, в котором пользователи могут поиграть с настройками и посчитать, во сколько им обойдётся ипотека в зависимости от размера кредита, первоначального взноса и других факторов. Основная часть проекта — сам калькулятор: форма с несколькими полями для ввода; бэкенд, поставляющий данные о ставке кредита и доступных программах; и табличка с результатами, вычисленными с помощью несложных математических операций. И этот калькулятор нужно встроить в проекты других команд, и даже других организаций: например, он встроен в сайт Сбербанка.
Часто такую встраиваемую функциональность называют виджетами. Но нужно ответить на вопросы:
-
Что мы считаем виджетом?
-
Как разграничить зону ответственности между разработчиками виджета и хост-проектом, в который он встраивается?
-
В каком виде разработчики должны поставлять виджеты? Каким образом они должны подключаться в хост-проекты?
Я расскажу, к каким ответам и техническим решениям мы пришли. Но сначала поясню некоторые термины, которые буду использовать дальше:
-
разработчики виджета — команда, которая разрабатывает и релизит виджет;
-
потребители — разработчики из других команд, которые подключают наш виджет в свои проекты;
-
хост-проект — какое-то фронтенд-приложение (одна или много веб-страниц), в которое встроен наш виджет.
Что мы считаем виджетом?
Виджет в нашем понимании — это самостоятельное фронтенд-приложение, которое разрабатывается отдельной командой и встраивается в хост-проект. При этом релизным циклом виджета управляют его разработчики. Они должны иметь возможность раскатить новую версию на все хост-проекты, без привлечения к этому авторов каждого конкретного проекта.
Что это означает на практике? Например, моя команда создаёт виджет авторизации пользователей, который встроен в разные места нашего сайта: в навигационное меню под кнопкой «Войти»; в страницу с калькулятором ипотеки, чтобы пользователь мог после расчёта авторизоваться и сразу подать заявку; и т.п. Выглядит виджет примерно так:
Если меняется что-то в процессе авторизации (например, добавляется возможность авторизоваться через Госуслуги), то нужно уметь разом обновить виджет везде, где он используется.
В каком виде поставлять виджет другим командам?
В целом, во фронтенд-приложениях есть два основных пути поставки кода другим разработчикам: npm-пакет или скрипт, выложенный на какой-то сервер.
Npm-пакет
Npm-пакеты сейчас являются самым популярным и удобным способом поставки кода. Их легко публиковать и устанавливать. Мы распространяем в виде таких пакетов обычные UI-компоненты, написанные на React. Для потребителей подключение такого компонента выглядит очень просто:
npm install @domclick/login-form;
import { LoginForm } from '@domclick/login-form';
// ...
React.render(<LoginForm />, document.querySelector('#root'));
Также у npm-пакетов есть система версионирования (semver), позволяющая разработчику пакета выпускать разные типы новых версии (patch, minor, major), а потребителям — легко обновляться на нужную версию. Но такой способ не подходит для наших виджетов, потому что после публикации новой версии пакета нужно ждать. когда все потребители обновятся на неё и запустят в эксплуатацию уже новые версии своих проектов с нашим обновлением. Из-за этого релиз новой функциональности в нашей форме авторизации для конечных пользователей затягивается. Также в какой-то момент в одном проекте может быть одна версия виджета, а в другом — другая, и тогда UI получается неконсистентным.
Скрипт
Из-за перечисленных выше проблем мы были вынуждены поставлять наши виджеты в виде скрипта. Собираем из нашего исходного кода с помощью Webpack JS-файл со всем необходимым кодом и выкладываем его в CDN. Потребителям остаётся просто подключить скрипт к своей странице и написать немного кода для конфигурации виджета.
Это старый и надёжный способ, который работал во фронтенде ещё до появления npm или React, но в современной разработке он не совсем удобен. Когда разработчики думают об остальном проекте в терминах React-компонентов, такие виджеты-скрипты сильно выбиваются из обычного процесса. Поэтому мы пришли к компромиссному варианту:
Lazy npm-пакет
Мы по-прежнему собираем наши виджеты в виде скриптов и выкладываем их в CDN, но для потребителей также поставляем специальные пакеты, которые содержат внутри себя всю логику по загрузке и конфигурации этих скриптов. Для потребителей подключение такого пакета ничем не отличается от обычного:
npm install @domclick/login-form-lazy;
import { LoginFormLazy } from '@domclick/login-form-lazy';
// ...
React.render(<LoginFormLazy />, document.querySelector('#root'));
А внутри lazy-компонент устроен примерно так:
class LoginFormLazy extends React.Component {
// ...
render() {
const { Component } = this.state;
if (Component) return <Component {...this.props} />;
return 'loading...';
}
async loadComponent() {
const URL = 'https://statics.domclick.ru/login-form/bundle.js';
// про функцию loadModule будет подробнее рассказано ниже.
const module = await loadModule(URL);
this.setState({ Component: module.LoginForm });
}
componentDidMount() {
this.loadComponent();
}
// ...
}
Таким образом, при подключении lazy-компонента на странице сначала рендерится пустой компонент-заглушка, и с CDN-начинает загружаться скрипт с актуальной версией виджета. Как только он загрузится, виджет отобразится на месте компонента-заглушки.
При таком подходе легко опубликовать новую версию: достаточно заменить скрипт на CDN. Все потребители загружают его и всегда используют актуальную версию виджета, причем работают они с ним как с обычным npm-пакетом и React-компонентом, что улучшает developer experience.
А ещё мы получаем некоторую выгоду от lazy-загрузки. Например, основное содержимое страницы будет загружено и показано пользователю в первую очередь, а уже позже будет лениво загружен наш виджет. Основной контент важнее показать быстрее, ведь за ним пользователи и пришли.
Но есть у подхода и недостатки. Для работы кода хост-проекта нужны некоторые библиотеки вроде React или React-DOM, и они же используются в виджетах. При сборке виджетов в каждый скрипт попадает копия этих библиотек, поэтому в проекте возникает дублирование кода.
Чтобы обойти это, мы делаем так: при сборке скрипта с помощью Webpack указываем в конфиге формат "commonjs" и все нужные библиотеки как externals:
const webpackConfig = {
// ...
libraryTarget: 'commonjs',
externals: [
‘react’,
‘react-dom’,
],
//...
}
Далее используем для загрузки нашу функцию loadModule
, передавая в неё необходимые зависимости:
const module = await loadModule(URL, {
'react': require('react'),
'react-dom': require('react-dom'),
});
Как реализована функция loadModule
:
async function loadModule(url, dependencies) {
const response = await fetch(url);
const source = await response.text();
const require = moduleId => dependencies[moduleId] || undefined;
const runModule = new Function('exports', 'require', source);
const exports = {};
runModule(exports, require);
return exports;
}
Она загружает скрипт модуля в виде текста и создает new Function
, передавая в неё аргументами необходимые зависимости.
Таким образом, в скриптах наших виджетов используются не собственные копии общих библиотек, а те же, что и в хост-проекте. В package.json lazy-пакета эти библиотеки указаны как peerDependencies
, чтобы без них нельзя было установить пакет в хост-проект и получить неожиданную ошибку.
Ssr npm-пакет
Другая проблема, которая может возникнуть при использовании lazy-пакетов, — это работа с server side rendering. Он может использоваться в хост-проекте, например, для того, чтобы сделать содержимое страницы доступным поисковым роботам. Но поскольку наши виджеты лениво загружаются уже после загрузки страницы, то на сервере они не будут отрендерены и поисковые роботы их не увидят. Для некоторых типов виджетов это может быть не важно, но, например, для виджета с навигационным меню это критично. Поэтому мы пошли дальше и сделали еще ssr npm-пакеты. Для потребителей ничего не изменилось, подключение виджета осталось почти таким же:
npm install '@domclick/login-form-ssr';
import { LoginFormSSR } from '@domclick/login-form-ssr';
//...
ReactDOMServer.renderToString(<LoginFormSSR />) // на сервере
React.render(<LoginFormSSR />, document.querySelector('#root')); // на клиенте
В коде самого виджета изменилось следующее:
import LoginForm from '@domclick/login-form';
class LoginFormSSR extends React.Component {
// ...
render() {
const { Component } = this.state;
if (Component) return <Component {...this.props}/>;
return <LoginForm {...this.props} />
}
// ...
}
Идея в том, что в ssr-пакете для первоначального отображения используется встроенная версия базового пакета с обычным React-компонентом, а потом уже лениво загружается актуальная версия из скрипта с CDN и заменяет первоначально отрисованную. Таким образом, и на сервере, и при первом отображении на клиенте у нас может быть устаревшая версия виджета, но это не критично: через мгновение будет загружена актуальная, и пользователи будут взаимодействовать уже с ней.
Инвалидация кеша
Когда мы распространяем код через скрипт, путь к которому не меняется, нам нужно как-то решить вторую основную проблему программирования после наименования переменных — инвалидацию кеша.
В идеале, мы хотим, чтобы после релиза новой версии кеш сразу инвалидировался и пользователи сразу стали загружать новую версию. Сделать это, к сожалению, не так просто, потому что у нас есть несколько уровней кеша, например:
-
Кеш в CDN, через который мы распространяем наши скрипты. Им мы можем управлять и сбросить при необходимости.
-
Кеш в браузерах пользователей. Его мы сбросить никак не можем.
Так что у нас есть несколько возможных стратегий:
1) Указать нужное время жизни кеша
Поставить небольшое время жизни кеша, например, пять минут. Тогда максимум через пять минут кеш автоматически сбросится и все получат новую версию. Для этого достаточно указать заголовок Cache-Control
:
Cache-Control: max-age=300
2) Использовать кеширование по etag или last-modified
Для этого также нужно будет указать заголовок ETag
или Last-Modified
:
ETag: W/«0815"
Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
Etag — это хеш, вычисленный от содержимого ресурса, а Last-Modified — дата обновления. После релиза новой версии значение этих заголовков изменится. Браузер будет обращаться к ресурсу, даже если он есть у него в кеше, но не станет загружать его целиком, а только получит заголовки и сравнит их с сохранёнными. И только если они отличаются, тогда загрузит целиком новую версию.
Лучше вместе с ETag
или Last-Modified
использовать заголовок Cache-Control
с небольшим значением, чтобы после истечения этого периода браузеры точно запрашивали ресурс для сравнения заголовков. Если Cache-Control
отсутствует, то браузеры будут использовать хитрые эвристические алгоритмы, чтобы определить, пора ли уже делать запрос для сравнения заголовков, или ещё можно брать версию из кеша. Логика работы алгоритмов может быть неочевидной.
3) Разбить наш скрипт на два с разными настройками кеширования
Сделать очень маленьким отдельный первый скрипт, загружаемый из lazy-пакетов (например, https://statics.domclick.ru/login-form/inject.js
). Он будет подгружать основной код из другого скрипта с указанием конкретной версии в адресе: https://statics.domclick.ru/login-form/bundle-1.2.3.js
. При этом первому скрипту можно указать небольшой Cache-Control
— он весит мало, и его не страшно раз в пять минут загружать заново. Второму скрипту можно указать большой Cache-Control
(например, год), чтобы он кешировался, условно, навечно, так как после релиза будет новая версия с другим URL, которой ещё нет в кеше.
Мы перепробовали все эти варианты и остановились на последнем.
Заключение
Я описал, как мы работаем с виджетами на React. Вам не обязательно делать так же, вариантов множество. Но точно следует помнить:
-
Используйте npm-пакеты, это практически стандарт в современном фронтенде, все к нему привыкли.
-
Думайте про версионирование: нужно ли вам обновлять виджеты сразу везде, где они используются, или это должны делать потребители, когда сочтут нужным.
-
Решите, как будут кешироваться виджеты и как инвалидировать кеш.
Автор: Игорь Савин