Marko.js - frontend from ebay.com

Marko.js is not as popular as Angular, React.js, Vue.js or Svelte. Marko.js is an ebay.com project that has become an opensource property since 2015. Actually, it is on this library that the ebay.com front-end is built, which allows us to draw a conclusion about its practical value for developers.



The situation with Marko.js is a bit like the situation with the Ember.js framework, which, despite the fact that it works as a frontend for several highly loaded sites (for example, Linkedin), does not know much about the average developer. In the case of Marko.js, it can be argued that he does not know at all.



Marko.js is fiercely fast, especially when rendering server-side. When it comes to server-side rendering, Marko.js's speed is likely to remain out of reach for its leisurely counterparts, for good reason. We will talk about them in the proposed material.



SSR-first framework



Marko.js can be the basis for a classic front-end (with server-side rendering), for a single-page application (with client-side rendering), and for an isomorphic / universal application (an example of which will be discussed later). But still, Marko.js can be considered an SSR-first library, that is, focused primarily on server rendering. What distinguishes Marko.js from other component frameworks is that the server-side component does not build the DOM, which is then serialized into a string, but is implemented as an output stream. To make it clear what this is about, I will give a listing of a simple server component:



// Compiled using marko@4.23.9 - DO NOT EDIT
"use strict";

var marko_template = module.exports = require("marko/src/html").t(__filename),
    marko_componentType = "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko",
    marko_renderer = require("marko/src/runtime/components/renderer");

function render(input, out, __component, component, state) {
  var data = input;

  out.w("<p>Not found</p>");
}

marko_template._ = marko_renderer(render, {
    ___implicit: true,
    ___type: marko_componentType
  });

marko_template.meta = {
    id: "/marko-lasso-ex$1.0.0/src/components/notFound/index.marko"
  };


The idea that the server component should not be the same as the client component seems very natural. And on this basis, the Marko.js library was originally built. I can assume that in the case of other frameworks that were originally built as client-oriented, server-side rendering was tape-bound to an already very complex codebase. This is where this architecturally flawed decision arose, where the DOM is re-created on the server side so that existing client code can be reused unchanged.



Why is it important



The progress in the creation of single page applications, which is observed with the widespread use of Angular, React.js, Vue.js, along with the positive moments, revealed several fatal mistakes of the client-oriented architecture. Back in 2013, Airbnb's Spike Brehm published a programmatic article in which the relevant section is titled "A fly in the ointment." At the same time, all negative points hit the business:



  • the loading time of the first page increases;
  • content is not indexed by search engines;
  • accessibility problems for people with disabilities.


As an alternative, frameworks for the development of isomorphic / universal applications were finally created: Next.js and Nust.js. And then another factor comes into play - performance. Everyone knows that node.js is not that good when loaded with complex calculations. And in the case when we create the DOM on the server and then start serializing it, node.js fizzles out very quickly. Yes, we can hoist an infinite number of node.js replicas. But maybe try to do the same thing but in Marko.js?



How to get started with Marko.js



For the first acquaintance, I recommend starting as described in the documentation with the command npx @marko/create --template lasso-express.



As a result, we will get a basis for further development of projects with a configured Express.js server and a Lasso linker (this linker is developed by ebay.com and is the easiest to integrate with).



Components in Marko.js are usually located in the / components directories in files with the .marko extension. Component code is intuitive. As the documentation says, if you know html, then you know Marko.js.



The component is rendered on the server and then hydrated on the client. That is, on the client we receive not static html, but a full-fledged client component, with state and events.



When starting a project in development mode, hot reloading works.



To build a complex application, we most likely need to have something else besides the component library, for example, routing, store, a framework for creating isomorphic / universal applications. And here, alas, the problems are the same that the developers of React.js faced in the early years - there are no ready-made solutions and well-known approaches. Therefore, everything that came up to this point can be called an introduction to the conversation about building an application based on Marko.js.



Building an isomorphic / universal application



As I said, there are not many articles about Marko.js, so everything below is the fruit of my experiments, based in part on working with other frameworks.



