Документация Engee

Реактивность

Реактивность в приложениях Genie позволяет разработчикам создавать интерактивные и адаптивные пользовательские интерфейсы, которые обновляются автоматически при изменении базовых данных или взаимодействии пользователя с пользовательским интерфейсом. Это достигается за счет комбинации реактивных переменных, обработчиков и компонентов пользовательского интерфейса. На этой странице представлены основные концепции реактивности в приложениях Genie и объясняется, как вместе они формируют динамический пользовательский интерфейс.

Реактивные переменные, обработчики и компоненты пользовательского интерфейса

Реактивные переменные

Реактивные переменные служат для хранения состояния компонентов пользовательского интерфейса, чтобы бэкенд мог получать информацию об изменениях, вносимых во фронтенде, и наоборот. Реактивные переменные определяются внутри блока @app с помощью макросов @in и @out, которые означают следующее:

  • @in: эту переменную можно изменить в пользовательском интерфейсе, и ее изменение будет передано в бэкенд.

  • @out: эта переменная доступна только для чтения, и ее нельзя изменить из пользовательского интерфейса. Однако ее можно обновить в бэкенде, чтобы отразить изменения в данных.

Кроме того, существует макрос @private для определения реактивных переменных, которые не отображаются в пользовательском интерфейсе. Эти переменные будут копироваться в каждый сеанс пользователя, а внесенные в них изменения не будут передаваться в пользовательский интерфейс или другим пользователям. Тем не менее эти переменные могут активировать реактивный обработчик.

Реактивные переменные привязываются к компонентам пользовательского интерфейса для хранения информации об их состоянии, например числа, выбранного с помощью ползунка, или содержимого текстового поля. При каждом изменении содержимого такой переменной активируется обработчик. Например, компонент textfield можно привязать к переменной следующим образом:

using GenieFramework
@app begin
    @in msg = ""
end
ui() = textfield("Message", :msg )
@page("/", ui)

Каждый раз, когда пользователь вводит текст в поле в браузере, переменная msg будет обновляться в бэкенде.

Реактивные обработчики

Реактивные обработчики определяют код, который выполняется при изменении значения реактивной переменной. Такие обработчики определяются с помощью макроса @onchange или @onbutton и активируются при каждом изменении значения указанной реактивной переменной, будь то из фронтенда или из бэкенда.

@app begin
    @in msg = ""
    @out msg_length = 0
    @onchange N begin
        msg_length = length(msg)
    end
end
ui = [textfield("Message", :msg), p("Length: {{msg_length}}")]

Макрос @onbutton предназначен для отслеживания логических переменных и после выполнения обработчика присваивает им значение false.

    @in trigger = false
    @onbutton trigger begin
        print("Action triggered")
    end
ui = [btn("Trigger action", @click(:trigger))]

Реактивные компоненты пользовательского интерфейса

Реактивные переменные привязываются к компонентам пользовательского интерфейса для хранения информации об их состоянии, например числа, выбранного с помощью ползунка, или содержимого текстового поля. При каждом изменении содержимого такой переменной активируется обработчик. Например, textfield компонент можно привязать к реактивной переменной N из предыдущего примера:

textfield("N", :N )

Полученный код HTML включает атрибут v-model, который связывает поле ввода с реактивной переменной N:

<q-input label="N" v-model="N"></q-input>

Это гарантирует, что любое изменение значения поля ввода в браузере будет отражено в N в коде Julia и блок реактивного кода будет выполнен соответствующим образом. Аналогичным образом, любое изменение N в бэкенде будет приводить к обновлению поля ввода в браузере.

Определение реактивных переменных

Для определения новой реактивной переменной требуется начальное значение соответствующего типа. Например, в приведенном выше примере и N, и total имеют тип Int. Если в пользовательском интерфейсе для N будет введено значение типа Float, приложение выдаст ошибку. Более того, реактивную переменную нельзя использовать для инициализации другой переменной, как в следующем примере:

@in N = 0
@in M = N + 1

Этот код выдаст ошибку, так как N не существует.

Реактивные переменные можно изменять только внутри обработчика, реализованного с помощью макроса @onchange или @onbutton. Изменения, вносимые за пределами этого блока, не будут отражаться в пользовательском интерфейсе. Связано это с тем, что переменные находятся в блоке @app и их экземпляры создаются для каждого сеанса пользователя.

Наконец, переменные, объявленные внутри блока @onchange, имеют ограниченную область, то есть не перезаписывают глобальные переменные, а создают новые. Для изменения глобальной переменной необходимо использовать ключевое слово global:

N = 0
M = 0
@app begin
    @in toggle = false
    @onchange toggle begin
        global N = N + 1
        M = M+1 # Будет создана новая переменная M
    end
end

Типы переменных

