设计稿即代码 —— 拖拽编辑器

avatar
@杭州海康威视数字技术股份有限公司

1 前言

拖拽编辑器其实并不是一个新鲜的东西,现在市面上也有许多可用的产品或是开源的项目。笔者第一次接触拖拽编辑器还是在多年以前刚刚学习前端的时候,在学习了 html 和 css 基础后接触到一款使用 jquery 和 bootstrap 开发的拖拽编辑器后,内心产生了巨大的震撼,并一度产生了“既然有这么方便的东西那还要学习前端有什么用”的想法。虽然这在现在看来,当时还是太过于年少无知,但一款适用的拖拽编辑器若是在项目开发中运用得当,确实能提升很大的开发效率。

在公司 web 端项目的开发过程中,由于企业产品输出风格的统一性和底层 UI 库样式风格的一致性,大多数页面(尤其是中后台页面)的设计风格都非常相似。而且在项目开发流程中,设计师完成原型稿/视觉稿后,前端开发人员往往都是先开发静态页面,再向上补充逻辑。项目提测后,设计师还需进行设计稿还原度跟踪,耗费不少时间和精力,还给双方带来不少沟通成本。那么是否存在一种方式可以避免静态页面/典型页面的重复开发呢?

在公司项目前端开发流程优化工作的推动下,我们设计并开发了一款适用性强,体验度高的拖拽编辑器,它可以实现所见即所得的开发设计一体化,给前端人员节省大量的设计稿还原和项目开发的时间。这里给大家带来该编辑器的设计思路。

2 产品定位

拖拽编辑器是一个基于公司前端典型页面系统上的一个项目,它用于改变传统的产品设计开发模式,减少设计、页面还原中间环节的成本浪费,真正做到设计开发一体化。

在使用过程中,用户体验设计师为该产品的主流用户群体,即第一用户群体,是产品核心 JDC 的主要来源,用户侧重比例为 70%。前端开发作为该产品的第二用户群体,设计侧重比例为 30%。因此在设计时尽可能在保留开发的页面 DOM 元素结构搭建的前提下,去贴近设计师的日常工作习惯。

3 产品架构

编辑器的核心功能是将页面元件/组件使用拖拽或是其它可视化编辑的方式来生成前端可用的静态文件代码,由于公司前端大部分项目与 UI 库使用的是 vue 技术框架,因此编辑器暂不考虑生成其它框架的代码成果物。

编辑器项目不包含自己的数据库,所有的数据/代码均以文件的形式存储在公司的 gitlab 服务器上。编辑器调用Gitlab API 来获取和存储数据。为方便前端开发过程中检索、拉取和使用代码,编辑器保存成果物时会将页面代码以单个 .vue 文件的方式保存在 gitlab 的对应项目仓库中。同样,编辑器也支持通过 Gitlab API 读取 .vue 文件的 gitlab 地址获取对应的页面代码来进行二次编辑。

提到编辑器的输入和输出不得不详情说明下元件素材页面的概念。

元件

元件是页面的最小组成单位,它可以是 divspanimg 等源生标签元素;可以是 UI 库中的基础组件,以ElementUI 为例,如el-inputel-button等;也可以自定义的业务组件,如定义一个布局组件 page-container

拖拽编辑器需要接入元件库。元件库用于记录和存储页面所有元件的信息,每个元件信息单独存放在一个package.json 中。元件库的结构如下:

# 元件库的结构
.
├── Div
│   └── package.json
├── span
│   └── package.json
├── ElButton
│   └── package.json
└── ElInput
    └── package.json

ElButtonpackage.json 为例(仅列出部分属性作为示意):

{
  "name": "el-button",
  "label": "按钮",
  "description": "ElementUI组件 —— 按钮",
  "dependencies": {
    "scriptUrl": "https://unpkg.com/element-ui/lib/index.js",
    "linkUrl": "https://unpkg.com/element-ui/lib/theme-chalk/index.css"
  },
  "slots": [],
  "props": [
    {
      "name": "type",
      "label": "样式",
      "description": "类型",
      "type": "string",
      "options": [
        { "label": "", "value": "默认" },
        { "label": "主题色", "value": "primary" },
        { "label": "成功(绿)", "value": "success" },
        { "label": "警告(黄)", "value": "warning" },
        { "label": "危险(红)", "value": "danger" },
        { "label": "信息(蓝)", "value": "info" },
        { "label": "文字按钮", "value": "text" }
      ],
      "defaultValue": ""
    },
    {
      "name": "icon",
      "label": "图标",
      "description": "图标,已有的图标库中的图标名或引入新的图标库",
      "type": "string",
      "defaultValue": ""
    },
    {
      "name": "disabled",
      "label": "是否禁用",
      "description": "是否禁用状态",
      "type": "boolean",
      "defaultValue": false
    }
  ]
}

