前言

UI(User Interface),即用户界面,是软件和用户之间进行交互和信息交换的媒介,实现信息的内部形式与人类可接受形式间的转换。UI开发一般需要经过UI设计、UI实现两个过程。UI设计是对软件的交互、操作逻辑、界面的设计,通常由UI设计师和交互设计师按照用户对软件的需求完成一套UI界面的设计,并最终以UI设计稿的形式呈现(psd、png、jpeg文件等)。UI实现是对UI设计阶段产生的UI设计稿进行编码实现,这部分是前端工程师的任务。

​ 随着互联网的快速发展,从最早只有简单的超文本文档内容,逐渐发展成丰富多彩的灵活动态体验平台,各种手机App,PC端应用和网站更是多得迎接不暇。用户从最早只注重软件功能的实现,到如今不仅需要软件功能实现,还对软件整体UI界面非常挑剔。目前软件为满足用户的审美,软件UI被设计的越来越复杂,无论是布局还是元素样式,前端开发起来越来越费劲,开发成本越来越高,并且对于大量需要快速上线的页面,没有足够的人力物力去开发。

​ 在字节跳动直播活动中台-前端的业务中,经常需要开发多个平台的活动页面。而活动页面通常布局、逻辑相似、需求频率高且需要快速迭代。如果使用常规的开发方式去开发一个活动页面,需要产品、前端、服务端、测试等多方参与,并且每一个活动页面上线周期长,无法快速响应产品的需求。对于活动页面开发, 较优的流程是使用页面可视化搭建平台来实现,即直播活动中台的魔方平台。平台基于DOM实现了一个组件化的UI编辑器,并且提供封装良好的UI组件供运营同学使用,以此完成一个活动页面。从以前需要4人天完成活动页面的开发,到2小时就能拖拽出一个活动页面并且上线,极大的提高了页面开发效率。

​ 但魔方平台也有一定的局限性,由于只需要针对活动相关业务,因此平台只能适用于活动页面的生成。通过拓展JSON来定义schema的形式描述一个编辑的UI页面,而基于JSON的schema描述能力有限,只能通过对应的client端去解析schema来还原UI页面,并且不能适用到其他平台。

​ 因此基于魔方平台提出了更通用的UI编辑App,将拖拽出来的页面使用更加通用的DSL来描述,并能将DSL代码编译到各平台代码。类似于阿里Imgcook,基于WebGL实现UI编辑器,基于DSL编译到多端代码,提升UI开发效率。

运行效果展示&所用技术

运行效果展示

主页面:左侧提供基础组件,中间则是使用WebGL实现的UI编辑器,右侧实现对选中的UI组件的属性修改

代码编译:将当前UI页面生成到目标代码,并导出相应的代码文件

DSL编辑页面:提供DSL代码的编辑,并生成到UI页面

所用技术

一般的拖拽式UI生成平台会做成一个网站,本文则是尝试将其实现为一个Electron App。

  • Electron: Electron是使用Web前端技术(HTML/CSS/JavaScript/React等)来创建原生跨平台桌面应用程序的框架。可以使用electron-react-boilerplate模版快速使用React去开发,但本文则是使用手动搭建React环境,使用Webpack、Electron-builder完成资源打包和App构建,参考文章:使用Webpack/React去打包构建Electron应用。

  • Node.js:Node.js是一个开源、跨平台、基于Chrome V8引擎的JavaScript运行时,可以让JavaScript运行在服务端环境下。Node.js采用单线程、异步非阻塞IO、事件驱动架构,使得Node.js在处理IO密集型任务时效率极高。

  • React:React是一个用于构建Web UI的JavaScript库,允许开发者以数据驱动、组件化、声明式的方式编写UI。

  • WebGL:是一种在Web端运行的3D绘图协议,这种绘图协议把JavaScript和OpenGL ES2.0结合起来,提供硬件加速3D渲染并借助显卡来在浏览器里渲染3D场景和模型。WebGL技术的诞生解决了现有的Web 3D渲染的两个关键问题:1.跨平台,使用原生的canvas标签即可实现3D渲染。2.渲染效率高,图形的渲染基于底层的硬件加速实现。

  • Konva:一个基于Canvas开发的2D JavaScript库,可以轻松的用于实现桌面应用和移动应用的图形交互效果,可以高效实现动画、变换、节点嵌套、局部操作、滤镜、缓存、事件等功能。Konva最大的特点是图形可交互,Konva的所有的图形都可以监听事件,实现类似于原生DOM的交互方式。事件监听是在层(Konva.Layer)的基础上实现的,每一个层有一个用于显示图形的前台渲染器和用于监听事件的后台渲染器,通过在后台渲染器中注册全局事件来判断当前触发事件的图形,并调用处理事件的回调。Konva很大程度上借鉴了浏览器的DOM,比如Konva通过定义舞台(Konva.Stage)来存储所有图形,类似于html标签,定义层来显示图形,类似于body标签。其中的节点嵌套、事件监听、节点查找等等也借鉴了DOM操作,这使得前端开发者可以很快速的上手Konva框架。

应用设计

需求分析

App核心功能包括WebGL UI编辑器和DSL代码编辑器以及DSL代码编译器,系统功能需求如下图。

  • 基础功能:系统需要实现基础的登录注册功能、登出功能、全局快捷键绑定等功能。

  • UI编辑器:可视化WebGL UI编辑器,提供基础的通用UI组件库,允许用户通过拖拽基础的通用UI组件库的组件来绘制一个UI页面;提供组件工具栏,允许用户对画布上的组件进行复制、删除、粘贴、重做等操作。提供组件的属性面板,允许用户对组件的背景、边框、位置、大小等属性进行修改;提供DSL代码构建工具栏,允许用户将画布上的UI页面生成到DSL代码,进而编译DSL代码到目标平台代码。

  • DSL代码编辑器:提供一个编写DSL代码的编辑器,支持代码高亮、复制、粘贴、保存等功能。提供文件系统,允许用户新建、删除一个DSL代码文件;提供代码运行工具,将DSL代码生成到UI页面或者生成到目标代码。

  • 帮助中心:DSL代码语法帮助、UI编辑器使用帮助。

