2021-09-21

Experimentos tentando fazer pre-rendering de React em Clojure(script)

Por cerca de três meses eu estive meio fascinado com a ideia de fazer pre-render das minhas aplicações react. Tudo isso começou quando comecei a desenvolver minha aplicação brundij e percebi que não conseguiria obter boas performances sem usar técnicas de pre-rendering, o que, por fim, me levou a ler bastante coisa sobre o assunto.

Eu desenvolvo aplicações React por algum tempo, mas nunca precisei construir qualquer mecânismo de pre-rendering sem frameworks como Gatsby ou Next, então fazer o pre-render "na mão" foi um desafio para mim. Acabei decidindo escrever esse post falando sobre todas as coisas que tentei e o que penso sobre cada uma dessas. Esse post não deve ser visto como um tutorial: tudo aqui é altamente experimental.

Enquanto escrevia, percebi que este post ficaria bem longo, então também criei um repositório com o código necessário para seguir o tutorial. Você pode checá-lo aqui.

Pré-render de aplicações React

Pre-rendering em aplicações React é comumente alcançado utilizando frameworks como Gatsby e Next.js. Essas ferramentas/frameworks compartilham uma feature: elas tornam possível gerar o HTML estático das páginas da aplicação enquanto se faz o build. (Next.js também torna possível gerar esse conteúdo a cada request, usando server-side rendering).

O que acontece para ambas as estratégias (static site generation e server-side rendering) é que as telas/componentes React são renderizadas para uma string que depois se conecta a seus event handlers. Isso significa que o client baixa um HTML estático pré-renderizado e, com o HTML montado, o javascript da página (React) conecta os devidos event handlers. Isso é feito usando ReactDomServer.renderToString() e React.hydrate().

Pré-render em Clojurescript

Eu gosto de escrever minhas aplicações usando Clojurescript e ainda sim gostaria de poder fazer o pré-render delas. O problema disso é que os frameworks supracitados não funcionam bem com Clojuresccript, como apontado pelo Thomas Heller nesse post.

Isso me levou a pensar em como eu poderia fazer o pré-rendering das minhas aplicações Reagent/re-frame sem precisar subir um servidor Node. Comecei então a procurar por conteúdos sobre o assunto, que me levaram a alguns achados:

Esses links me ajudarem a entender algumas coisas e tentar alguns setups diferentes, que descreverei aqui.

Primeiro setup: usando GraalVM e Polyglot

Esse setup é altamente baseado no post linkado acima, React Server Side Rendering with GraalVM for Clojure, mas decidi mudar alguns detalhes para que isso se adaptasse melhor ao meu workflow:

  • Ao invés de usar a versão customizada de Clojurescript usada pelo Nextjournal, optei por usar shadow-cljs e sua versão de Clojurescript.
  • Eu gostaria de usar re-frame para controlar o estado da aplicação.Para conseguir isso, precisei procurar um pouco e realizar alguns setups meio "hackish" que não são tão recomendados. Os passos para fazer isso são:

Criar nosso arquivo deps.edn

;;deps.edn
{:deps {org.clojure/clojure {:mvn/version "1.10.3"}
        reagent/reagent {:mvn/version "1.1.0"}
        thheller/shadow-cljs {:mvn/version "2.15.10"}
        re-frame/re-frame {:mvn/version "1.2.0"}
        org.clojure/core.async {:mvn/version "1.3.618"}}

 :paths ["src" "dev"]

 :aliases {:cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"]
                  :paths ["src" "dev"]}

           :repl {:extra-deps {nrepl/nrepl {:mvn/version "0.8.3"}
                               cider/cider-nrepl {:mvn/version "0.26.0"}}
                  :extra-paths ["public/assets"]
                  :main-opts ["-m" "nrepl.cmdline"
                              "--interactive"
                              "--middleware"
                              "[cider.nrepl/cider-middleware]"]}}}

Configurar o shadow-cljs

;;shadow-cljs.edn
{:nrepl {:port 8777}

 :deps true

 :dev-http
 {8280 "public"}

 :builds
   {:app {:target :graaljs
          :output-to "public/assets/graal.js"
          :entries [app.component]
          :jvm-opts ["-Xmx4G"]
          :modules
          {:app {:init-fn [app.component/countinghtml]}}}}}

