Ключевая особенность нового JS-бэкенда GHC — возможность создавать колбэки из JavaScript в Haskell-код. Это открывает дорогу разработке полноценных браузерных приложений, позволяя реагировать на действия пользователя прямо из Haskell. Фактически это означает, что теперь мы можем писать интерфейсы пользователя на Haskell и компилировать их непосредственно в JavaScript.
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| foreign import javascript unsafe
"""
((f) => {
var node = document.createElement("button");
node.textContent = "Click me!";
node.onclick = () => {
node.textContent = `Clicked ${f()} times`;
};
document.body.appendChild(node);
})
"""
install_handler :: Callback (IO JSVal) -> IO ()
main :: IO ()
main = do
ref <- newIORef 0
let incRef = toJSInt <$> (modifyIORef' ref (+1) *> readIORef ref)
syncCallback' incRef >>= install_handler |
|
В этом простом примере мы создаём счетчик кликов, который увеличивается при каждом нажатии на кнопку. Обратите внимание на ключевой момент: колбэк, созданный в Haskell, передается в JavaScript-функцию. При этом сохраняется доступ к переменным Haskell-среды (замыкание над IORef ), что обеспечивает корректное обновление счетчика.
До недавнего времени GHCJS — форк GHC, специализированный для компиляции Haskell в JavaScript — был основным инструментом для этого подхода. Однако его использование часто вызывало трудности: отдельный форк требует дополнительного сопровождения, застрял на версии GHC 8.10 и зачастую требует сложной настройки (обычно через Nix). Ситуация изменилась с появлением полноценного JavaScript-бэкенда в основной ветке GHC. Теперь мы можем использовать все преимущества современного Haskell непосредственно в браузере, причём всё работает "из коробки" без сложных настроек и дополнительных зависимостей.
В этой статье мы рассмотрим, как использовать JavaScript-бэкенд GHC для интеграции с существующими JavaScript-библиотеками, создании полноценных браузерных приложений, и как справиться с типичными проблемами, возникающими в процессе.
Технические основы
Чтобы понимать, как работает интеграция JavaScript и Haskell, важно разобраться в механизмах компиляции и взаимодействия между этими языками. Современный GHC JavaScript бэкенд представляет собой технически сложное, но элегантное решение.
Механизм компиляции Haskell в JavaScript
GHC при использовании JavaScript-бэкенда компилирует Haskell-код не напрямую в JavaScript, а через промежуточное представление Cmm (C-минус-минус). Затем Cmm транслируется в код WebAssembly, который, в свою очередь, конвертируется в JavaScript с помощью Emscripten. Такой подход позволяет повторно использовать значительную часть инфраструктуры GHC без необходимости писать новый кодогенератор специально для JavaScript.
Результатом компиляции становится несколько JavaScript-файлов, включающих скомпилированный код Haskell, среду выполнения GHC и код взаимодействия с JavaScript API. Ключевые файлы:
all.js — основной бандл, включающий ваш код, зависимости и две системы выполнения.
all.externs.js — файл, декларирующий внешние переменные для минификатора.
Этот подход отличается от того, как работает, например, PureScript, который генерирует более "человекочитаемый" JavaScript-код, близкий к тому, что написал бы разработчик вручную. GHC производит низкоуровневый код, ориентированный на производительность.
Haskell | 1
2
3
4
5
6
7
| -- Простая функция в Haskell
factorial :: Int -> Int
factorial 0 = 1
factorial n = n * factorial (n - 1)
-- FFI для экспорта функции в JavaScript
foreign export javascript "factorial" factorial :: Int -> Int |
|
Скомпилированный код будет сложнее, но позволит вызвать функцию factorial непосредственно из JavaScript.
Механизмы FFI для JavaScript
Foreign Function Interface (FFI) в GHC JavaScript бэкенде реализует двустороннее взаимодействие:
1. Экспорт Haskell-функций в JavaScript — позволяет вызывать Haskell-код из JavaScript.
2. Импорт JavaScript-функций в Haskell — позволяет вызывать JavaScript-код из Haskell.
Для импорта JavaScript-функций используются конструкции вида:
Haskell | 1
2
| foreign import javascript unsafe "Math.sqrt"
js_sqrt :: Double -> Double |
|
Ключевое слово unsafe указывает, что импортируемая функция не блокирует поток выполнения Haskell и не выполняет никаких побочных действий с точки зрения памяти GHC. Существует также вариант safe , который обеспечивает дополнительные гарантии, но с небольшими накладными расходами. Для более сложных случаев можно импортировать анонимные функции JavaScript:
Haskell | 1
2
3
4
5
6
7
| foreign import javascript unsafe
"new Date().getTime()"
js_currentTime :: IO Double
foreign import javascript unsafe
"document.getElementById($1).textContent = $2"
js_setElementText :: JSString -> JSString -> IO () |
|
Здесь $1 , $2 и т.д. — это позиционные параметры, которые будут заменены аргументами функции при вызове. Наиболее важное нововведение в GHC 9.8 — поддержка колбэков из JavaScript в Haskell:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
| -- Функция для создания колбэка
syncCallback' :: IO JSVal -> IO (Callback (IO JSVal))
-- Импорт JavaScript-функции, принимающей колбэк
foreign import javascript unsafe
"document.getElementById($1).addEventListener('click', function() { $2(); })"
js_addClickListener :: JSString -> Callback (IO ()) -> IO ()
-- Использование
setupButton :: JSString -> IO () -> IO ()
setupButton buttonId action = do
callback <- syncCallback' (action *> pure jsNull)
js_addClickListener buttonId callback |
|
Этот механизм позволяет Haskell-коду реагировать на события DOM, что критично для создания интерактивных веб-приложений.
Типы данных и конвертация
Взаимодействие между Haskell и JavaScript требует преобразования типов данных. GHC JavaScript бэкенд предоставляет ряд примитивов для этого:
JSVal — непрозрачный тип для JavaScript-значений,
JSString — тип для JavaScript-строк,
toJSInt , fromJSInt — функции для конвертации чисел,
toJSString , fromJSString — функции для конвертации строк.
Haskell | 1
2
3
4
5
6
7
8
9
10
| -- Пример конвертации типов
updateCounter :: IORef Int -> IO JSVal
updateCounter ref = do
count <- readIORef ref
modifyIORef' ref (+1)
return $ toJSInt count
foreign import javascript unsafe
"console.log('Count: ' + $1)"
js_logCount :: JSVal -> IO () |
|
Для сложных типов необходимо использовать JSON или другие сериализационные форматы:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
| import Data.Aeson (encode, decode, FromJSON, ToJSON)
data User = User { name :: String, age :: Int }
deriving (Show, Generic, ToJSON, FromJSON)
-- Передача объекта в JavaScript
foreign import javascript unsafe
"renderUser(JSON.parse($1))"
js_renderUser :: JSString -> IO ()
displayUser :: User -> IO ()
displayUser user =
js_renderUser $ toJSString $ Text.unpack $ decodeUtf8 $ encode user |
|
Оптимизация сгенерированного кода
Важный аспект работы с GHC JavaScript бэкендом — оптимизация сгенерированного кода. По умолчанию сгенерированный JavaScript может быть довольно объёмным. Например, код в примере достигал 1.8 МБ до оптимизации. Для минимизации размера сгенерированного кода можно использовать Google Closure Compiler:
Bash | 1
2
3
4
5
6
| npx google-closure-compiler --language_in UNSTABLE \
--compilation_level ADVANCED_OPTIMIZATIONS \
--warning_level QUIET --isolation_mode IIFE \
--assume_function_wrapper --emit_use_strict \
--js all.js --js all.externs.js \
--js_output_file index.js |
|
Этот подход значительно уменьшает размер — с 1.8 МБ до 396 КБ в примере из статьи. После сжатия с помощью brotli размер уменьшается ещё больше — до 76 КБ.
Для более современных проектов можно использовать swc — Rust-реализацию компилятора JavaScript, которая обеспечивает более глубокую интеграцию с современными инструментами сборки.
Конфигурация .swcrc может выглядеть примерно так:
JSON | 1
2
3
4
5
6
7
8
9
10
| {
"minify": true,
"jsc": {
"minify": {
"compress": true,
"mangle": true,
"sourceMap": false
}
}
} |
|
При этом для webpack можно использовать swc-loader :
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| module: {
rules: [
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: "swc-loader"
}
}
]
} |
|
Несмотря на то, что размер бандла при использовании swc может быть немного больше (844 КБ против 803 КБ с Google Closure Compiler), этот подход проще интегрировать с современными инструментами сборки и не требует дополнительных обходных путей для работы с зависимостями от npm.
Интеграция с инструментами сборки JavaScript
Одна из сложностей при разработке на Haskell для веба - это интеграция с инструментами сборки экосистемы JavaScript, которые нередко превышают по сложности сам JavaScript-код. Это особенно очевидно, когда мы начинаем использовать популярные инструменты вроде Parcel, Webpack или Rollup. Начнём с простого эксперимента: попробуем использовать Parcel, один из самых простых бандлеров, который известен своими разумными настройками по умолчанию и минимальной или нулевой конфигурацией.
Bash | 1
| npx parcel dev/index.html |
|
Казалось бы, должно работать? К сожалению, мы сразу сталкиваемся с ошибкой:
Bash | 1
2
3
| Uncaught Error: process.binding is not supported
at process.binding (browser.js:177:11)
at Object.<anonymous> (index.js:144:368) |
|
Что произошло? После некоторого расследования оказывается, что Parcel оборачивает модули в "заголовок", который предоставляет реализацию require . Это сбивает с толку код ghc-internal , который пытается определить, в какой среде он запущен. Заметив доступность require , он решает, что работает в Node.js, и вызывает process.binding , который не полифиллирован. У этой проблемы есть обходное решение - компилировать GHC специальным образом:
Bash | 1
2
3
| CONF_CC_OPTS_STAGE2="-sENVIRONMENT=web" emconfigure ./configure \
--target=javascript-unknown-ghcjs \
--with-js-cpp-flags="-DGHCJS_BROWSER -E -CC -Wno-unicode -nostdinc" |
|
Это указывает компилятору исключить весь код проверки среды и использовать только веб-части среды выполнения Emscripten. Однако такой подход сложный в использовании и поддержке, особенно учитывая, что это опция времени компиляции GHC, а не времени линковки. Но есть лучшее решение - Webpack! В отличие от Parcel, Webpack позволяет нам более тонко контролировать, что именно полифиллируется:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // config/webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './dev/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, '../dist'),
},
resolve: {
fallback: {
os: false,
fs: false,
child_process: false,
path: false,
}
},
plugins:
[ new HtmlWebpackPlugin({
title: 'Blog'
})]
}; |
|
В этой конфигурации мы явно указываем, что не нужно полифиллить модули Node.js, которые не нужны в браузерной среде. Такой подход работает прекрасно и не требует сложных изменений в самом GHC.
Управление памятью и сборка мусора
При работе с Haskell в браузере важно понимать, как работает управление памятью. JavaScript и Haskell имеют разные подходы к управлению памятью и сборке мусора. В Haskell для этого используется своя система, основанная на поколенческой сборке мусора, тогда как JavaScript имеет свою встроенную сборку мусора. Когда мы компилируем Haskell в JavaScript с помощью GHC JS бекэнда, изначальная система управления памятью Haskell должна быть реализована поверх JavaScript. Это создаёт несколько слоев сборки мусора:
1. Сборщик мусора Haskell, который работает в эмулированной среде.
2. Нативный сборщик мусора JavaScript в браузере.
Это может привести к неожиданному поведению и утечкам памяти, если не учитывать особенности этого взаимодействия. Например, если объект JavaScript ссылается на данные Haskell, а Haskell ссылается обратно на JavaScript, может возникнуть циклическая ссылка, которую трудно освободить. На практике нужно придерживаться нескольких правил:
1. Избегать сохранения больших объёмов данных на границе между языками.
2. Освобождать ресурсы JavaScript явно, когда они больше не нужны.
3. Внимательно отслеживать жизненный цикл объектов, созданных через FFI.
Haskell | 1
2
3
4
5
6
7
8
9
10
11
| -- Пример корректного освобождения ресурсов
data JSResource = JSResource (Foreign JSResource)
foreign import javascript unsafe "createResource"
js_createResource :: IO JSResource
foreign import javascript unsafe "destroyResource"
js_destroyResource :: JSResource -> IO ()
withJSResource :: (JSResource -> IO a) -> IO a
withJSResource action = bracket js_createResource js_destroyResource action |
|
Асинхронное программирование и обработка событий
Веб-программирование глубоко асинхронно по своей природе, и это создаёт определённые сложности при работе с Haskell, который изначально проектировался с акцентом на чистых функциях и явной обработке эффектов. GHC JS бекэнд предлагает несколько подходов к асинхронному программированию:
1. Колбеки и обработчики событий
Наиболее простой подход — использование колбеков для обработки асинхронных событий. Мы уже видели пример с обработчиком клика:
Haskell | 1
2
3
4
5
6
| foreign import javascript interruptible
"setTimeout(function() { $c(null); }, $1);"
js_setTimeout :: Int -> IO ()
delay :: Int -> IO ()
delay ms = js_setTimeout ms |
|
Ключевое слово interruptible указывает, что функция может прервать поток выполнения Haskell и возобновить его позже. Переменная $c — это функция продолжения, которая будет вызвана, когда асинхронная операция завершится.
2. Промисы и асинхронные операции
Для работы с промисами в JavaScript мы можем использовать аналогичный подход:
Haskell | 1
2
3
4
5
6
7
8
9
| foreign import javascript interruptible
"fetch($1).then(response => response.json()).then($c);"
js_fetchJSON :: JSString -> IO JSVal
fetchUserData :: String -> IO (Maybe User)
fetchUserData userId = do
jsonVal <- js_fetchJSON (toJSString $ "/user/" ++ userId)
let jsonStr = fromJSString $ js_stringify jsonVal
return $ decode $ encodeUtf8 $ Text.pack jsonStr |
|
Здесь мы ожидаем завершения промиса, прежде чем продолжить выполнение кода Haskell.
3. Интеграция с моделью событий DOM
Для обработки событий DOM используется механизм колбеков:
Haskell | 1
2
3
4
5
6
7
8
| foreign import javascript unsafe
"document.getElementById($1).addEventListener($2, function(event) { $3(event); });"
js_addEventListener :: JSString -> JSString -> Callback (JSVal -> IO ()) -> IO ()
setupForm :: IO ()
setupForm = do
submitCallback <- syncCallback1' $ \_ -> handleSubmit
js_addEventListener (toJSString "loginForm") (toJSString "submit") submitCallback |
|
Стратегии взаимодействия с DOM
Прямое манипулирование DOM из Haskell возможно, но часто бывает сложным и неэффективным. Вместо этого можно использовать один из трёх основных подходов:
1. Виртуальный DOM
По аналогии с React и другими современными фреймворками, можно реализовать виртуальный DOM на Haskell. Затем изменения виртуального DOM будут эффективно применяться к реальному DOM. Примеры библиотек с таким подходом — haskell-halogen и miso .
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import Halogen.HTML as HH
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties as HP
render :: State -> HH.HTML Query
render state =
HH.div [HP.class_ (HH.ClassName "container")]
[ HH.h1_ [HH.text "Hello, World!"]
, HH.button
[ HP.class_ (HH.ClassName "btn")
, HE.onClick (HE.input_ Increment)
]
[ HH.text "Increment" ]
, HH.div_ [HH.text $ "Count: " <> show state.count]
] |
|
2. Компоненты с состоянием
Можно создавать компоненты, которые инкапсулируют своё состояние и логику. Этот подход похож на компонентную модель React или Vue.js:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| data State = State { counter :: Int, ripple :: Maybe MDCRipple }
data Action = Initialize | Finalize | Click
component :: Component Query Input Output m
component =
H.component
{ initialState: \_ -> { counter: 0, ripple: Nothing }
, render
, eval: H.mkEval $ H.defaultEval { handleAction = handleAction }
}
where
handleAction :: Action -> H.HalogenM State Action slots Output m Unit
handleAction = case _ of
Initialize -> do
-- Initialize component
Click -> do
H.modify_ \st -> st { counter = st.counter + 1 }
Finalize -> do
-- Cleanup resources |
|
3. Реактивное программирование
Третий подход — использование реактивного программирования с библиотеками вроде reflex или reactive-banana . Это позволяет декларативно описывать, как состояние приложения меняется в ответ на события:
Haskell | 1
2
3
4
5
6
7
8
| import Reflex
import Reflex.Dom
app :: MonadWidget t m => m ()
app = el "div" $ do
btn <- button "Click me"
count <- count (domEvent Click btn)
display count |
|
Простой обфускатор JavaScript кода на Haskell Здраствуйте.
Нуждаюсь в помощи в написании простого обфускатора для JS кода.
Обфускатор должен извлекать пробельные символы, комментарии. Имена... Место ФП и Haskell в компьютерной индустрии (Для чего он нужен, этот Haskell?) "У нас" ?
А где преподавание этой экзотики на высоте?
Добавлено через 2 минуты
А где такие "пришедшие" используют... Функции в haskell, адаптация из Ruby -> Haskell Добрый день, помогите адаптировать функциональный код с Ruby на Haskell.
Напишите функцию, строящую по заданному списку строк новый список, в... Help with Haskell 1) Зачем параметры у Note?
2) getByLetter :: -> Char -> String ->
Какие параметры у этой функции? Особенно интересует последний входной...
Пошаговая реализация взаимодействия
Теперь, когда мы разобрались с техническими аспектами, давайте перейдем к практической части — пошаговой реализации взаимодействия между Haskell и JavaScript. Мы рассмотрим настройку среды разработки и создадим простой, но показательный пример интеграции.
Настройка проекта
Первым шагом в работе с JavaScript-бэкендом GHC является настройка проекта. Вам понадобится GHC версии 9.8 или выше с поддержкой JavaScript-бэкенда. Установить его можно через ghcup:
Bash | 1
| ghcup install ghc 9.12.1 --flavor javascript-unknown-ghcjs |
|
После установки создадим базовую структуру проекта:
Bash | 1
2
3
| mkdir haskell-js-integration
cd haskell-js-integration
cabal init --minimal --non-interactive |
|
Далее отредактируем файл .cabal , добавив необходимые параметры:
Bash | 1
2
3
4
5
6
7
8
| executable haskell-js-app
main-is: Main.hs
build-depends: base ^>=4.17.0.0
hs-source-dirs: src
default-language: Haskell2010
js-sources: js/app.js
if impl(ghc >= 9.8)
ghc-options: -no-hs-main |
|
Обратите внимание на параметр js-sources , который указывает на JavaScript-файлы, которые будут включены в сборку. Флаг -no-hs-main указывает компилятору, что точка входа будет на стороне JavaScript.
Создадим базовую структуру файлов:
Bash | 1
2
| mkdir src js
touch src/Main.hs js/app.js |
|
Создание простой кнопки с счетчиком
Начнем с простого примера — кнопки, которая считает количество кликов. Сначала напишем JavaScript-часть:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // js/app.js
window.HaskellApp = {
createButton: function(initialText, onClick) {
const button = document.createElement('button');
button.textContent = initialText;
button.addEventListener('click', function() {
const newText = onClick();
button.textContent = newText;
});
document.body.appendChild(button);
return button;
}
}; |
|
Теперь напишем Haskell-код для взаимодействия с этой функцией:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| -- src/Main.hs
module Main where
import Data.IORef
import GHC.JS.Foreign
import GHC.JS.Prim
foreign import javascript unsafe
"window.HaskellApp.createButton($1, $2)"
js_createButton :: JSString -> Callback (IO JSString) -> IO JSVal
main :: IO ()
main = do
counterRef <- newIORef 0
-- Функция, которая будет вызываться при клике
let onClick = do
counter <- readIORef counterRef
let newCounter = counter + 1
writeIORef counterRef newCounter
return $ toJSString $ "Кликов: " ++ show newCounter
-- Создаем колбэк для JavaScript
callback <- syncCallback' onClick
-- Создаем кнопку с начальным текстом
js_createButton (toJSString "Нажми меня") callback
-- Блокируем выполнение main, чтобы программа не завершилась сразу
js_blockForever
where
foreign import javascript unsafe
"(function() { return new Promise(function() {}); })()"
js_blockForever :: IO () |
|
Мы создали простое приложение, где JavaScript отвечает за создание и отображение кнопки, а Haskell управляет логикой и состоянием. Когда пользователь кликает кнопку, вызывается колбэк, написанный на Haskell, который увеличивает счетчик и возвращает новый текст для кнопки. Соберем и запустим наше приложение:
После сборки вы получите JavaScript-файлы в каталоге dist-newstyle/build/js-unknown-ghcjs/ghc-9.xx/haskell-js-app-0.1.0.0/x/haskell-js-app/build/haskell-js-app/haskell-js-app.jsexe/ . Для запуска просто создайте HTML-файл, который загружает all.js :
HTML5 | 1
2
3
4
5
6
7
8
9
10
| <!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Haskell JS Integration</title>
</head>
<body>
<script src="all.js"></script>
</body>
</html> |
|
Интеграция с Material Design
Теперь перейдем к более сложному примеру — интеграции с библиотекой Material Design для создания стилизованной кнопки. Для этого нам понадобится установить несколько npm-пакетов:
Bash | 1
2
| npm init -y
npm install @material/button @material/ripple |
|
Создадим JavaScript-файл с функциями для работы с Material Design:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // js/material.js
import {MDCRipple} from '@material/ripple';
window.MaterialComponents = {
initRipple: function(element) {
return new MDCRipple(element);
},
destroyRipple: function(ripple) {
ripple.destroy();
}
}; |
|
Обновим файл .cabal , чтобы включить новый JavaScript-файл:
Haskell | 1
2
3
4
5
6
7
8
9
| executable haskell-js-app
main-is: Main.hs
other-modules: MaterialButton
build-depends: base ^>=4.17.0.0
hs-source-dirs: src
default-language: Haskell2010
js-sources: js/app.js js/material.js
if impl(ghc >= 9.8)
ghc-options: -no-hs-main |
|
Теперь создадим модуль для работы с Material-кнопкой:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
| -- src/MaterialButton.hs
module MaterialButton where
import GHC.JS.Foreign
import GHC.JS.Prim
import Data.IORef
newtype MDCRipple = MDCRipple (Foreign MDCRipple)
foreign import javascript unsafe
"window.MaterialComponents.initRipple($1)"
js_initRipple :: JSVal -> IO MDCRipple
foreign import javascript unsafe
"window.MaterialComponents.destroyRipple($1)"
js_destroyRipple :: MDCRipple -> IO ()
foreign import javascript unsafe
"document.createElement('button')"
js_createButtonElement :: IO JSVal
foreign import javascript unsafe
"$1.classList.add($2)"
js_addClassName :: JSVal -> JSString -> IO ()
foreign import javascript unsafe
"$1.textContent = $2"
js_setTextContent :: JSVal -> JSString -> IO ()
foreign import javascript unsafe
"$1.appendChild($2)"
js_appendChild :: JSVal -> JSVal -> IO ()
foreign import javascript unsafe
"document.body.appendChild($1)"
js_appendToBody :: JSVal -> IO ()
foreign import javascript unsafe
"$1.addEventListener('click', $2)"
js_addEventListener :: JSVal -> Callback (IO ()) -> IO ()
createMaterialButton :: String -> (Int -> String) -> IO () -> IO JSVal
createMaterialButton initialText formatText onClick = do
-- Создаем счетчик
counterRef <- newIORef 0
-- Создаем элементы DOM
button <- js_createButtonElement
js_addClassName button (toJSString "mdc-button")
js_addClassName button (toJSString "mdc-button--raised")
-- Создаем span для ripple эффекта
rippleSpan <- js_createButtonElement
js_addClassName rippleSpan (toJSString "mdc-button__ripple")
-- Создаем span для текста
textSpan <- js_createButtonElement
js_addClassName textSpan (toJSString "mdc-button__label")
js_setTextContent textSpan (toJSString initialText)
-- Собираем элементы вместе
js_appendChild button rippleSpan
js_appendChild button textSpan
js_appendToBody button
-- Инициализируем ripple эффект
ripple <- js_initRipple button
-- Создаем обработчик клика
let handleClick = do
count <- readIORef counterRef
let newCount = count + 1
writeIORef counterRef newCount
js_setTextContent textSpan (toJSString $ formatText newCount)
onClick
-- Подключаем обработчик события
callback <- syncCallback' handleClick
js_addEventListener button callback
return button |
|
Теперь обновим основной файл:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| -- src/Main.hs
module Main where
import GHC.JS.Foreign
import MaterialButton
main :: IO ()
main = do
-- Создаем Material-кнопку
_ <- createMaterialButton "Нажми меня" formatClickCount (pure ())
-- Блокируем выполнение main
js_blockForever
where
formatClickCount 0 = "Нажми меня!"
formatClickCount 1 = "Нажата 1 раз"
formatClickCount n = "Нажата " ++ show n ++ " раз"
foreign import javascript unsafe
"(function() { return new Promise(function() {}); })()"
js_blockForever :: IO () |
|
Сборка с использованием webpack
Чтобы корректно обрабатывать импорты из npm-пакетов и CSS, нам понадобится webpack. Создадим файл конфигурации:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| // webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: [
'./dist-newstyle/build/js-unknown-ghcjs/ghc-9.xx/haskell-js-app-0.1.0.0/x/haskell-js-app/build/haskell-js-app/haskell-js-app.jsexe/all.js',
'./styles/main.scss'
],
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
resolve: {
fallback: {
os: false,
fs: false,
child_process: false,
path: false,
}
},
module: {
rules: [
{
test: /\.s[ac]ss$/i,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.m?js$/,
exclude: /(node_modules)/,
use: {
loader: 'swc-loader'
}
}
],
},
plugins: [
new HtmlWebpackPlugin({
title: 'Haskell JS Integration'
})
]
}; |
|
Создадим SCSS-файл для стилей:
CSS | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // styles/main.scss
@use "@material/button/styles";
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
font-family: 'Roboto', sans-serif;
background-color: #f5f5f5;
}
.mdc-button {
margin: 10px;
} |
|
Установим необходимые npm-пакеты для сборки:
Bash | 1
| npm install --save-dev webpack webpack-cli html-webpack-plugin style-loader css-loader sass-loader sass @swc/core swc-loader |
|
Теперь мы можем собрать наше приложение:
Bash | 1
2
| cabal build # Компилируем Haskell-код
npx webpack # Собираем окончательный бандл |
|
Минимизация кода и его оптимизация
Оптимизация размера и производительности сгенерированного кода - одна из ключевых задач при разработке веб-приложений на Haskell. Размер бандла напрямую влияет на время загрузки страницы и общее впечатление пользователя.
Существует несколько подходов к оптимизации JavaScript-кода, получаемого из Haskell:
Bash | 1
2
3
4
5
6
7
8
| # Минификация с помощью google-closure-compiler
npx google-closure-compiler --language_in UNSTABLE \
--compilation_level ADVANCED_OPTIMIZATIONS \
--warning_level QUIET --isolation_mode IIFE \
--assume_function_wrapper --emit_use_strict \
--js dist-newstyle/build/.../all.js \
--js dist-newstyle/build/.../all.externs.js \
--js_output_file dist/index.js |
|
В моих экспериментах минификация уменьшила размер сгенерированного JavaScript с 1.8МБ до 396КБ. Дополнительное сжатие brotli уменьшило размер до 76КБ - уже гораздо более приемлемое значение для передачи по сети. Однако есть одна проблема при использовании google-closure-compiler - он не очень хорошо работает с современными практиками импорта из npm-пакетов. Например, при попытке импортировать модуль из npm-пакета в нашем JavaScript-коде:
JavaScript | 1
| import {MDCRipple} from '@material/ripple'; |
|
Компилятор выдаст ошибку:
Haskell | 1
| ERROR - [JSC_INVALID_MODULE_PATH] Invalid module path "@material/ripple" for resolution mode "BROWSER" |
|
Есть два пути решения этой проблемы:
1. Использование swc вместо google-closure-compiler .
2. Создание обходного пути через window и externs-файлы.
Первый вариант обычно предпочтительнее, так как он лучше интегрируется с современным инструментарием JS.
Работа с зависимостями от npm
При интеграции Haskell-кода с JavaScript-библиотеками мы часто сталкиваемся с необходимостью использовать пакеты из npm. В нашем примере с Material Design мы использовали @material/button и @material/ripple .
Чтобы упростить работу с npm-зависимостями, можно создать отдельный файл с экспортами, который будет служить мостом между Haskell и JavaScript:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // js/bridge.js
import {MDCRipple} from '@material/ripple';
import {MDCMenu} from '@material/menu';
import {MDCDialog} from '@material/dialog';
// Экспортируем все компоненты через глобальный объект
window.MaterialComponents = {
ripple: {
init: element => new MDCRipple(element),
destroy: ripple => ripple.destroy()
},
menu: {
init: element => new MDCMenu(element),
open: menu => menu.open = true,
close: menu => menu.open = false
},
dialog: {
init: element => new MDCDialog(element),
open: dialog => dialog.open(),
close: dialog => dialog.close()
}
}; |
|
Затем в Haskell мы можем обращаться к этим функциям:
Haskell | 1
2
3
4
5
6
7
| foreign import javascript unsafe
"window.MaterialComponents.ripple.init($1)"
js_initRipple :: JSVal -> IO MDCRipple
foreign import javascript unsafe
"window.MaterialComponents.ripple.destroy($1)"
js_destroyRipple :: MDCRipple -> IO () |
|
Такой подход делает интерфейс между Haskell и JavaScript более чистым и предсказуемым.
Использование паттерна "Компонент"
Чтобы сделать наш код более модульным и поддерживаемым, можно использовать паттерн "Компонент" - объект, который инкапсулирует внутреннее состояние и предоставляет методы для взаимодействия. Пример реализации компонента кнопки:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| data MaterialButton = MaterialButton
{ buttonElement :: JSVal
, ripple :: MDCRipple
, counter :: IORef Int
, callbacks :: [Callback (IO ())] -- Храним колбеки, чтобы они не были собраны сборщиком мусора
}
createButton :: String -> IO MaterialButton
createButton initialText = do
-- Создаем элементы DOM и настраиваем их
button <- js_createButtonElement
js_addClassName button (toJSString "mdc-button")
rippleElement <- js_createElement "span"
js_addClassName rippleElement (toJSString "mdc-button__ripple")
labelElement <- js_createElement "span"
js_addClassName labelElement (toJSString "mdc-button__label")
js_setTextContent labelElement (toJSString initialText)
-- Собираем структуру DOM
js_appendChild button rippleElement
js_appendChild button labelElement
-- Инициализируем состояние
ripple <- js_initRipple button
counter <- newIORef 0
-- Возвращаем компонент
return $ MaterialButton button ripple counter []
-- Метод добавления обработчика клика
addClickHandler :: MaterialButton -> (Int -> IO ()) -> IO MaterialButton
addClickHandler btn@MaterialButton{..} handler = do
callback <- syncCallback' $ do
count <- readIORef counter
let newCount = count + 1
writeIORef counter newCount
handler newCount
js_addEventListener buttonElement (toJSString "click") callback
return btn { callbacks = callback : callbacks }
-- Метод для добавления кнопки в DOM
appendToBody :: MaterialButton -> IO ()
appendToBody MaterialButton{..} = js_appendToBody buttonElement
-- Метод для уничтожения компонента и освобождения ресурсов
destroy :: MaterialButton -> IO ()
destroy MaterialButton{..} = do
js_destroyRipple ripple
-- Освобождаем колбеки
mapM_ js_releaseCallback callbacks
-- Удаляем элемент из DOM
js_removeFromDOM buttonElement
foreign import javascript unsafe
"($1).remove()"
js_removeFromDOM :: JSVal -> IO ()
foreign import javascript unsafe
"$1.release()"
js_releaseCallback :: Callback a -> IO () |
|
Использование этого компонента будет выглядеть так:
Haskell | 1
2
3
4
5
6
7
8
9
10
| main :: IO ()
main = do
btn <- createButton "Нажми меня"
>>= addClickHandler (\count ->
js_setTextContent (buttonText btn) (toJSString $ "Нажали " ++ show count ++ " раз"))
appendToBody btn
-- При необходимости можно уничтожить компонент
-- destroy btn |
|
Жизненный цикл компонентов
При разработке веб-приложений на Haskell важно учитывать жизненный цикл компонентов, особенно при работе с внешними JavaScript-библиотеками. Типичный жизненный цикл компонента включает следующие фазы:
1. Инициализация - создание DOM-элементов и начальная настройка.
2. Монтирование - добавление элементов в DOM.
3. Обновление - реакция на изменения состояния или входных данных.
4. Размонтирование - удаление из DOM и освобождение ресурсов.
Важно корректно обрабатывать каждую из этих фаз, особенно размонтирование, чтобы избежать утечек памяти. Многие JavaScript-библиотеки, включая Material Design Components, требуют явного вызова методов для освобождения ресурсов.
В нашем примере с Material Button мы реализовали метод destroy , который:
1. Останавливает ripple-эффект.
2. Освобождает колбеки, чтобы предотвратить утечки памяти.
3. Удаляет элемент из DOM.
Для более сложных компонентов может потребоваться более тщательное управление жизненным циклом, особенно если они имеют собственное внутреннее состояние или подписки на события.
Интеграция с библиотекой Halogen
Halogen — популярная библиотека для создания пользовательских интерфейсов в функциональном стиле. Изначально она была разработана для PureScript, но теперь портирована и для Haskell. Для использования Halogen в нашем проекте нужно добавить зависимость в файл .cabal :
Haskell | 1
2
3
| build-depends:
base ^>=4.17.0.0,
halogen |
|
Затем мы можем использовать Halogen для создания более сложных компонентов:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| import Halogen
import Halogen.HTML as HH
import Halogen.HTML.Properties as HP
import Halogen.HTML.Events as HE
import Halogen.HTML.Properties.ARIA as HPA
data Action = Initialize | Finalize | Click
data State = State { ripple :: Maybe MDCRipple, counter :: Int }
button :: Component q i o IO
button = mkComponent $ ComponentSpec
{ initialState = const $ State Nothing 0
, render
, eval = mkEval $ defaultEval
{ handleAction = handleAction
, initialize = Just Initialize
, finalize = Just Finalize
}
}
where
ref = RefLabel "mdc-button"
render State{counter} =
HH.div [HP.class_ (HH.ClassName "mdc-touch-target-wrapper")]
[ HH.button
[ HP.classes [ HH.ClassName "mdc-button"
, HH.ClassName "mdc-button--outlined"
, HH.ClassName "mdc-button--icon-leading"
]
, HE.onClick (const Click)
, HP.ref ref
]
[ HH.span [HP.class_ (HH.ClassName "mdc-button__ripple")] []
, HH.span [HP.class_ (HH.ClassName "mdc-button__touch")] []
, HH.i [HP.classes [HH.ClassName "material-icons", HH.ClassName "mdc-button__icon"], HPA.hidden "true"]
[HH.text "add"]
, HH.span [HP.class_ (HH.ClassName "mdc-button__label")]
[HH.text $ if counter == 0 then "Click me!" else "Clicked " ++ show counter ++ " times"]
]
] |
|
Примеры из практики
После освоения базовых принципов интеграции Haskell и JavaScript, давайте рассмотрим несколько более практических примеров. Эти примеры помогут увидеть, как применять полученные знания в реальных проектах.
Интерактивная форма с валидацией
Первый пример — создание формы с валидацией данных на стороне клиента. Здесь мы воспользуемся преимуществом сильной типизации Haskell для построения надежной системы валидации.
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
| -- Определяем типы данных для формы
data FormState = FormState
{ username :: String
, email :: String
, password :: String
, usernameError :: Maybe String
, emailError :: Maybe String
, passwordError :: Maybe String
}
-- Начальное состояние
initialState :: FormState
initialState = FormState
{ username = ""
, email = ""
, password = ""
, usernameError = Nothing
, emailError = Nothing
, passwordError = Nothing
}
-- Действия формы
data FormAction
= SetUsername String
| SetEmail String
| SetPassword String
| ValidateUsername
| ValidateEmail
| ValidatePassword
| SubmitForm
-- Валидаторы
validateUsername :: String -> Maybe String
validateUsername name
| length name < 3 = Just "Имя пользователя должно содержать минимум 3 символа"
| otherwise = Nothing
validateEmail :: String -> Maybe String
validateEmail email
| not (contains '@' email) = Just "Некорректный email адрес"
| otherwise = Nothing
where
contains c = elem c
validatePassword :: String -> Maybe String
validatePassword pwd
| length pwd < 8 = Just "Пароль должен содержать минимум 8 символов"
| not (any isUpper pwd) = Just "Пароль должен содержать хотя бы одну заглавную букву"
| not (any isDigit pwd) = Just "Пароль должен содержать хотя бы одну цифру"
| otherwise = Nothing
-- Компонент формы
formComponent :: Component FormQuery FormInput FormOutput IO
formComponent = mkComponent
{ initialState: const initialState
, render
, eval: mkEval $ defaultEval { handleAction = handleAction }
}
where
render :: FormState -> H.HTML FormAction
render state =
HH.form [ HE.onSubmit (\_ -> SubmitForm) ]
[ HH.div [ HP.class_ (HH.ClassName "form-group") ]
[ HH.label [ HP.for "username" ] [ HH.text "Имя пользователя" ]
, HH.input
[ HP.type_ HP.InputText
, HP.id "username"
, HP.value state.username
, HE.onValueInput SetUsername
, HE.onBlur (const ValidateUsername)
]
, renderError state.usernameError
]
, HH.div [ HP.class_ (HH.ClassName "form-group") ]
[ HH.label [ HP.for "email" ] [ HH.text "Email" ]
, HH.input
[ HP.type_ HP.InputEmail
, HP.id "email"
, HP.value state.email
, HE.onValueInput SetEmail
, HE.onBlur (const ValidateEmail)
]
, renderError state.emailError
]
, HH.div [ HP.class_ (HH.ClassName "form-group") ]
[ HH.label [ HP.for "password" ] [ HH.text "Пароль" ]
, HH.input
[ HP.type_ HP.InputPassword
, HP.id "password"
, HP.value state.password
, HE.onValueInput SetPassword
, HE.onBlur (const ValidatePassword)
]
, renderError state.passwordError
]
, HH.button
[ HP.type_ HP.ButtonSubmit
, HP.class_ (HH.ClassName "submit-button")
, HP.disabled (not (isFormValid state))
]
[ HH.text "Отправить" ]
]
renderError :: Maybe String -> H.HTML FormAction
renderError (Just err) = HH.div [ HP.class_ (HH.ClassName "error") ] [ HH.text err ]
renderError Nothing = HH.text ""
isFormValid :: FormState -> Boolean
isFormValid state =
isNothing state.usernameError &&
isNothing state.emailError &&
isNothing state.passwordError &&
not (null state.username) &&
not (null state.email) &&
not (null state.password)
handleAction :: FormAction -> H.HalogenM FormState FormAction () FormOutput IO Unit
handleAction = case _ of
SetUsername val ->
H.modify_ \st -> st { username = val }
SetEmail val ->
H.modify_ \st -> st { email = val }
SetPassword val ->
H.modify_ \st -> st { password = val }
ValidateUsername ->
H.modify_ \st -> st { usernameError = validateUsername st.username }
ValidateEmail ->
H.modify_ \st -> st { emailError = validateEmail st.email }
ValidatePassword ->
H.modify_ \st -> st { passwordError = validatePassword st.password }
SubmitForm -> do
state <- H.get
-- Здесь можно отправить данные на сервер
H.raise $ FormSubmitted state |
|
Примеры из практики
Теоретические знания и пошаговые инструкции — это хорошо, но настоящее понимание приходит через практический опыт. Рассмотрим несколько реальных примеров, которые демонстрируют, как GHC JS бэкенд может применяться в различных сценариях веб-разработки.
Создание диаграмм с D3.js
D3.js — мощная библиотека для создания интерактивных визуализаций данных. Совмещение её с типобезопасностью Haskell даёт интересные возможности. Реализуем простую круговую диаграмму. Сначала подключим D3 через npm:
Создадим мост между Haskell и D3:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| // js/d3-bridge.js
import * as d3 from 'd3';
window.D3Bridge = {
createPieChart: function(selector, data) {
const width = 450;
const height = 450;
const margin = 40;
const radius = Math.min(width, height) / 2 - margin;
// Удаляем существующую диаграмму, если есть
d3.select(selector).selectAll("*").remove();
const svg = d3.select(selector)
.append("svg")
.attr("width", width)
.attr("height", height)
.append("g")
.attr("transform", `translate(${width/2}, ${height/2})`);
const color = d3.scaleOrdinal()
.domain(data.map(d => d.name))
.range(d3.schemeCategory10);
const pie = d3.pie()
.value(d => d.value);
const data_ready = pie(data);
svg.selectAll('path')
.data(data_ready)
.enter()
.append('path')
.attr('d', d3.arc()
.innerRadius(0)
.outerRadius(radius)
)
.attr('fill', d => color(d.data.name))
.attr("stroke", "white")
.style("stroke-width", "2px")
.style("opacity", 0.7);
svg.selectAll('text')
.data(data_ready)
.enter()
.append('text')
.text(d => d.data.name)
.attr("transform", d => {
const pos = d3.arc().innerRadius(radius/2).outerRadius(radius).centroid(d);
return `translate(${pos})`;
})
.style("text-anchor", "middle")
.style("font-size", 15);
return true;
}
}; |
|
Теперь напишем Haskell-код для работы с этим мостом:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| -- src/D3Chart.hs
module D3Chart where
import GHC.JS.Foreign
import GHC.JS.Prim
import Data.Aeson (ToJSON, toJSON, encode)
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text as T
-- Модель данных для диаграммы
data ChartItem = ChartItem
{ name :: String
, value :: Double
} deriving (Show)
instance ToJSON ChartItem where
toJSON item = object
[ "name" .= name item
, "value" .= value item
]
-- FFI для создания диаграммы
foreign import javascript unsafe
"window.D3Bridge.createPieChart($1, JSON.parse($2))"
js_createPieChart :: JSString -> JSString -> IO JSVal
-- Обертка для создания диаграммы
createPieChart :: String -> [ChartItem] -> IO Bool
createPieChart selector items = do
let jsonData = T.unpack $ decodeUtf8 $ encode items
result <- js_createPieChart (toJSString selector) (toJSString jsonData)
return $ fromJSBool result
-- Пример использования
examplePieChart :: IO ()
examplePieChart = do
let data =
[ ChartItem "JavaScript" 67.8
, ChartItem "TypeScript" 25.4
, ChartItem "CoffeeScript" 6.8
, ChartItem "Haskell" 3.7
, ChartItem "Elm" 2.3
]
-- Создаем элемент для диаграммы
createDiv "#chart-container"
-- Отрисовываем диаграмму
success <- createPieChart "#chart-container" data
if success
then putStrLn "Диаграмма успешно отрисована"
else putStrLn "Ошибка при отрисовке диаграммы"
-- Вспомогательная функция для создания div
foreign import javascript unsafe
"document.body.innerHTML += '<div id=' + $1.substring(1) + ' style=\"width:500px;height:500px;\"></div>'"
createDiv :: JSString -> IO () |
|
Интеграция с Three.js для 3D-визуализации
Three.js — популярная библиотека для создания 3D-графики в веб-браузере. Интеграция её с Haskell позволяет создавать типобезопасные 3D-приложения. Настроим мост:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| // js/three-bridge.js
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
window.ThreeBridge = {
scene: null,
camera: null,
renderer: null,
cube: null,
controls: null,
init: function(containerId) {
// Удаляем существующий canvas, если есть
const container = document.getElementById(containerId);
if (!container) return false;
container.innerHTML = '';
// Создаем сцену
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf0f0f0);
// Создаем камеру
this.camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000);
this.camera.position.z = 5;
// Создаем рендерер
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
container.appendChild(this.renderer.domElement);
// Добавляем освещение
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(1, 1, 1);
this.scene.add(light);
// Орбитальные контролы для управления камерой
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
// Запускаем анимацию
this.animate();
return true;
},
addCube: function(x, y, z, size, color) {
const geometry = new THREE.BoxGeometry(size, size, size);
const material = new THREE.MeshStandardMaterial({ color: color });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(x, y, z);
this.scene.add(cube);
return cube.id; // Возвращаем ID для последующего управления
},
rotateCube: function(id, x, y, z) {
const cube = this.scene.getObjectById(id);
if (cube) {
cube.rotation.x += x;
cube.rotation.y += y;
cube.rotation.z += z;
return true;
}
return false;
},
animate: function() {
requestAnimationFrame(() => this.animate());
this.controls.update();
this.renderer.render(this.scene, this.camera);
}
}; |
|
Haskell-интерфейс для Three.js:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
| -- src/ThreeJS.hs
module ThreeJS where
import GHC.JS.Foreign
import GHC.JS.Prim
import Data.IORef
-- Типы для Three.js
newtype SceneId = SceneId JSVal
newtype ObjectId = ObjectId JSVal
-- FFI для Three.js
foreign import javascript unsafe
"window.ThreeBridge.init($1)"
js_initThree :: JSString -> IO JSVal
foreign import javascript unsafe
"window.ThreeBridge.addCube($1, $2, $3, $4, $5)"
js_addCube :: Double -> Double -> Double -> Double -> JSString -> IO JSVal
foreign import javascript unsafe
"window.ThreeBridge.rotateCube($1, $2, $3, $4)"
js_rotateCube :: JSVal -> Double -> Double -> Double -> IO JSVal
-- Обертки для типобезопасного использования
initThreeScene :: String -> IO Bool
initThreeScene containerId =
fromJSBool <$> js_initThree (toJSString containerId)
addCube :: Double -> Double -> Double -> Double -> String -> IO ObjectId
addCube x y z size color =
ObjectId <$> js_addCube x y z size (toJSString color)
rotateCube :: ObjectId -> Double -> Double -> Double -> IO Bool
rotateCube (ObjectId id) x y z =
fromJSBool <$> js_rotateCube id x y z
-- Интерактивный пример с вращающимся кубом
example3DScene :: IO ()
example3DScene = do
-- Создаем контейнер для сцены
createContainer "three-container"
-- Инициализируем Three.js
success <- initThreeScene "three-container"
if not success
then putStrLn "Ошибка инициализации Three.js"
else do
-- Добавляем куб
cubeId <- addCube 0 0 0 1 "#ff5533"
-- Запускаем анимацию с обновлением каждые 16мс (примерно 60fps)
animateCube cubeId
where
animateCube cubeId = do
-- Создаем ссылку для отслеживания времени
timeRef <- newIORef 0
-- Устанавливаем функцию-колбек для анимации
let animate = do
time <- readIORef timeRef
let newTime = time + 0.01
writeIORef timeRef newTime
-- Вращаем куб с разной скоростью по каждой оси
rotateCube cubeId 0.01 0.02 0.005
-- Запланировать следующий кадр
js_requestAnimationFrame animate
-- Запускаем анимацию
callback <- syncCallback' animate
js_requestAnimationFrame callback
foreign import javascript unsafe
"document.body.innerHTML += '<div id=\"' + $1 + '\" style=\"width:600px;height:400px;\"></div>'"
createContainer :: JSString -> IO ()
foreign import javascript unsafe
"requestAnimationFrame($1)"
js_requestAnimationFrame :: Callback (IO ()) -> IO () |
|
Создание SPA с маршрутизацией
Одним из важных аспектов современных веб-приложений является одностраничная архитектура (SPA) с клиентской маршрутизацией. Реализуем простой роутер для Haskell-приложения:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
| -- src/Router.hs
module Router where
import GHC.JS.Foreign
import GHC.JS.Prim
import Control.Monad (void)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- | Типы для маршрутизации
type RouteHandler = IO ()
type Routes = Map String RouteHandler
-- | FFI для работы с History API
foreign import javascript unsafe
"window.addEventListener('popstate', $1)"
js_addPopStateListener :: Callback (IO ()) -> IO ()
foreign import javascript unsafe
"window.history.pushState(null, '', $1)"
js_pushState :: JSString -> IO ()
foreign import javascript unsafe
"window.location.pathname"
js_getPathname :: IO JSString
-- | Тип роутера
data Router = Router
{ routes :: Routes
, notFoundHandler :: RouteHandler
, currentRoute :: String
}
-- | Создание нового роутера
newRouter :: RouteHandler -> IO Router
newRouter notFound = do
path <- fromJSString <$> js_getPathname
return $ Router
{ routes = Map.empty
, notFoundHandler = notFound
, currentRoute = path
}
-- | Добавление маршрута
addRoute :: Router -> String -> RouteHandler -> Router
addRoute router path handler =
router { routes = Map.insert path handler (routes router) }
-- | Запуск роутера
startRouter :: Router -> IO ()
startRouter router = do
-- Обрабатываем текущий маршрут
handleRoute router =<< fromJSString <$> js_getPathname
-- Устанавливаем слушателя для обработки навигации по истории
callback <- syncCallback' $ handleRoute router =<< fromJSString <$> js_getPathname
js_addPopStateListener callback
-- Перехватываем клики по ссылкам для SPA-навигации
installLinkInterceptor router
-- | Обработка маршрута
handleRoute :: Router -> String -> IO ()
handleRoute router path =
case Map.lookup path (routes router) of
Just handler -> handler
Nothing -> notFoundHandler router
-- | Программная навигация
navigateTo :: Router -> String -> IO ()
navigateTo router path = do
js_pushState (toJSString path)
handleRoute router path
-- | Перехват кликов по ссылкам для SPA-навигации
foreign import javascript unsafe
"""
(function(callback) {
document.body.addEventListener('click', function(e) {
if (e.target.tagName === 'A' &&
e.target.getAttribute('href').charAt(0) === '/' &&
!e.ctrlKey && !e.metaKey) {
e.preventDefault();
const path = e.target.getAttribute('href');
callback(path);
}
});
})
"""
js_installLinkInterceptor :: Callback (JSString -> IO ()) -> IO ()
installLinkInterceptor :: Router -> IO ()
installLinkInterceptor router = do
callback <- syncCallback1' $ \pathJS -> do
let path = fromJSString pathJS
navigateTo router path
js_installLinkInterceptor callback |
|
Пример использования роутера:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| -- Прример SPA с маршрутизацией
exampleSPA :: IO ()
exampleSPA = do
-- Создаем базовый HTML
createSPAContainer
-- Создаем роутер с обработчиком 404
router <- newRouter $ do
setContent "app-container" "<h1>Страница не найдена</h1><p>Вернитесь на <a href='/'>главную</a></p>"
-- Добавляем маршруты
let router' = router
[INLINE]addRoute[/INLINE] "/" $ do
setContent "app-container" "<h1>Главная страница</h1><p>Добро пожаловать в наше SPA приложение на Haskell!</p><ul><li><a href='/about'>О нас</a></li><li><a href='/contact'>Контакты</a></li></ul>"
[INLINE]addRoute[/INLINE] "/about" $ do
setContent "app-container" "<h1>О нас</h1><p>Мы пишем типобезопасные веб-приложения на Haskell!</p><p><a href='/'>На главную</a></p>"
[INLINE]addRoute[/INLINE] "/contact" $ do
setContent "app-container" "<h1>Контакты</h1><p>Напишите нам на [email protected]</p><p><a href='/'>На главную</a></p>"
-- Запускаем роутер
startRouter router'
-- Вспомогательные функции
foreign import javascript unsafe
"document.body.innerHTML = '<div id=\"app-container\"></div>'"
createSPAContainer :: IO ()
foreign import javascript unsafe
"document.getElementById($1).innerHTML = $2"
setContent :: JSString -> JSString -> IO () |
|
Интеграция с библиотекой анимаций GSAP
GSAP (GreenSock Animation Platform) — одна из самых производительных библиотек для создания анимаций в вебе. Рассмотрим пример её интеграции с Haskell:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // js/gsap-bridge.js
import { gsap } from "gsap";
window.GSAPBridge = {
animate: function(selector, duration, properties) {
return gsap.to(selector, {
duration: duration,
...JSON.parse(properties)
});
},
timeline: function() {
return gsap.timeline();
},
addToTimeline: function(timeline, selector, duration, properties) {
timeline.to(selector, {
duration: duration,
...JSON.parse(properties)
});
return timeline;
},
playTimeline: function(timeline) {
timeline.play();
return true;
}
}; |
|
Теперь Haskell-интерфейс:
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
| -- src/GSAP.hs
module GSAP where
import GHC.JS.Foreign
import GHC.JS.Prim
import Data.Aeson (ToJSON, toJSON, encode, object, (.=))
import Data.Text.Encoding (encodeUtf8)
import qualified Data.Text as T
-- Типы для GSAP
newtype Timeline = Timeline (Foreign Timeline)
newtype Animation = Animation (Foreign Animation)
-- Свойства анимации
data AnimProps = AnimProps
{ x :: Maybe Double
, y :: Maybe Double
, rotation :: Maybe Double
, opacity :: Maybe Double
, scale :: Maybe Double
, backgroundColor :: Maybe String
, ease :: Maybe String
, delay :: Maybe Double
, onComplete :: Maybe (IO ())
} deriving (Show)
instance ToJSON AnimProps where
toJSON props = object $ filter ((/= "null") . show . snd) $
[ "x" .= x props
, "y" .= y props
, "rotation" .= rotation props
, "opacity" .= opacity props
, "scale" .= scale props
, "backgroundColor" .= backgroundColor props
, "ease" .= ease props
, "delay" .= delay props
]
defaultProps :: AnimProps
defaultProps = AnimProps
{ x = Nothing
, y = Nothing
, rotation = Nothing
, opacity = Nothing
, scale = Nothing
, backgroundColor = Nothing
, ease = Nothing
, delay = Nothing
, onComplete = Nothing
}
-- FFI для GSAP
foreign import javascript unsafe
"window.GSAPBridge.animate($1, $2, $3)"
js_animate :: JSString -> Double -> JSString -> IO Animation
foreign import javascript unsafe
"window.GSAPBridge.timeline()"
js_timeline :: IO Timeline
foreign import javascript unsafe
"window.GSAPBridge.addToTimeline($1, $2, $3, $4)"
js_addToTimeline :: Timeline -> JSString -> Double -> JSString -> IO Timeline
foreign import javascript unsafe
"window.GSAPBridge.playTimeline($1)"
js_playTimeline :: Timeline -> IO JSVal
-- Обёртки для типобезопасного использования
animate :: String -> Double -> AnimProps -> IO Animation
animate selector duration props = do
let jsonProps = T.unpack $ decodeUtf8 $ encode props
-- Устанавливаем колбек onComplete, если он есть
case onComplete props of
Just callback -> do
cbRef <- syncCallback' callback
js_setOnComplete (toJSString jsonProps) cbRef
Nothing -> pure ()
Animation <$> js_animate (toJSString selector) duration (toJSString jsonProps)
-- Вспомогательная функция для установки колбека
foreign import javascript unsafe
"""
(function(propsJSON, callback) {
var props = JSON.parse(propsJSON);
props.onComplete = callback;
return JSON.stringify(props);
})
"""
js_setOnComplete :: JSString -> Callback (IO ()) -> IO JSString
createTimeline :: IO Timeline
createTimeline = js_timeline
addToTimeline :: Timeline -> String -> Double -> AnimProps -> IO Timeline
addToTimeline tl selector duration props = do
let jsonProps = T.unpack $ decodeUtf8 $ encode props
-- Обрабатываем колбек аналогично animate
propsWithCallback <- case onComplete props of
Just callback -> do
cbRef <- syncCallback' callback
js_setOnComplete (toJSString jsonProps) cbRef
Nothing -> pure $ toJSString jsonProps
js_addToTimeline tl (toJSString selector) duration propsWithCallback
playTimeline :: Timeline -> IO Bool
playTimeline tl = fromJSBool <$> js_playTimeline tl |
|
Сравнение подходов и альтернативные решения
Haskell для веб-разработки — не единственный вариант использования функциональных языков в браузере. Существует ряд альтернативных подходов, каждый со своими сильными и слабыми сторонами. Давайте сравним GHC JS бэкенд с другими популярными решениями.
Сравнение GHC JS бэкенда с PureScript
PureScript — это небольшой функциональный язык, вдохновленный Haskell, но изначально созданный для компиляции в JavaScript. Он имеет несколько существенных отличий от подхода GHC JS бэкенда:
Преимущества PureScript:
1. Легковесность: PureScript имеет значительно меньшую стандартную библиотеку и генерирует более компактный JavaScript.
2. Экосистема: Инструменты как spago (менеджер пакетов) и интеграция с инструментами сборки JavaScript лучше проработаны.
3. Генерация кода: PureScript создает более читаемый JavaScript-код, похожий на тот, что написал бы человек.
4. Скорость компиляции: Компилятор PureScript работает быстрее, чем GHC.
Преимущества GHC JS бэкенда:
1. Совместимость с экосистемой Haskell: Можно использовать тысячи существующих Haskell-библиотек.
2. Совместный код: Серверная и клиентская части могут использовать один и тот же код, скомпилированный для разных платформ.
3. Расширения языка: Доступны все расширения GHC, включая многострочные строки, шаблонный Haskell, generic-типизацию и другие.
4. Производительность: GHC имеет более мощные оптимизации на уровне компилятора.
Выбор между ними часто зависит от требований проекта. Если вам важны легкость и быстрая итерация, PureScript может быть лучшим выбором. Если же критична совместимость с экосистемой Haskell и общий кодбейс между сервером и клиентом, GHC JS бэкенд предпочтительнее.
Haskell | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| -- Пример компонента на PureScript
module Components.Counter where
import Prelude
import Halogen as H
import Halogen.HTML as HH
import Halogen.HTML.Events as HE
type State = { count :: Int }
data Action = Increment | Decrement
component :: forall q i o m. H.Component q i o m
component =
H.mkComponent
{ initialState: const { count: 0 }
, render
, eval: H.mkEval H.defaultEval { handleAction = handleAction }
}
where
render :: State -> H.ComponentHTML Action () m
render state =
HH.div_
[ HH.h1_ [ HH.text "Counter" ]
, HH.p_ [ HH.text (show state.count) ]
, HH.button [ HE.onClick (const Increment) ] [ HH.text "+" ]
, HH.button [ HE.onClick (const Decrement) ] [ HH.text "-" ]
]
handleAction :: Action -> H.HalogenM State Action () o m Unit
handleAction = case _ of
Increment -> H.modify_ \st -> st { count = st.count + 1 }
Decrement -> H.modify_ \st -> st { count = st.count - 1 } |
|
Сравнение с Elm
Elm — еще один функциональный язык, специально созданный для фронтенд-разработки. Он отличается от Haskell более строгим и минималистичным подходом.
Преимущества Elm:
1. Предсказуемость: Elm гарантирует отсутствие ошибок времени выполнения, включая знаменитые "undefined is not a function".
2. Архитектура: Четко определенная The Elm Architecture (TEA) направляет разработчиков к единообразной структуре приложения.
3. Дружелюбие к новичкам: Более простая система типов и ясные сообщения об ошибках делают вход ниже.
4. Маленький размер бандла: Компилятор Elm создает очень компактный JavaScript-код.
Ограничения Elm:
1. Строгая изоляция: Взаимодействие с JavaScript требует дополнительных усилий через порты или флаги.
2. Ограниченная экспрессивность: Отсутствуют многие абстракции высшего порядка, доступные в Haskell.
3. Небольшая экосистема: Меньше библиотек по сравнению с экосистемой JavaScript или Haskell.
Code | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| -- Пример компонента на Elm
module Main exposing (..)
import Browser
import Html exposing (Html, button, div, h1, p, text)
import Html.Events exposing (onClick)
-- MODEL
type alias Model = { count : Int }
init : Model
init = { count = 0 }
-- UPDATE
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment -> { model | count = model.count + 1 }
Decrement -> { model | count = model.count - 1 }
-- VIEW
view : Model -> Html Msg
view model =
div []
[ h1 [] [ text "Counter" ]
, p [] [ text (String.fromInt model.count) ]
, button [ onClick Increment ] [ text "+" ]
, button [ onClick Decrement ] [ text "-" ]
]
-- MAIN
main =
Browser.sandbox
{ init = init
, update = update
, view = view
} |
|
WebAssembly как альтернативный подход
WebAssembly (WASM) — бинарный формат инструкций, который можно запускать в современных браузерах. Он предлагает альтернативный путь компиляции Haskell для веба, минуя JavaScript.
Преимущества использования WASM:
1. Производительность: WASM выполняется быстрее JavaScript, особенно для вычислительно-интенсивных задач.
2. Размер: Бинарный формат часто компактнее эквивалентного JavaScript-кода.
3. Безопасность: WASM исполняется в песочнице с четкими ограничениями памяти и средствами защиты.
4. Совместимость: Поддерживается всеми современными браузерами.
Текущие ограничения WASM для Haskell:
1. Сборка мусора: WASM пока не имеет встроенной сборки мусора, что создает сложности для языков с автоматическим управлением памятью.
2. Интеграция с DOM: Взаимодействие с DOM необходимо организовывать через JavaScript, что требует дополнительных слоев абстракции.
3. Зрелость инструментов: Инструменты для компиляции Haskell в WASM находятся в ранней стадии разработки.
Текущие эксперименты с Haskell и WASM обычно используют Asterius — компилятор, который транслирует GHC Core в WebAssembly. Однако эти проекты пока еще не достигли уровня зрелости GHC JS бэкенда.
Haskell | 1
2
3
4
5
6
7
8
9
| -- Пример потенциального использования Haskell с WASM
foreign import wasm "math_functions" wasmSqrt :: Double -> Double
computeHypotenuse :: Double -> Double -> Double
computeHypotenuse a b = wasmSqrt (a*a + b*b)
-- WASM-функции могут быть значительно быстрее для вычислений
performHeavyCalculation :: [Double] -> [Double]
performHeavyCalculation = map (\x -> wasmSqrt x * sin x * cos x / log (x + 1)) |
|
HASKELL Добрый вечер, прошу помощи у знающих Haskell, не понимаю его, не для меня видимо, но сдать дисциплину надо, не хотелось бы вылететь с последнего... Haskell и указатели Здравствуйте! Я новенький в Haskell, заинтересовался возможностью взаимодействия Haskell с другими языками и застрял на том, как передать коду на С... Литература па Haskell Доброго времени суток! Решил изучить Haskell и столкнулся со следующей проблемой: для изучения функционального программирования необходимо знать... Haskell Tuples Добрый день, вообще не могу разобраться как сделать задачу.. Помогите пожалуйста используя unzip
Есть список из tuples:
нужно на выходе получить... Haskell return привет!
main:: String -> IO
main:: String -> IO()
main:: IO() Литература по Haskell Посоветуйте хорошую литературу (желательно на русском). Selenium+haskell Здравствуйте!
Появилось свободное время. Решил узнать, подойдет ли хаскелл для автотестов.
По логике моего автотеста, мне надо на странице... Template Haskell Кто в теме, можете дать пример объявления типов и классов типов с помощью цитирующих скобок, а то я что-то туплю. Haskell Monad При изучении Haskell, нашёл довольно интересное задание и пробую его выполнить, но возникли определённые проблемы. Суть задания состоит в том, что... Интеграл Haskell Всем доброго времени суток. Помогите, пожалуйста, написать программу на Haskell: Найти интеграл функции методом правых прямоугольников. Интеграл... Деревья в Haskell Здравствуйте! Пишу лабораторную и возникла проблема: не получается описать 2 функции. Вот мое задание: Лексические деревья (trie-деревья)... Сервер на Haskell Здравствуйте.
Захотелось написать небольшую серверную программу на Haskell. До этого писал только на C++ с использованием ст. библиотеки Си, а...
|