How-to: Пользовательские клиентские модули JS
Когда приложению нужен собственный браузерный JavaScript — пользовательское представление объекта или свойства, функция, привязанная действием INTERNAL CLIENT, или любой другой клиентский код — этот код можно хранить как обычные исходные файлы в проекте и собирать в веб-клиент на этапе сборки. Платформа собирает каждый основной файл и регистрирует то, что он экспортирует, поэтому приложению не нужно писать явную загрузку или привязку для него. Это относится только к веб-клиенту.
Где размещаются файлы
Браузерный JavaScript размещается в папке src/main/web модуля логики, например src/main/web/OrderBoard.jsx или src/main/web/util.js. Файлы могут иметь расширение .js, .jsx, .ts или .tsx.
Во время сборки каждый файл в src/main/web (кроме вложенной папки lib) собирается (при помощи esbuild) в один файл web/.compiled/<имя>.js в classpath, где <имя> берётся из пути к исходному файлу (файл во вложенной папке становится <папка>_<имя>.js). Собранный файл загружается автоматически при открытии страницы — запись в действии onWebClientInit для него не нужна.
Вложенная папка src/main/web/lib считается общим вспомогательным кодом: её файлы не собираются в отдельные бандлы, но их можно импортировать из основных файлов, и они включаются в их сборку.
Без сборки
Проект без настроенной сборки — без esbuild, без зависимостей org.mvnpm — всё равно может подключить пользовательский клиентский JS обычным файлом в папке src/main/resources/web, который используется как есть: без сборки, без разрешения сторонних библиотек. (Этот же путь использует eval.) Поэтому представление React в файле .js пишется через React.createElement с использованием предоставляемого платформой window.React, а не через JSX (в файле .jsx синтаксис JSX сохраняется — см. раздел об облегчённом .jsx ниже), и компонент задаётся на глобальном объекте window (запасной вариант, описанный ниже), а не как именованный экспорт. Имя custom, совпадающее с [A-Z][A-Za-z0-9_$]*, по-прежнему распознаётся как React:
function HelloBoard(props) {
var React = window.React;
var rows = (props.data.o || {}).list || [];
return React.createElement("div", { className: "hello-board" }, rows.length + " orders");
}
Поскольку такой файл не упаковывается, он не может import-ировать другие локальные модули — он делится кодом только через глобальные переменные на window (платформа предоставляет window.React и window.ReactDOM). Импорт локальных помощников и npm-пакетов — это то, что добавляет сборка.
Такой файл загружается одним из двух способов — то же разделение, что отличает автоматически загружаемые сборки со сборочного пути от всего, что приложение перечисляет в onWebClientInit.
Автоматически — поместите его в resources/web/init. Файл в папке src/main/resources/web/init регистрируется автоматически при открытии страницы, без записи в onWebClientInit — это аналог без сборки того, как сборки из src/main/web загружаются автоматически после сборки. Подходит для самодостаточного компонента или таблицы стилей:
DESIGN orders {
BOX(o) { custom = 'HelloBoard'; } // из resources/web/init/helloBoard.js — больше ничего подключать не нужно
}
Файлы в web/init должны быть независимыми от порядка загрузки: сканирование даёт им всем один порядок загрузки, поэтому каждый должен регистрировать или определять что-либо при загрузке и обращаться к другой библиотеке отложенно (при отрисовке или по событию), а не обращаться к другому скрипту в момент загрузки.
Явно — перечислите его в onWebClientInit. Любой файл в src/main/resources/web вне web/init загружается указанием его имени в действии onWebClientInit с целочисленным порядком. Используйте это, когда важен порядок загрузки — сторонняя библиотека должна загрузиться раньше использующего её компонента — или для условной загрузки файла:
onWebClientInit() + {
onWebClientInit('helloBoard.js') <- 1;
}
Сборочный путь и пути без сборки соотносятся так:
| Со сборкой (src/main/web) | Без сборки, авто (resources/web/init) | Без сборки, явно (resources/web) | |
|---|---|---|---|
| Загрузка | собирается в web/.compiled, загружается автоматически | автозагрузка по сканированию папки | указывается в onWebClientInit |
| Исходный код | .js/.jsx/.ts/.tsx, допустим JSX | .js или .jsx (преобразуется при отдаче) | .js или .jsx (преобразуется при отдаче) |
| Регистрация | именованный экспорт | имя на window | имя на window |
| Порядок загрузки | один порядок (сборки самодостаточны) | один порядок (файлы должны быть независимы от порядка) | явный целочисленный порядок |
| Сторонние библиотеки | включаются в сборку через org.mvnpm | загружаются отдельно, берутся из window | загружаются отдельно, берутся из window |
Облегчённый .jsx (без сборки)
Файл без сборки можно писать и на JSX: файл .jsx в src/main/resources/web преобразуется на сервере при отдаче, поэтому синтаксис JSX работает без node, без esbuild и без шага сборки, а браузер получает обычный скрипт. Меняется только синтаксис — файл остаётся файлом без сборки: JSX компилируется в вызовы React.createElement с использованием предоставляемого платформой window.React, а файл выполняется как классический скрипт верхнего уровня, поэтому объявление function верхнего уровня уже является именем на window — больше ничего не требуется. import или export в файле .jsx — ошибка: она выводится в консоль браузера, и файл пропускается — импорт модулей добавляет именно сборка (src/main/web). Подходящие компоненты при том же серверном преобразовании также автоматически мемоизируются (React Compiler), поэтому компонент без сборки перерисовывается только при изменении его входных данных — без аннотаций и без шага сборки. Поскольку это преобразование выполняется на сервере, сервер приложения должен работать на Java 11 или новее; на более старой JVM отдаваемый .jsx выводит это требование в консоль браузера вместо отрисовки (обычный .js не затрагивается).
Файл .jsx загружается теми же двумя способами, что и обычный .js: помещённый в resources/web/init, он загружается автоматически без какой-либо привязки, а в любом другом месте resources/web — перечисляется в onWebClientInit:
// resources/web/init/helloBoard.jsx — загружается автоматически, больше ничего подключать не нужно
function HelloBoard(props) {
const rows = (props.data.o || {}).list || [];
return <div className="hello-board">{rows.length} orders</div>;
}
DESIGN orders {
BOX(o) { custom = 'HelloBoard'; }
}
Порядок загрузки: файл .jsx загружается и выполняется в своей позиции среди ресурсов, ровно как обычный .js — порядок onWebClientInit действует буквально для .js и .jsx в обе стороны, поэтому в момент загрузки файл любого вида может читать глобальное имя, заданное любым более ранним ресурсом; единственный зарезервированный диапазон — платформенный: файл .jsx, зарегистрированный с порядком -108 и ниже, выполняется раньше предоставляемой платформой среды мемоизации и выходит за рамки контракта.
Именованные экспорты и автоматическая регистрация
Каждый модуль предоставляет свои компоненты и функции как именованные экспорты. При загрузке каждый именованный экспорт регистрируется в реестре window.lsfusion.custom под своим именем, и клиент сначала ищет пользовательское имя в этом реестре. Поэтому custom = 'OrderBoard' в DESIGN, представление объекта CUSTOM 'orderBoard' или действие INTERNAL CLIENT 'formatSum' находят экспорт с совпадающим именем:
export function OrderBoard(element, controller, list) {
// отрисовка списка объектов внутри element
}
Имя, заданное прямо на глобальном объекте window, по-прежнему работает как запасной вариант, поэтому существующие скрипты, объявляющие window.OrderBoard = ..., продолжают работать, но именованные экспорты предпочтительнее.
React и ReactDOM предоставляются платформой: единственная встроенная производственная сборка загружается до любого пользовательского скрипта, и импорты react, react-dom и react-dom/client в модуле разрешаются в неё. Приложение не должно включать в сборку свою копию React или ReactDOM.
Подключение сторонней библиотеки
Сторонняя браузерная библиотека подключается как обычная офлайн-зависимость Maven через координаты mvnpm (org.mvnpm:*, зеркало npm в Maven Central) — без шага npm или yarn. Зависимость разрешается из локального репозитория Maven, как и любая другая, и библиотека вместе с npm-пакетами, от которых она зависит, включается в сборку тех модулей, которые её импортируют:
<dependency>
<groupId>org.mvnpm</groupId>
<artifactId>apexcharts</artifactId>
<version>3.54.1</version>
</dependency>
Пакет npm с областью видимости (@scope/name) использует группу org.mvnpm.at.<scope> и простое имя как artifact id. Затем модуль импортирует библиотеку по её имени в npm:
import ApexCharts from "apexcharts";
Подключение сторонней библиотеки без сборки
Без сборки нет упаковки через org.mvnpm, поэтому сторонняя библиотека загружается отдельным браузерным скриптом и используется по тому глобальному имени, которое она задаёт, — сборкой, присваивающей себя в window (сборка UMD или обычный скрипт). Компонент берёт её оттуда (window.confetti, window.dayjs, …), а не импортирует. Подать этот скрипт можно двумя способами.
С библиотекой, включённой в проект, — для офлайн-проекта. Поместите браузерную сборку библиотеки (тот .js, который задаёт глобальное имя) статическим файлом в src/main/resources/web/init рядом с компонентом. Оба файла загрузятся автоматически, и компонент работает, потому что обращается к глобальному имени отложенно, при отрисовке или по событию, — поэтому неважно, какой из двух скриптов сканирование внедрит первым. Нужны только закоммиченные файлы — без интернета, без зависимости org.mvnpm, без сборки:
function ConfettiBoard(props) { // resources/web/init/confettiBoard.js
var React = window.React;
function celebrate() { window.confetti({ particleCount: 200, spread: 120 }); }
return React.createElement("button", { onClick: celebrate }, "Celebrate");
}
// resources/web/init/confetti.umd.js задаёт window.confetti — оба файла загружаются автоматически, без onWebClientInit
По URL, когда во время выполнения доступен интернет. URL — это не файл, который можно положить в web/init, поэтому он передаётся прямо в onWebClientInit: значение, которое не является локальным ресурсом web/ и при этом является абсолютным URL, загружается как <script src> на странице (правило разрешения описано в INTERNAL), перед использующим её компонентом:
onWebClientInit() + {
onWebClientInit('https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.3/dist/confetti.browser.min.js') <- 1;
onWebClientInit('confettiBoard.js') <- 2;
}
React Compiler
JSX модуля (любой файл .jsx или .tsx) проходит через React Compiler — общую автоматическую мемоизацию компонентов React, заменяющую ручные useMemo / useCallback / React.memo. Проход выполняется, когда в модуле есть JSX, — так же, как в тире .jsx без сборки, — а компонент, нарушающий правила React, остаётся без изменений, поэтому настройки он не требует. Модуль только с обычными .js/.ts собирается без него.
Как и обычная сборка, этот проход не требует ни Node, ни каких-либо других внешних инструментов: компилятор (Babel с babel-plugin-react-compiler) поставляется вместе с плагином сборки и выполняется внутри процесса JVM. Это шаг только времени сборки — машины времени выполнения он не касается. Модуль с JSX требует, чтобы JVM сборки была Java 11 или новее; на более старой JVM он завершается ошибкой с этим указанием — модуль только с .js/.ts собирается в любом случае, либо задайте -Dlsfusion.web.skip=true, чтобы полностью пропустить сборку web. Офлайн-сборка (mvn -o) работает как обычно.
Проход не заменяет window.lsfusion.List: типичный кейс производительности больших таблиц — перерисовка только реально изменившихся строк — решается List независимо.
Пример
Поместим небольшой модуль в src/main/web/orderUtil.js, экспортирующий функцию форматирования:
export function formatSum(amount, currency) {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
Привяжем его к клиентскому действию и вызовем:
formatOrderSum 'Format' (Order o) {
INTERNAL CLIENT 'formatSum' (sum(o), 'USD');
}
Сборка компилирует orderUtil.js в web/.compiled/orderUtil.js и регистрирует formatSum; при открытии формы действие находит имя в реестре и вызывает функцию. Запись в onWebClientInit для модуля не пишется.
Стили
Модуль может импортировать CSS. esbuild собирает весь CSS, достижимый из модуля — и собственный import "./styles.css", и CSS любой подключённой сторонней библиотеки — в соседний файл web/.compiled/<имя>.css, который загружается автоматически вместе с бандлом (без записи в onWebClientInit и без отдельной регистрации CSS библиотеки). Шрифты и изображения, на которые этот CSS ссылается через url(), встраиваются в скомпилированную таблицу стилей как data-URL, поэтому она самодостаточна; крупные изображения загружайте отдельно (например, через onWebClientInit), чтобы таблица стилей не разрасталась.
Рекомендуемый способ оформления:
- CSS-модули (
import styles from "./Component.module.css", применяется какclassName={styles.root}) для собственных стилей компонента — имена классов локализуются по модулю, поэтому стили разных компонент на одной форме не конфликтуют. - Inline-стили
style={{ ... }}для значений, вычисляемых из данных (цвета и размеры по строке, условное оформление). - Обычный
import "./Component.css"(или CSS сторонней библиотеки) — глобальный; используйте его для библиотечных или намеренно глобальных стилей и задавайте именам классов префикс. Скомпилированный .css не нужно регистрировать вручную — он уже загружается автоматически.
Для полноценной системы стилей помимо статических классов подходит CSS-in-JS времени выполнения (например, styled-components или @emotion): такая библиотека подключается обычной зависимостью org.mvnpm, собирается вместе с модулем и добавляет свои стили во время выполнения. Используйте API styled или className={css(...)}; prop css у Emotion (<div css={...} />) требует JSX-преобразования, которого в сборке нет, поэтому он недоступен.
Препроцессоры CSS (Sass/SCSS, Less, Stylus) и утилитарные фреймворки, генерирующие CSS на этапе сборки (Tailwind, UnoCSS), не входят в эту сборку — обычная сборка запускает только бинарник esbuild, без шага Node или плагинов. Нативный CSS (вложенность, пользовательские свойства) и CSS-модули покрывают большую часть того, ради чего применялись препроцессоры; если такой инструмент всё же нужен, сгенерируйте CSS отдельным шагом и поставьте результат обычной таблицей стилей через onWebClientInit.
Отдельную таблицу стилей, не входящую в сборку, по-прежнему можно поставлять обычным файлом и загружать через действие onWebClientInit, как CSS классического пользовательского компонента (см. How-to: Пользовательские компоненты (объекты)).
Эта статья описывает общую упаковку любого пользовательского браузерного JavaScript. О представлениях, специфичных для React, и о вызовах сервера, которые пользовательское представление делает через контроллер, см. How-to: Пользовательские представления формы на React и How-to: API контроллера пользовательского представления.