Se compilarmos o código usando a configuração acima e tentarmos usar qualquer função assíncrona do javascript como setTimeout ou setInterval receberemos uma mensagem de erro dizendo que async ainda não é suportado pelo target graaljs. Como visto nessa issue do repositório do shadow-cljs, os "shims" necessários para que Clojurescript rodasse as funções async no target graaljs foram removidos. Para resolver isso, precisaremos fazer com que o shadow-cljs faça um prepend de nosso código com os shims e delete as definições de função não suportada. Isso pode ser feito adicionando a key prepend-js apontando para esse arquivo em nosso módulo app e criando um build hook

;;shadow-cljs.edn
{...
 :builds
   {:app {...
          :modules
            {:app {:init-fn [app.component/countinghtml]}
           :prepend-js "./graal-bootstrap.js"}
          :build-hooks [(util.clean/hook)]}}}

Hook:

;;src/util/clean.clj
(ns util.clean
  (:require [clojure.java.io :as io]
            [clojure.string :as string]))

(defn hook
  {:shadow.build/stage :flush}
  [build-state & args]
  (let [original (slurp (io/file "public/assets/graal.js"))
        start (- (string/index-of original "function graaljs_async_not_supported()") 0)
        to-replace (subs original start (+ start 664))]
    (spit "public/assets/clean.js"
          (-> original
              (string/replace to-replace " "))))
  build-state)

O build hook acima cria uma cópia do código buildado pelo shadow e remove todas as definições async_not_supported do código, fazendo com que nossas chamadas a essas funções rodem as funções dos "shims" ao invés de causar erros. Com isso feito, é hora de configurar o Polyglot e nosso primeiro componente:

(ns app.render
  (:require [clojure.java.io :as io])
  (:import (org.graalvm.polyglot Context Source Engine)
           (org.graalvm.polyglot.proxy ProxyArray ProxyObject)))

(defn serialize-arg [arg]
  (cond
    (keyword? arg)
      (name arg)

    (symbol? arg)
      (name arg)

    (map? arg)
      (ProxyObject/fromMap (into {} (map (fn [[k v]]
                                           [(serialize-arg k) (serialize-arg v)])
                                         arg)))

    (coll? arg)
      (ProxyArray/fromArray (into-array Object (map serialize-arg arg)))

    :else
      arg))

(defn execute-fn [context fn & args]
  (let [fn-ref (.eval context "js" fn)
        argsv (into-array Object (map serialize-arg args))]
    (assert (.canExecute fn-ref) (str "cannot execute " fn))
    (.execute fn-ref argsv)))

