前言
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.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 组件以及一些相关工具函数。
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 语法的定义:
以 Type.name 形式表达一个组件的类型和名称,以 “{” 开头,以 “}” 结尾,将组件的属性和相关信息进行包裹。
组件属性定义为两类,基础属性和样式属性,基础属性关键字包括 position、size、text、image,样式属性以 style 关键字定义,用大括号进行包裹,内层属性包括 background、border、shadow。属性与属性之间使用 “;” 分开。
一个属性的参数使用空格进行分隔,末尾使用 “;” 号结束一个属性的定义。
使用 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;const Cookies = Session.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) {
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);
});
};
复制代码
渲染进程调用
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);
let nameMatch = str.match(/^[\w\d\.\s]+\s*{/);
const [type, name] = nameMatch ? nameMatch[0].replace('{', '').trim().split('.') : ['', ''];
let positionMatch = str.match(/position\s*:([^;]+);/);
const [x = 0, y = 0] = positionMatch ? positionMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];
let sizeMatch = str.match(/size\s*:([^;]+);/);
const [width = 0, height = 0] = sizeMatch ? sizeMatch[1].trim().split(' ').map(v => Number.parseInt(v)) : [0, 0];
let backgroundMatch = str.match(/background\s*:([^;]+);/);
const [fill = 'white', opacity = 0] = backgroundMatch ? backgroundMatch[1].trim().split(' ') : ['', ''];
let shadowMatch = str.match(/shadow\s*:([^;]+);/);
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*:([^;]+);/);
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*:([^;]+);/);
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*:([^;]+);/);
const src = imageMatch ? imageMatch[1].trim().replace(/^'/, '').replace(/'$/, '') : '';
return {
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))
};
}
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文件渲染出页面,并注册相应的事件
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
});
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();
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);
}
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 => {
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);
editor.addComponent(cpn);
return cpn;
}
复制代码
Client 端 UI 组件实现
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);
}
}
复制代码
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 {
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);
}
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();
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': {
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);
return drawComponentFromJsonObject(json, renderer, true);
}
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 表示,从而编译到目标代码
写在最后
光阴似箭,大学四年的生活就要落下帷幕。在大学四年里,有人选择安逸,有人选择放弃,而我选择坚持努力学习,不留遗憾。在四年的不断学习过程中,我从一个电脑菜鸟,变成了技术达人,进入到字节跳动实习,并凭借优秀的成绩保送研究生。“天道酬勤”,学习没有轻松可言,在大学四年的学习中,有太多的幸酸和泪水,非常感谢曾经坚持过的自己。“博观而约取,厚积而薄发”,在四年的学习生涯,知识的堆积,让我有了今天的成果。人生路还很漫长,毕业并不是一个结束,未来在研究生或工作中仍需继续努力。祝自己毕业快乐!!!
参考
项目 Github 地址: github.com/sundial-dre…
https://juejin.im/post/5f03d4126fb9a07ea01a0909
