Перейти к основному содержимому
Версия: 7.0

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 контроллера пользовательского представления.