(defn template-html-2 [react-data]
  (str "<html>
        <head>
       <meta charset=\"utf-8\">
         <script src=\"/assets/clean.js\">
         </script>
        </head>
        <body>
        <div id=\"root\">"
       react-data
       "</div>
       </body>
       <script>
        app.component.countinghydrate();
       </script>
       </html>"))

(defn context-build []
  (let [engine (Engine/create)]
    (doto (Context/newBuilder (into-array String ["js"]))
      (.engine engine)
      (.allowExperimentalOptions true)
      (.option "js.experimental-foreign-object-prototype" "true")
      (.option "js.timer-resolution" "1")
      (.option "js.java-package-globals" "false")
      (.out System/out)
      (.err System/err)
      (.allowAllAccess true)
      (.allowNativeAccess true))))

(def build-page
  (memoize
    (fn [ctx js-file fun arg]
      (let [context (.build ctx)
            app-s (-> js-file
                      (io/file)
                      (#(.build (Source/newBuilder "js" %))))]
        (.eval context app-s)
        (.asString (execute-fn context fun arg))))))

ps.: a função serialize-arg foi encontrada aqui

O componente que utilizaremos para fazer pré-rendering:

(ns app.component
  (:require [app.events :as events]
            [app.subs :as subs]
            [re-frame.core :as re-frame]
            ["react-dom" :as react-dom]
            [reagent.core :as reagent]
            [reagent.dom.server :as dom-server]))

(defn counting-component []
  (let [click-2 (reagent/atom 0)
        name (re-frame/subscribe [::subs/name])
        users (re-frame/subscribe [::subs/users])]
    (fn []
      [:div
       "The atom " [:code "click-count"] " has value: "
       @click-2 ". "
       [:p "Name has value " @name]
       [:p "Users:"
        (for [user @users]
          ^{:key (:id user)}
          [:p (:first_name user)])]
       [:p "Test 2"]
       [:input {:type "button" :value "Click me!"
                :on-click #(swap! click-2 inc)}]
       [:button {:on-click #(re-frame/dispatch [::events/change-name "teste"])}
        "Change name"]
       [:button {:on-click #(re-frame/dispatch [::events/fetch])}
        "Fetch users"]])))

(defn wrapped-counter []
  (re-frame/dispatch-sync [::events/init-db])
  (fn []
    [counting-component]))

(defn countinghtml []
  (dom-server/render-to-string [wrapped-counter]))

(defn ^:export countinghydrate []
  (let [cb (.getElementById js/document "root")]
    (react-dom/hydrate (reagent/as-element [wrapped-counter])
                       cb)))

Por agora só usaremos event handlers mockados.

Existem duas maneiras de usar esse setup:

  • Com server-side rendering (como visto no post do NextJournal)
  • Gerando HTML estático no build e hidratando as páginas no client.

Server-side rendering

Iremos rodar a função app.render em cada request que nossa aplicação receber. Para montar um servidor base irei usar reitit, ring e jetty.

metosin/reitit {:mvn/version "0.5.5"}
ring/ring {:mvn/version "1.8.1"}

Criando um handler/router base:

(ns app.server
  (:require [app.render :as renderer]
            [reitit.dev.pretty :as pretty]
            [reitit.ring :as ring]
            [reitit.ring.middleware.exception :as exception]
            [ring.adapter.jetty :as jetty]))

(def router-options {:exception pretty/exception
                     :middleware [exception/exception-middleware]})

(defn server []
  (ring/ring-handler
    (ring/router
      [""
       ["/assets/*" (ring/create-resource-handler {:root "."})]
       ["/" {:get (fn [_]
                    {:body
                       (-> (renderer/build-page
                             (renderer/context-build)
                             "public/assets/clean.js"
                             "app.component.countinghtml"
                             {})
                           (renderer/template-html-2))})}]])
    router-options))

(defn run-server []
  (jetty/run-jetty (server) {:port 4000 :join? false}))

(comment
  (run-server))

Agora rodaremos clj -M:cljs watch app e navegaremos para http://localhost:4000 no browser, que mostrará para nós a aplicação pré-renderizada. Pronto, fizemos o server-side rendering usando Graal.

Gerando HTMLs estáticos

Também é possivel usar o Graal para gerar HTML estáticos de nossa aplicação durante o build. Esses arquivos HTMLs então podem ser hidratados pelo browser. Para fazer isso, podemos simplesmente modificar nosso build-hook para que ele gere HTML usando o graal e salve esses arquivos no nosso diretório public

(ns util.clean
  (:require [app.render :as renderer]
            [clojure.java.io :as io]
            [clojure.string :as string]))

(defn hook
  {:shadow.build/stage :flush}
  [build-state & args]
  (let [original (slurp (io/file "public/assets/graal.js"))
        start (- (string/index-of original "function graaljs_async_not_supported()") 0)
        to-replace (subs original start (+ start 664))]
    (spit "public/assets/clean.js"
          (-> original
              (string/replace to-replace " "))))
  (let [html-to-output (-> (renderer/build-page
                             (renderer/context-build)
                             "public/assets/clean.js"
                             "app.component.countinghtml"
                             {})
                           (renderer/template-html-2))]
    (spit "public/prerendered.html" html-to-output))
  build-state)

Rodar clj -M:cljs watch app e navegar até http://localhost:8280/prerendered.html deve nos mostrar nossa tela pré-renderizada.

Requests HTTP no client

Eu optei por não mostrar o código dos event handlers do re-frame de propósito. Você pode ter percebido que existe um evento chamado fetch. Esse evento deveria inciar um request HTTP, que muito provavelmente seria feito usando re-frame-http-fx em uma SPA clojurescript comum.

O problema é: quando usando o target graaljs será impossível usar cljs-ajax, que é a biblioteca por trás das requests do re-frame-http-fx: no target graaljs não temos acesso à XMLHTTPRequest, que é usado por essas bibliotecas. Para contornar esse problema, criei uma biblioteca que "envelopa" a biblioteca cljs-http em eventos/efeitos do re-frame. Vamos adicioná-la a nossas dependências:

cljs-http/cljs-http {:mvn/version "0.1.46"}
org.clojars.arthurbarroso/re-frame-cljs-http {:mvn/version "0.1.0"}

Com as dependências instaladas, vamos mudar/criar nosso event handler fetch

(ns app.events
  (:require [re-frame-cljs-http.http-fx]
            [re-frame.core :as re-frame]))

(re-frame/reg-event-fx
  ::init-db
  (fn [_ _]
    {:db
       {:name "app"
        :loading false
        :error nil
        :users []}}))

(re-frame/reg-event-db
  ::change-name
  (fn [db [_ v]]
    (assoc db :name v)))

(re-frame/reg-event-db
  ::success
  (fn [db [_ result]]
    (assoc db :users (-> result :body :data))))

(re-frame/reg-event-db
  ::failure
  (fn [db [_ result]]
    (assoc db :users [] :http-failure true :http-error result)))

(re-frame/reg-event-fx
  ::fetch
  (fn [cofx [_ _]]
    {:db (assoc (:db cofx) :b true)
     :http-cljs {:method :get
                 :url "https://reqres.in/api/users?page=2"
                 :params {:testing true}
                 :timeout 8000
                 :on-success [::success]
                 :on-failure [::failure]}}))

Agora, ao acessar a aplicação e clicar o botão "Fetch users" deve adicionar os resultados da request HTTP à lista de usuários.

Segundo setup: Chrome headless com Etaoin

Não escreverei muito deste setup aqui. O post escrito por Joel Sánchez cobre bem o assunto. Esse setup é de longe um dos mais fáceis de se fazer funcionar. Você pode fazer como o post dele sugere (usando um esquema de server-side rendering da sua aplicação) ou usá-lo para gerar HTMLs estáticos.

Terceiro setup: criando scripts de pré-render usando shadow-cljs

Outra abordagem possível é criar um projeto shadow que rode duas builds separadas: uma que tenha como target o browser e outra que tenha como target um script node. Esse script node será o responsável por gerar o HTML pré-renderizado.

Usarei a mesma codebase dos setups anteriores para facilitar o setup. Para configurar isso, adicionremos dois novos builds a nosso shadow-cljs.edn e criaremos um novo arquivo de código

{:nrepl {:port 8777}

 :deps true

 :dev-http
 {8280 "public"}

 :builds
   {:app {:target :graaljs
          :output-to "public/assets/graal.js"
          :entries [app.component]
          :jvm-opts ["-Xmx4G"]
          :modules
          {:app {:init-fn [app.component/countinghtml]
                 :prepend-js "./graal-bootstrap.js"}}
          :build-hooks [(util.clean-static/static-hook)]}
     :browser {:target     :browser
               :output-dir "public/assets/js"
               :asset-path "/js"

               :jvm-opts ["-Xmx6G"]
               :module-loader true

               :modules
               {:shared {}
                :counting {:entries [app.component
                                     app.events
                                     app.subs
                                     app.render-server
                                     app.render-client]
                           :depends-on #{:shared}}}}

     :pre-render {:target :node-script
                  :main app.render-server/main-to-html
                  :output-to "public/prerenderscript.js"}}}

(ns app.render-server
  (:require [app.component :refer [counting-component]]
            [app.events :as events]
            [clojure.string :as string]
            [re-frame.core :as re-frame]
            [reagent.core :as r]
            [reagent.dom.server :as dom-server]
            ["react-dom" :as react-dom]
            ["fs" :as fs]))

(defn ^:export main-hydrate []
  (re-frame/dispatch-sync [::events/init-db])
  (let [cb (.getElementById js/document "root")]
    (react-dom/hydrate (r/as-element [counting-component])
                       cb)))

(defn main-to-html []
  (re-frame/dispatch-sync [::events/init-db])
  (let [html-base "
<html>
  <head>
    <meta charset=\"utf-8\">
    <script src=\"/assets/js/shared.js\"></script>
    <script src=\"/assets/js/counting.js\"></script>
  </head>
  <body>
    <div id=\"root\">${{html-string}}</div>
  </body>
  <script>app.render_server.main_hydrate();</script>
</html>"
        pre-rendered-view (dom-server/render-to-string [counting-component])
        final (clojure.string/replace html-base "${{html-string}}" pre-rendered-view)]
    (fs/writeFileSync "public/counting-view.html" final)))

A build browser cria a build de browser. Essa build é importante pois torna possível importar o javascript necessário para nossa página (existem outras maneiras de fazer isso, mas você provavelmente consegue descobrí-las sozinho). A build pre-render usa as funções do nosso novo namespace para criar um script node que usa fs para criar o HTML pré-renderizado.

Com isso configurado, é hora de rodar clj -M:cljs compile pre-render, depois clj -M:cljs watch app e depois acessar http://localhost:8280/couting-view.html.

Quarto setup: usando nbb

nbb é uma ferramenta para scripting ad-hoc em clojurescript. Ela permite que usemos CLojurescript para rodar scripts em Node.js.

Para pré-renderizarmos a aplicação com nbb, primeiro iremos modificar nosso counting-component para que ele aceite uma prop initial-data. Falaremos mais sobre essa prop em breve.

(defn counting-component [initial-data]
  (let [click-2 (reagent/atom 0)
        name (re-frame/subscribe [::subs/name])
        users (re-frame/subscribe [::subs/users])]
    (fn []
      [:div
       "The atom " [:code "click-count"] " has value: "
       @click-2 ". "
       [:p "Name has value " (if @name @name (:name initial-data))]
       [:p "Users:"
        (for [user (if @users @users (:users initial-data))]
          ^{:key (:id user)}
          [:p (:first_name user)])]
       [:p "Test 2"]
       [:input {:type "button" :value "Click me!"
                :on-click #(swap! click-2 inc)}]
       [:button {:on-click #(re-frame/dispatch [::events/change-name "teste"])}
        "Change name"]
       [:button {:on-click #(re-frame/dispatch [::events/fetch])}
        "Fetch users"]])))

Iremos então modificar nossa função app.render-server/main-to-html para que ela também aceite dados (initial-data)

(defn ^:export main-hydrate []
  (re-frame/dispatch-sync [::events/init-db])
  (let [cb (.getElementById js/document "root")]
    (react-dom/hydrate (r/as-element [counting-component])
                       cb)))

(defn main-to-html [initial-data]
  (re-frame/dispatch-sync [::events/init-db])
  (let [html-base "
<html>
  <head>
    <meta charset=\"utf-8\">
    <script src=\"/assets/js/shared.js\"></script>
    <script src=\"/assets/js/counting.js\"></script>
  </head>
  <body>
    <div id=\"root\">${{html-string}}</div>
  </body>
  <script>app.render_server.main_hydrate();</script>
</html>"
        pre-rendered-view (dom-server/render-to-string [counting-component initial-data])
        final (clojure.string/replace html-base "${{html-string}}" pre-rendered-view)]
    (fs/writeFileSync "public/counting-view.html" final)))

Agora, por fim, criaremos nosso script no root do projeto:

(ns re-frame.core)

(defn reg-event-db [& _args])
(defn reg-event-fx [& _args])
(defn reg-sub [& _args])
(defn subscribe [& _args] (atom false))
(defn dispatch [& _args])
(defn dispatch-sync [& _args])

(ns re-frame-cljs-http.http-fx)

(ns cljs-http)

(ns render
  (:require [app.render-server :refer [main-to-html]]))

(defn render-server []
  (main-to-html {:name "app" :users []}))

(println (render-server))

Você deve ter percebido algo estranho aqui: estamos redefinindo o namespace re-frame.core - isso é necessário pois o nbb ainda não suporta o re-frame, então o mockamos. Essa também a razão por precisarmos passar um initial-data para nossos componentes. Nosso initial-data deve ser igual ao conteúdo que será inicializado no db do re-frame para que o React não aponte erros de diferença entre o conteúdo renderizado e o conteúdo hidratado. Como nosso componente não está dentro do arquivo do script, precisamos passar nosso classpath ao script e rodá-lo:

classpath="$(clojure -A:nbb -Spath -Sdeps '{}')"
nbb --classpath "$classpath" script.cljs

Agora deve ser possível rodar clj -M:cljs watch browser e visitar http://localhost:8280/counting-view.html para checar a página pré-renderizada.

Concluindo

Foi bem legal testar todas essas coisas: acabei aprendendo mais sobre React, Clojurescript e a web.

Gostaria de salientar que provavelmente existem outros setups melhores e mais seguros por aí, mas, como eu disse, eu queria testar e fazer as coisas sozinho. Se você precisa de algo pronto para a produção talvez faça mais sentido pesquisar no slack clojurians.

Se você gostaria de testar qualquer um dos setups listados neste post, recomendo que leve em conta as seguintes coisas: