组件是爱速搭的前端页面可视化模块的核心能力之一,它将前端研发人员从无休止的页面样式微调和分辨率兼容工作中解放了出来。
目前,爱速搭通过内置的上百种功能组件(120+),基本可以覆盖大部分中后台页面的可视化设计场景。组件的相关的设计理念和实现细节我们可以在前文面向复杂业务场景下的低代码平台组件设计与实践分享中看到。
实际开发的过程中,前端研发人员往往会面临大量定制 UI 或者有极为复杂交互的页面,使用单一的组件实现会比较困难。因此,爱速搭提供了自定义组件扩展机制,让开发人员可以定制化开发功能组件和业务组件,丰富可视化搭建物料,从而实现复杂业务场景的页面的设计与搭建。
目前爱速搭已支持三种自定义组件扩展方式:Custom 自定义组件、线上版自定义组件和 NPM 组件扩展包。对于交互和功能较为简单的自定义组件,可以使用 Custom 自定义组件和线上版自定义组件;而对于复杂的功能,我们建议使用 NPM 组件扩展包方式实现。
本文主要介绍 NPM 组件扩展包的设计原理和开发实践。
1 组件扩展包功能简介
NPM 组件扩展包是一种通过导入 NPM 包来扩展和补充爱速搭组件物料的机制,支持本地 IDE 纯 Coding 的开发模式,自由度较高,可用于开发任何复杂业务场景的自定义组件。
为了方便用户在本地开发自定义组件,百度智能云低代码平台爱速搭提供了一套自定义组件扩展包的开发工具(amis-widget-cli),支持各类自定义组件模板的下载、本地预览&调试、平台预览(linkDebug)和编译等一系列工具。
NPM组件扩展包主要包括如下四个功能点:
支持同时扩展多个自定义组件
一个组件扩展包中可放置多个不同类型的自定义组件,比如 multiple-custom-widget-template 内包括三种技术栈的自定义组件。如果我们将该组件添加到爱速搭应用中,会同时导入 react-info-card、hello-jquery、vue-info-card 三种不同类型的自定义组件。
支持多种技术栈
爱速搭的组件扩展支持 Vue 2.0、Vue 3.0、React、jQuery 和 UniApp 等多种技术栈,用户可以使用自己熟悉的一种技术栈来开发自定义组件。
支持三种自定义组件本地预览&调试方式
组件预览模式(preview): 用于预览当前自定义组件内容。
本地开发模式(dev):在本地页面设计器中预览&调试自定义组件,用于确认页面设计器能否正常使用和展示当前自定义组件。 外链调试(linkDebug):在爱速搭中预览&调试本地的自定义组件,用于确认爱速搭平台中能否正常使用和展示当前自定义组件。
amis 自定义组件:用于扩展和补充 web 应用/普通页面的自定义组件物料。 小程序自定义组件:用于扩展和补充小程序应用页面的自定义组件物料。 快应用自定义组件:用于扩展和补充快应用和快应用卡片的自定义组件物料。
2 组件扩展包工作原理
NPM 组件扩展包的运行框架图如下所示:
「amis 渲染器」是页面渲染时需要调取的模块,如果没有它则意味着页面中不能正常显示自定义组件内容。 「amis-editor 插件」是打通页面设计器的关键功能模块,可用于设置自定义组件在页面设计器中的组件物料面板中的显示位置(哪个分类下展示,展示顺序是什么,描述信息是什么等),也可用于设置首次插入页面时的初始数据是什么,所有和页面设计器关联的数据都在 amis-editor 插件中。
2.2 本地开发 NPM 组件扩展包
NPM 扩展包编译时默认会剔除第三方 npm 依赖,使用多个组件扩展包时,第三方依赖只会打包一次,避免平台运行时重复加载第三方依赖功能的代码。
尽可能将同一类型的自定义组件放在一个组件扩展包中,多个自定义组件或者多个组件扩展包用到的公共模块建议封装成单独的NPM模块,最后通过 NPM 依赖的形式进行引用。
2.3 在应用中添加 NPM 组件扩展包
3 多技术栈支持
将 Vue 2.0 组件对象转换成 React 组件关键方法
import React from 'react';
import ReactDOM from 'react-dom';
import Vue from 'vue';
import { ScopedContext, IScopedContext, RendererProps } from 'amis-core';
import { extendObject } from '../utils';
export function createVue2Component(vueObj: any) {
if (!vueObj || (typeof vueObj !== 'function' && typeof vueObj !== 'object')) {
return;
}
class VueFactory extends React.Component<RendererProps> {
domRef: any;
vm: any;
isUnmount: boolean;
static contextType = ScopedContext;
constructor(props: RendererProps, context: IScopedContext) {
super(props);
this.domRef = React.createRef();
const scoped = context;
scoped.registerComponent(this);
this.resolveAmisProps = this.resolveAmisProps.bind(this);
this.renderChild = this.renderChild.bind(this);
}
componentDidMount() {
const { amisData, amisFunc } = this.resolveAmisProps();
const { data, ...rest } = (vueObj =
typeof vueObj === 'function' ? new vueObj() : vueObj);
const vueData = typeof data === 'function' ? data() : data;
const curVueData = extendObject(vueData, amisData);
this.vm = new Vue({
...rest,
data: () => curVueData,
props: extendObject(amisFunc, rest.props || {}),
});
Object.keys(amisFunc).forEach((key) => {
this.vm.$props[key] = amisFunc[key];
if (key === 'render') {
this.vm.$props['renderChild'] = (
schemaPosition: string,
childSchema: any,
insertElemId: string,
) => {
this.renderChild(schemaPosition, childSchema, insertElemId);
};
}
});
this.domRef.current.appendChild(this.vm.$mount().$el);
}
renderChild(
schemaPosition: string,
childSchema: any,
insertElemId: string,
) {
let childElemCont = null;
if (this.props['render'] && childSchema && insertElemId) {
const childElem = this.props['render'](schemaPosition, childSchema);
childElemCont = ReactDOM.render(
childElem,
document.getElementById(insertElemId),
);
}
return childElemCont;
}
componentDidUpdate() {
if (!this.isUnmount) {
const { amisData } = this.resolveAmisProps();
if (this.vm) {
Object.keys(amisData).forEach((key) => {
this.vm[key] = amisData[key];
});
this.vm.$forceUpdate();
}
}
}
componentWillUnmount() {
this.isUnmount = true;
const scoped = this.context as IScopedContext;
scoped.unRegisterComponent(this);
this.vm.$destroy();
}
resolveAmisProps() {
let amisFunc: any = {};
let amisData: any = {};
Object.keys(this.props).forEach((key) => {
const value = this.props[key];
if (typeof value === 'function') {
amisFunc[key] = value;
} else {
amisData[key] = value;
}
});
return { amisData, amisFunc };
}
render() {
return <div ref={this.domRef}></div>;
}
}
return VueFactory;
}
将 Vue 3.0 组件对象转换成 React 组件的关键方法
import React from 'react';import { ScopedContext, IScopedContext, RendererProps } from 'amis-core';import { createApp, getCurrentInstance, ref, isProxy, shallowRef } from 'vue';import { isObject, extendObject } from '../utils';export function createVue3Component(vueObj: any) { if (!vueObj || (typeof vueObj !== 'function' && typeof vueObj !== 'object')) { return; } class VueFactory extends React.Component<RendererProps> { domRef: any; app: any; vm: any; isUnmount: boolean; static contextType = ScopedContext; constructor(props: RendererProps, context: IScopedContext) { super(props); this.domRef = React.createRef(); const scoped = context; scoped.registerComponent(this); this.resolveAmisProps = this.resolveAmisProps.bind(this); } componentDidMount() { const { amisData, amisFunc } = this.resolveAmisProps(); const { data, ...rest } = (vueObj = typeof vueObj === 'function' ? new vueObj() : vueObj); const vueData = typeof data === 'function' ? data() : data; const curVueData = extendObject(vueData, amisData); this.app = createApp({ data: () => curVueData, ...rest, props: extendObject(amisFunc, rest.props || {}), }); this.vm = this.app.mount(this.domRef.current); } componentDidUpdate() { if (!this.isUnmount) { const { amisData } = this.resolveAmisProps(); if (this.vm) { Object.keys(amisData).forEach((key) => { this.vm[key] = amisData[key]; }); this.vm.$forceUpdate(); } } } componentWillUnmount() { this.isUnmount = true; const scoped = this.context as IScopedContext; scoped.unRegisterComponent(this); this.app.unmount(); } resolveAmisProps() { let amisFunc: any = {}; let amisData: any = {}; Object.keys(this.props).forEach((key) => { const value = this.props[key]; if (typeof value === 'function') { amisFunc[key] = value; } else { if (isProxy(value)) { amisData[key] = shallowRef(value); } else if (isObject(value)) { amisData[key] = ref(value); } else { amisData[key] = value; } } }); return { amisData, amisFunc }; } render() { return <div ref={this.domRef}></div>; } } return VueFactory;}
4 小程序和快应用自定义组件扩展机制
5 NPM 组件扩展包开发实战
下面我们将通过一个简单的示例(使用 Vue 2.0)来开发并发布一个 amis 组件扩展包。
需要准备的环境
node(推荐 v17.4.0,或更新版本) npm(推荐 8.3.1,或更新版本)
需要使用到的 NPM 工具:
amis-widget-cli(自定义组件开发脚手架) amis-widget(自定义组件注册器,支持 React 和 Vue2.0 技术栈,用于注册自定义渲染器和插件)
开发 amis 组件扩展包关键步骤:
步骤 1:全局安装 amis-widget-cli
步骤 2:初始化 NPM 组件扩展包
步骤 3:开发一个自定义组件
步骤 4:注册为一个爱速搭可用的 amis 自定义组件
import InfoCard from './widget/info-card';
import { registerRendererByType } from 'amis-widget';
registerRendererByType(InfoCard, {
type: 'vue-info-card',
usage: 'renderer',
weight: 99,
framework: 'vue',
});
步骤 5:为 amis 自定义组件设置基本属性和可配置项
import { registerAmisEditorPlugin } from 'amis-widget';
export class InfoCardPlugin {
rendererName = 'vue-info-card';
$schema = '/schemas/UnkownSchema.json';
name = 'vue组件';
description = '信息展示卡片';
tags = ['自定义'];
icon = 'fa fa-file-code-o';
scaffolds = [
{
type: 'vue-info-card',
label: 'vue组件1',
name: 'info-card1',
scaffold: {
type: 'vue-info-card',
label: 'vue组件1',
title:
'amis 是一个低代码前端框架,它使用 JSON 配置来生成页面,可以减少页面开发工作量,极大提升效率。',
backgroundImage:
'https://suda.cdn.bcebos.com/widget-tpl/%E6%99%BA%E8%83%BD%E7%94%9F%E6%80%81.png',
img_count: 5,
comment_count: 2021,
},
}
];
previewSchema = {
type: 'vue-info-card',
label: 'vue-info-card',
};
panelTitle = '配置';
panelControls = [
{
type: 'textarea',
name: 'title',
label: '卡片title',
value:
'amis 是一个低代码前端框架,它使用 JSON 配置来生成页面,可以减少页面开发工作量,极大提升效率。',
},
{
type: 'text',
name: 'backgroundImage',
label: '展示图片',
value:
'https://search-operate.cdn.bcebos.com/64c279f23794a831f9a8e7a4e0b722dd.jpg',
},
{
type: 'input-number',
name: 'img_count',
label: '图片数量',
value: 3,
},
{
type: 'input-number',
name: 'comment_count',
label: '评论数',
value: 2021,
},
];
}
registerAmisEditorPlugin(InfoCardPlugin);
export default InfoCardPlugin;
步骤 6:本地预览&调试自定义组件内容
步骤 7:在爱速搭中调试自定义组件
步骤 8:发布 NPM 组件扩展包
构建自定义组件静态脚本
在 package.json 中声明自定义组件信息
发布到 npm 仓库