上述 json 可以看到,除了记录元件基本标签名称、描述外,还会记录 slotsprops 等属性、插槽等元件特性,这在后续的设计篇内会讲到。

这里额外说明下 dependencies 字段,其中包含了 scriptUrllinkUrl 。一般在 vue 项目开发中,使用某组件常见的方式为先 install 该组件依赖,然后 import 引入改组件。但是编辑器采用 umd 动态引入的方式。组件可以在元件库中录入元件信息,scriptUrljs 文件地址, linkUrlcss 文件地址,编辑器读取元件库时会根据 umd 信息动态引入文件,然后再编辑器中就可以正常使用了。

素材

素材是由一个或多个元件组合而成,它是提供给编辑器的拖动材料。

素材库与编辑器本身解耦,它是托管在 gitlab 服务器上的文件仓库。

# 素材库的结构
.
└── packages
    ├── 基础
    │   └── 基础元素
    │       ├── div.vue
    │       ├── span.vue
    │       └── img.vue
    └── 组件
        ├── 按钮
        │   ├── 基础按钮.vue
        │   └── 主题色按钮.vue
        ├── 选择框
        │   └── 基础选择框.vue
        └── 输入框
            ├── 基础输入框.vue
            └── 文本域.vue

一个素材就是一个 .vue 文件。素材文件不分代码是否复杂,内部元件数量是多少,只要想作为页面可拖动、可复用的材料都可以保存为素材文件。以下两个 .vue 文件均为素材:

// div.vue
<template>
  <div></div>
</template>
// 基础选择框.vue
<template>
  <el-select>
    <el-option label="男" value="1"></el-option>
    <el-option label="女" value="2"></el-option>
    <el-option label="未知" value="0"></el-option>
  </el-select>
</template>

编辑器可以读取一个或多个素材库作为页面拖动编辑的基本材料。编辑页可以将所编辑的内容保存为素材,以便于下一次拖动时的材料复用(和 sketch 的使用类似)。

页面

页面就很好理解了,它是使用编辑完成页面编辑后,可以直接提供给前端开发人员使用的代码成果物。编辑器在保存时会将页面以 .vue 文件的形式保存在 gitlab 上对应的项目仓库中。前端人员在开发项目时,可以直接通过 git clone 拉取整个项目的代码,也可以通过公司内部工具获取单个页面的代码文件到本地项目,省去静态页面开发的环节,十分的方便。

我们将刚才所说的元件库、素材库、页面库(项目文件库)整理一下,可以得到以下产品架构:

4 产品设计

4.1 效果演示

在介绍整体设计之前,我们先来看看拖拽编辑器制作页面的效果演示。

以下是拖拽编辑器制作一个简易的“百度”首页,为了方便演示,我们事先将一些素材给制作好了。

4.2 设计思想

市面上的可视化编辑工具或是 low code 产品其实有很多。在设计之前我们调研了一些网上可用的产品:

  • magicalcoder 就是一款优秀的拖拽编辑器,它具有很强的通用性,功能也比较强大,但是它的适用人员多是产品经理、后端开发,输出的成果物对页面样式和页面代码的可迭代性要求一般,并不能让前端人员很好的进行二次编辑和后期维护,自定义功能弱,且操作略显复杂;
  • 云凤蝶 是阿里的移动建站平台,它可以通过拖拽的方式快速搭建移动端的页面,但是它搭建的页面基本都是上下结构的流式布局,对于PC的复杂页面搭建就没有什么参考性了。
  • 飞冰 也是一款用于构建前端应用的产品,飞冰可以在vscode中安装插件,然后拖拽构建物料和区块。飞冰给我们的前期设计提供了一些很好的灵感,然而我们的设计团队在实际使用飞冰搭建页面后一致反馈:用起来过于复杂。

