Виджеты
Виджеты
Как мы в Домклике делаем виджеты на React
Как мы в Домклике делаем виджеты на React
В каком виде поставлять виджет другим командам?
В целом, во фронтенд-приложениях есть два основных пути поставки кода другим разработчикам: npm-пакет или скрипт, выложенный на какой-то сервер.
npm-пакет
npm install @domclick/login-form;
Но такой способ не подходит для наших виджетов, потому что после публикации новой версии пакета нужно ждать. когда все потребители обновятся на неё и запустят в эксплуатацию уже новые версии своих проектов с нашим обновлением. Из-за этого релиз новой функциональности в нашей форме авторизации для конечных пользователей затягивается. Также в какой-то момент в одном проекте может быть одна версия виджета, а в другом — другая, и тогда UI получается неконсистентным.
Скрипт
Из-за перечисленных выше проблем мы были вынуждены поставлять наши виджеты в виде скрипта. Собираем из нашего исходного кода с помощью Webpack JS-файл со всем необходимым кодом и выкладываем его в CDN. Потребителям остаётся просто подключить скрипт к своей странице и написать немного кода для конфигурации виджета.
Когда разработчики думают об остальном проекте в терминах React-компонентов, такие виджеты-скрипты сильно выбиваются из обычного процесса. Поэтому мы пришли к компромиссному варианту:
Lazy npm-пакет
Мы по-прежнему собираем наши виджеты в виде скриптов и выкладываем их в CDN, но для потребителей также поставляем специальные пакеты, которые содержат внутри себя всю логику по загрузке и конфигурации этих скриптов. Для потребителей подключение такого пакета ничем не отличается от обычного:
npm install @domclick/login-form-lazy;
import { LoginFormLazy } from '@domclick/login-form-lazy';
// ...
React.render(<LoginFormLazy />, document.querySelector('#root'));
А внутри lazy-компонент устроен примерно так:
export const MainpageMortgageWidgetLazy: VFC<IInitialPropsLazy> = ({
widgetUrl = "https://cdn.cian.site/finance/mainpage-mortgage-widget",
Placeholder,
...widgetProps
}) => {
const [Component, setComponent] = React.useState<VFC<InitialState>>();
React.useEffect(() => {
if (!Component) {
const fetchComponent = async () => {
try {
const [module] = await Promise.all([
loadModule(`${widgetUrl}/index.js`),
loadStyle(`${widgetUrl}/index.css`),
]);
if (module) {
setComponent(() => module.MainpageMortgage.MainpageMortgage);
} else {
console.error("No MainpageMortgage widget in fetch", { module });
}
} catch (e) {
console.error("Cannot fetch MainpageMortgage widget", e);
}
};
fetchComponent().catch(e => {
console.error("Error in MainpageMortgage widget fetch", e);
});
}
}, [Component, widgetUrl]);
if (Component) {
return <Component {...widgetProps} />;
}
return Placeholder || <div>loading...</div>;
};
Таким образом, при подключении lazy-компонента на странице сначала рендерится пустой компонент-заглушка, и с CDN-начинает загружаться скрипт с актуальной версией виджета. Как только он загрузится, виджет отобразится на месте компонента-заглушки.
При таком подходе легко опубликовать новую версию: достаточно заменить скрипт на CDN. Все потребители загружают его и всегда используют актуальную версию виджета, причем работают они с ним как с обычным npm-пакетом и React-компонентом, что улучшает developer experience
Но есть у подхода и недостатки. Для работы кода хост-проекта нужны некоторые библиотеки вроде 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
:
export async function loadModule(
url: string,
dependencies: Record<string, any>
): Promise<any> {
const response = await fetch(url, { mode: "cors", credentials: "omit" });
const source = await response.text();
const require = (moduleId: string) => 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. Он может использоваться в хост-проекте, например, для того, чтобы сделать содержимое страницы доступным поисковым роботам. Но поскольку наши виджеты лениво загружаются уже после загрузки страницы, то на сервере они не будут отрендерены и поисковые роботы их не увидят.
export const MainpageMortgageWidgetSsr: React.VFC<IInitialPropsSsr> = ({
widgetUrl,
...widgetProps
}) => {
return (
<MainpageMortgageWidgetLazy
widgetUrl={widgetUrl}
Placeholder={<MainpageMortgage {...widgetProps} />}
{...widgetProps}
/>
);
};
Идея в том, что в ssr-пакете для первоначального отображения используется встроенная версия базового пакета с обычным React-компонентом, а потом уже лениво загружается актуальная версия из скрипта с CDN и заменяет первоначально отрисованную.
Инвалидация кеша
Указать нужное время жизни кеша
Cache-Control: max-age=300
Использовать кеширование по etag или last-modified
ETag: W/«0815"
Last-Modified: Wed, 21 Oct 2015 07:28:00 GMT
Разбить наш скрипт на два с разными настройками кеширования
Сделать очень маленьким отдельный первый скрипт, загружаемый из 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, которой ещё нет в кеше.