Привет всем любителям покопаться в технологиях фронтенда! В этой статье я расскажу про то, как можно встроить JSX в проект на ванильном TypeScript со сборщиком Vite. Материал будет интересен, если вы:

  • Работали с React, но не знаете, что у него под капотом.

  • Интересуетесь всей теорией, связанной с фронтендом.

  • Гик, создающий проекты на ванильном JS/TS.

Зачем? По большей части ради веселья! Вряд ли описанная идея может быть использована в реальных проектах без дополнительных переусложнений и создания нового фронтенд-фреймворка. Так что откройте в соседней вкладке GitHub-репозиторий с кодом проекта и устройтесь поудобнее. Впереди – глубокое погружение в JSX.

Что такое JSX?

JSX – это синтаксическая обертка над JS. Его нет в стандартах ECMAScript, так что инструменты вроде Babel и React занимаются его транспиляцией в обычный JavaScript код. Рассмотрим классический пример JSX:

const profile = (
  <div>
    <img src="avatar.png" className="profile" />
    <h3>{[user.firstName, user.lastName].join(" ")}</h3>
  </div>
);

После прогона @babel/plugin-transform-react-jsx, код превратится в уже понятный браузерам JS:

const profile = React.createElement(
  "div",
  null,
  React.createElement("img", { src: "avatar.png", className: "profile" }),
  React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
);

Как можно заметить, Babel успешно превратил JSX в аккуратную функцию React.createElement, состоящую из тега-обертки, его свойств (в примере – null) и дочерних элементов, которые тоже, в свою очередь, создаются с помощью этой функции.

Фреймворки React, Vue, Solid умеют обрабатывать JSX, но делают это по-разному. Все потому что у них разные реализации функции createElement, которую по-другому называют JSX Pragma. Узнав об этом, я сразу захотел создать свою прагму.

Парсинг JSX

Прежде чем прыгать в создание прагмы, нужно научиться парсить JSX. Для небольшого и современного проекта, где не требуется поддержка старых браузеров, не хочется использовать Babel. Благо развернуть легкий и быстрый проект всегда можно с Vite и TypeScript. Ну, или с чем-то одним.

Vite – современный сборщик проектов. Его особенность заключается в том, что, в отличие от Webpack, он поставляет исходный код через ES модули. Чтобы развернуть проект на Vite и TypeScript, нужно лишь выполнить команду:

npm create vite@latest

По умолчанию и Vite, и TypeScript, увидев файлы .jsx или .tsx, будут парсить JSX внутри них и подставлять функцию React.createElement. Чтобы направить их на кастомную функцию, нужно поменять настройки в tsconfig.json.

{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment"
  }
}

Или, если вы пишете проект без TypeScript, настройте vite.config.js.

import { defineConfig } from 'vite';

export default defineConfig({
  esbuild: {
    jsxFactory: 'h',
    jsxFragment: 'Fragment'
  }
});

Эти свойства скажут парсеру, что при обработке JSX должна использоваться функция h (от слова hyperscript, hypertext + javascript), если в JSX только один родительский элемент, и Fragment, если их несколько.

JSX Pragma

Настроив парсер на обработку функции h, попробуем создать ее в src/pragma.ts.

// Тег может быть как строкой, так и функцией – если парсим 
// функциональный компонент
type Tag = string | ((props: any, children: any[]) => JSX.Element);

// Атрибуты элемента – объект либо null
type Props = Record<string, string> | null;

// Дети элемента – возвращаемое значение функции h()
type Children = (Node | string)[];

export const h = (tag: Tag, props: Props, ...children: Children) => {
  // Если тег – компонент, вызываем его
  if (typeof tag === 'function') {
    return tag({ ... props }, children);
  }

  // Создаем html-элемент с переданными атрибутами
  const el = document.createElement(tag);
  if (props) {
    Object.entries(props).forEach(([key, val]) => {
      if (key === 'className') {
        el.classList.add(...(val as string || '').trim().split(' '));
        return;
      }

      el.setAttribute(key, val);
    });
  }

  // Добавляем дочерние элементы к родительскому
  children.forEach((child) => {
    el.append(child);
  });

  return el;
};

Функция h, как и createElement, принимает название тега (или функциональный компонент), атрибуты и результат выполнения функции h для дочерних элементов.

Все .jsx файлы должны импортировать функцию h, чтобы она была в зоне видимости кода после транспиляции. К примеру, создадим такой компонент:

import { h } from '../pragma';
import { LikeComponent } from './like';

export const App = (
  <main className="hello">
    <h1>
      Hello JSX!
    </h1>
    <LikeComponent big />
  </main>
);

Осталось лишь отобразить наше приложение в HTML:

import { App } from './components/app';

const app = document.querySelector<HTMLDivElement>('#app')!
app.append(App);

Готово! Теперь TypeScript парсит JSX, а прагма правильно формирует DOM для отображения простейшей верстки, разбитой по JSX-компонентам!

Практическое применение

Как было сказано в начале статьи, это приложение не предназначено для реальных проектов. Оно лишь показывает, как просто можно обрабатывать JSX без использования рантайм-библиотек, буквально работая в ванильном JS.

При этом данную концепцию довольно сложно будет масштабировать. Ведь, чтобы наделить функциональные компоненты логикой, в JSX прагме придется прописывать работу с переменными и обработчиками событий. Занимаясь подобным, вы скорее всего переизобретете реактивность.

Заключение

Как выяснилось, такой нестандартный концепт как JSX Pragma довольно легко можно собрать своими руками в домашних условиях. Я призываю вас экспериментировать со всеми технологиями, до которых доходят руки, углубляться в них.

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

Спасибо @illright за редактуру и код-ревью.

Комментарии (0)