整体架构设计

系统采用Client/Server模式进行架构,前后端分离方式开发,Client端为Electron App,服务端则使用Express实现。

  • Client端,采用Electron、React、Node.js来实现一个跨平台的PC端App。

  • Server端,基于Node.js Express编写的服务端,并暴露出相应的API供Client端调用。集成WebSocket服务,独立运行在Node.js侧,共享相应的数据库连接等公共类和函数,提供Socket支持。并基于Niginx搭建一个静态资源服务器,提供图片等文件的存储服务。

  • 数据库使用MySQL/MongoDB数据库,MongoDB存储UI页面信息,比如UI元素位置、大小、样式等信息,以及其他类JSON形式的信息。MySQL存储用户信息、组件信息等一些基础信息。

Client端架构设计

Client端是一个PC端应用,采用Electron技术进行开发。Electron虽然是使用前端技术来创建跨平台应用的框架,但又与传统的网站开发方式不一样。Electron基于主从进程模型,即由一个主进程和多个渲染进程组成,进程之间使用IPC进行通信。基于这种进程模型,对系统进程进行功能划分:

  • 主进程负责进程间通信、窗口管理、服务端请求和native C++插件加载
  • 渲染进程只负责Web页面的渲染和具体的业务逻辑

渲染进程使用Typescript/React/Redux开发,借助React Hooks可以更好的将通用UI逻辑抽离,提高代码复用率。主进程使用Typescript/C++开发,其中C++开发Node.js插件并打包成.node文件,主进程加载.node文件从而调用到C++代码。借助Webpack编译工具,将渲染进程所有代码编译为index.htmlrenderer.jsstyle.css并进行代码压缩和代码分割优化,提高代码运行效率。主进程所有代码编译只编译为一个main.js,并在main.js中加载渲染进程的index.html完成整个系统的运行。最后再利用electron-builder将编译后的主进程代码和渲染进程代码以及其他资源文件打包成一个.dmg应用文件,完成整个系统的构建。

主进程设计

Client端主进程可分为三部分模块:widget模块、services模块、compile模块。

  • Widget模块负责窗口创建和管理,比如创建login窗口,实现最小化、关闭login窗口等IPC调用。

  • Services模块负责提供系统基础服务,包括IPC调用服务,用于渲染进程与主进程之间的通信;fetch服务,提供后端接口调用能力;session服务,存储用户session,记录登录等信息;socket服务,提供后端socket连接;fileSave服务,提供文件保存功能。

  • Compile模块负责执行DSL代码编译,通过实现多种编译器来实现多平台代码构建。

渲染进程设计

在渲染进程打包过程中,采用多页面打包设计,将部分UI页面从一个渲染进程中分离,设计成多个独立的新窗口(渲染进程),开发时在每个渲染进程中都注入模块热更新代码实现开发环境页面热更新。在Webpack的entry字段中添加多个页面入口实现独立打包,并且每个打包页面使用HtmlWebpackPlugin插件生成对应的HTML文件。主进程实例化一个独立窗口加载对应页面打包后的index.html完成一个新窗口的创建。

在多个窗口中,主窗口是系统最核心的窗口,实现的模块和功能相对复杂,使用React Hooks开发的组件避免不了相互通信,故使用采用Redux进行全局状态管理,优化组件间的通信流程。

在Redux的工作流中,将state提取到Redux状态树store中存储,通过dispatchaction进入reducer去更新state,更新完state后触发一次React render去更新视图。设计Redux状态树的关键点在于抽离组件状态,将多个组件依赖的状态抽离到Redux状态树中,并在组件使用useSelectorHooks订阅状态树中的某个状态,使用useDispatch获取dispatch去更新Redux状态树中的某个状态。

在主窗口渲染进程中,包括Redux模块、Page模块、Components模块、WebGL模块。

  • Page模块,主窗口页面类似于单页应用,每一个子页面就在Page下实现,包括UI编辑器子页面、DSL代码编辑器子页面等等。

  • Redux模块,实现Redux基本事件流store、action、reducer,用于组件间通信。

  • Components模块,通用UI组件实现,比如toast、modal等通用组件。

  • WebGL模块,基于WebGL原生JavaScript实现UI画布和UI组件以及一些相关工具函数。

Sever端架构设计

Server端使用Node.js Express框架搭建,在Express的基础上进行封装、扩展。

  • Core层是对Express的封装以及扩展,包括实现App类、Middleware抽象类、Controller抽象类,以及defineRouter路由装饰器等。

  • Services是对基础服务的封装以及第三方服务的调用,如文件上传、文件下载等。

  • Socket是对Socket服务的抽象,提供Socket类来支持服务端Socket功能,底层基于SocketIO开发。

  • Controller是具体业务逻辑控制器的实现,利用类来抽象一个业务,利用路由装饰器对类中方法进行装饰来表达一个业务逻辑。

  • Database提供对MySQL、MongoDB的连接和操作的抽象。

  • Model提供数据库表的基本模型,包括User表、WebGLPage表等。

服务端使用Typescript编程语言实现,在运行时根据tsconfig.json来运行tsc命令来将所有Typescript文件编译成JavaScript并在Node.js环境下运行。

数据库设计

MongoDB是键值数据库,存储结构类似于JSON,具有一定的层级结构,能够很好的表示一个正在编辑中的UI页面状态。所以系统利用MongoDB的这种特性来存储每一个正在编辑的UI页面信息,存储结构如下。

DSL语法设计

