After giving ParenScript another chance
I became hopeful of the possibilities
for front-end development in Common Lisp
. It is not the language it is the
tooling, but Lisp is a language that lets you develop your tooling faster. Could
I close the gap to the ClojureScript+Reagent+Re-frame
ecosystem? Well,
optimization is my favorite procrastination, so I might as well give it a try.
From that last post, I’ll take the function dom-el
as a minimalist version of
react+jsx
or reagent+hiccup
. It is nowhere near a fair comparison to those
projects, but it is just enough utility to transform a nested tree of data into
a tree of elements in the DOM
. That is a good start. Fixing speed issues is a
problem I’ll address when I get there. Until now, on a PC dumping the old DOM
tree and rendering a new one is fast enough. It might not be true for mobile
device or battery efficient.
Re-frame
is a framework to build user interface on the web. To me it is the
correct architecture to manage state, follow events and then react rendering a
new view. All I need to copy is the event loop to see how far I get with it.
Choosing to render a view directly from the app database, the is no need for
subscriptions. With that choice I can create the loop with two functions, the
event listener and the caller, here named send
1(defvar *app-db*)
2
3(defun mount (element view)
4 ((chain element add-event-listener)
5 "prelude:main"
6 (lambda (event)
7 (setf (inner-html element) "")
8 ((@ element append-child) (dom-el (funcall view *app-db*))))))
9
10(defun send (name value &optional (elem document))
11 (let ((event (new (-custom-event (+ "prelude:" name)
12 (create bubbles t
13 cancelable t
14 detail value)))))
15 (chain elem (dispatch-event event))))
Next are functions to register events. Events are the only allowed way to modify
the application database. An event, gets registered using reg-event
, and it
can modify the database and/or trigger some other side effect. reg-event-db
registers events which only modify the database is a convenience version of
reg-event
. Side effect events go into a separate registry use a different
registration function reg-fx
.
1(defvar *app-events* (create))
2
3(defun reg-event (name fn)
4 (setf (getprop *app-events* name) fn))
5
6(defun reg-event-db (name fn)
7 (setf (getprop *app-events* name)
8 (lambda (obj &rest args)
9 (apply fn (getprop obj 'db) args)
10 obj)))
11
12(defvar *app-effects* (create))
13
14(defun reg-fx (name fn)
15 (setf (getprop *app-effects* name) fn))
What brings everything together is the event trigger dispatch
.
1(defun dispatch (name &rest args)
2 (let ((handler (getprop *app-events* name)))
3 (logger :log "enter dispatch for" name "with args" args)
4 (if handler
5 (with-slots (db fx)
6 (apply handler (create :db *app-db* :fx (list)) args)
7 (logger :log "Handled" name "changing db to" db "and remaining fx" fx)
8 (for-of (action fx)
9 (destructuring-bind (task . args) action
10 (logger :log "FX " task)
11 (apply (getprop *app-effects* task) args)))
12 (send "main" "DB update" (getprop db "__mounted-element")))
13 (logger :error "unknown handler to dispatch" name))))
I have included some logging on the way for debugging purposes.
This is to my own surprise all you really need to get far in user interface
design. Next lets build a demo.
That is the most boring example, yet any framework must cover that example.
This app has no persistence. The application database is ephemeral running on
the current page memory. All the contents of the database is the to do list,
which is a simple list of JS
key-value pair objects, with only two entries the
task
and if done
.
Let’s start generating the views directly from the database of to do lists. I
only need to generate the list data structures that represent the DOM
elements. It is a lot of boilerplate to use list
to define every list, but the
quasi-quote doesn’t really work on ParenScript
. I style all elements using
tailwindcss
1(defun todo-item (item idx)
2 (with-slots (task done) item
3 (list "li"
4 (list "div.hover:underline.hover:bg-gray-200.cursor-pointer.inline"
5 (create onclick (lambda (e)
6 (dispatch "todo-state" idx (not done))))
7 (list "input.mx-2.size-4"
8 (create type "checkbox" checked done))
9 task)
10 (list "i.fas.fa-xmark.text-red-600.p-2.text-lg.cursor-pointer"
11 (create onclick (lambda (e) (dispatch "todo-delete" idx)))))))
12
13(defun todo-list (todos)
14 (list "div.my-4"
15 (list "h2.text-3xl" "Todo lists")
16 (append
17 (list "ul.py-2")
18 ((chain todos map) #'todo-item))
19 (list "input.border.p-2"
20 (create type "text"))
21 (->> (lambda (e)
22 (dispatch "todo-new" (@ e target previous-sibling value)))
23 (create type "submit" onclick)
24 (list "input.border.bg-blue-200.p-2"))))
Pay attention to lines 6, 11, 22
, those are the controls in the app. They
dispatch the events to toggle the state, delete the item and create a new to do
respectively. Next are the definitions of those states and how the modify the
database db
. As a good habit, I name-spaced the events with the todo-
prefix, and the database holds the to do list under the key todos
.
1(reg-event-db
2 "todo-state"
3 (lambda (db index state)
4 (setf (getprop db 'todos index 'done) state)
5 db))
6
7(reg-event-db
8 "todo-delete"
9 (lambda (db index)
10 (chain db todos (splice index 1))
11 db))
12
13(reg-event-db
14 "todo-new"
15 (lambda (db value)
16 (let ((trimmed (chain value (trim))))
17 (when trimmed
18 ((chain db todos push)
19 (create task trimmed done f)))
20 db)))
In Clojure(Script)
you work with immutable data. We are however on mutable
JavaScript
, the database changes on each of these events by mutation not by an
atomic update. That is a sad state of affairs, but it is fine because we still
keep the discipline of only changing the database using the events.
The last thing to do is to load the app. Have fun with it.
1(add-event-listener
2 "load"
3 (lambda (event)
4 (let ((el (chain document (query-selector "#todo-app"))))
5 (mount el (lambda (app-db) (todo-list (getprop app-db 'todos))))
6 (setf *app-db* (create todos (list) "__mounted-element" el))
7 (send "main" "Initial render" el)))
8 (create once true))
I’m again amazed how simple it was. If you are curios about the JavaScript
code, you’ll have to read the source code of this page.
If you want to use side effects you register an event that pushes the side
effect instruction to the list under the fx
key. Refer to the dispatch
function. Then you register the side effect itself using reg-fx
. That is an
exercise left to the reader.