background

Each business line of the company often produces some activity pages for promotion and marketing of activities, attracting new ones and retaining them. Such pages have similar layout, high frequency of demand, rapid iteration, repetitive development tasks and consumption of communication time and manpower of all parties. In order to solve these pain points and improve human efficiency, it is necessary to provide a set of easy-to-use, powerful visualization platform for business

Technical design

At present, the three front-end frameworks all advocate the componentized development mode, because the componentized mode has the advantages of high cohesion, low coupling, high reusability and convenient extension. That page visualization can also use the componentized thinking? The answer is yes. Imagine planning an aggregation platform of building blocks on which users could randomly pick and drop components into the canvas, generating pages by assembling building blocks. The platform is only responsible for collecting and displaying components. By virtue of the component props feature, the dynamic editing capability can be realized by modifying the parameter values of the components. Then these building blocks can be expanded by the business side itself. The EMP micro front-end solution being implemented by the company can easily solve the problem of component expansion. Its principle is to use the module-federation feature of webpack5 to share the components of each application

Final platform rendering:

Page creation Process

  1. Operations creates blank pages
  2. Filter components by component list and drag to canvas (page preview area)
  3. Dynamically enable the component using the right editor (modify the component props)
  4. Save the page (publish the test)
  5. Test the page with a pre-release environment
  6. Release page (officially online)

Overall Technical architecture

The final technology stack selection is React + hooks+ TS. Meanwhile, the development mode based on base station is introduced by using EMP micro-front-end solution developed by the company as the underlying technology support, and the main base station + business base station is used for business expansion. The main base station is responsible for the collection and rendering of components, while the business base station is only responsible for the realization of their own business components. Each base station is deployed independently without the limitation of the central base station.

The main technical points

The core function

  • Jsonization of page data and design of component tree data model
  • Obtain components shared by remote applications and render them asynchronously
  • Component access specification design
  • Component data configurator
  • Real-time edit preview effect implementation
  • Style editor implementation

Other features

  • Cross component communication

Jsonization of page data and design of component tree data model

The essence of visual editing is to abstract a page into a JSON-type data structure with the ability of adding, deleting and modifying. The rendering of a page only needs to implement the corresponding renderer (CSR/SSR) for this set of data

  • Page data JSON design