DSL(Domain Specific Language),即特定领域语言,是一种为特定领域而设计,表达性受限的编程语言,包含内部DSL和外部DSL两种:

1.外部DSL 与传统编程语言不通,外部DSL通常采用自定义语法,并利用相应的编程语言去解析DSL代码。比如正则表达式、SQL和一些配置文件等。

2.内部DSL 是编程语言的一个特定语法表现,用内部DSL写成的代码是一段合法的程序,只不过具有特定的风格,而且用到了编程语言的一部分特性,仅用于处理系统的某些特定问题。

系统使用外部DSL定义,用于描述一个UI页面,并对DSL进行解析生成目标代码。DSL语法设计参考了SCSS语法,采用一个嵌套结构来表达UI页面嵌套关系。对UI页面中的组件进行属性抽象,得到了以下DSL语法的定义:

1.以Type.name形式表达一个组件的类型和名称,以“{”开头,以“}”结尾,将组件的属性和相关信息进行包裹。

2.组件属性定义为两类,基础属性和样式属性,基础属性关键字包括position、size、text、image,样式属性以style关键字定义,用大括号进行包裹,内层属性包括background、border、shadow。属性与属性之间使用“;”分开。

3.一个属性的参数使用空格进行分隔,末尾使用“;”号结束一个属性的定义。

4.使用children关键字表达一个组件的所有子组件,使用“[”和“]”对所有的子组件进行包裹,子组件DSL代码以“,”分开。

一个简单的DSL组件定义如下。

功能实现

主进程相关服务实现

Client端采用主进程与渲染进程分离模式开发,主进程实现Session管理,Socket连接,服务端接口调用,页面通信等服务。

1.Session服务的实现 主进程对Session进行全局管理,存储用户的登录信息。在Electron中可以使用sessionAPI来获取当前session

