Организованная разработка фронтенда должна отвечать на три вопроса:
как делать компоненты
как производить ререндер при изменении состояния
как работать с CSS-стилями
И казалось бы библиотека, отвечающая на три таких вопроса может быть простой и давно закончена так как сами HTML и JS давно не претерпевали больших изменений. Единственное, однозначно новой вехой можно назвать появление TypeScript, как возможность следовать строгой типизации, но очевидно это не ограничивающих фактор.
Однако история фронтенд-разработки богата технологиями и различными парадигмами, "вершиной" которых на данный момент можно назвать реактивность и html-like шаблоны. Все это можно найти в доминирующих технологиях вроде React, Angular и Vue, которые, как ни странно, "развиваются" до сих пор (Angular пробил 22 версии). Мы не будем надолго останавливаться на их проблемах, назовем только одну – сложна, в разных смыслах. А мы не любим сложности в разработке простых вещей и поэтому появился LandTS.
Что такое LandTS
https://github.com/pain64/land-ts
LandTS это библиотека для разработки фронтендов на TypeScript. Весь LandTS это state-of-art файл land.ts (177 строк кода) и плагин к vite (100 строк кода), хотя это может быть любой бандлер. Ядро библиотеки это всего лишь две функции l (land) и lz (land zone for rerender). Буква l выбрана так как она имеет наименьший синтаксический шум, после этого мы выбрали слово land, которое имеет удачный перевод "приземлить". Приведем базовый пример:
import { css, l } from "./land.ts"; document.body.appendChild( l('div', _ => { css` color: #005143; `.applyTo(_) _.innerText = 'Hello from Land.ts!' }) )
Функция l
l имеет несколько перегрузок.
const aDiv: HTMLDivElement = l('div', newDiv => { // this lambda is __init__ for newDiv newDiv.innerText = 'Hello, from Land.ts!' })
В данном случае первый аргумент это html-tag, а второй это init-лямбда элемента.
l('div', _ => { l(_, 'p', _ => { _.innerText = 'LandTS №1' }) l(_, 'p', _ => { _.innerText = 'React №2' }) })
А при таком вызове функции l, первым аргументом передается часть DOM-дерева, куда нужно вмонтировать новый элемент. Заметим, что JavaScript позволяет использовать переиспользовать имя переменной _ и мы получаем минимум синтаксического шума.
Асинхронность
В случае, если создаваемый элемент имеет асинхронный init, LandTS сделает плейсхолдер, в который будет вмонтирован элемент, когда будет получен init
l('div', _ => { l(_, 'p', _ => { _.innerText = 'paragraph 1' }) l(_, 'p', async _ => { await someAsyncInvokation() _.innerText = 'paragraph 2' }) l(_, 'p', _ => { _.innerText = 'paragraph 3' }) })
Условный рендеринг
Делается очень просто, вообще с LandTS вы всю логику пишете на TypeScript
const root = l('div', _ => { const tasks = [ { isImportant: false, description: 'Go for a walk' }, { isImportant: true, description: 'Repair the TV' } ] for (task of tasks) l(_, 'div', _ => { _.innerText = task.description if (task.isImportant) { css`color: red;`.applyTo(_) _.innerText += "!!!!!!!!!!!!!" } }) })
Программирование стилей, элементов, контента и т.д. Удобно! Не правда ли?
Компоненты
Компонент в LandTS это просто класс, который должен реализовать один из интерфейсов Component и AsyncComponent с одним методом
interface Component<H extends HTMLElement> { render(): H } interface AsyncComponent<H extends HTMLElement> { render(): Promise<H> }
Почему их два? Потому что разработчики JS решили что должен явно существовать Promise. Других причин нет, возможно стоит оставить один интерфейс, но пока так. Сделаем и смонтируем свой компонент
class Button implements Component<HTMLButtonElement> { render(): HTMLButtonElement { return l('button', _ => { // ... }) } } // usage l('div', _ => { l(_, new Button()) })
В данном случае в вызове функции l нет init лямбды так как init находится в конструкторе нашего класса.
Функция lz и ререндеринг
LandTS предлагает простой и удобный способ делать ререндеринг "РУКАМИ" ?, хотя и не останавливает вас от прямого управления DOM-деревом.
Каждая функция l имеет брата-близнеца lz, которая расширяет init-функцию вторым параметром z (rerender zone). Также нам понадобится функция sync
function sync(zones: Array<Zone | null>, changesReason?: any): void
Мы используем функцию sync чтобы синхронизировать локальное состояние с DOM-деревом, посредством вызова ререндера (функцией sync) на одной или нескольких зонах (Zone).
l('div', _ => { let counter = 0 lz(_, 'div', (_, z) => { // will be rerendered fully l(_, 'span', _ => { _.innerText = 'Current count: ' + count }) l(_, 'button', _ => { _.innerText = 'click to increment' _.onclick = () => sync([z], counter++) // triggers rerender }) }) })
Более того, вы можете делать ререндер раздельно
l('div', _ => { let z1 = lz(_, 'p', _ => { _.innterText = 'zone 1' }) let z2 = lz(_, 'p', _ => { _.innterText = 'zone 2' }) l(_, 'button', _ => { _.onclick = () => { sync([z1]) // or sync([z2]) } }) })
CSS
Итак, вы можете настраивать стили отдельно в своих css-файлах, а можете использовать функцию css`` и стилизовать разметку локально
// project_root/src/Button.ts import { css, l } from "./land.ts"; class Button implements Component<HTMLButtonElement> { render(): HTMLButtonElement { return l('button', _ => { css` font-size: 16px; `.applyTo(_) }) } }
Во время сборки проекта для каждого .ts файла будет создан .css файл. Для каждого вызова функции css`` будет сгенерирован вызов конструктора Css-класса
/* project_root/.css/src/Button.ts.css */ .g1 { font-size: 16px; }
А вызов функции в файле .ts будет трансформирован перед загрузкой
// project_root/src/Button.ts after transform return l('button', _ => { new Css('g1') .applyTo(_) })
Используя .this-class селектор вы можете встраивать любой css-код
class SectionHeader implements ClassComponent<HTMLDivElement> { render(): HTMLDivElement { return l('h2', _ => { css` @media (max-width: 550px) { .this-class { text-align: center; } } `.apply(_) }) } }
->
@media (max-width: 550px) { .g2 { text-align: center; } }
Это буквально всё что есть в LandTS :)
Порт игры крестики-нолики
В рамках демонстрации LandTS, на стриме мы портировали игру крестики-нолики с React и получили в 2 раза меньше кода с учетом стилей. Оставим здесь реализацию, вы можете оценить выразительность и чистоту кода
class GameExample implements ClassComponent<HTMLDivElement> { render(): HTMLDivElement { let currentMove = 0 const history: Array<Array<string>> = [ ['', '', '', '', '', '', '', '', ''], ] function isWinnerContested() { const winPatterns = [ [0, 1, 2], [3, 4, 5], [6, 7, 8], [0, 3, 6], [1, 4, 7], [2, 5, 8], [0, 4, 8], [2, 4, 6], ] const board = history[currentMove] for (let [a, b, c] of winPatterns) if (board[a] != '' && board[a] === board[b] && board[a] === board[c]) return true return false } function currentPlayer() { return currentMove % 2 == 1 ? 'O' : 'X' } return lz('div', (_, z) => { l(_, 'div', _ => { l(_, 'div', _ => { _.innerText = (isWinnerContested() ? 'Winner: ' : 'Next player: ') + currentPlayer() }) l(_, 'div', _ => { css` display: grid; grid-template-rows: repeat(3, 32px); grid-template-columns: repeat(3, 32px); `.applyTo(_) const board = history[currentMove] for (let i = 0; i < 9; i++) { l(_, 'button', _ => { _.innerText = board[i] _.onclick = () => { if (board[i] != '' || isWinnerContested()) return sync([z], (() => { history.push(board.toSpliced(i, 1, currentPlayer())) currentMove++ })()) } }) } }) }) l(_, 'ol', _ => { for (let i = 0; i < history.length; i++) l(_, 'li', _ => { l(_, 'button', _ => { _.innerText = 'Go to ' + (i == 0 ? 'game start' : 'move #' + i) _.onclick = () => { sync([z], currentMove = i) } }) }) }) }) } }
Скачав код из репозитория вы можете убедиться сами, что он работает безупречно быстро.
Используйте LandTS, ставьте звездочки https://github.com/pain64/land-ts!
Также есть видео с подробным обзором https://www.youtube.com/watch?v=x-whF7gJhOI
Спасибо!