Editing platforms generally have two operations: save and publish. Save is used to save the page editing state, and publish is used to publish the page online. These two sets of page data are separate, because the save operation does not affect the previously published page data, the specific page data can be stored in two interfaces, or the same interface but using different fields to distinguish


  const pageData = {
     pageId:'6988736888656330'.test: {   // Save the page data after operation
         pageConfig: {title: 'Page title'.theme: 'blue'.keywords: ' '.description: ' ',},pdList: []// Page component tree
     },
     prod: {    // Publish the page data after operation
         pageConfig: {title: 'Page title'.theme: 'blue'.keywords: ' '.description: ' ',},pdList: []// Page component tree}}Copy the code
  • Page component tree JSON structure design

    Canvas layout adopts flexible streaming layout, and business scenarios need to support multi-level nesting of components, so the data structure design of the page component tree needs to be a recursive structure

 const tree = {
     id: 'App'.rm: {
       rmn: 'topic_emp_base'.// Name of the remote module
       rmp: './EMPBaseContainer'.// Component path
     },
  chs: [{id: 'App_3966'.img: 'https://xxx'.rm: {
       http: {
         prod: 'https://xxx'.test: 'https://xxx'.dev: 'https://xxx'.pathname: 'topic_emp_base.js'./ / js
          projectName: 'Base'.// Remote project name
         moduleName: 'topic_emp_base'.// Name of the remote module
       },
       rmn: 'topic_emp_base'.// The name of the remote module where the component resides
       rmp: '. / components/Base/NewTab_1. 1 '.// Component path
     },
     name: 'general TAB'.chs: [].extend: {},
     pid: 'App'.cts: {
       App_3966_tab1: {
         name: ' '.id: 'App_3966_tab1'.alias: 'tab1'.rm: {
           rmn: 'topic_emp_base'.rmp: './EMPBaseContainer',},chs: [{id: 'App_3966_tab1_7115'.rm: {
               http: {
                 prod: 'https://xxx'.test: 'https://xxx'.dev: 'https://xxx'.pathname: 'topic_emp_base.js'.title: 'Chameleon Special Configuration Platform'.projectName: 'Base'.moduleName: 'topic_emp_base',},rmn: 'topic_emp_base'.rmp: './components/Base/Button',},name: 'button'.chs: [].extend: {
               bgImage:'https://xxx'.text: 'Button component'.interactive: null,},pid: 'App_3966_tab1'],},extend: {},
         theme: ' ',},},}, {id: 'App_5567'.img: 'https://xxx'.rm: {
       http: {
         prod: 'https://xxx'.test: 'https://xxx'.dev: 'https://xxx'.pathname: 'topic_emp_base.js'.title: 'Chameleon Special Configuration Platform'.projectName: 'Base'.moduleName: 'topic_emp_base',},rmn: 'topic_emp_base'.rmp: './components/Base/ImageComp',},category: ['base'].name: 'images'.projectName: 'Base'.chs: [].extend: {},pid: 'App',},],}Copy the code
Obtain components shared by remote applications and render them asynchronously

With the module-federation capability of Webpack5, components can be shared among multiple projects. The platform needs to collect components exposed by multiple lines of business, so it needs to maintain a mapping relationship of service base stations for loading component information. It corresponds to the JS address of each sub-application after deployment

  • Business component library base station list mapping
 {
  prod: 'https://qyxxx.com'.test: 'https://qy-testxx.com'.dev: 'https://qy-testxx.com'.pathname: 'topic_emp_qingyujiaoyou.js'.projectName: 'light language'.title: 'Whisper Thematic Platform'.moduleName: 'topic_emp_qingyujiaoyou'}, {prod: 'https://hagoxxx.net'.test: 'https://hago-testxxx.net'.dev: 'https://hago-testxxx.net'.pathname: 'topic_emp_hago.js'.projectName: 'Hago'.title: 'Hago Thematic Platform '.moduleName: 'topic_emp_hago',}Copy the code
  • Mapping service base stations to external shared components

    
     './Tab': {
      path: 'src/components/Tab/index.tsx'.img: 'https://xxx/61121b285e6bf953ccf6a244'.catagary: ['base'].name: ['general TAB'],},'./components/Banner': {
      path: 'src/components/Banner/index'.img: 'https://xxx/615fb3a8d4e653419f09b253'.catagary: ['base'].name: ['Header component'],}Copy the code
  • Load the module-Federation remote sharing component


async function clienLoadComponent({url, scope, module}) {
  await registerHost(url)
  await __webpack_init_sharing__('default')
  const container: any = window[scope]
  await container.init(__webpack_share_scopes__.default)
  const factory = await container.get(module)
  return factory()
}
const remoteHosts: any = {}
const registerHost = (url: string) => {
  return new Promise((resolve, reject) => {
    if (remoteHosts[url]) {
      resolve(true)
    }
    remoteHosts[url] = {}
    remoteHosts[url].element = document.createElement('script')
    remoteHosts[url].element.src = url
    remoteHosts[url].element.type = 'text/javascript'
    remoteHosts[url].element.async = true
    document.head.appendChild(remoteHosts[url].element)

    remoteHosts[url].element.onload = () => {
      resolve(true)
    }
    remoteHosts[url].element.onerror = () => {
      reject(false)
    }
  })
}


const ImageComp =  clienLoadComponent('https:xxx.js', 'topic_emp_base', './components/ImageComp')



Copy the code
Component access specification design

In order to achieve visual editing, it is also necessary to provide dynamic enabling for components. Dynamic enabling is the ability to provide dynamic editable functions for the components passed in. This part can be realized through form configuration. The component also needs to tell the platform what dynamic parameters it needs. We designed the empConfig field to describe the dynamic types the component needs

import React from 'react'
import {EmpFC} from 'topic_emp_base/components/Base/type'
const Input = React.lazy(() = > import('.. /.. /customConfig/input')) // configurator code split

interface ButtonProps {
  backgroundImage: string
  jsCode: string
  text: string
  buttonStatus: 'active' | 'invalid'
  unButtonBg: string
}


const Button: EmpFC<ButtonProps> = props= > {

  const onClick = (event: any) = > {
    props.jsCode && eval(props.jsCode)
  }
  
  const appid = props.empStore.useStore<GlobalStore>(state= > state.appid)
  const isActive = props.buttonStatus === 'active'


  return (
    <>
      <div
        onClick={onClick}
        className={styles.container_button}
        style={{
          backgroundImage: `url(${isActive ? props.backgroundImage : props.unButtonBg}) `,}} >
        {props.text}
      </div>
      <p>globalEmpConfigAppId:{appid}</p>
    </>
  )
}


Button.empConfig = {
  backgroundImage: {
    type: 'upload'.// Image upload
    defaultValue:'xxx'.label: 'Button background'.group: 'Base Settings'.weight: 100.options: {
      maxSize: 1024 * 300,}},text: {
    type: 'inputText'./ / input box
    defaultValue: 'Button text'.label: 'Button text'.group: 'Base Settings',},custom: {
    defaultValue: ' '.label: 'Custom Render Configurator'.group: 'Advanced Configuration'.type: 'custom'.// Custom configurator
    comp: (props: any) = > {
      return <Input {. props} / >}},extend: {styleEditable: true.styleAttr: ['width'.'height'.'postion'.'top'.'left'].compMenuBar: {
          container: [{name: 'Container box'.alias: 'container',}]}}}Copy the code
Component data configurator

The platform needs to parse the EmpConfig static attribute of the component and render dynamically according to the type. Based on these rules, we encapsulated and extracted EMPForm, a dynamic form library dedicated to low code platforms

 
 Button.empConfig = {
     style: {
      type: 'style'.// Style editor
      label: 'Title Style Editing'.group: 'Base Settings'.weight: 1,},jsCode: {
      type: 'codeEditor'.// Code editor
      label: 'JS code'.defaultValue: 'alert("click")'.group: 'Base Settings'.weight: 1,},text: {
      type: 'inputText'./ / input box
      defaultValue: 'Button text 1'.label: 'Button text 1'.group: 'Base Settings'.weight: 97,},select: {
      type: 'select'./ / a drop-down box
      defaultValue: 'Button state'.label: 'Button state'.group: 'Base Settings'.data:[
          {
              label: 'Inactive button',value:'Inactive button'
          },
           {
              label: 'Activate button',value:'Activate button'},].weight: 97,}}Copy the code
  • EmpForm

import EMPForm from 'src/base-components/setting/form/EmpForm'
import FormItem from 'src/base-components/setting/form/FormItem'

const PList = () = > {

   return <EMPForm>
      <FormItem
        type="style"
        typeCompProps={{
          mode: 'customGroup',
          attrGroupConfig: {
            group:{base properties edit: ['width', 'height', 'top', 'position', 'left'], font related: ['fontSize', 'color', 'textAlign'],}}}} />
      
       <FormItem
        type="switch"
        typeCompProps={{
          checked: false}} / >
      
      <FormItem type="input" /> 
      
    </EMPForm>
}

Copy the code
Real-time edit preview effect implementation

The form configurator on the right side of the platform modifies the data, and components need to be able to be updated in real time. A common way to do this is to design a higher-order component. A div is wrapped for the outer layer of the asynchronously loaded component through higher-order components, which is used to carry the drag-and-insert events of components, etc. At the same time, a set of publish-subscribe logic needs to be implemented, which is used to notify corresponding components of the changes of the subscription form configurator to update in real time (forceRender). The publish-subscribe logic can be handed over to a global state machine, which can be mobx or anything else. Here we use our own state library, imoOK


const baseHoc = (props) = >{
    const {compId} = props
    
    const Comp =  useClienLoadComponent({`http:/xxx,js`.'topic'.'./button'})
    
    // Changes to the subscription form configurator data
    const compData = store.useState(state= > state[compId] )

    return <div
             data-id={compId}
            >
              <Comp {. compData} ></Comp>
         </div>
  
}



Copy the code
Style editor implementation

Visual editing is absolutely indispensable for style visualization. We need to implement a style editor that allows the component’s outer divs to edit styles, as well as to style any div in the component. Instead, you need to access the style editor capability by declaring it directly on the empConfig component



const Button: EmpFC<ButtonProps> = props= > {

  return (
    <>
      <div
        className={styles.btn}
        style={{ . props.emp.style }}
        >
        {props.text}
      </div>
      <p style={props.titleStyle.style}> click me </p>
    </>
  )
}


Button.empConfig = {
 titleStyle: {type: 'style'.label: 'Header Style',
    typeCompProps={{
         mode: 'customGroup'.attrGroupConfig: {
             group{base properties edit: ['width'.'height'.'top'.'position'.'left'], font related: ['fontSize'.'color'.'textAlign'],},},}}},extend: {styleEditable: true.styleAttr: ['width'.'height'.'postion'.'top'.'left'].// If not, use the default properties}}}Copy the code

Component style editing

Node style editing within the component

Cross component communication

In some cases, the business side needs to communicate across components, for example, the login state of the page needs to be shared, and all building blocks on the page need to be accessible. Because we have a multi-line of business scenario, we need to initialize a local state for each business for the respective line of business datastore. We can identify which business a component belongs to based on its remote module name. Initialize a local state machine for each business in the higher-order component and drop it in the component props


interface QingyuStore {
    age:number.name:string
}
const Button = (props) = >{

  // Business state machine value, data initialization in baseHoc higher-order component
  const obj = props.empStore(state= > ({age: state.age ,name:state.name }))
  
  useEffect(() = >{
     props.empStore.set<QingyuStore>({age:16.name:'xx'})
  },[])
  
    
  return <div
            onClick={()= >{
               props.empStore.set<QingyuStore>({age: Math.random() })
            }}
          >
           click me 
      </div>
}

Copy the code