export const Session = {setCookies(name: string, value: string) {const Session = session.defaultSession; // 主进程中获取默认sessionconst Cookies = Session.cookies; // 获取cookiesreturn Cookies.set({url: domain,name,value,});},getCookies(name: string | null = null) {const Session = session.defaultSession;const Cookies = Session.cookies;if (!name) return Cookies.get({ url: domain });return Cookies.get({ url: domain, name });},clearCookies(name: string) {const Session = session.defaultSession;return Session.cookies.remove(domain, name);}
};

2.Socket连接实现与封装 服务端使用SocketIO库实现一个Socket服务,同样在主进程使用SocketIO库来建立一个Socket连接

class SocketService {static instance: SocketService | null = null;static getInstance() {return !SocketService.instance ? (SocketService.instance = new SocketService()) : SocketService.instance;}private socket: SocketIOClient.Socket;constructor() {this.socket = SocketIO(url);this.socket.on('connect', () => { // 连接console.log('connect !');})}emit(event: string, data: any) {if (!this.socket.connected) this.socket.connect();this.socket.emit(event, data);}on(event: string, callback: Function) {this.socket.on(event, callback)}
}

3.fetch服务端调用实现与封装 主进程中使用Node.js request模块来实现服务端接口请求,渲染进程则通过IPC调用来间接使用request模块,进而实现服务端接口的请求

export const fetch = {get(url: string, data: any) {return fetch.handle('GET', url, data);},post(url: string, data: any) {return fetch.handle('POST', url, data);},handle(method: 'GET' | 'POST', url: string, data: any) { // 封装request模块return new Promise((resolve, reject) => {const params = {method,baseUrl,url,...(method === 'GET' ? { qs: data } : { form: data })};request(params, (err, res, body) => {try {if (err) {reject(err);return;}resolve(JSON.parse(res.body));} catch (e) {reject(e);}});});}
};

4.IPC进程间通信实现与封装 渲染进程与主进程的通信是整个系统的核心,合理的定义通信接口能提高系统运行效率。在主进程中,Electron提供ipcMain对象来处理渲染进程的消息;在渲染进程中,使用ipcRenderer处理主进程的消息。例如服务端请求逻辑的IPC调用,主进程使用ipcMain.handle注册IPC调用

export const handleFetch = () => {ipcMain.handle(IpcEvent.FETCH, async (event, args: { method: 'GET' | 'POST', url: string, data: any }) => {return await fetch.handle(args.method, args.url, args.data); // fetch});
};

渲染进程调用

function fetch(method: 'GET' | 'POST', url: string, data: any = null) {return ipcRenderer.invoke(IpcEvent.FETCH, {method,data,url}).catch(console.error);
}// fetch('GET', '/user/login', { email, password });

5.编译逻辑封装 渲染进程通过IPC调用将DSL代码发送到主进程,主进程调用编译服务完成代码编译并把结果返回到渲染进。一般DSL代码的解析都是生成到抽象语法树,再对抽象语法树进行节点的修改最后生成到目标代码。但是考虑到设计的DSL较为简单,只需要利用正则表达式解析相应的属性并拼接到JSON即可

parser.id_index = 0;
export function parser(str: string): any {let childrenMatch = str.match(/children\s*:\s*\[(.+)/);const childrenToken = childrenMatch ? childrenMatch[1].trim().replace(/\]\s*\}$/, '').trim() : '';if (childrenMatch) {str = str.substring(0, childrenMatch.index);}const children = getChildrenToken(childrenToken); // 子组件tokenlet nameMatch = str.match(/^[\w\d\.\s]+\s*{/); // 解析组件type, nameconst [type, name] = nameMatch ? nameMatch[0].replace('{', '').trim().split('.') : ['', ''];let positionMatch = str.match(/position\s*:([^;]+);/); // 组件position属性const [x = 0, y = 0] = positionMatch ? positionMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];let sizeMatch = str.match(/size\s*:([^;]+);/); // 组件size 属性const [width = 0, height = 0] = sizeMatch ? sizeMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];let backgroundMatch = str.match(/background\s*:([^;]+);/); // 组件background属性const [fill = 'white', opacity = 0] = backgroundMatch ? backgroundMatch[1].trim().split(' ') : ['', ''];let shadowMatch = str.match(/shadow\s*:([^;]+);/); // 组件shadow属性let [offsetX = 0, offsetY = 0, blur = 0, shadowFill = 'white'] = shadowMatch ? shadowMatch[1].trim().split(' ').map((v, i) => {if (i === 3) return v;return Number.parseInt(v);}) : [0, 0, 0, ''];let borderMatch = str.match(/border\s*:([^;]+);/); // 组件border属性const [borderWidth = 0, radius = 0, borderFill = 'white'] = borderMatch ? borderMatch[1].trim().split(' ').map((v, i) => {if (i === 2) return v;return Number.parseInt(v);}) : [0, 0, ''];let textMatch = str.match(/text\s*:([^;]+);/); // 组件text属性const textMatchRes = textMatch ? textMatch[1].trim() : '';let text = textMatchRes.match(/'(.+)'/);if (text) {text = (text[0] as any).replace(/^'/, '').replace(/'$/, '');}let textFill = textMatchRes.split(' ');textFill = (textFill[textFill.length - 1] as any).trim();let imageMatch = str.match(/image\s*:([^;]+);/); // 组件image属性const src = imageMatch ? imageMatch[1].trim().replace(/^'/, '').replace(/'$/, '') : '';return { // 拼接JSONname,type: type.toLocaleUpperCase(),id: `${type.toLocaleUpperCase()}-${name}-${parser.id_index++}`,props: {position: { x , y },size: { width, height },...(backgroundMatch ? { background: { fill, opacity: +opacity } } : {}),...(shadowMatch ? {shadow: {offsetY,offsetX,blur,fill: shadowFill}} : {}),...(borderMatch ? {border: {width: borderWidth,radius: radius,fill: borderFill}} : {}),...(textMatch ? { text: { text, fill: textFill } } : {}),...(imageMatch ? { image: { src } } : {})},children: children.map(str => parser(str)) // 递归解析子组件token};
}
// 计算子组件token
function getChildrenToken(childrenToken: string) {let count = 0;let child = '';const result = [];for (let i = 0; i < childrenToken.length; i++) {child += childrenToken[i];if (childrenToken[i] === '{') {count++;}if (childrenToken[i] === '}') {count--;}if ((childrenToken[i] === ',' && count === 0) || (count === 0 && i === childrenToken.length - 1)) {result.push(child.replace(/,$/, '').trim());child = '';}}return result;
}

而生成目标代码的过程则是根据JSON对象的组件类型进行条件判断

function compileToElementToken(obj: any): any {switch (obj.type) {case TYPES.WIDGET: { // return (`<div id="${obj.id}">${obj.children.map((v: any) => compileToElementToken(v)).join('\n')}</div>`);}case TYPES.BUTTON: {return (`<button id="${obj.id}">${obj.props.text ? obj.props.text.text : ''}</button>`);}case TYPES.SHAPE: {return (`<div id="${obj.id}">${obj.children.map((v: any) => compileToElementToken(v)).join('\n')}</div>`);}case TYPES.TEXT: {return (`<div id="${obj.id}">${obj.props.text ? obj.props.text.text : ''}</div>`);}case TYPES.INPUT: {return (`<input id="${obj.id}" placeholder="some text"/>`);}case TYPES.IMAGE: {return (`<img id="${obj.id}" src="${obj.props.image ? obj.props.image.src : ''}" alt="none"/>`);}}
}

最后拼接成目标代码

const jsonObject = compileToJson(code);
let style = (`
* { box-sizing: border-box; margin: 0; padding: 0 }
html, body { height: 100%; width: 100% }
${compileToStyleToken(jsonObject)}`).replace(/\n(\n)*(\s)*(\n)*\n/g, '\n');
let div = compileToElementToken(jsonObject).replace(/\n(\n)*(\s)*(\n)*\n/g, '\n');
const html = (`<!DOCTYPE>
<html lang="zh">
<head><title>auto ui</title></head>
<style>${style}</style>
<body>${div}</body>
</html>`);

主进程多窗口管理

Client端App由用户信息窗口、主窗口、登录窗口、头像选择窗口等若干窗口组成,每一个窗口都是一个独立的渲染进程,主进程负责管理所有的窗口。Electron本身并没有提供多窗口的管理,因此需要手动去管理每一个窗口的状态、窗口间的交互逻辑等。

App中将每一个窗口抽象成一个Widget类,由于窗口的特殊性,每一个Widget类都基于单例模式去设计。

父类Widget实现IWidget接口,实现一个窗口基本的功能,比如create()创建窗口,close()关闭窗口等。其子类是一个单例类,使用静态方法getInstance()去获取。每一个窗口都是一个frame窗口,即去除了操作系统的状态栏装饰,因此需要手动实现关闭、最小化、最大化窗口以及窗口的拖拽的功能。对于窗口拖拽,在Electron中可以使用-webkit-app-region: drag一行CSS属性去实现。对于关闭、最小化、最大化窗口则是通过在渲染进程中调用注册的关闭、最小化、最大化窗口的IPC调用实现。

Widget类的create()方法是创建窗口的关键方法,使用Electron.BrowserWindow去实例化一个窗口,并用实例对象的loadURL()loadFile()去加载.html文件渲染出页面,并注册相应的事件

// DSL代码预览窗口
export default class CodeWidget extends Widget {static instance: CodeWidget | null = null;static getInstance() {return CodeWidget.instance ? CodeWidget.instance : (CodeWidget.instance = new CodeWidget());}constructor() {super();// 窗口关闭事件onCloseWidget((event, args: { name: string }) => {if (args.name === WidgetType.CODE) {if (this._widget) {this._widget.close();}}});}create(parent?: Electron.BrowserWindow, data?: any): void {if (this._widget) return;// 实例化窗口this._widget = new Electron.BrowserWindow({...CustomWindowConfig,parent,width: 550,height: 600,resizable: false,minimizable: false,maximizable: false});//加载.html文件loadHtmlByName(this._widget, WidgetType.CODE);// 初始数据if (data) {this._widget.webContents.on('did-finish-load', () => {this._widget?.webContents.send('code', data);});}parent?.on('close', () => this.reset());this._widget.on('close', () => this.reset());}
}

多个窗口之间避免不了相互间的通信,比如头像选择窗口和用户信息窗口的通信。用户信息窗口点击修改头像打开头像选择窗口,头像选择窗口选择完头像后需要将选择结果发送到用户信息窗口。

窗口间的通信最简单的方式是使用ipcMain对象和ipcRenderer对象去实现,即在一个窗口的渲染进程中向主进程中发送消息,主进程再向另一个窗口的渲染进程中发送消息,实现两个窗口的通信。

但在这种实现模式下,需要额外定义事件名,并需要利用主进程去实现两个窗口的通信。因此Electron提供了更方便的remote模块,可以在不发送进程间消息的方式实现通信。Electron的remote模块类似于Java的RMI(Remote Method Invoke,远程方法调用),一种利用远程对象互相调用来实现双方通信的一种通信机制。对应有父子结构的窗口,通信时只需要在子窗口中使用remote方法向父窗口中的渲染进程发送消息即可

remote.getCurrentWindow().getParentWindow().webContents.send('avatar-data', { ...avatar });

其中remote的通信机制大致原理如下图。

Client端UI画布实现

UI画布是系统的核心之一,基于WebGL Konva框架实现。

1.UI画布的实现 在使用Konva实现画布时,只需要使用Konva.Stage定义舞台以及使用Konva.Layer定义绘制层

this.renderer = new Konva.Stage({container: container.id,width: CANVAS_WIDTH,height: CANVAS_HEIGHT
});
// 管理画布中的所有组件
this.componentsManager = new ComponentManager();
this.layer = new Konva.Layer();
// Redux dispatch,webgl与react通信的核心
this.dispatch = dispatch;
// 像画布中添加辅助线
WebGLEditorUtils.addGuidesLineForLayer(this.layer, this.renderer);
this.renderer.add(this.layer);

2.向UI画布中添加一个组件 向UI画布中添加UI组件时,首先要为组件绑定Konva内的事件,包括选中、拖拽、修改大小等事件;然后将组件绘制到Layer层;然后隐藏上一个组件锚点,显示拖拽过来的组件的锚点;检测拖拽过来的组件是否位于某个组件内,如果位于某个组件内,则将拖拽的组件添加到该组件内部,形成嵌套结构;通知调用dispatch,通知React侧,保存当前组件的状态;最后重绘画布。

addComponent(webGLComponent: WebGLComponent) {// 为组件添加事件this.addSomeEventForComponent(webGLComponent);// 将组件添加到绘制层webGLComponent.appendToLayer(this.layer);this.componentsManager.pushComponent(webGLComponent);// 检测拖入的组件是否位于某个组件内部const id = WebGLEditorUtils.checkInSomeGroup(this.layer,this.renderer,webGLComponent.getGroup());if (id) {// 如果在则添加到对应的组件内部this.componentsManager.appendComponentById(id, webGLComponent);}// 通知react侧this.dispatch(selectComponent(webGLComponent.getId(),webGLComponent.getType(),webGLComponent.getName(),this.componentsManager.getPathOfComponent(webGLComponent).join('>'),getComponentProps(webGLComponent)));// 重绘画布this.render();
}

对应的addSomeEventForComponent()函数实现如下,主要添加选中事件、拖拽事件、修改事件

addSomeEventForComponent(component: WebGLComponent) {component.onSelected(e => { // 组件选中事件this.componentsManager.showCurrentComponentTransformer(component.getId());component.moveToTop();this.dispatch(selectComponent(component.getId(),component.getType(),component.getName(),this.componentsManager.getPathOfComponent(component).join('>'),getComponentProps(component)));this.render();});component.onDragEnd(e => { // 组件拖拽结束事件this.dispatch(dragComponent(e.target.position()));});component.onTransformEnd(e => { // 组件transform结束事件this.dispatch(transformComponent(component.getSize()));})component.onDragEnd(e => { // 组件拖拽结束事件const id = WebGLEditorUtils.checkInSomeGroup(this.layer,this.renderer,component.getGroup());if (id) {this.componentsManager.appendComponentById(id, component);}this.render();});
}

3.检测一个组件是否位于画布中某个组件内部 在拖动组件事件结束时,需要检测拖动后的组件是否位于某个组件内部,并移动到对应的目标组件中,形成嵌套结构。首先获取画布中除拖动组件的所有组件的坐标和大小信息,并以{id, w, h, x, y}格式存储到数组points中;然后获取拖动组件的坐标和大小信息,记为groupPoint,格式为{id, w, h, x, y};遍历points数组,判断能能包含拖拽组件的项,并添加到includePoints数组中,代码如下:

const points = getAllGroupPoints();
const groupPoint = getGroupPoint(group);
const includePoints: PointType[] = [];
points.forEach(point => {if (groupPoint.x >= point.x &&groupPoint.y >= point.y &&groupPoint.x + groupPoint.w <= point.x + point.w &&groupPoint.y + groupPoint.h <= point.y + point.h) {includePoints.push(point);}
});

遍历includePoints数组中所有项,按欧式距离选择出与拖拽组件距离最小的组件作为父组件。

检测组件是否位于某个组件内部的算法流程如下

let minDistance = Number.MAX_SAFE_INTEGER;
let id = '';
const distance = (p0: { x: number, y: number }, p1: { x: number, y: number }) => {return Math.sqrt(Math.pow(p0.x - p1.x, 2) + Math.pow(p0.y - p1.y, 2));
};
includePoints.forEach(point => {const diff =distance({ x: groupPoint.x, y: groupPoint.y },{ x: point.x, y: point.y }) +distance({ x: groupPoint.x + groupPoint.w, y: groupPoint.y },{ x: point.x + point.w, y: point.y }) +distance({ x: groupPoint.x, y: groupPoint.y + groupPoint.h },{ x: point.x, y: point.y + point.h }) +distance({ x: groupPoint.x + groupPoint.w, y: groupPoint.y + groupPoint.h },{ x: point.x + point.w, y: point.y + point.h });if (diff < minDistance) {minDistance = diff;id = point.id;}
});

4.WebGL与React通信 通过WebGL绘制的画布已经脱离了浏览器的DOM,里面的元素都是一条线一条线绘制而成,不同与DOM。WebGL与React的通信,利用Redux提供的全局状态树实现。在构造WebGL画布时传入dispatch函数,用于触发全局状态树的更改从而通知到React。

5.HTML5拖拽API实现拖入组件到UI画布 在HTML5中,拖拽被定义为数据的移动,将一份数据移动到另一个区域,因此借助这个思路,可以实现一个组件拖拽到UI画布中的操作

// 拖动
export function drag(type: string, name: string, event: DragEvent<any>) {event.dataTransfer?.setData('component', JSON.stringify({type, name}));
}
// 放下
export function drop(callback: Function, event: DragEvent<any>) {event.preventDefault();const { type, name } = JSON.parse(event.dataTransfer?.getData('component'));callback({type,name,position: {clientX: event.clientX,clientY: event.clientY}});
}

解析出拖拽过来的组件类型和名称,UI画布根据类型和名称实例化一个组件对象并添加到画布中

export function dropComponentToWebGLEditor(type: string, name: string, position: { x: number, y: number }, editor: CanvasEditorRenderer) {const cpn = new (ComponentMap as any)[type][name](position); // 根据type和name实例化对应的组件editor.addComponent(cpn);return cpn;
}

Client端UI组件实现

UI组件依然使用WebGL Konva框架实现,并将其封装为一个Typescript类。

IWebGLComponentProps接口抽象出一个组件的可用属性以及获取、设置属性的方法,比如获取、设置位置属性,获取、设置背景属性等。IWebGLComponentEvents接口抽象出一个组件需要绑定的事件,比如拖拽事件、选中事件等。WebGLComponent类,对WebGL组件基本结构进行封装,比如描述组件层级结构的childrenparent属性,将组件添加到画布中的appendToLayer()方法等,并实现IWebGLComponentProps()接口,定义一个WebGL组件的属性,实现IWebGLComponentEvents接口,定义一个组件需要监听的事件。每一个组件都通过继承WebGLComponent父类来实现,比如WebGLRect类、WebGLText类。

通过定义一个WebGLComponent父类来实现一个组件的通用逻辑,一个组件的基础就是grouptransformer,分别是渲染到WebGL的画布的形状组和可以自由变换的锚点。

1.绘制UI组件 一个UI组件由若干个Konva图形组成,比如按钮组件由矩形(Konva.Rect)和文本(Konva.Text)组成。通过向group中添加若干个形状,绘制出一个组件。

2.删除组件 删除组件是只需要依次删除三部分即可,即从父组件中移除当前组件,从画布中移除组件的group,从画布中移除组件的transformer

removeFromLayer() {  this.parent?.removeChild(this.getId());this.getGroup().remove();this.getTransformer().remove();
}

3.父组件添加子组件 将一个组件添加到另一个组件中只需要将该组件的grouptransformer移动到父组件中即可,并且在子组件中使用parent引用父组件,父组件中使用children存储所有子组件的引用。

因此在添加子组件时需要建立父子组件的层级关系

appendComponent(component: WebGLComponent) {if (!this.isRawComponent) {const group = component.getGroup();const transformer = component.getTransformer();group.moveTo(this.getGroup()); // 移动到父组件中transformer.moveTo(this.getGroup()); // 移动到父组件中if (component.parent) { // 移除子组件原来的父组件component.parent.removeChild(component.getId());}component.parent = this; // 重新指向父组件this.appendChild(component);}
}

Client端UI页面与JSON的相互转化

服务端使用MongoDB来存储一个编辑的UI页面,因此需要实现UI页面到JSON的转化,以及 JSON对象到UI页面的转化。

1.UI页面与JSON对象的转化 从根组件开始遍历,提取出类型、名称、子组件、样式等属性,再递归解析子组件

export function webGLComponentToJsonObject(component: WebGLComponent): TRawComponent {return {id: component.getId(),type: component.getType(),name: component.getName(),props: getComponentProps(component),children: component.getChildren().size ?[...component.getChildren().values()].map(value => {return webGLComponentToJsonObject(value);}) : []};
}

2.JSON转化到UI页面 利用广度优先搜索,遍历JSON对象,并依次实例化父组件和对应的子组件,设置组件属性,并将子组件添加到父组件中,记录根节点,区分是否以粘贴的形式生成,添加到画布中

export function drawComponentFromJsonObject(jsonObject: TRawComponent, renderer: CanvasEditorRenderer, isPaste = false): WebGLComponent {let root: WebGLComponent | null = null; // 记录根节点const queue = [jsonObject]; // 广度优先搜索队列const map = new Map<string, WebGLComponent>(); // 记录当前组件是否实例化while (queue.length) { // 广度优先搜索const front = queue.shift() as TRawComponent; // 记录父节点let parent;if (map.has(front.id)) { // 如果父组件实例过,则直接拿到实例化的引用parent = map.get(front.id);} else { // 未实例化,则对组件进行实例化,并记录到map中parent = new (ComponentMap as any)[front.type][front.name](front.props.position) as WebGLComponent;setComponentProps(parent, front.props); // 设置属性map.set(front.id, parent);}if (root === null) { // 获取根节点root = parent as WebGLComponent;renderer.addRootComponent(root as WebGLComponent); // 将根节点绘制到UI画布中}for (let v of front.children) { // 遍历子组件queue.push(v);const child = new (ComponentMap as any)[v.type][v.name](v.props.position, v.props.size) as WebGLComponent;setComponentProps(child, v.props);renderer.addComponentForParent(parent as WebGLComponent, child); // 绘制到父组件中map.set(v.id, child);}}const component = root as WebGLComponent;// 是否以粘贴的形式isPaste && component.setPosition({x: component.getPosition().x + 10,y: component.getPosition().y + 10});renderer.getComponentManager().showCurrentComponentTransformer(root?.getId() as string);renderer.render(); // 重新渲染UI画布return component;
}

Client端UI组件编辑功能实现

React与WebGL的通信是基于Redux状态树实现,通过在WebGL侧调用dispatch()来通知React渲染,在渲染React Editor组件时使用useEffect Hooks来实现通信。

对于编辑功能的实现,需要在Redux状态树中记录一个编辑状态的state,格式为{id, editType },其中id表示组件id,editType表示编辑类型。

点击编辑操作时调用dispatch()函数发送编辑的组件id和编辑类型,React Editor组件使用useEffect Hooks接收变化并使用CanvasEditorRenderer类提供的编辑组件方法实现组件的编辑功能

const editToolsDeps = [editToolsState.id, editToolsState.editType];
useEffect(() => {if (editToolsState.id) {const renderer = (webglEditor.current as CanvasEditorRenderer);switch (editToolsState.editType) {case 'delete': { // 删除组件// 移除画布中对应Id的组件const rmCpn = removeComponentFromWebGLEditor(editToolsState.id, renderer);EventEmitter.emit('auto-save', webGLPageState.pageId); // 自动保存// 新增编辑历史dispatch(addEditHistory(editToolsState.id, 'delete', {old: '',new: webGLComponentToJsonObject(rmCpn as WebGLComponent)}));return;}case 'paste': { // 粘贴组件const newCpn = pasteComponentToWebGLEditor(editToolsState.id, renderer);// 新增编辑历史dispatch(addEditHistory(editToolsState.id, 'paste', { old: '', new: newCpn?.getId() }));EventEmitter.emit('auto-save', webGLPageState.pageId);return;}case 'save': { // 保存savePage(webGLPageState.pageId, renderer.toJsonObject() as object).then((v: any) => {if (!v.err) {toast('save!');dispatch(resetComponent());}});return;}case 'undo': { // 重做dispatch(removeEditHistory());dispatch(resetComponent());return;}default: {return;}}}
}, editToolsDeps);

1.从画布中移除一个组件 当选中UI画布中的组件时,Redux状态树中会存储选中的组件id,通过组件id,调用CanvasEditorRenderer类移除对应id的方法,其内部实现如下

const cpn = this.componentsManager.getComponentById(id);
this.componentsManager.removeComponentById(id);
this.render();
this.dispatch(resetComponent());
return cpn;

2.复制粘贴一个组件 复制组件时将组件id记录下来,在粘贴时,查找对应id的组件,将其转化为JSON对象,再由JSON对象重新构造出UI组件并添加到UI画布中,实现粘贴逻辑

if (this.webGLComponentCollection.has(id)) {const cpn = this.webGLComponentCollection.get(id) as WebGLComponent;const json = webGLComponentToJsonObject(cpn); // 转化到JSON对象return drawComponentFromJsonObject(json, renderer, true); // 再由JSON对象生成新的组件
}
return null;

3.重做组件 通过记录一个编辑历史,来实现重做组件逻辑。编辑历史使用一个数组来存储,当存在编辑操作时,将该操作存储到数组中,存储格式为{id, operator, data}id表示组件id,operator表示操作名称,data表示operator操作的逆操作所需的数据。执行重做命令时,取出数组最后一个项,并对该项对应的操作进行一个逆操作,达到重做的效果。

以粘贴组件操作为例,粘贴一个组件,向数组中添加一个粘贴操作

dispatch(addEditHistory(editToolsState.id, 'paste', { old: '', new: newCpn?.getId() }));

而粘贴组件操作的逆操作就是删除组件,因此拿到data中粘贴组件的id,并从UI编辑器中删除,达到重做的效果。

const { id, data } = editHistory.current;
renderer.removeComponent(data.new);

Client端修改UI组件属性功能实现

通过对WebGL组件样式属性进行一个抽象,抽象出background属性、border属性、shadow属性、text属性、image属性这5类属性。当修改一个组件的属性时,先判断修改的属性类型,再对该类型的属性在UI画布中进行修改渲染。在修改属性时,属性面板Propspanel组件通过dispatch()修改Redux状态树的状态,然后重绘UI。UIEditor组件通过useEffect副作用监听状态改变,并调用CanvasEditorRenderer类的modifyComponentProps方法实现组件属性修改。

总结

本项目是我的毕业设计,在字节跳动实习期间接触到了魔方平台,魔方平台的UI编辑器的实现是基于DOM技术,对比设计软件Figma使用WebGL实现的UI编辑器,项目也尝试着使用用WebGL去实现一个UI编辑器,并将其构建为一个App。

存在的不足
  • WebGL实现UI组件难度大,目前实现的可用UI组件并不多,所以并不能编辑出任意的UI

  • DSL代码编译目标代码出现目标代码可读性差

  • 打包后的应用包体积过大等等问题

未来规划
  • 将研究如何使用计算机视觉、机器学习算法等对UI设计稿进行识别,并转化到系统的DSL表示,从而编译到目标代码。

  • 研究如何解析PSD文件,并将PSD转化到DSL表示,从而编译到目标代码

参考

Electron在Taro IDE的开发实践

分享这半年的Electron应用开发和优化经验

Konvajs.Konva Tutorials

项目Github地址: https://github.com/sundial-dreams/MagicUI

使用WebGL去实现一个拖拽式UI代码生成App相关推荐

  1. java使用重绘实现拖动_原生JS使用Canvas实现拖拽式绘图功能

    一.实现的功能 1.基于oop思想构建,支持坐标点.线条(由坐标点组成,包含方向).多边形(由多个坐标点组成).圆形(包含圆心坐标点和半径)等实体 2.原生JavaScript实现,不依赖任何第三方j ...

  2. 关于怎么在手机端实现一个拖拽的操作

    手机端,肯定是监听touchstart,touchmove,touchend事件 先来看看效果 当拖拽时,拖拽到哪个节点下面,就把哪个节点添加到这个下面 <div>1111</div ...

  3. html5拖拽表单设计器,require+jquery+backbone实现拖拽式报表设计器-拖拽式表单设计器...

    HTML我帮您-拖拽式报表设计器-拖拽式表单设计器是一个可视化设计器,基于require+jquery+backbone+underscore+bootstrap实现的表单设计器.思想来源于 Layo ...

  4. 拖拽式创建小程序原型 - 小piu神器 - 腾讯lowCode - 软件开发

    免费的拖拽式小程序.App原型设计稿生成器(小piu) - 感谢神器! https://www.xiaopiu.com/ 收费的微信小程序 拖拽生成开发: 腾讯云微搭低代码 WeDa https:// ...

  5. 拖拽式Vue组件代码生成平台(LCG)新版详细介绍

    拖拽式Vue组件代码生成平台是一款小猴自研的Vue代码生成工具,英文全称:Low Code Generator,简称LCG.它也是一种LowCode解决方案.通过它可以快速完成Vue组件的代码骨架搭建 ...

  6. 机器学习初体验(傻瓜拖拽式)

    一.目的 通过美国人口普查数据预测收入 二.平台介绍 Azure平台:微软推出的一款基于web的机器学习服务,使用拖拽式,便于应用.这个应用为收费服务,但可申请免费试用. 网址:http://stud ...

  7. 拖拽式可视化设计,打造高效流程管理体系

    编者按:企业管理离不开流程管理,怎样的流程管理系统才是我们需要的?本文针对企业流程管理中的常见问题,介绍了有助于企业高效管理的流程管理系统--天翎BPM. 概要: (1)流程管理常见问题 (2)我们需 ...

  8. Jupyter Notebook 交互式编程 低代码拖拽式编程 | 数据科学生态下的理想平台

    近几年,Jupyter Notebook 为数据科学家们提供了与数据有效交互的工具.用户可以运行代码.查看结果,然后重复数据之间的循环和迭代.使用 Jupyter Notebook 进行研究成为了数据 ...

  9. WIX:html5拖拽式建站,个人建站首选,操作方便快捷

    自己做了一个网站,只有几个静态页面,不会前端所以界面很简陋拿不出手,准备完善页面之后找个前端外包美化一下.这一等就是好几天. 在看youtube视频的时候无意中看到一个广告,介绍了一个网站:www.w ...

最新文章

  1. PyTorch框架:(6)图像识别实战常用模块解读
  2. 计算机用户停用无法登录,电脑开机无法登录提示您的账户已被停用如何解决。 如何解决电脑开机无法登录提示您的账户已被停用的问题。...
  3. python可变长参数(非关键字及关键字参数)
  4. 个人数据上云怎么办?树莓派+kodexplorer为你造云
  5. poj 3486 A Simple Problem with Integers(树状数组第三种模板改段求段)
  6. 春节期间,我用责任链模式重构了业务代码
  7. javascript自制函数图像生成器
  8. jdbc如何使用oracle数据库连接池,使用JDBC连接池技术连接Oracle数据库
  9. 嵌入式系——软件管理工程
  10. Visual Studio2010安装步骤
  11. 阿里资深数据分析师回答那些关于数据分析师的最常见的几个问题
  12. DL1 - Neural Networks and Deep Learning
  13. 如何在Godot中使用自发光材质
  14. 酷睿i5 12490f什么水平 i5 12490f属于什么档次 i512490f怎么样
  15. S6系统激活、软件升级和软件授权激活
  16. 程序人生:hello程序的P2P
  17. Sparse coding(稀疏编码)
  18. 和其他设备一样,电解法次氯酸钠发生器也需要用户精心维护
  19. 技术干货|昇思MindSpore NLP模型迁移之Bert模型—文本匹配任务(二):训练和评估
  20. win7计算机优盘,如何用u盘装win7系统?U盘如何安装win7?

热门文章

  1. 悟空分词与mysql结合_悟空分词的搜索和排序源码分析之——索引
  2. SpringBoot - 构建监控体系01_使用 Actuator 组件实现及扩展系统监控
  3. 从 1 秒到 10 毫秒!在 APISIX 中减少 Prometheus 请求阻塞
  4. 《Android NFC开发实战详解》——6.4节Android NFC P2P开发进阶
  5. 计算机的CPU和GPU的区别,CPU 和 GPU 有什么区别
  6. 2-1 windows软件 --- x-shell/seurecrt/puty
  7. wordpress主题html5,Salutation 强大的HTML5商务WordPress主题集成BuddyPress[更新至v3.0.7]
  8. 一份鲜为人知的Python特性
  9. 中国青年报:有多少网络论坛永远免费
  10. 乐视网:贾跃亭解质押150万股股票系法院司法执行操作