How Low-Code Platforms Actually Work, and Where They Stop

Published on |阅读中文原文

Frontend low-code platforms have been getting steadily more attention. This post is my thinking on how they are implemented, where they fit, and what the design forces you to confront.

What low-code is

I have worked on web, app and game development. Game development used the engine's IDE, animation editor and behaviour trees; nearly everything else was hand-written code.

As a programmer my output is code. And in development, repetitive pages are unavoidable.

Internal admin pages

Take a data list page in an admin console. The structure and features repeat:

  • A search form at the top.
  • A create button.
  • A data table.
  • An operations column — edit, view detail, delete.
  • Pagination at the bottom.

The content of each page differs, but the code is nearly the same every time:

  • Wire up the CRUD endpoints.
  • Create and edit modal forms.
  • Page switching and refresh.

One improvement is to abstract all of it into a component — call it crudTemplate.

It takes getList, post, put, delete resource endpoints plus slots for the search form, the columns and the edit form, and handles the calls internally.

I did exactly this in an admin project. It started as an attempt to write the pagination logic once. As more pages adopted it, the component grew:

  • An immediate flag for whether to call getList on first render.
  • The search form wanting a way to reset pagination.
  • Columns rendered conditionally based on the response.

To accommodate more pages, I kept extending it.

Beyond a component, hooks and the composition API let you split the individual concerns more finely and recompose them.

Later I came across amis, which describes a page declaratively. To build a data list page you write JSON:

js
{
  title: 'CSS support by browser engine',
  type: 'page',
  body: {
    type: 'crud',
    draggable: true,
    syncLocation: false,
    api: 'https://example.com/api/sample',
    keepItemSelectionOnPageChange: true,
    autoGenerateFilter: true,
    footerToolbar: ['statistics', 'switch-per-page', 'pagination'],
    columns: [
      {
        name: 'id',
        label: 'ID',
        width: 20,
        sortable: true,
        type: 'text',
        searchable: {
          type: 'input-text',
          name: 'id',
          label: 'Primary key',
          placeholder: 'Enter an id',
        },
      },
      {
        name: 'browser',
        label: 'Browser',
        searchable: {
          type: 'select',
          name: 'browser',
          label: 'Browser',
          placeholder: 'Select a browser',
          options: [
            { label: 'Internet Explorer', value: 'ie' },
            { label: 'AOL browser', value: 'aol' },
            { label: 'Firefox', value: 'firefox' },
          ],
        },
      },
      {
        name: 'grade',
        label: 'CSS level',
        type: 'select',
        options: ['A', 'B', 'C', 'D', 'X'],
      },
      {
        type: 'operation',
        label: 'Actions',
        width: 100,
        buttons: [
          {
            type: 'button',
            actionType: 'ajax',
            label: 'Delete',
            confirmText: 'Are you sure?',
            api: 'delete:https://example.com/api/sample/$id',
          },
        ],
      },
    ],
  },
}

A CRUD page becomes a declaration. No additional code, and the developer never needs to know whether React or Vue is underneath.

The other highly repetitive admin page is the form page, which composes a large number of form controls. The community has "form builders" that use the same declarative approach.

User-facing pages

On the user-facing side, the repetitive case is campaign pages. Marketing and promotional cycles require a stream of largely static pages.

They are mostly images and copy, some animation, a little interaction. Display-oriented, weak on business and interaction logic, short-lived. Which makes them a good fit for a visual drag-and-drop tool.

Most visual page builders in the industry exist to serve that case:

  • Limited editors inside publishing platforms created demand for third-party WYSIWYG rich-text editors, aimed at marketing rather than engineering users.
  • Multi-screen animated page builders provide rich templates plus placeholder controls for swapping images and text, aimed at users with no development ability at all.

Note the difference from something like Dreamweaver, whose users were web developers. These builders exist to make non-frontend people productive at producing pages.

A visual editor's output is, ultimately, the same kind of description data as amis, rendered into a page at runtime.

Some tools in this space: h5-Dooring, OutSystems, Mendix, iVX.

Summary of the two cases

From both cases, low-code means using little code, or in the no-code case none, and assembling an application by dragging blocks.

Unlike a traditional IDE, a low-code platform provides a higher-level, business-oriented one. The developer does not program by hand but works through visual composition (an editor) or parameter configuration (amis and similar).

How it is implemented

Since the editor's output is description data, start with the data: what fields, what structure.

Describing a page

Begin with some HTML:

html
<h1>hello title</h1>
<table>
  <th>
    <td>id</td>
    <td>name</td>
  </th>
  <tr>
    <td>1</td>
    <td>aaa</td>
  </tr>
  <tr>
    <td>2</td>
    <td>bbb</td>
  </tr>
</table>

That is a heading and a table. As JSON:

js
[
  {
    type: 'h1',
    props: {
      text: 'hello title',
    },
  },
  {
    type: 'table',
    props: {
      columns: [
        { label: 'id', prop: 'id' },
        { label: 'name', prop: 'name' },
      ],
      data: [
        { id: 1, name: 'aaa' },
        { id: 2, name: 'bbb' },
      ],
    },
  },
]

How do we deserialise that back into HTML? Write a component that accepts the config:

jsx
const PageTemplate = (configList) => {
  return (
    <>
      {configList.map((config) => {
        const { type: Comp, props } = config
        return <Comp {...props} />
      })}
    </>
  )
}

Implement H1 and Table and the page renders in full:

jsx
const H1 = ({ text }) => {
  return <h1>{text}</h1>
}

const Table = ({ columns, data }) => {
  return (
    <table>
      <th>
        {columns.map(column => <td>{column.label}</td>)}
      </th>
      {data.map(row => (
        <tr>
          {columns.map(column => <td>{row[column.prop]}</td>)}
        </tr>
      ))}
    </table>
  )
}

That is the whole principle of description data. And it exposes the precondition immediately:

The declared components must already be implemented in the runtime.

The visual editor

The editor's main job is to let a user produce that description file without hand-writing JSON. It has three basic regions:

  • The component palette.
  • The preview area.
  • The configuration panel for the selected component.

Two mainstream layout models:

  • Flow layout, positioning components in document order. Simpler to implement and to operate.
  • Absolute positioning, controlling position on a canvas with position: absolute. More freedom when editing.

The demo I built while working on vue-page-builder used flow layout: drag from the palette on the left into the preview in the middle, select a component, configure it.

One question cannot be avoided: how does the preview stay consistent with the real runtime?

  • Editor and runtime share one component library, each rendering it themselves.
  • The preview runs in an iframe, which is essentially identical to the real runtime.

The first looks intuitive, but has an obvious defect: for one set of description data supporting several targets, the editor has to carry a preview component set for each. And adding a component means updating the component library, then the editor's dependency, and only then can it be used.

An iframe solves that cleanly — the editor does not care which components the description data maps to, only about the data. But the preview area also carries drag-and-drop interaction, so cross-iframe dragging needs work.

One approach: a transparent overlay that mirrors the heights of the components inside the iframe, do the drag-and-reorder in the host page, and notify the iframe of the new component list to re-render.

Dynamic data

Suppose we want a div to display the user's name:

html
<div>Hi, <%= userName %></div>

In our structure that becomes:

js
{
  type: 'div',
  props: {
    text: 'Hi, ${username}',
  },
}

${username} is the dynamic part. How do we obtain and render it?

In hand-written code the value comes from an API and lives in a variable:

js
async fetchUserInfo() {
  const { data: { username } } = await getUserInfo()
  this.text = `Hi, ${username}`
}

So for low-code, after obtaining the description data and before rendering, we must initialise whatever dynamic data it needs:

jsx
const globalData = await fetchGlobalData()
const config = paddingData(configList)
render(config)

fetchGlobalData can be an HTTP endpoint, or a cloud function that assembles everything a page needs and returns it, leaving the page to consume data only.

paddingData has to find the placeholders in the description data. The crudest approach is eval. Given the config below, as long as username exists in the execution context, the template literals get substituted:

js
const { username } = globalData
const configList = [
  {
    type: 'div',
    props: {
      text: `Hi, ${username}`,
    },
  },
]

Template literals have a second advantage: you can write JavaScript inside ${}, which matters for the conditional logic below. The downside is that the description is no longer pure JSON — it is a piece of JavaScript.

render is the component-list rendering described above.

Conditional logic

Beyond dynamic data, an application needs branching.

Structured code has sequence, selection and repetition, which in a template correspond to ordering, conditional display, and iterating over a collection.

A visual editor is nowhere near as expressive as a Turing-complete language, and neither is parameter configuration.

So anything requiring a judgement is hard to express as pure declaration:

  • Linked form fields.
  • A component whose visible or disabled state depends on some value.

Making components react to each other means giving them a way to communicate:

  • A component registers the data it cares about.
  • A component updates itself when that data changes.

Here is a button disabled when username is admin:

js
{
  type: 'button',
  props: {
    text: `Hi, ${username}`,
  },
  expression: {
    disabledOn: `${username === 'admin'}`,
  },
}

It has to watch username and set itself disabled when the value is admin — closer to Vue's watch:

js
watch: {
  username: (newVal) => {
    this.disabledOn = newVal === 'admin'
  },
}

And if functions cannot be serialised into the description data, that is another structural constraint to design around.

Event binding

Pages interact with users through events, so low-code has to provide some basic event registration.

The approach mirrors the component list: pre-register handlers in the runtime.

js
const CLICK_TYPE = {
  TO_ANCHOR: 1,
  TO_PAGE: 2,
  SHOW_DIALOG: 3,
  SHARE: 4,
}

const clickHandler = {
  [CLICK_TYPE.TO_ANCHOR](anchor) {},
  [CLICK_TYPE.TO_PAGE]: (url) => {},
  [CLICK_TYPE.SHOW_DIALOG](dialogConfig) {},
  [CLICK_TYPE.SHARE](shareConfig) {},
}

Then add event configuration to each component:

js
{
  type: 'button',
  props: {
    text: `Hi, ${username}`,
  },
  on: [
    {
      type: CLICK_TYPE.SHARE,
      params: JSON.stringify({ imgUrl: '', title: 'Share', desc: 'Join the campaign' }),
    },
  ],
}

At render time, read on and register each handler by type.

The weakness is obvious: there is no flexible way to define a custom handler.

The component library is the ceiling

Everything above shows that low-code depends heavily on a runtime component library.

Because low-code is only a pile of declarative description data, the actual page depends entirely on runtime components. When the current library cannot do something, the component has to be implemented, released, the dependency updated, and only then can it be configured in the editor.

Put differently: the capability and inventory of the runtime components bound what pages can be built.

Custom components

If we have an editor for pages, why not one for components?

The editor would provide primitive building blocks, including raw HTML tags, from which new custom components are composed and published to the library. The page editor would then drag them in exactly like runtime components.

To satisfy real business requirements, a custom component also needs editing for styles and logic, not just markup.

A different idea: dynamically rendered async components

I once considered a different approach: separate the page framework from page components, developing and storing each component as its own JavaScript file. Because it is JavaScript, the developer writes whatever logic they want; when the page is visited, the framework loads and renders the component dynamically.

Each component file looks roughly like this — depending on no global modules, with everything supplied at runtime through props:

jsx
export default ({ store, router, global }) => {
  // custom logic
  return <div>custom component</div>
}

Problems to solve:

  • Async component delivery: a separate component repository, modules loaded with SystemJS.
  • Loading: React.lazy plus React.Suspense.
  • A parser that handles content, async components and the rendering logic.
  • Communication between component and host application.

Compared with declarative data, writing a code component is far more flexible. Unlike code in the main project, the file concerns only this one page and does not interfere with project code. The downsides are equally clear: hard to debug, version tracking is difficult, and it is no longer really low-code.

Closing

Opinion on low-code is polarised. One camp sees an inevitable future that frees productivity and makes everyone a developer. The other sees a fake requirement, useful only for simple display pages and producing unmaintainable output.

This post has not tried to define low-code comprehensively. It works from my own experience toward the situations where low-code fits, and the design problems you must solve to build it.

I do think low-code frees up real capacity in specific situations — and that some of its ideas are worth borrowing for internal declarative components, such as form configurators and CRUD templates.

Whether it justifies pouring significant engineering effort into a schema editor full of complex configuration, aimed at non-specialist or non-technical users, deserves more scepticism. People specialise for a reason, and learning an editor and filing requests to extend it are not free either.

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。