总结了市面上产品的一些优劣和设计思路,我们对产品进行了一个初步的构想,然后经过多次内部评审与讨论,最终确定设计方向。这款拖拽编辑器遵循实用易用专业的设计思想,切实打造一款能有效优化开发流程,提升开发效率的前端工具。

实用

编辑器以 “输出开发人员可直接使用的代码” 为第一目标。如果输出的代码,开发人员还需要大量修改的,那这就不是一个合适的产品。

易用

设计师作为产品的第一用户群体,他们对前端知识的了解有限,在很多结构性、样式性的页面搭建时,与前端开发人员存在理解上的偏差(比如组件的属性设置)。如何降低学习成本,降低操作复杂度,提高用户体验,引导用户输出正确的页面代码是编辑器追求的第二目标。

专业

市面上多数产品都只能支持基础的流式布局,显然公司这么多的中后台页面是不适用的。编辑器充分调研公司前端项目的代码特征,围绕内部 UI 组件库以及布局体系展开设计。

4.3 页面设计

由于产品模块设计涉及到的内容多且杂,这边只介绍几个核心模块的设计思路

和众多的可视化编辑工具一样,编辑器页面分为操作和视图两个部分。

  • 操作模块负责业务交互和信息整理,并将拖拽的元素信息传递给视图;
  • 视图模块负责解析元素信息来生成代码字符串,并渲染到页面上。

4.4 拖动设计

在设计拖拽编辑器的前期往往会陷入一个误区,认为拖拽就是将操作栏中的素材元素绑定拖放事件进行操作,其实并不是。我们要生成的页面是个流式布局而不是绝对定位。视图渲染页面是靠代码解析器生成的代码来渲染的,所以没有必要将整个素材元素 DOM 传给视图模块,只要告诉它控件代码片段的路径、在什么位置释放即可。所以,拖动的并不是 DOM 元素,拖动的只是信息

如上图所示,编辑器左侧操作栏中展示所有可用的素材列表,此时的素材节点仅仅包含素材的名称与其代码文件所在的 gitlab 路径。用户拖动某个素材进入视图并释放后,编辑器再根据该素材的代码文件地址获取对应的代码片段。

这里详细解释一下该如何确定被拖动元素落在哪个元件容器中。

我们将这一设计抽象成“位置解析器”,它分为画板和虚拟元素块两个部分,画板是覆盖在视图上的一个操作层,所有的拖放操作均在画板上完成,这样不会因为视图上元件的一些特性(如 mouseover 事件等)产生什么负面影响。每一个视图上的元件都存在唯一标识,且都会在画板上生成一个虚拟元素块。换句话说,每一个画板上的虚拟元素块,都对应视图上的一个元件。虚拟元素块是一个 div,相对于画板容器绝对定位,它的 topleftwidthheight 值都和其对应的控件元素一样。这样一来,我们只要给每个虚拟元素块都绑定鼠标进出事件来监听拖动操作,就可以知道用户在那个元件容器中释放了。

4.5 代码解析设计

拖动完成后,视图模块会根据素材所在的 gitlab 文件地址,通过调用 Gitlab API 获取代码片段,然后和释放容器的标识一起,传入代码解析器。

代码解析器将传入的代码片段转换为 AST 数片段,然后根据父容器的标识,插入到指定的节点中去,组合成完整的 AST 树。打个比方,下图是将一个按钮拖动进入一个 div 后的代码处理示意:

代码处理部分,我们选取了一些核心的处理逻辑。

// 使用 vue-template-compiler 可以把vue的模板字符串给转换为AST树
import { compile, parseComponent } from 'vue-template-compiler'

let ast = {
  // 假设这边预先有个div的节点
}
// nodeMap是以map的方式存储所有ast树节点的全局变量,方便节点检索
let nodeMap = {
  // 假设这边预先有个div的节点
}

// 代码字符串转换成ast树
function stringToAst (str) {
  const parse = parseComponent(str)
  const code = compile(parse.template.content)

  return code.ast
}

