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端是一个PC端应用,采用Electron技术进行开发。Electron虽然是使用前端技术来创建跨平台应用的框架,但又与传统的网站开发方式不一样。Electron基于主从进程模型,即由一个主进程和多个渲染进程组成,进程之间使用IPC进行通信。基于这种进程模型,对系统进程进行功能划分:
渲染进程使用Typescript/React/Redux开发,借助React Hooks可以更好的将通用UI逻辑抽离,提高代码复用率。主进程使用Typescript/C++开发,其中C++开发Node.js插件并打包成.node
文件,主进程加载.node
文件从而调用到C++代码。借助Webpack编译工具,将渲染进程所有代码编译为index.html
、renderer.js
、style.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中存储,通过dispatch
action进入reducer
去更新state
,更新完state后触发一次React render去更新视图。设计Redux状态树的关键点在于抽离组件状态,将多个组件依赖的状态抽离到Redux状态树中,并在组件使用useSelector
Hooks订阅状态树中的某个状态,使用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组件以及一些相关工具函数。
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(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中可以使用session
API来获取当前session
export const Session = { setCookies(name: string, value: string) { const Session = session.defaultSession; // 主进程中获取默认session const Cookies = Session.cookies; // 获取cookies return 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); // 子组件token let nameMatch = str.match(/^[\w\d\.\s]+\s*{/); // 解析组件type, name const [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 { // 拼接JSON name, 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的通信机制大致原理如下图。
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; } 复制代码
UI组件依然使用WebGL Konva框架实现,并将其封装为一个Typescript类。
IWebGLComponentProps
接口抽象出一个组件的可用属性以及获取、设置属性的方法,比如获取、设置位置属性,获取、设置背景属性等。IWebGLComponentEvents
接口抽象出一个组件需要绑定的事件,比如拖拽事件、选中事件等。WebGLComponent
类,对WebGL组件基本结构进行封装,比如描述组件层级结构的children
和parent
属性,将组件添加到画布中的appendToLayer()
方法等,并实现IWebGLComponentProps()
接口,定义一个WebGL组件的属性,实现IWebGLComponentEvents
接口,定义一个组件需要监听的事件。每一个组件都通过继承WebGLComponent
父类来实现,比如WebGLRect
类、WebGLText
类。
通过定义一个WebGLComponent
父类来实现一个组件的通用逻辑,一个组件的基础就是group
和transformer
,分别是渲染到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.父组件添加子组件 将一个组件添加到另一个组件中只需要将该组件的group
和transformer
移动到父组件中即可,并且在子组件中使用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); } } 复制代码
服务端使用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; } 复制代码
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); 复制代码
通过对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地址: github.com/sundial-dre…