Виджеты

Виджеты

Как мы в Домклике делаем виджеты на 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, которой ещё нет в кеше.