Реактивные переменные сериализуются и отправляются в браузер как объекты JavaScript. Большинство базовых типов Julia, например Int, String, Vector{String}, Dict, можно объявить как реактивные. Более того, в пользовательском интерфейсе могут отображаться пользовательские определения структур, как в этом примере:

using GenieFramework
@genietools

mutable struct MyContent
    c::Int
end
mutable struct MyData
    description::String
    data::MyContent
end

@app begin
    @out d = MyData("hello", MyContent(1))
end

ui() = [p("{{d.description}}"),p("{{d.data}}"),p("{{d.data.c}}")]
@page("/", ui)
up()

Если какой-либо объект невозможно сериализовать, придется специализировать Stipple.render.

Рекурсивная реактивность

В общем случае составные объекты не являются рекурсивно реактивными. Это означает, что изменение одного из полей не всегда приводит к срабатыванию реактивного обработчика. Например, в случае со словарями изменение поля словаря в коде Julia не приведет к передаче нового значения в браузер. К срабатыванию обработчика и передаче изменений приведет только замена всего словаря или изменение поля в браузере.

Это поведение показано в примере ниже. Для изменения поля data в словаре есть три кнопки: одна запускает код в браузере, а две другие активируют обработчик в бэкенде. Реактивный обработчик, отслеживающий словарь d, срабатывает только при нажатии первой (фронтенд) и третьей (замена словаря) кнопок. Изменение поля из бэкенда с помощью второй кнопки увеличивает значение счетчика, но не активирует обработчик.

using GenieFramework
@genietools

@app begin
    @in d = Dict("description" => "hello", "data" => 1)
    @in change_field = false
    @in replace_dict = false
    @onchange d begin
        @show d
    end
    @onbutton change_field begin
        d["data"] += 1
    end
    @onbutton replace_dict begin
        d = Dict("description" => d["description"], "data" => d["data"] + 1)
    end
end

ui() = [p("{{d.data}}"), btn("Frontend +1 to field", @click("d.data += 1")), br(), btn("backend +1 to field", @click(:change_field)), br(), btn("backend replace dict", @click(:replace_dict))]
@page("/", ui)
up()

Внутреннее устройство: реактивные модели

Для работы реактивных моделей в них поддерживается внутреннее представление реактивных переменных и блоков кода. При определении реактивных переменных и блоков кода они сохраняются в словарях REACTIVE_STORAGE и HANDLERS модуля GenieFramework.Stipple.ReactiveTools. Например, хранилище для блока @app из предыдущего примера имеет следующее содержимое:

julia> GenieFramework.Stipple.ReactiveTools.REACTIVE_STORAGE[Main]
LittleDict{Symbol, Expr, Vector{Symbol}, Vector{Expr}} with 6 entries:
  :channel__    => :(channel__::String = Stipple.channelfactory())
  :modes__      => :(modes__::Stipple.LittleDict{Symbol, Any} = $(QuoteNode(LittleDict{Symbol, Any, Vector{Symbol}, Vector{Any}}())))
  :isready      => :(isready::Stipple.R{Bool} = false)
  :isprocessing => :(isprocessing::Stipple.R{Bool} = false)
  :N            => :(N::R{Int64} = R(0, 1, false, false, "REPL[2]:2"))
  :total        => :(total::R{Int64} = R(0, 4, false, false, "REPL[2]:3"))

julia> GenieFramework.Stipple.ReactiveTools.HANDLERS[Main]
1-element Vector{Expr}:
 quote
    #= /Users/pere/.julia/packages/Stipple/pgem3/src/ReactiveTools.jl:689 =#
    on(__model__.N) do N
        #= /Users/pere/.julia/packages/Stipple/pgem3/src/ReactiveTools.jl:690 =#
        #= REPL[2]:5 =#
        print("N value changed to $(N)")
        #= REPL[2]:6 =#
        __model__.total[] = __model__.total[] + N
    end
end

Когда пользователь отправляет HTTP-запрос к маршруту, в хранилище для данного сеанса пользователя создается новый экземпляр ReactiveModel. Это гарантирует наличие у каждого пользователя изолированного состояния и возможности взаимодействовать с приложением независимо от состояния других пользователей. Доступ к модели, созданной для запроса, можно получить с помощью @init при использовании функции route вместо @page:

route("/") do
    model = @init
    @show model
    page(model, ui()) |> html
end
var"##Main_ReactiveModel!#292"("OSINKNHRJHNKBFXCFZVKSWQVMWTUMUNN", LittleDict{Symbol, Any, Vector{Symbol},
Vector{Any}}(), Reactive{Bool}(Observable(false), 1, false, false, ""), Reactive{Bool}(Observable(false), 1, false, false, ""),
Reactive{Int64}(Observable(0), 1, false, false, "REPL[2]:2"), Reactive{Int64}(Observable(0), 4, false, false, "REPL[2]:3"))

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