Остановите войну в Украине!

Прин­цип мо­за­и­ки, или Как мы сде­лали JavaScript по-на­сто­я­ще­му мо­дуль­ным

Редактура Вадим Макеев

Идея создания модульной системы родилась и развивалась внутри нашего опенсорсного проекта ScandiPWA — витрины для онлайн-коммерции на React. Нашей главной задачей было разработать расширяемую основу для наших проектов, в которую мы могли бы внедрять расширения, при этом имея возможность менять облик (создавать темы) как самой основы, так и любого из расширений. При этом, важно было предоставить возможность быстрого и удобного их (базы и расширений) обновления.

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

С расширениями дела обстояли ещё хуже. Потребность в них росла, а обычные подходы к этой проблеме заставляли на каждом шагу останавливаться, и оставлять места для внедрения плагинов. В приложении таких набиралось всё больше, и нас это решительно не устраивало, код регистрации точки внедрения дублировался, а следить и документировать каждый становилось просто невозможно. Наш опыт с языками на бэкенде подсказывал, что должен был быть способ внедрять плагины на уровне кода, а не точки выхода, разбросанные по приложению. Однако JavaScript такой возможности из коробки не предоставлял.

Мы долго задавались вопросом: как живут все больше проекты в вебе, искали подходы, технологии… Даже тогда, два года назад, когда мы только начинали, уже были технологии типа DI (dependency injection — прим. редактора). И всё с ними было хорошо, да есть у нас проблема: наш продукт ScandiPWA делался для простых разработчиков. Для закоренелых PHP-разработчиков, которые и React может первый раз в жизни видят. Приходилось прямо в документации рассказывать о преимуществах над jQuery. Представьте как сложно было бы объяснить непонятные реестры.

В общем, нам нужно было что-то элементарно простое. Пришлось, как принято, делать самим.

К чему мы пришли: плагиныСкопировать ссылку

Как уже сказано выше, чтобы работать с плагинами, обычно приходится писать ещё и кучу кода. Расставлять точки входа, регистрировать, импортировать файлы регистрации. Это всё долго, сложно, затратно и очень плохо масштабируется. Мы решили, что всё это можно убрать. Скрыть и делать плагины сразу к самому коду. Мы очень не хотели менять прототипы, мы хотели чтобы плагины просто перехватывали вызов той или иной функции и позволяли модифицировать процесс и результат её выполнения. При этом было важно работать на уровне стека вызова оригинальной функции, чтобы программу было легко отлаживать.

Итак, что же мы там придумали? Теперь, вы можете делать сущности вашего языка в вашем приложении (функции, классы и методы) точкой входа для плагинов с помощью всего одного комментария! Комментарий — @namespace позволит изменить отмеченную сущность через плагины из любого модуля в приложении. А ещё он прекрасно подсвечивается вашим редактором! Как это в итоге выглядит:

/** @namespace Application/getData */
const getData = () => {
    return ['Initial data'];
}

console.log(getData());

И что, это всё? Да, всё. Теперь вы можете расширять эту функцию, например, добавить к результату её выполнения новый элемент, сделать это, достаточно просто, смотрите:

export default {
    // Тот самый @namespace нашей функции
    'Application/getData': {
        // Тип изменяемой сущности
        function: (args, callback) => {
            return [
                // Вызов оригинальной функции
                ...callback(...args),
                // Наш новый элемент
                'Data from the plugin'
            ];
        }
    }
}

