This episode was created in collaboration with the amazing Amanda Cavallaro.
In previous episode we wrote a Hello World in Marko. Let's try to write something more substantial - a very simple file manager. To keeps things manageable, we're not going to try to reach feature parity with Svelte version, in particular there will be no keyboard support.
window
problem
And instantly we run into our first problem. We'd like to access the window
object from our Marko code. Unfortunately Marko firmly believes that everything should be possible render server-side, so window
is not available. Code like this will absolutely crash:
<file-list initial=(window.api.currentDirectory()) />
That is sort of fine for the Web, but it's absolutely terrible idea for Electron, and it will make a lot of code awkward.
src/pages/index/index.marko
As I mentioned before, all components need -
in their names. Other than that, it's very straigthforward.
<app-layout title="File Manager">
<file-manager></file-manager>
</app-layout>
src/components/buttons-footer.marko
Instead of starting from the top, let's start from the simplest component.
The footer buttons bar does only one thing, and disregarding labels on the buttons, by mouse click only.
$ function quit() {
window.close()
}
<footer>
<button>F1 Help</button>
<button>F2 Menu</button>
<button>F3 View</button>
<button>F4 Edit</button>
<button>F5 Copy</button>
<button>F6 Move</button>
<button>F7 Mkdir</button>
<button>F8 Delete</button>
<button on-click(quit)>F10 Quit</button>
</footer>
<style>
footer {
text-align: center;
grid-area: footer;
}
button {
font-family: inherit;
font-size: inherit;
background-color: #66b;
color: inherit;
}
</style>
Contrary to what you might expect from Svelte, $
is not reactive statement, it's just inline Javascript not wrapped inside class { ... }
or such.
There are many ways to handle events. on-click(quit)
means to call quit
function. Very similar looking on-click("quit")
would mean to call this.quit()
method.
src/components/file-manager.marko
Let's go through the main component one section at a time. This time it's more complicated, so we wrap it in a class.
We'd love to just set this.state.cwd = window.api.currentDirectory()
- or even don't bother with state and put that in the template part - unfortunately Marko believes in server side rendering so we need to postpone setting that up to onMount
.
We have one event - activate left or right panel.
class {
onCreate() {
this.state = {
cwd: null,
active: "left",
}
}
onMount() {
this.state.cwd = window.api.currentDirectory()
}
activate(panel) {
this.state.active = panel
}
}
Template part should be understandable enough, but it has a few complications. First as state.cwd
is null
, and we really don't want to bother panels with null
directory, we wrap the whole thing in state.cwd
. Essentially we disable server-side rendering here, as server really has no way of knowing what files we have.
on-activate("activate", "left")
means that when given component emits custom activate
event, this.activate("left")
will be called. Marko strongly believes in custom events over React-style callbacks - Svelte works both ways, but custom events are generally nicer.
<div class="ui">
<header>
File Manager
</header>
<if(state.cwd)>
<file-list
initial=(state.cwd)
id="left"
active=(state.active==="left")
on-activate("activate", "left")
/>
<file-list
initial=(state.cwd + "/node_modules")
id="right"
active=(state.active==="right")
on-activate("activate", "right")
/>
</if>
<buttons-footer />
</div>
At least the style section is completely straightforward:
<style>
body {
background-color: #226;
color: #fff;
font-family: monospace;
margin: 0;
font-size: 16px;
}
.ui {
width: 100vw;
height: 100vh;
display: grid;
grid-template-areas:
"header header"
"panel-left panel-right"
"footer footer";
grid-template-columns: 1fr 1fr;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.ui header {
grid-area: header;
}
header {
font-size: 24px;
margin: 4px;
}
</style>
src/components/file-list.marko
And finally, the most complex component. We'll go through it out of code order, to make understanding easier.
Styling is completely straighforward:
<style>
.left {
grid-area: panel-left;
}
.right {
grid-area: panel-right;
}
.panel {
background: #338;
margin: 4px;
display: flex;
flex-direction: column;
}
header {
text-align: center;
font-weight: bold;
}
.file-list {
flex: 1;
overflow-y: scroll;
}
.file {
cursor: pointer;
}
.file.selected {
color: #ff2;
font-weight: bold;
}
.panel.active .file.focused {
background-color: #66b;
}
</style>
Template has a few tricks:
<div class={panel: true, active: input.active}>
<header>${state.directory.split("/").slice(-1)[0]}</header>
<div class="file-list">
<for|file,idx| of=state.files>
<div
class={
file: "file",
focused: (idx === state.focusedIdx),
selected: state.selected.includes(idx),
}
on-click("click", idx)
on-contextmenu("rightclick", idx)
on-dblclick("dblclick", idx)
>${file.name}
</div>
</for>
</div>
</div>
Marko has similar shortcut for setting multiple classes as Vue - class={class1: condition1, class2: condition2, ...}
. I think Svelte's class:class1=condition1
is a bit more readable, but it's perfectly fine either way.
<for|file,idx| of=state.files>
is Marko version of a loop. Every framework has some sort of loops, and some sort of ifs, with its unique syntax. All do basically the same thing.
Template refers to two objects - state
and input
. state
is the state of the component (this.state
).
input
is component's props as they currently are, and this is strangely not available in the class, and there's no reactive way to do things based on props changing! We'd need to write onInput
lifecycle method, and do all the logic there. I find this much more complicated than Svelte's or React's system.
Let's get to the class. It starts with onCreate
setting up initial state:
class {
onCreate(input) {
this.state = {
directory: input.initial,
id: input.id,
files: [],
focusedIdx: 0,
selected: [],
}
}
...
}
It's important to know that this input
is the props as they were when the component was created. It's not going to be called again when active
prop changes. We can either use onInput
to react to props changes, or we can use input.active
in the template - where it always corresponds to the latest value. I find it very non-intuitive.
And as mentioned before, we don't have access to window
in onCreate
.
Once component mounts, we can ask Electron (more specifically our preload) for list of files in the directory:
onMount() {
this.fetchFiles()
}
fetchFiles() {
let filesPromise = window.api.directoryContents(this.state.directory)
filesPromise.then(x => {
this.state.files = x
})
}
We'd like to make this reactive like in Svelte $:
(or like React would do with useEffect
). Doesn't seem like we can, we need to call fetchFiles
manually every time this.state.directory
changes.
Now the event handlers. Various kinds of mouse clicks change this.state.focusedIdx
to the clicked file's index, emit custom activate
event to the parent, and then do some specific action based on left, right, or double click.
click(idx) {
this.emit("activate")
this.state.focusedIdx = idx
}
rightclick(idx) {
this.emit("activate")
this.state.focusedIdx = idx
this.flipSelected(idx)
}
dblclick(idx) {
this.emit("activate")
this.state.focusedIdx = idx
this.enter()
}
}
Right click flips selection:
flipSelected(idx) {
if (this.state.selected.includes(idx)) {
this.state.selected = this.state.selected.filter(f => f !== idx)
} else {
this.state.selected = [...this.state.selected, idx]
}
}
And double click enters the clicked file if it's a directory. As we can't make this reactive, we need to call fetchFiles
manually here.
enter() {
let focused = this.state.files[this.state.focusedIdx]
if (focused?.type === "directory") {
if (focused.name === "..") {
this.state.directory = this.state.directory.split("/").slice(0, -1).join("/") || "/"
} else {
this.state.directory += "/" + focused.name
}
this.fetchFiles()
}
}
First Impressions of Marko
Overall I haven't been very impressed. I despise boilerplate (and that's why there will be zero TypeScript in this series), so I can definitely appreciate Marko's concise syntax.
On the other hand, we ran into a lot of cases where we had to explicitly handle updates while Svelte's (or even React Hooks, just with more explicit dependency list) reactivity would do it for us.
There were also issues one might expect from a less popular framework. VSCode Marko plugin was fairly bad - it couldn't guess how to comment out code due to Marko's complex syntax, so it would put try <!-- -->
in Javascript section, and getting syntax error. Error messages were very confusing, and often I had to reset npm run dev
after fixing syntax error, as it would strangely not pick up that file changed when I reloaded the page. Documentation on the website was very poor, and googling answers was not very helpful.
Marko's website features Marko vs React section, which is fair enough, as React is the most popular framework of previous generation, but it compares it with fairly old style of React - hooks style React tends to cut on boilerplate a good deal with small components like that.
It also doesn't really try to compare with current generation frameworks like Svelte or Imba. I don't think comparison would go too well.
Result
Here's the results:
In the next episodes, we'll be back to improving our Svelte version.
As usual, all the code for the episode is here.
Top comments (0)