Marko.js allows you to set and change the name of a tag or component dynamically (that is, programmatically) - that's what we'll use. Let's match routes - names of components. Since there is no routing out of the box in Marko.js (it’s interesting to know how this is built on ebay.com), we will use the package, which is just for such cases - universal-router:



const axios = require('axios');
const UniversalRouter = require('universal-router');

module.exports = new UniversalRouter([
  { path: '/home', action: (req) => ({ page: 'home' }) },
  {
    path: '/user-list',
    action: async (req) => {
      const {data: users} = await axios.get('http://localhost:8080/api/users');
      return { page: 'user-list', data: { users } };
    }
  },
  {
    path: '/users/:id',
    action: async (req) => {
      const {data: user} = await axios.get(`http://localhost:8080/api/users/${req.params.id}`);
      return { page: 'user', data: { req, user } };
    }
  },
  { path: '(.*)', action: () => ({ page: 'notFound' }) }
])


The functionality of the universal-router package is outrageously simple. It parses the url string, and calls the asynchronous action (req) function with the parsed string, inside which we can, for example, access the parsed string parameters (req.params.id). And since the action (req) function is called asynchronously, we can initialize the data with API requests right here.



As you remember, in the last section a project was created by a team npx @marko/create --template lasso-express. Let's take it as the basis for our isomorphic / universal application. To do this, let's slightly change the server.js file



app.get('/*', async function(req, res) {
    const { page, data } = await router.resolve(req.originalUrl);
    res.marko(indexTemplate, {
            page,
            data,
        });
});


We will also change the template of the loaded page:



<lasso-page/>
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>Marko | Lasso + Express</title>
    <lasso-head/>
    <style>
      .container{
        margin-left: auto;
        margin-right: auto;
        width: 800px;
    }
    </style>
  </head>
  <body>
    <sample-header title="Lasso + Express"/>
    <div class="container">
      <router page=input.page data=input.data/>
    </div>
    <lasso-body/>
    <!--
    Page will automatically refresh any time a template is modified
    if launched using the browser-refresh Node.js process launcher:
    https://github.com/patrick-steele-idem/browser-refresh
    -->
    <browser-refresh/>
  </body>
</html>


The <router /> component is exactly the part that will be responsible for loading dynamic components, the names of which we get from the router in the page attribute.



<layout page=input.page>
  <${state.component} data=state.data/>
</layout>

import history from '../../history'
import router from '../../router'

class {
  onCreate({ page, data }) {
    this.state = {
      component: require(`../${page}/index.marko`),
      data
    }
    history.listen(this.handle.bind(this))
  }

  async handle({location}) {
    const route = await router.resolve(location);
    this.state.data = route.data;
    this.state.component = require(`../${route.page}/index.marko`);
  }
}


Traditionally, Marko.js has this.state, changing which causes the component's view to change, which is what we are using.



You also have to implement work with history yourself:



const { createBrowserHistory } = require('history')
const parse = require('url-parse')
const deepEqual = require('deep-equal')
const isNode = new Function('try {return !!process.env;}catch(e){return false;}') //eslint-disable-line
let history

if (!isNode()) {
  history = createBrowserHistory()
  history.navigate = function (path, state) {
    const parsedPath = parse(path)
    const location = history.location
    if (parsedPath.pathname === location.pathname &&
      parsedPath.query === location.search &&
      parsedPath.hash === location.hash &&
      deepEqual(state, location.state)) {
      return
    }
    const args = Array.from(arguments)
    args.splice(0, 2)
    return history.push(...[path, state, ...args])
  }
} else {
  history = {}
  history.navigate = function () {}
  history.listen = function () {}
}

module.exports = history


And finally, there should be a navigation source that intercepts the click events on the link, and calls the navigation on the page:



import history from '../../history'

<a on-click("handleClick") href=input.href><${input.renderBody}/></a>

class {
  handleClick(e) {
    e.preventDefault()
    history.navigate(this.input.href)
  }
}


For the convenience of studying the material, I presented the results in the repository .



apapacy@gmail.com

November 22, 2020



All Articles