LandTS или новый ответ на старые вопросы о разработке фронтенда

Организованная разработка фронтенда должна отвечать на три вопроса:
как делать компоненты
как производить ререндер при изменении состояния
как работать с 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
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.