laitimes

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

author:Flash Gene

introduction

"Quick Page" is a platform within Zhihu to quickly build a back-end management page, and users can develop a background page of regular complexity in only half an hour.

The cornerstone of the Fast Page platform is its "renderer", a React component that renders JSON configurations into pages.

This article will provide an idea of how to implement a configurable renderer.

But before we get into the principle, I want to do a simple analysis and evaluation of the value of this type of tool, and understand why we are doing it.

Core Objective - Improve development efficiency

Some skepticism

When I first came up with the idea of making a platform called "Quick Pages", I wondered if such a thing would really improve efficiency. Wouldn't the cost of learning actually outweigh the benefits?

In fact, configurable page rendering is a very old topic, because under normal circumstances, a front-end team of more than a dozen people, as long as it continues to receive a large number of highly similar management backend requirements, will give birth to such a page configuration tool.

However, there is no such tool that has been widely used in the community today, and most of them are only used as internal systems of various companies.

Now, a year after the launch of the "Fast Page" platform, I can be sure that it will really improve development efficiency.

The back-end requirements of the first phase of many projects are very simple, a form is used for creation and editing, a form is used for querying, and then a public button is added to the table, this kind of demand uses fast page development, with an average of half an hour per page, and a maximum of one hour to complete the launch.

However, it is also impossible to ignore the fact that in order to offset the learning costs it brings, it is necessary to do a lot of supporting work such as documentation, smart editors, and version management. This will be a longer and more tortuous process than making renderers and proprietary components.

The key point of efficiency improvement

In fact, the key points of this type of tool to improve efficiency are different, and "Quick Page" improves efficiency through the following three points:

  1. Constrain the scope of requirements, and conventions are better than configurations
  2. Eliminate the need to build and deploy and go live quickly
  3. It becomes possible for non-front-end to participate in front-end page development

Constrain the scope of requirements, and conventions are better than configurations

Changing the page from code to configuration is equivalent to creating a DSL, eliminating the need for import statements, and the efficiency gain is limited.

The key to efficiency is to analyze high-frequency business requirements, simplify them into fixed processes, limit the scope of requirements, and abandon the need to support overly flexible requirements.

For example, for the general form requirements, we break it down into the following parts:

"Request Data→ "Set Default Value→ "Specify POST Address", → "User and Form Interaction", → "Verify", →"Submit", → "Jump After Success"

Other details such as "where to put the submit button" and "submit button copy" and other low-frequency requirements are not considered.

Some requirements that are difficult to express with configuration, such as "deal with the object structure first after getting the request data", "send two interfaces when committing", "delete some fields when committing", etc., they are actually a kind of callback function, which is varied and endless, unless it is a high-frequency requirement, try to give up support.

We abstracted the common parts of the high-frequency requirements and expressed them in a self-explanatory configuration, giving up flexibility and improving efficiency. This is known as "convention over configuration".

Eliminate the need to build and deploy and go live quickly

Our configuration is in the form of JSON, which is different from js code in that it is short, communicable, and storable.

Since a JSON corresponds to a page, if you store it in the database, read and modify it with an interface, and then make an online editor, you should be able to break away from the project construction and deployment process, and go online as soon as the development is completed.

In fact, in this project, it saves the waiting time of more than 10 minutes after each code merge, and indirectly saves the necessary work before the development of git clone code, installing dependencies, starting the project, etc.

It becomes possible for non-front-end to participate in front-end page development

With the online smart editor and documentation, the backend can also quickly develop a front-end page of general complexity according to the configuration examples of other pages.

If this online smart editor were more powerful and moved away from the reliant editing of JSON to visual interactions, it could become a sketch editor, allowing more people to participate in the development of front-end pages.

When the back-end can independently complete the front-end page development with the help of the online intelligent editor, the cost of communication and joint debugging is greatly reduced.

Sample configuration

This is a simplified sample of the table query requirements configuration

{
    "component": "Layout",
    "title": "页面标题",
    "children": {
      "component": "AutoTable",
      "source": "/projects/{{match.params.id}}/resources",
      "columns": [
        {
          "header": "ID",
          "accessor": "id"
        },
        {
          "header": "名称",
          "accessor": "name",
          "render": {
            "component": "Enter",
            "caption": "{{value}}",
            "href": "https://example.xxx.com/resources/{{record.id}}"
          }
        },
        {
          "header": "字段拼接",
          "render": "{{record.id}}: {{record.community_name}}"
        },
        {
          "header": "类别",
          "accessor": "type",
          "render": {
            "component": "MapBadge",
            "map": {
              "a": "类型 A",
              "b": "类型 B",
              "c": "类型 C"
            }
          }
        },
        {
          "header": "更新时间",
          "accessor": "updated_at",
          "unit": "second"
        }
      ]
    }
  }           

As shown in the example, the component field is used to represent the components to be used, and the components are nested to form a JSON configuration that represents the entire page content.

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Page render results

Notice that some values in the configuration contain "double curly braces", such as {{record.id}}: {{record.community_name}}

This format indicates that they are dynamically changing, gives the component the ability to display different UIs as the state changes, and supports expression evaluation. More on this feature later in the article.

The component tree in this example can be simplified to the following image (only the component part is shown)

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Component tree

where Layout affects the title of the page, the margins;

AutoTable is a powerful table component, which is responsible for sending requests, table pagination and other logic;

Enter is a link button;

MapBadges are often used to display various states or types, and are a bit more prominent on the UI than plain text.

This JSON is a very concise representation of the contents of a page, Layout AutoTable, Enter and MapBadge (two columns in the table, one for links and one for types), and the amount of code is much smaller than the original JSX version.

Rendering process

We can roughly divide the rendering process into "React component rendering" and "double curly brace expression rendering"

React component rendering

Hives

A closer look at the configuration structure shows that the key to nesting is the component, and those fields that are sibling to the component will be passed in as properties of the component, i.e

{
	"component": "Layout",
	"width": "750px",
	"title": "标题",
	"children": "内容"
}
// 相当于 JSX
<Layout title="标题" width="750px">内容</Layout>
// 相当于 JS 代码
React.createElement(Layout,{ width: '750px', title: '标题', children: '内容'})

           

We call an object with a component a "hive", and just as a React component can be freely passed in as an arbitrary property of another component, a "hive" can be nested as a property of the other.

The basic operation for each hive is to call React.createElement() to instantiate it as a React Element.

Bottom-up

When we try React.createElement() on a two-layer nested hive, it looks like we need to determine a render order.

This sequence is bottom-up. Take Layout - AutoTable above as an example:

Let's say it's top-down, that is

React.createElement(
	Layout,
	{ 
		title: '页面标题',
		children: { 
			component: 'AutoTable',
			source: '',
			columns: []
		}
	}
)
           

In fact, Layout is a simple UI component, without any complex logic, and will pass the children passed to it by the outside world to the React API as it is, and there is no doubt that an error will be reported.

In retrospect, the bottom-up order is a matter of course, because JS code translated from JSX is inherently bottom-up, just think of the "function execution stack".

So the rendering order is: bottom-up.

Depth-first traversal

Now that you know the rendering order, and that each layer is executing React.createElement(), you can write a depth-first traversal. The code is simplified as follows:

function dfs(configUnit, path = []) {
  for (const key of configUnit) {
    dfs(configUnit[key], path.concat(key))
 
    if (configUnit.component) {
      collect(configUnit, path)
    }
  }
}
           

A common recursive traversal is just a collection of an array that follows the bottom-up order of the components through dfs, and then execute React.createElement() on each of the elements and replace them.

// config 是整个页面的配置,paths 是深度优先遍历时收集到的配置单元路径
paths.forEach(path => {
  const configUnit = config.get(path)
  const { component: name, ...props } = configUnit
  const component = getComponentByName(name)
  const element = React.createElement(component, props)
  config.set(path, element)
})
           

where getComponentByName is the method to find the component based on the component name, which is what we will talk about next.

Locate the component class based on the component name

Start by implementing a component referencing the cache manager

// componentProvider
function ComponentProvider() {
  this.cached = {}
  this.add = (name, component) => {
    Object.assign(this.cached, { [name]: component })
  }
  this.get = name => this.cached[name]
}

const componentProvider = new ComponentProvider()

export default componentProvider
           

Then inject all the components

// injectComponents.js 文件
import * as components from 'components'

function injectComponents(provider) {
  for (const name in components) {
    provider.add(name, components[name])
  }
}

export default injectComponents
           

Retrieve the component based on its name

import provider from 'componentProvider'

provider.get('Layout')
           

It's all very simple and straightforward logic

At this point, a basic static configuration rendering process has been implemented, which is sufficient if our page is like writing static HTML tags without any dynamic requirements.

But the backend requirements will not be so simple, after the actual use of us, we will find that compared to writing JSX, this JSON configuration has a fatal flaw, that is, we can't even do a little bit of computation on the data before it is passed to the UI component, and we can't write callback functions. Therefore, the second part of the following "Double Brace Expression Rendering" is required.

Double curly brace expression rendering

What role does the expression play

First of all, what role does the "double curly brace expression" play in the page configuration, and can we find a corresponding role in the traditional JSX writing process?

In this project, the "double curly brace expression" is satisfied

  • The need for computational processing of data
  • Implement part of the callback function as needed

Computational processing of data

The most common example is that the HTTP interface address of a form request in a page needs to be affected by the current routing of the page

For example, we want to be in

https://example.xxx.com/projects/:id

This page is requested

https://api.xxx.com/projects/:id

This interface address

It is clear that the parameter id in the interface address is derived from the page route

Then write it as a "double curly brace expression".

'https://api.xxx.com/projects/{{match.params.id}}'

This type of calculation logic is common and important, and "double curly brace expressions" can meet this kind of need.

part of the callback function

In the JSON configuration, you can only write simple types such as numbers, strings, and booleans, and cannot write functions.

What about generating functions via eval? In JSON, it exists as a string.

We abandoned this idea because it was too complex and too flexible.

We continue to support only high-frequency needs

However, this means that we need to do some special components to change the logic that would have been passed in to the callback function into a small piece of JSON, such as filling out a form after clicking a button, or asking the user to confirm a dangerous action, etc

Implementation of expression computation

To implement expression computation, you can generate an immediate function by eval, and there are a few key points to note here:

  • Mask global variables
  • The namespace of the function variable generated by eval may have an intersection with the global variable
  • There may be variable names in global variables that do not conform to identifier naming conventions
  • Errors are reported during the calculation process

Mask global variables

The global variable here actually refers to the property on the window object, because we take advantage of the closure property of the immediate execution function, so it will be affected by the property on the window object during execution, resulting in strange calculation results.

Once this happens, it is not easy to find the cause, and it is better to block it out for the sake of safety.

The way to mask is to enumerate the properties on the window in a loop and execute it.

let windowProp = undefined
           

The namespace of the function variable generated by eval may have an intersection with the global variable

The data source of the expression may have the same name attribute as the global variable, so it cannot be assigned as undefined as above, for example:

// 表达式中系统预先定义了一个 prompt 变量,它和 window.prompt 重名了
let prompt = _data.prompt 
           

There may be variable names in global variables that do not conform to identifier naming conventions

Some third-party libraries may inject their own custom identification variables into the window, but they don't follow the variable naming conventions and use special symbols such as the "minus sign -".

This identifier may cause a mistake in masking global variables, so be sure to filter it.

Errors are reported during the calculation process

It is very likely that expression evaluation will fail, for example, the following error must have been seen too much.

TypeError: Cannot read property 'someProp' of undefined

Capturing and printing out by trying catch can greatly help users debug.

The source of the data when the expression is evaluated

Our "double parenthesis expression" is meant to affect the UI.

In React, there are only three sources of data that can instantly impact the UI, state, props, and context.

state

state is some of the internal properties of a component, such as the pagination of a table, that are managed by the table itself.

props

props are the properties we pass to the component, which are actually written in the "hive".

context

借助一些状态管理库,如 redux + react redux,context 就变成了组件的 props。

All three data sources can only be obtained in the component's render method, and can also affect the UI immediately as the data changes.

Bottom-up limitations

Taking the example shown above as an example, assume that the structure of the component tree in the current JSON configuration has the following three layers.

Layout
  |-- AutoTable
      |-- Enter ( href =  https://example.xxx.com/resources/{{record.id}} ) 
      // record 是表格任意一行的数据           

The meaning is very simple, there is a table in the page, and there is a column in the table to put a link entry.

按照自底向上的顺序,应当是先执行 createElement(Enter) ,再执行 createElement(AutoTable)

But the href attribute we pass to Enter is a "double curly brace expression", and the record in the expression depends on the entire row of data in the table it is in, which belongs to the private variable of the AutoTable component.

Our original bottom-up process ignored private relationships and found that some private variables were missing when we tried to evaluate the expression.

This is the limitation of the original bottom-up, and it seems that the rendering process needs to be improved to support expression computation.

Relay between bottom-up processes

Since those variables are private, they should be rendered bottom-up on the premise of respecting the private relationship.

How to follow it? That is, in the bottom-up process, the child configuration of some component is ignored, and the component itself is responsible for the bottom-up rendering of the child configuration.

As a result, the bottom-up rendering, which was originally only one time, is split into two times due to the AutoTable component, as if it were two relays.

We call handoff components like AutoTable "relay components".

仍是以上面的 Layout - AutoTable - Enter 为例

In this process, there is a "handoff component", AutoTable, which requires two bottom-up renderings

For the first time, AutoTable and all of its child fields are treated as a whole hive from the bottom up, so that AutoTable is the "hive" at the bottom.

The second bottom-up is relayed by AutoTable, which renders all of its child fields from the bottom up.

And so on. Even with more "relay components", the process is the same.

At the end of this article, there will be a detailed description of the process in the form of pictures.

Relay components

Which components are relay components

Mainly those components that need to provide private variables to "double brace expressions", such as tables that need to provide data for each row of the table, forms that need to provide the current value of the form, and other components that have similar needs.

How does the renderer know if the current component is a "relay component"

Whitelisting is one way to do this, but in doing so, the whitelist needs to be changed every time a new "handoff component" is added, and there is a coupling between the renderer and the component.

So it's better to do a HOC

Let's combine the two common logics of "traversal and replace double curly brace expressions" and "call React.createElement from the bottom up" into a single method, let's call it autoRender.

To make a HOC, it has two functions:

  1. The packaged component is marked as a "relay component"
  2. Provide the autoRender method mentioned above to the wrapped component, and the wrapped component will use autoRender to render the rest of the configuration to complete the handoff

In this way, it is very easy to make a "relay component" by wrapping the HOC and then calling the autoRender method provided by the HOC as you like in the wrapped component.

Decoupling between renderers and components.

An expression variable scope that looks like a closure

Since the Handoff component has some private variables, the intuitive scope should be:

The parent cannot read the variables of the children of the Handoff Component, but the Handoff Component can use the variables of the parent.

Just like the scope of a closure, the current function can use the variables of the outer function, but the outer function cannot use the variables of the current function.

This is not difficult to implement, in a nutshell: each relay component injects data into all the relay components of its children.

Injecting data is to add a specific field, such as __injectedData, to a child's "relay component".

In this case, you want to inject __injectedData into the AutoTable, a "relay component", which is the routing information of the page and other data.

{
  "component": "AutoTable",
  "columns": []
}
           

(assuming the parameter id is 20 in the page route) is injected

{
  "component": "AutoTable",
  "columns": [],
  "__injectedData": {
    "match": { "params": { "id": 20 } }
  }
}
           

Later, when AutoTable uses the autoRender method, it will merge the injected data with its own private data to render the "double curly brace expression" in the child configuration

Flowchart

The above text-only description is very unintuitive, and the following is a complete process in the form of a picture.

In this example, there are two "relay components": Page and AutoTable

Page can provide page routing data for expressions, including the result of parameter matching, i.e., match.

Let's say there is a parameter id in the page route with a value of 3, i.e., match.params.id = 3.

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Begin

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Start rendering

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Evaluate expressions and inject data

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

The relay components are treated as a whole

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

createElement(AutoTable)

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Start the relay

(AutoRender 笔误,是 AutoTable)

The value of the children property of the Text component is an expression in which two variables, record and match, are used

record 是表格中每一行的数据,由 AutoTable 提供,假设 record.type = 'typeA'

match is the result of a page routing parameter match, and obviously AutoTable itself can't provide match data

但之前 Page 已向 AutoTable 注入了 injectedData,其中含有 match 变量

As a result, the expression of the children property of the Text component can be used to calculate the result

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

Evaluate expressions

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

createElement(Text)

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

AutoTable 已被实例化,只剩 Layout

An introduction to the principle of the "Quick Page" dynamically configurable page renderer

createElement(Layout),流程结束

summary

This article introduces a back-end page building platform "Quick Page" within Zhihu, and the main content is the implementation principle of the renderer.

Before introducing the principle, some preliminary analysis of the existence of such tools is firstly made.

Then, a configuration example is used as an example to introduce the implementation principle of the renderer, including "React component rendering" and "double curly brace expression rendering".

Each configurable tool should be the result of a deep combination of business direction, project foundation, team input, etc.

Therefore, theoretically, there should be many similar tools in the industry, so this article is just an implementation idea.

Friends who are interested in this kind of tool are welcome to communicate in the comment area.

Author: Ma Liang Liang Liangjun

Source: https://zhuanlan.zhihu.com/p/100708653