// 递归处理ast树
function joinAst (node) {
  if (node.type === 1) {
    for (const key in node.attrsMap) {
      const value = node.attrsMap[key]
      if (key === 'id') {
        this.nodeMap[value] = node
      }
    }
    str += `<${node.tag}>`
    for (let i = 0; i < node.children.length; i++) {
      joinAst(node.children[i])
    }
    str += `</${node.tag}>`
  } else if (node.type === 3) {
    str += node.text
  }
}

// ast树转换成代码字符串
function astToString (ast) {
  const TMP_START = '<template>'
  const TMP_END = '</template>'

  let str = TMP_START
  nodeMap = {}

  joinAst(ast)

  return str + TMP_END
}

function insertCode (code, parentId) {
  const codeAst = stringToAst(code)

  const parent = nodeMap[parentId]
  if (parent) {
    parent.children.push(codeAst)
  } else {
    ast = codeAst
  }

  return astToString(ast)
}

insertCode('<template><el-button>确定</el-button></template>')
// => <template><div><el-button></el-button></div></template>

4.6 视图渲染设计

拿到了代码字符串后,我们将其解析为 SFC(单文件组件)渲染到页面上。这里我们在 server 层使用了 rolluprollup-plugin-vue 来处理它,然后通过接口的方式返回给 client。

// app/service/dragTool.js
'use strict';
const egg = require('egg');
const tmpdir = require('os').tmpdir;
const path = require('path');
const writeFile = require('fs-extra').writeFile
const readFileSync = require('fs-extra').readFileSync
const remove = require('fs-extra').remove
const rollup = require('rollup').rollup
const VuePlugin = require('rollup-plugin-vue');
module.exports = class DragToolService extends egg.Service {
  /**
   * 编译vue单文件
   * @param content vue单文件内容
   */
  async compileSFC(content) {
    const filePath = path.resolve(tmpdir(), `sfc_compile_${Math.random()}.vue`);

    await writeFile(filePath, content);

    const bundle = await rollup({
      input: filePath,
      external: 'vue',
      onwarn: message => {
        if (message.code === 'UNUSED_EXTERNAL_IMPORT') return;
        console.warn(message);
      },
      plugins: [
        // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
        // @ts-ignore
        VuePlugin({}),
      ],
    });

    const { output } = await bundle.generate({
      name: 'myComponent',
      exports: 'named',
      globals: {
        vue: 'Vue',
      },
      format: 'commonjs',
    });

    await remove(filePath);

    return output[0].code;
  }
};

前端通过调用接口,将代码字符串传给 server,处理完后拿到返回的 SFC 字符串,通过 eval 函数解析后,在页面以 <component /> 组件动态渲染。

async codeRender (code) {
  const { data } = await legoApi.getCompileSFC({ content }) // 调用接口
  const exports = {}
  eval(data)
  this.activeComponent = exports.default
  return Promise.resolve()
}
<div class="view-container">
  <component :is="activeComponent" />
</div>

4.7 修改属性

通过 拖动 -> 代码解析 -> 视图渲染 这一系列过程可以得到页面的基本结构,但很显然仅仅如此并不能完成我们想要的页面。当素材完成拖动后,拖过代码解析设计可以看到,素材会打散成一个个的元件。元件支持选中、删除、复制、修改属性和修改样式。页面上的大多数元件都有自己的属性(特别是 UI 组件库中的组件),改变这些属性可能会影响页面的显示、样式或是布局结构。

在介绍元件时我们提到,存储元件信息的 package.json 中会预先录入元件的属性( props 字段)。选中元件后,编辑器会显示属性编辑面板。面板上会以元件的 props 属性列表展示一个表单,可以配置元件的属性。

el-button 按钮组件为例,我们预先录入了 typeicondisabled 三个属性,分别对应按钮的类型,图标和是否禁用。现在我们希望将一个默认样式的按钮改为主题色按钮,在代码中只需要在 el-button 标签上加一个 type="primary" 即可,在编辑器的设计流程为:

4.8 修改样式

在页面制作的过程中,为了能保证输出代码的合理性,需遵从修改属性 >> 修改样式的原则,能修改属性达成的修改尽量不修改样式,比如上一章节说的修改主题色,其实本质上就是修改按钮元素的背景颜色、边框和字体颜色,但通过属性达成效果的代码才是我们正在想要的。

