Levsha is a fast HTML template engine and eDSL for Scala 2.12, 2.13 and Scala 3. Optimized templates works without additional memory allocation. Levsha supports changeset inference, which allows to use it as virtual-dom-like middleware.
You can use Levsha as a static HTML renderer.
// build.sbt
libraryDependencies += "com.natural-transformation" %% "levsha-core" % "1.6.0"// In your code
import levsha.text.renderHtml
import levsha.dsl._
import html._
val features = Seq("Very fast", "Memory-effective")
val html = renderHtml {
optimize {
body(
div(
clazz := "title",
backgroundColor @= "red",
"Hello, I'm Levsha!"
),
ul(clazz := "list",
features map { feature =>
li(class := "item", feature)
}
)
)
}
}
println(html)<body>
<div style="background-color: red" class="title">Hello, I'm Levsha!</div>
<ul class="list">
<li class="item">Very fast</li>
<li class="item">Memory-effective</li>
</ul>
</body>Benchmarks show that Levsha is really fast. Unlike Twirl, Levsha's performance does not depend on template complexity.
| Test | Engine | Ops/s |
|---|---|---|
| simpleHtml | levsha | 1336693,499 |
| simpleHtml | scalatags | 533740,566 |
| simpleHtml | twirl | 5950436,854 |
| withConditionAndLoop | levsha | 1299646,768 |
| withConditionAndLoop | scalatags | 531345,430 |
| withConditionAndLoop | twirl | 239537,158 |
| withVariables | levsha | 1140298,804 |
| withVariables | scalatags | 483508,457 |
| withVariables | twirl | 2146419,329 |
In your sbt shell.
bench/jmh:run .StaticRenderingComparision
Levsha can be used as virtual-DOM-like middleware. Unlike other popular virtual DOM solutions, Levsha doesn't allocate additional memory for construction of a new virtual DOM copy. Also it does not allocate memory in changes inferring phase. Levsha's memory usage is constant.
// build.sbt
libraryDependencies += "com.natural-transformation" %%% "levsha-dom" % "1.6.0"// In your code
import org.scalajs.dom._
import levsha.dom.render
import levsha.dom.event
import levsha.dsl._
import html._
case class Todo(id: String, text: String, done: Boolean)
def onSubmitClick() = {
val input = document
.getElementById("todo-input")
.asInstanceOf[html.Input]
val inputText = input.value
// Reset input
input.value = ""
val newTodo = Todo(
id = Random.alphanumeric.take(5).mkString,
text = inputText,
done = false
)
renderTodos(todos :+ newTodo)
}
def onTodoClick(todo: Todo) = {
renderTodos(
todos.updated(
todos.indexOf(todo),
todo.copy(done = !todo.done)
)
)
}
def renderTodos(todos: Seq[Todo]): Unit = render(document.body) {
optimize {
body(
div(clazz := "title", "Todos"),
ul(clazz := "list",
todos map { todo =>
li(
todo match {
case Todo(_, text, true) => strike(text)
case Todo(_, text, false) => span(text)
},
event("click")(onTodoClick(todo))
)
}
),
input(id := "todo-input", placeholder := "New ToDo"),
button("Submit", event("click")(onSubmitClick()))
)
}
}
val todos = Seq(
Todo("1", "Start use Levsha", done = false),
Todo("2", "Lean back and have rest", done = false)
)
renderTodos(todos)Levsha is a small core engine for building and diffing HTML-like trees in Scala. The key idea is: a template is a function that writes rendering operations into a render context. Depending on the render context implementation, those operations become either:
- an HTML string (static rendering), or
- a compact byte-buffer encoded tree that can be diffed to infer changes (virtual-DOM-like).
levsha-core: DSL + optimizer + render contexts (includingDiffRenderContext)levsha-events: shared event/id model used by integrationslevsha-dom: Scala.js DOM backend that applies diffs to the browser DOM
flowchart LR
Core[levsha-core<br/>DSL + optimizer + DiffRenderContext] --> Dom[levsha-dom<br/>apply changes to browser DOM]
Events[levsha-events<br/>ids + event model] --> Dom
Core --> Events
At runtime, a Levsha template is a Document[M] (usually created via the DSL as Node[M], Attr[M], Style[M]). Applying a document means calling methods on a RenderContext:
openNode(XmlNs, tag)setAttr(XmlNs, name, value)setStyle(name, value)addTextNode(text)closeNode(tag)addMisc(value)(integration side-channel)
flowchart TD
State["Application state"] --> Doc["Document[M]<br/>(Levsha template)"]
Doc --> RC["RenderContext"]
RC -->|text.renderHtml| Html["HTML string"]
RC -->|DiffRenderContext| Buf["ByteBuffers<br/>lhs/rhs"]
Buf --> Diff["diff(lhs,rhs)"]
Diff --> Perf["ChangesPerformer"]
Perf --> Backend["DOM backend / integration"]
DiffRenderContext keeps two internal buffers:
- lhs: the most recently rendered document (current)
- rhs: the previous document (baseline)
Diffing is a lifecycle. The important steps are:
- Render by applying a
Documentto the render context (writes ops into lhs) - Call
finalizeDocument()(flip lhs for reading + reset traversal state) - Call
diff(performer)(read lhs/rhs and call the performer with inferred changes) - Call
swap()to make the current buffer become the next baseline
sequenceDiagram
participant Doc as Document
participant RC as DiffRenderContext
participant Perf as ChangesPerformer
Doc->>RC: render into lhs<br/>(openNode/setAttr/addText/closeNode)
RC->>RC: finalizeDocument()
RC->>Perf: diff(performer)
RC->>RC: swap()
Design note: DiffRenderContext is intentionally stateful. You typically keep one instance per render-loop (single-threaded) and reuse it across updates.
addMisc(value) attaches an out-of-band payload to the current element id. Integrations (eg frameworks built on Levsha) use it to collect things like:
- event handlers (keyed by element id + event type)
- element ids for imperative access
- component markers / other metadata
As noted below Levsha does not make additional
memory allocations if template optimized. It is
possible because optimized template, in compile-time
rewrites into calls of RenderContext methods
(unlike other template engines which represent
their templates as AST on-heap).
For example,
optimize {
div(clazz := "content",
h1("Hello world"),
p("Lorem ipsum dolor")
)
}Will be rewritten to
Node { renderContext =>
renderContext.openNode(XmlNs.html, "div")
renderContext.setAttr(XmlNs.html, "class", "content")
renderContext.openNode(XmlNs.html, "h1")
renderContext.addTextNode("Hello world")
renderContext.closeNode("h1")
renderContext.openNode(XmlNs.html, "p")
renderContext.addTextNode("Lorem ipsum dolor")
renderContext.closeNode("p")
renderContext.closeNode("div")
}In turn, RenderContext (namely DiffRenderContext implementation)
saves instructions in ByteBuffer to infer changes in the future.
Of course, Levsha optimizer does not cover all cases.
When optimization can't be performed Levsha just
applies current RenderContext to the unoptimized node.
optimize {
ul(
Seq(1, 2, 3, 4, 5, 6, 7).collect {
case x if x % 2 == 0 => li(x.toString)
}
)
}
// ==>
Node { renderContext =>
renderContext.openNode(XmlNs.html, "ul")
Seq(1, 2, 3, 4, 5, 6, 7)
.collect {
case x if x % 2 == 0 =>
Node { renderContext =>
renderContext.openNode(XmlNs.html, "li")
renderContext.addTextNode(x.toString)
renderContext.closeNode("li")
}
}
.foreach { childNode =>
childNode.apply(renderContext)
}
renderContext.closeNode("ul")
}When you write your Levsha templates, keep in your mind this list of optimizations:
- Nodes and attrs in branches of
ifexpression will be moved to currentRenderContext - Same for cases of pattern matching
xs.map(x => div(x))will be rewritten into awhileloopmaybeX.map(x => div(x))will be rewritten into anifexpressionvoidwill be removed
The third item of this list shows us how to rewrite previous example so that optimization could be performed.
optimize {
ul(
Seq(1, 2, 3, 4, 5, 6, 7)
.filter(x => x % 2 == 0)
.map { x => li(x.toString) }
)
}
// ==>
Node { renderContext =>
renderContext.openNode(XmlNs.html, "div")
val iterator = Seq(1, 2, 3, 4, 5, 6, 7)
.filter(x => x % 2 == 0)
.iterator
while (iterator.hasNext) {
val x = iterator.next()
renderContext.openNode(XmlNs.html, "li")
renderContext.addTextNode(x.toString)
renderContext.closeNode("li")
}
renderContext.closeNode("div")
}You can pass this options to SBT.
$ sbt -Doption=value| Option | Description | Possible values | Default |
|---|---|---|---|
levsha.optimizer.logUnableToOptimize |
Write positions of unoptimized parts of code to a file. | true/false or file name | false |
levsha.optimizer.unableToSort.forceOptimization |
If tag node content couldn't be sorted in compile time, optimizer will keep the code unoptimized (so content will be sorted in runtime). You can force optimizer to ignore unspecified Documents (that couldn't be sorted) and optimize anyway, but keep in mind that node content should be ordered (styles, attrs, nodes). | true/false | false |
levsha.optimizer.unableToSort.warnings |
Warns that optimizer can't sort tag content when optimization is forced. | true/false | true |