Я не начинал с идеи написать «убийцу JavaScript». Честно говоря, такие заявления обычно только мешают думать.
Сначала была гораздо более скромная мысль: мне нравится Go, мне нравится его простота, типизация и ощущение предсказуемого инструмента. Но когда дело доходит до интерактивного веб‑интерфейса, почти неизбежно оказываешься в другой вселенной: JavaScript, Node.js, сборщики, плагины, отдельная модель компонентов, отдельная экосистема.
Это нормально. Веб таким стал не случайно. Но мне было интересно проверить другую гипотезу: можно ли писать заметную часть браузерного приложения на Go, не превращая интерфейс в кашу из ручной сборки дерева узлов?
Так появился мой небольшой экспериментальный проект GoFrame. Сейчас это не готовый фреймворк для продакшена, а исследование в сторону Go‑first подхода для браузера через WebAssembly: маленький рантайм, JSX‑подобная разметка .gox, компиляция этой разметки в обычный Go‑код и утилита goxc, которая способна сгенерировать, собрать и упаковать приложение. В README проекта это прямо описано как экспериментальный Go‑first фреймворк и набор инструментов для интерактивных браузерных приложений на WebAssembly; там же честно указано, что проект не заявляет готовность к продакшену, стабильный API 1.0, SSR, гидратацию, серверные API, форматтер или LSP.
В этой статье я не хочу пересказывать ридми. Интереснее другое: как маленькая идея «а что если сделать JSX для Go?» довольно быстро потянула за собой компилятор, компонентную идентичность, состояния, эффекты, маршрутизацию, проверки в браузере и даже бюджет размера WebAssembly‑бандла.
Я не начинал в вакууме
Было бы странно делать вид, что до этого никто не пытался писать фронтенд на Go.
Перед тем как лезть в реализацию, я смотрел на существующие подходы. Например, есть Vecty — проект, который позволяет писать динамические веб‑интерфейсы на Go через WebAssembly и прямо сравнивает себя с современными фреймворками вроде React и Vue. Его README говорит о компонентах, WebAssembly, небольших сборках и разделении Go‑кода между фронтендом и бэкендом; при этом сам проект долгое время позиционировался как экспериментальная работа в процессе, с меняющимися API и незакрытыми темами вроде маршрутизации и серверного рендера. Сейчас репозиторий Vecty архивирован и доступен только для чтения. (gh:Vecty)
Мне понравилась сама смелость идеи. Но не очень понравился стиль, в котором разметка собирается как Go‑дерево. Это честный и типобезопасный путь, но глазами он читается не как интерфейс, а как вручную собранное абстрактное синтаксическое дерево. Вроде всё правильно, но через несколько уровней вложенности начинаешь думать не о странице, а о скобках.
Потом я смотрел на templ и htmx. templ решает другую, очень практичную задачу: писать HTML‑компоненты на Go, компилировать их в Go‑код, использовать обычные if, switch и for, рендерить на сервере или в статические файлы, без обязательного JavaScript. (templ.guide) htmx идёт ещё с другой стороны: он расширяет HTML атрибутами вроде hx-get, hx-post, hx-put, hx-patch, hx-delete, позволяя делать запросы и обновлять части страницы прямо из разметки, обычно получая с сервера HTML‑фрагменты. (htmx.org)
Оба подхода мне нравятся. Но это не совсем то, что я хотел проверить.
templ и htmx ближе к серверному или гипермедийному стилю: сервер отдаёт HTML, клиент обновляет фрагменты, JavaScript можно сильно уменьшить или почти убрать. А меня интересовал другой вопрос: что если оставить компонентную модель, локальное состояние, обработчики событий, эффекты, контекст, маршрутизацию и ощущение «клиентского приложения», но попробовать выразить это на Go через WebAssembly?
На меня явно повлиял React. Не как объект для копирования один в один, а как набор идей: компоненты, свойства, локальное состояние, обновление интерфейса как функция от данных. Ещё мне всегда нравилась широта Next.js: файловая маршрутизация, разделение серверных и клиентских компонентов, серверный рендер, гидратация, понятная структура приложения. Например, в документации Next.js прямо описана файловая маршрутизация через папки и файлы, а также разделение серверных и клиентских компонентов, где JavaScript на клиенте используется для гидратации интерактивных частей. (nextjs.org)
Но именно здесь пришлось себя остановить.
Если начать pet‑проект с фразы «сейчас сделаю Next.js на Go», он почти наверняка умрёт под весом обещаний. Поэтому я начал с меньшего: браузерный слой на WebAssembly, маленький рантайм, JSX‑подобная разметка, компиляция в обычные Go‑вызовы и сборочная утилита, которая может довести пример до работающего статического набора файлов.
Где мне стало неудобно
На самом нижнем уровне интерфейс можно описывать обычными Go‑вызовами. Решение изящно в своей простоте: есть элемент, есть свойства, есть дочерние узлы.
Условно подобный счётчик можно написать так:
func App() gf.Node {count, setCount := gf.UseState(0)return gf.El("div", gf.Props{"class": "app"},gf.El("h1", nil,gf.Text("Counter: "),gf.Child(count),),gf.El("button", gf.Props{"onClick": func() {setCount(count + 1)},}, gf.Text("Increment")),)}
Это нормальный код на Go. Он явно показывает, что происходит. Более того, такие низкоуровневые функции действительно есть в GoFrame: gf.El создаёт элемент, gf.Text создаёт текстовый узел, gf.Fragment группирует дочерние узлы, gf.Key добавляет устойчивый ключ для сопоставления узлов при обновлениях.
Но как только интерфейс становится чуть больше счётчика, такая запись начинает мешать. Не потому что она «плохая», а потому что человек читает интерфейс не так. Глаз хочет видеть дерево страницы, а видит дерево вызовов.
Мне не хотелось устраивать раскопки в поисках интерфейса. Хотелось писать так:
func App() gf.Node {count, setCount := gf.UseState(0)return (<div class="app"><h1>Counter: {count}</h1><button onClick={func() {setCount(count + 1)}}>Increment</button></div>)}
Похожая реализация счётчика лежит в examples/counter: функция App берёт состояние через UseState, возвращает <div class="app">, показывает значение и увеличивает его по нажатию кнопки.
На первый взгляд, это просто вопрос синтаксиса. Было много скобок, стало меньше. Но это обманчивое ощущение. Как только разметка перестаёт быть строкой и начинает компилироваться в Go‑код, приходится решать гораздо больше вопросов.
Разметка — это не HTML‑строка
Мне не хотелось делать шаблонизатор, который превращает .gox в HTML‑строку. Идея была другой: пусть .gox будет маленьким синтаксисом внутри обычного Go‑файла, а результатом будут обычные вызовы рантайма.
Например, такая разметка:
<main class="page"><input type="text" /><p>Hello</p></main>
должна превращаться примерно в такой Go‑код:
gf.El("main", gf.Props{"class": "page",},gf.El("input", gf.Props{"type": "text"}),gf.El("p", nil, gf.Text("Hello")),)
Именно такая форма описана в документации GOX: теги с маленькой буквы становятся HTML‑элементами, атрибуты попадают в gf.Props, а события вроде onClick обрабатываются браузерной частью рантайма.
Здесь синтаксический сахар перестаёт быть «просто сахаром». Появляется лексер, парсер, дерево разбора, генератор Go‑кода и диагностика ошибок. Нужно понимать, где обычное Go‑выражение, где разметка, где текст, где один узел, где список узлов, а где примитивное значение, которое надо превратить в текст.
И главное: как только появляются компоненты, простой вызов функции уже не всегда означает то же самое, что компонент в интерфейсе.
Компонент — это не просто функция
В Go можно написать так:
Button(ButtonProps{Label: "Increment",OnClick: increment,})
И это будет обычный вызов функции. Но для пользовательского интерфейса этого мало.
Если компонент — просто функция, рантайм не получает отдельную границу компонента. А без такой границы трудно говорить о локальном состоянии, слотах состояний, эффектах, сохранении поддерева, сбросе при смене идентичности и независимом обновлении части интерфейса.
Поэтому запись:
<Button Label="Increment" OnClick={increment} />
генерирует не прямой вызов Button(...), а узел компонента:
var _goxComponent_app_Button = gf.NewComponentType("main.Button", "Button")gf.ComponentT(_goxComponent_app_Button, ButtonProps{Label: "Increment",OnClick: increment,}, Button)
В документации GOX это отличие сформулировано явно: прямой вызов Button(ButtonProps{...}) остаётся обычным Go‑вызовом, а тег <Button /> создаёт компонентную границу с явным токеном идентичности через gf.NewComponentType и gf.ComponentT.
Вот здесь проект начал превращаться из «разметки» в фреймворк. Потому что теперь надо понимать, где «тот же самый компонент», а где уже другой. Нужно не перепутать два компонента с одинаковым именем из разных пакетов. Нужно решить, когда состояние сохраняется, а когда сбрасывается.
И это не академическая проблема. Если идентичность компонента слабая, пользователь рано или поздно получит странное поведение: состояние живёт не там, где ожидалось, эффект не очищается вовремя, обработчик держит старые данные, а перестановка элементов ломает интерфейс не сразу, а через три клика после фильтрации списка.
Типы Go помогают, но требуют дисциплины
Одна из приятных сторон такого подхода — свойства компонента остаются обычной Go‑структурой:
type ButtonProps struct {Label stringOnClick func()}func Button(props ButtonProps) gf.Node {return (<button class="button" type="button" onClick={props.OnClick}>{props.Label}</button>)}
А использовать компонент можно коротко:
<Button Label="Increment" OnClick={func() {setCount(count + 1)}} />
В examples/components это видно на нескольких маленьких компонентах: Header, Button, Card. Там свойства — обычные Go‑структуры, дочерние узлы передаются через []gf.Node, а приложение собирается из типизированных компонентов.
Это хороший компромисс. Я не хочу тащить в .gox отдельную систему типов. Пусть Go остаётся Go: типы, функции, обработчики, управляющая логика и проверка несовместимых значений должны оставаться на стороне обычного компилятора Go.
Но за это приходится платить ограничениями языка разметки. Например, имя свойства компонента должно быть валидным именем поля Go‑структуры. Если разрешить произвольные имена в разметке, генератор может выплюнуть Go‑код, который потом развалится странной ошибкой. Значит, компилятор .gox должен поймать часть таких проблем раньше и показать нормальную диагностику на уровне исходной разметки.
Вот таких мелочей много. Каждая по отдельности не выглядит большой архитектурной темой. Но именно из них складывается ощущение, что инструмент либо помогает, либо постоянно царапает.
Почему это не htmx 2.0
Мне важно проговорить это отдельно: GoFrame не пытается быть htmx 2.0.
htmx хорош именно тем, что расширяет HTML и возвращает разработчика к модели «сервер отдаёт HTML, браузер обновляет нужный фрагмент». Кнопка с hx-post, цель обновления через hx-target, замена фрагмента через hx-swap — всё это очень прямолинейно и часто оказывается достаточным. В документации htmx прямо говорится, что библиотека позволяет обращаться к возможностям браузера из HTML, а на сервере обычно возвращать HTML, а не JSON. (htmx.org)
GoFrame делает другую ставку. Здесь в браузере живёт Go‑код, скомпилированный в WebAssembly. Компоненты имеют состояния, эффекты, контекст, ресурсы, маршрутизацию и сопоставление дерева при обновлениях. .gox не отправляется в браузер и не интерпретируется там; он компилируется в Go‑код заранее.
Это не делает GoFrame «лучше». Для многих задач htmx и templ проще, понятнее и практичнее. Если приложение хорошо ложится на серверный HTML и небольшие интерактивные фрагменты, я бы сам сначала посмотрел в их сторону.
GoFrame интересен в другой зоне: когда хочется проверить, можно ли получить клиентскую компонентную модель, но оставить большую часть прикладного кода на Go.
Почему размер внезапно становится архитектурой
WebAssembly быстро отрезвляет.
На сервере лишние десятки килобайт кода редко становятся темой разговора. В браузере все иначе. Особенно если ты приносишь с собой рантайм Go, прослойку для DOM, обработку событий, состояние, эффекты, маршрутизацию и всё остальное.
Поэтому в GoFrame довольно рано появился бюджет размера. Не как маркетинговая цифра «мы самые маленькие», а как защита от незаметного расползания.
В текущем базовом срезе TinyGo‑сборок, например, простой counter даёт около 83.5 KB сырого .wasm и около 28 KB после Brotli. Более насыщенный router-dashboard — около 225.6 KB сырого .wasm и около 74.4 KB после Brotli. Это снимок, а не обещание на все времена, но он помогает видеть цену изменений.
|
Пример |
Сырой WASM |
Сжатый Brotli |
|---|---|---|
|
counter |
~83.5 KB |
~28.0 KB |
|
components |
~89.2 KB |
~29.2 KB |
|
router‑dashboard |
~225.6 KB |
~74.4 KB |
Самое полезное в такой таблице не конкретная цифра. Полезна привычка спрашивать перед каждой новой возможностью: она действительно улучшает разработку настолько, чтобы стоить своих байтов?
Хочется добавить автоматическое сравнение свойств через отражение? Удобно, но может быть дорого. Хочется сделать умное сопоставление всех перестановок списков? Хорошо, но это больше кода и больше случаев для тестирования. Хочется богатую маршрутизацию? Отлично, но она быстро тянет за собой сохранение состояния между страницами, обработку ошибок, загрузку данных, историю браузера и требования к размещению приложения на сервере.
Так маленький вопрос «как записать разметку приятнее?» вытягивает за собой очень взрослые темы.
Counter ничего не доказывает
Первый работающий счётчик обманывает. Он создаёт ощущение, что проект почти готов: кнопка нажимается, число меняется, WebAssembly загружается.
На самом деле счётчик почти ничего не доказывает.
Для интерфейсного рантайма важнее другие вопросы. Сохраняется ли ввод в поле при обновлении соседнего узла? Не текут ли обработчики событий? Не растёт ли DOM при переключении фильтров туда‑сюда? Не выполнится ли старый отложенный обработчик после сброса очереди обновлений? Не перезапишет ли устаревший ответ ресурса более свежие данные?
Поэтому в проекте появились браузерные проверки. Они смотрят не только на простые примеры, но и на список задач, отладку повторяющихся ключей, ошибки рантайма, границы ошибок, панель с таблицей, контекст, виртуализированные списки, маршрутизацию, справочную панель с фильтрами, формы и ресурсные состояния.
Отдельно проверяется давление на DOM в более тяжёлом примере с таблицей. В одном из базовых прогонов логических строк было 300, но одновременно смонтированных строк оставалось не больше 28; при циклах переключения live DOM оставался стабильным, а чистого роста обработчиков событий не было.
Это всё ещё не делает проект готовым для продакшена. Но это меняет характер эксперимента. Он перестаёт держаться на фразе «у меня локально работает» и начинает держаться на проверяемых инвариантах.
Где подход проигрывает
Самый простой способ испортить такую статью — сделать вид, что минусов нет.
Минусы есть.
Во‑первых, свой синтаксис — это долг. Даже маленький. Как только появляется .gox, появляются ошибки разбора, сообщения об ошибках, спорные места грамматики, форматирование, подсветка, автодополнение, интеграция с редактором. Нельзя просто сказать «это как JSX, только для Go» и уйти пить чай.
Во‑вторых, WebAssembly в браузере — это не бесплатный билет. Нужно грузить .wasm, нужно моститься к DOM, нужно думать о размере, старте, отладке и поведении в разных окружениях. Для многих задач серверный HTML с htmx или templ будет проще и в разы дешевле.
В‑третьих, GoFrame сейчас не является полным веб‑фреймворком. Есть браузерный слой, есть сборка, есть примеры, есть простой server-backed пример, где приложение раздаётся обычным net/http и общается с API того же происхождения. В README этот пример так и описан: браузерное WebAssembly‑приложение, раздаваемое обычным Go net/http, с API на том же источнике. Но это именно пример интеграции, а не заявление «мы сделали Next.js на Go».
В‑четвёртых, часть ранней работы, особенно вокруг лексера, парсера и генерации кода, была сделана с помощью ИИ. Я не хочу это скрывать. Для меня это не повод меньше проверять код, а наоборот: повод делать больше маленьких тестов, больше примеров, больше ревью, меньше самоуверенных обещаний.
В‑пятых, экосистема важна. React, Next.js, htmx, templ и другие инструменты ценны не только кодом, но и документацией, привычками, редакторной поддержкой, чужими ошибками, уже найденными до тебя. Маленький проект не может честно конкурировать с этим «в лоб».
Зачем тогда всё это делать?
Потому что мне интересна не война с JavaScript, а удобство разработки.
Мне нравится мысль, что небольшое веб‑приложение может быть написано одним человеком почти целиком на Go. Модель данных, обработчики, часть интерфейса, сборка, тесты, маленький сервер — всё в одном языке и в одной экосистеме.
Но между этой мыслью и настоящим полным фреймворком огромная дистанция. Поэтому текущая проверяемая часть GoFrame гораздо уже: браузерный рантайм на WebAssembly, .gox как компилируемая разметка, goxc как сборочная утилита, несколько примеров и набор проверок.
Мне не хочется обещать SSR, гидратацию, файловую маршрутизацию и полноценную серверную модель просто потому, что эти идеи мне нравятся. Нравятся — да. Но проект должен сначала доказать более простые вещи: что компоненты читаются, что состояние живёт предсказуемо, что сборка не ломает структуру Go‑проекта, что размер не расползается, что ошибки можно диагностировать, что примеры можно запустить без шаманства.
Сейчас для меня GoFrame — это не ответ, а вопрос:
можно ли сделать Go‑first путь для небольших интерактивных веб‑приложений таким, чтобы он был понятным, читаемым и не стыдился своих ограничений?
Что я хотел получить в итоге
Если убрать весь технический шум, моя цель довольно простая.
Я хочу писать интерфейс так, чтобы он выглядел как интерфейс:
<Card Title="Counter"><p>Current value: {count}</p><Button Label="Increment" OnClick={func() {setCount(count + 1)}} /></Card>
Но при этом не терять Go:
type CardProps struct {Title stringChildren []gf.Node}type ButtonProps struct {Label stringOnClick func()}
Я хочу, чтобы компилятор занимался превращением разметки в дерево узлов. Чтобы рантайм занимался жизненным циклом компонентов. Чтобы Go отвечал за типы и обычную логику. Чтобы сборка оставалась скучной и воспроизводимой. И чтобы в любой момент можно было открыть сгенерированный код и понять, что на самом деле происходит.
Может оказаться, что этот путь слишком дорогой. Может оказаться, что размер WebAssembly, отладка и редакторная поддержка убьют идею раньше, чем она станет практически удобной. Может оказаться, что htmx и templ закрывают 80% задач проще и честнее.
Но именно это и интересно проверить.
Если будете смотреть, я бы начинал не с внутренностей рантайма, а с маленьких примеров: examples/counter, examples/components, потом examples/router-dashboard. Так проще понять, какой опыт разработки вообще пытается получиться.
Мне особенно интересна не оценка «нравится/не нравится», а инженерная критика: где такая архитектура выглядит хрупкой, какой риск я недооцениваю, и на каком месте вы бы сами остановились и сказали: «дальше это уже не окупается».
ссылка на оригинал статьи https://habr.com/ru/articles/1057508/