但是在制作一些自由度比较高的页面时,往往经常会修改元件的样式。编辑器的样式面板内置了一套样式表单,选用项目开发常用的 CSS 样式。大致分为以下4类:

  • 尺寸布局:宽度、高度、最大宽度、最小宽度、最大高度、最小高度、变换(transform)、对齐方式、显示方式(display)、浮动、定位方式、偏移距离、内边距、外边距;
  • 形状样式:透明度、圆角、背景填充、边框、阴影
  • 文本:字体、字号、字重、字距、行高、颜色、对齐方式、装饰线、文字阴影、超出显示方式(overflow
  • 其它:区域光标(cursor

这套样式表单采用可视化、图形化、文本语义化的方式让设计人员尽可能理解,并提供一些快捷操作尽量符合设计师的操作习惯。比如边距,可以采用图形化的方式更直观的看到要改变属性所对应的效果位置。

修改样式的流程和修改属性类似,不同的是,在代码里,我们将样式列表放到了 <style> 中,并且在元件标签上加上了 class 与之一一对应;视图渲染时,在页面上动态创建 <style> 标签,添加样式表。元件默认将其 ID 字符串作为 class,且编辑器支持自定义元件 class

打个比方,我们要给一个 div 设置 width: 600px,用户可以在样式面板中的宽度项设置为 600px,在编辑器的设计流程为:

4.9 其它功能

除了上述介绍的一些设计外,编辑器还完成了以下功能,由于和之前的设计流程存在类似之处,就不详细展开介绍了。

  • 支持二次拖拽
  • 支持选中、复制、删除元件
  • 支持 AST 树展示,并可以在上面进行节点操作(复制、删除)
  • 支持查看/操作真实页面
  • 支持查看生成的代码
  • 支持修改文字节点
  • 支持上一步和下一步(撤销和重做)
  • 支持键盘快捷键操作
  • 支持同时编辑多个页面
  • 支持标尺和网格
  • 支持修改页面分辨率
  • 支持缩放页面
  • 支持智能间距度量
  • 支持防断电自动保存

在未来的版本中,我们同样也希望能加入更多的功能,去更大提升开发效率,包括:

  • 支持 Mock 数据
  • 支持添加弹框和提示
  • 支持简单的逻辑处理
  • 支持手动修改/调整代码(并回显渲染到页面上)
  • 支持接口调试

5 项目开发流程优化

拖拽编辑器的定位是作为一个能力工具,它可以向上封装成平台。为了方便设计师的使用以及管理,我们正在编辑器的基础之上开发一个在线页面设计平台(暂未有开源计划),支持设计稿的输出、展示和二次开发等功能。

正如标题所言,“设计稿即代码”,编辑器平台所做的是将静态页面搭建的工作从前端转移到了设计师,同时做到交互视觉一体化,既减轻了设计师的工作量,也大大节约了开发人员的时间成本,今后设计师在参与项目设计的流程变为:

我们计划开发一套 vscode 插件,前端开发人员通过图形化的方式,直接从项目库中获取代码文件,也可以直接运行项目库。完全省去了静态页面开发时间,也避免了前期页面还原与设计稿不一致的情况。

6 总结

如果你对如何使用可视化工具平台生成页面有兴趣或是也想开发一套拖拽编辑器,希望此文的一些设计思路对你有所帮助。

本文最主要讲解了一款输出 vue 页面的拖拽编辑器,以同样的设计思路也可以开发 React 和 Angular 等技术框架的页面。

我们希望通过搜集产品页面和构建流程的大数据,慢慢向智能化靠拢。最终实现能通过读取设计稿图片或是资源文件就生成可用的静态页面。“设计稿即代码”,相信最终的产品形态将不再拘泥于线上可视化构建平台,而是能提供智能化解析设计稿的能力。

7 招聘

我们是海康威视前端团队,一个能折腾点事的团队
如果你不局限于日复一日的机械工作,希望在整个公司 make some impact
如果你是技术达人,总感觉自己缺少那么些有愿景,有意思的 idea
如果你的想法新颖独到,想把他们付诸实践,需要和同伴一起讨论落地的方案
如果你和现在的工作没得聊,那欢迎你来我们这聊聊
投递简历联系: xiangxiao3@hikvision.com