А как решить проблему с импортом файлов регистрации? Определить для них одно место, и брать оттуда! Забегая немного вперёд: система ищет плагины по паттерну src/plugin/*.plugin.* во всех отмеченных модулях приложения.

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

К чему мы пришли: темыСкопировать ссылку

А что с темами? Мы бы хотели надстраивать их бесконечное количество (ну или больше двух) друг на друге. Например так:

  1. Голая тема с чистой функциональностью.
  2. Красивая тема № 101.
  3. Кастомизации под клиента.

Для нас ответ крылся в формулировке вопроса «надстраивать друг на друге». Мы решили сделать всё уже давно изведанным во многих бэкенд-фреймворках способом: при сборке предпочитать файлы детей родительским, в случае, если имена их файлов совпадают! Непонятно? Вот как это работает на практике. Спойлер, всё просто!

Этот файл лежит в файле App.js пакета nice-theme:

import { PureComponent } from 'react';

export default class App extends PureComponent {
    render() {
        return <p>Это написано в «Красивой теме №101».</p>;
    }
}

Это файл уже более новой темы «Кастомизации под клиента», но имя файла, что важно, совпадает: тоже App.js.

import ParentApp from 'nice-theme/App';

export default class App extends ParentApp {
    render() {
        return (
            <>
                { super.render() }
                <p>А это в «Кастомизации под клиента».</p>
            </>
        );
    }
}

В результате получаем следующий HTML в результате рендера:

<p>Это написано в «Красивой теме №101».</p>
<p>А это в «Кастомизации под клиента».</p>

Обратите внимание, мы использовали классы и наследование в React! Такая практика не рекомендуется. Однако в нашем случае, она решает больше проблем, чем создает:

  • Программист может сам решать, хочет ли он остаться совместимым с оригинальной темой (и сильно упростить последующие обновления), или же он хочет переписать файл с нуля. В таком случае, он может просто создать класс с нуля (из PureComponent).
  • Мы имеем возможность наследовать методы частями. Например, нас устраивает, как рендерится название, но мы недовольны ценой. Мы всегда можем переписать лишь несколько конкретных, необходимых нам методов.
  • Наконец, мы можем на уровне метода или свойства решать, хотим ли мы унаследовать его (через обращение к super).

Что важно, мы не даём возможности обратиться напрямую к классу родительской темы, если он переписан в новой теме. Теперь любое обращение к нему вернёт вам ваш новый, переписанный класс! Поэтому мы считаем такой подход валидным и, несмотря на популярность функциональных компонентов в React, продолжаем использовать классовые!

Как это попробовать?Скопировать ссылку

Всё очень просто. На самом деле, механизм может работать не только с React: как плагины, так и темы можно делать для любых сущностей и файлов. Мы же пока уверенно можем заявить о поддержке самых популярных фреймворков: Create React App и Next.js. Что, плагины в Next.js? Да-да, именно так!

Как выглядит интеграция? Даже слишком просто. Достаточно заменить зависимость с react-scripts на @tilework/mosaic-cra-scripts, а в самих скриптах, аналогично с react-scripts на cra-scripts. И всё! Как это выглядит:

"dependencies": {
-    "react-scripts": "4.0.3",
+    "@tilework/mosaic-cra-scripts": "0.0.2",
},
"scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
+    "start": "cra-scripts start",
+    "build": "cra-scripts build",
}

Для Next.js, всё точно также, только ставить надо @tilework/mosaic-nextjs-scripts, а скрипты менять нужно на nextjs-scripts.

Что делать всем остальным? Если ваш стек — это Webpack и Babel, то и для вас есть очень простое решение: ставим @tilework/mosaic-config-injectors и изменяем вашу Webpack-конфигурацию следующим образом:

const webpack = require('webpack');
const ConfigInjectors = require('@tilework/mosaic-config-injectors');

module.exports = ConfigInjectors.injectWebpackConfig({
    // Конфигурация
}, {
    webpack
});

Готово? Ура! Поделитесь опытом использования, для нас это очень важно!

Почему все-таки mosaic?Скопировать ссылку

Чем картины отличаются от мозаики? Мозаика состоит из отдельных элементов, а картина, хоть и окрашена в разные цвета, является одним, неразделимым целым.

Большинство приложений JavaScript — это картины. Наше изначальное приложение ScandiPWA — тоже. Даже разделив такое приложение на модули, нам всё равно придётся их переплетать, интегрируя один в другой. Модули — как цвета на картине, изначально выходящие из разных тюбиков, оказываются неразрывно связанными в одно целое.

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

Плагины позволят вам сделать это! Мы начали с интеграций в уже готовые приложения, а теперь предлагаем написать всё с нуля? Да, мы сами в шоке!

Но как это вам поможет? Какие проблемы решит? Честно говоря, вы просто напишете приложение изначально думая о том, как его можно будет расширять. И не только сторонними расширениями, но и собственной дополнительной логикой. Это зарядка для мозга, полезная сложность, которая делает наш код немного более качественным!

Наверное, не стоит переписывать уже готовые приложения на этот подход, но вот писать новые… Стоит попробовать! Особенно в случае, если вы хотите в будущем легко заменять одни его части на другие. Подумайте об этом :)

СсылкиСкопировать ссылку