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
- Operations creates blank pages
- Filter components by component list and drag to canvas (page preview area)
- Dynamically enable the component using the right editor (modify the component props)
- Save the page (publish the test)
- Test the page with a pre-release environment
- 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