带你7天玩转可视化建站平台

前言


对于互联网公司而言,活动运营页面发布频率高,每次开发又要人力成本不划算。因此需要有一套平台系统来满足运营产品自我快速建站。

此项目旨在用最少的代码实现可视化搭建、发布、预览、调试等核心功能。并对每一个关键原理进行说明。

首先我们来看看效果:


可视化编辑器面板

目前支持的功能

编辑器相关:
  • 拖拽菜单组件放入画布,能放置的位置标绿框,不能放的标红框;选中锚点、移入高亮。
  • 鼠标按住拖动画布内组件四周改变宽高,拖动中心改变定位,支持动态吸附算法
  • 属性面板输入样式、自定义属性配置,实时更新画布预览
  • 页面编辑快捷键操作,包括:保存(Ctrl+S),撤销(Ctrl+Z),恢复(Ctrl+Y),删除(DEL),复制(Ctrl+C),剪切(Ctrl+X),粘贴(Ctr+V),上移节点(↑),下移节点(↓),缩放移动画布(空格按下+左键拖动+滚轮缩放)
  • 直接拖动左下方树结构批量移动节点
服务层相关:
  • 提供平台前端页面(编辑器、预览页)的请求接口与路由模板
  • 打包构建:对组件仓库的分包,对编辑器SDK的打包
  • 开发组件调试模式的命令行脚本
预览相关:
  • 将页面搭建配置创建React组件树,动态加载所需组件JS文件
  • 组件懒加载功能

实现原理


主要划分为4个部分:编辑器、预览页、服务端、组件仓库
用户在编辑器内拖入组件仓库已开发的组件,设置样式与自定义属性,为页面生成JSON配置,将配置提交服务端保存。预览页请求返回配置,预览页根据配置动态下载组件文件并渲染。


整体流程
一、页面JSON配置与渲染的关系

不论在编辑器内还是预览页,页面都是根据JSON配置来递归渲染

{
        "name": "View",
        "style": {
          "position": "relative",
          "width": "1089px",
          "height": "820px"
        },
        "props": {
          "lazy": true
        },
        "el": "wc12",
        "children": [
             //{ ...}
        ]
  }

这是一个简单的布局组件所映射的JSON结构,其中包括了该组件的样式,传入组件的props属性,以及其唯一的key(el)值,还有他的子组件children数组,数组里的内容就是其包裹组件的JSON结构。通过这样的嵌套关系,可以将其映射成组件树。


compile.js

当页面JSON配置发生变化时,依靠react单向数据流会重新渲染,此时我们需要一个通用方法,来递归的创建组件的占位DIV,但是需要注意的是,首次创建的只是一个空壳,return的子组件为null。

global.js

与此同时我们调用异步加载组件js的方法,等该组件下载好后自动注入到这个壳里。这个方法的特点在于,我们每次加载新的组件会优先从window.comp下找是否有已缓存的组件对象,如果为undefined,说明这是一个全新的组件,就请求对应的JS下载,并且将window.comp下的这个组件标识为正在请求的Promise,这样如果相同组件并发调用此方法,会awati同一个Promise不会重复请求,而且组件缓存后也可以直接用await拿到组件对象。
compile.js -> CompBox

通过上述的几个方法,我们已经能够将JSON配置渲染为页面DOM,并且动态加载组件JS文件了~

二、编辑器内的操作

既然我们的页面是根据JSON配置来渲染的,那么对页面任何的增删改查,都可以抽象为对JSON树内某个节点的数据结构修改。我们需要一个通用的搜索方法,来搜索JSON树,并传入一个标识,来指明这次操作的类型


common.js

searchTree是所有操作的通用方法,本质上是对JSON配置树的BFS搜索,只要找到对应的key节点,根据EnumEdit中的枚举类型操作数据后返回修改后的结果树,dispatch新的配置树通知react重新渲染。

除此之外,具体操作这里涉及到各种键盘、鼠标事件的绑定,这部分暂不做赘述,可自行查询MDN文档。

三、样式、自定义属性的注入

我们在右侧编辑区填写的内容,都会在渲染时注入到对应的组件里,样式style会注入在包裹组件的壳里,自定义属性会当做prop传入子组件,在组件开发中,我们可以从props中拿到编辑器内填写的属性值。

compile.js -> CompBox

四、编辑历史记录管理

历史记录为一个队列的数据结构,如果我们保存1000条记录,每修改一次JSON配置,就将其入队,每次入队时发现记录大于1000,就将队列头部抛弃。
当前页面显示的配置为一个指针,指向队列中某条记录,撤销就指针后移,恢复就指针前移。每次触发compile时,将新的配置树计入队列,不需要手动记录。利用hooks自带的缓存机制非常容易实现。

record.js
五、画布的缩放处理

在搭建使用程中,我们寄希望于画布设计尺寸永远为1920(移动端则为750),但是视口显然没那么大,所以我们要将画布以左上角为缩放焦点transform-origin: 0 0,拖动导航slide或按下空格利用滚轮缩放。这个过程中不断改变transform: scale来刷新视图。
需要注意的是,scale的改变为浏览器重绘,并不会改变原有的DOM占位尺寸,因此缩小画布会有很大的空白区域,为了解决这个问题,我们需要在画布外再包一层div,每次画布改变缩放后,利用getBoundingClientRect()来获取缩放后画布实际的宽高,并将这个数值定义在外层div上,外层div设置为overflow: hidden,这样窗口滚动的距离就会依据外层容器来出滚动条。


画布的高度计算时,要计算出一个min-height,为当前搭建区域的offsetHeight,保证画布内没有组件撑开时,也能够铺满一个屏幕。

此外对画布根节点要设置一个padding-bottom: 300px,作用是保证底部永远有一个空白区域,能够让搭建者拖入新的组件到根节点下。

六、组件的开发

每一个组件都的固有结构,index.jsconfig.json是必须存在的(服务层会根据此文件构建,稍后会提到):

comp/Image

入口文件即为业务代码,配置为一个JSON文件,决定了编辑器内所能编辑的自定义选项:

{
    "name": "图片",
    "staticProps": [
        {
            "name": "点击链接",
            "prop": "link",
            "size": "long"
        },
        {
            "name": "是否在新窗口打开链接",
            "prop": "blank",
            "type": "switch",      // 配置类型,目前支持`text`默认,`select`,`switch`,`color`
            "size": "long"          // 配置是否占满编辑器一行
        },
        {
            "name": "图片地址",
            "prop": "src",
            "size": "long"
        }
    ],
    "defaultStyles": {        // 拖入组件到画布时默认的样式
        "position": "relative",
        "width": "180px",
        "height": "180px",
        "marginTop": "0px"
    },
    "defaultProps": {      // 拖入组件到画布时默认的自定义属性
        "src": "http://r.photo.store.qq.com/psb?/V14dALyK4PrHuj/h50SMf97hSy.BJlJw31fagrw.NUaJD83gvydmoGN77w!/r/dLgAAAAAAAAA",
        "blank": true
    },
    "hasChild": false,        // 是否允许有子组件,如果不允许拖拽的时候移入会标红,提示当前节点不能被注入
    "canResizeByMouse": true       // 是否允许通过拖动九宫格蒙版来修改组件的宽高位置
}

上述这样的一个图片组件,在编辑器内对应的配置项即为:


七、组件的构建打包

这是构建阶段非常重要的一环,我们上面说过,每一个组件对应一个JS文件,那么我们就需要在页面生成前将当前所有组件都构建好。

webpack.config.comp.js

这里首先找出仓库中的组件,加入打包的entry入口,然后利用webpack的library,libraryTarget配置,将组件打包到window[name]下,name为组件名(比如Image,View),我们来看看打包后的组件代码:

果不其然 ,组件js下载执行后直接被挂载到window下了,此时此刻你可以回头看开头提到的loadAsync加载组件的方法,是否恍然大悟了呢。
这里你可能又发现一个问题,组件都依赖react库,那每个组件单独打包,岂不是都要加载一遍,那包得多大?

从JS体积可以看出,实际上根本没有打包这些通用库,只包含了业务代码而已。这里同样利用webpack的externals属性,可以指定某些依赖直接从window下取:
webpack.config.comp.js

那么是什么时候将react注入window下的呢?
global.js

在编辑器或者预览页面,加载全局配置,也就是SDK初始化之前,就将组件所依赖的全局对象注入好了,这样后续组件异步下载后就可以直接执行。
关于编辑器和预览页的打包不做特别说明,就是普通的webpack配置打包,记得抽出公共模块就好。

七、组件的代理调试

平台开发好了,这个时候我们要往里开发业务组件了,那么如何调试呢。
通过npm run dev:comp debug=XXX,YYY命令(XXX为组件名)来执行调试脚本


脚本首先通过process.argv传入的参数获取要调试的组件
debugComp.js

然后使用node API来调用webpack-dev-server
需要注意的是,这里仅仅是在本地创建了组件的代理,还需要在组件资源加载上区分哪些组件需要请求本地调试地址,详情可见上方loadAsync方法,我们通过在预览页和编辑器后方加入debug_comp=XXX参数来告诉此方法该组件要请求本地调试地址
server/index.js

最后记得如果当前用户请求的URL是调试模式,在node express服务的ejs模板接口里加上webpack-dev-server的代码script标签,

八、服务端对页面配置的管理

因为此项目为演示项目,并没有对页面配置用id进行区分,每次提交都是存取同一个配置文件page.json

opPageJSON.js

生产环境下需要连接数据库,将每一份配置生产一个ID,在打开编辑时取对应的请求ID返回配置。
要额外注意的一点,我们在返回配置接口数据时,要去搜索当前构建文件夹中存在的js与哈希值的映射,这样保证前端页面能正确的加载最新构建的js地址

项目结构


├─config.js         // 前后端通用配置
├─comp              // 组件仓库
│  ├─Image              // 组件名
│  │      config.json           // 组件配置 
│  │      index.js              // 组件入口
│  │      index.less            // 组件样式
│  │      
│  ├─Text
│  │    ...
│  │      
│  └─View
│       ...
│          
├─script                // 配置脚本
│      debugComp.js                 // 组件调试脚本
│      webpack.config.comp.js       // 组件打包配置
│      webpack.config.edit.js       // 编辑器打包配置
│      webpack.config.page.js       // 预览页打包配置
│      
├─server                // 建站平台服务端
│  │  getCompUrlHook.js     // 生成组件js文件哈希映射
│  │  getCompJSONconfig.js      // 查询组件仓库内当前所有存在的组件配置
│  │  index.js          // 服务端总入口 
│  │  opPageJSON.js     // 存取页面对应的配置JSON树
│  │  
│  └─template           // 模板
│          index.ejs            // html渲染模板
│          page.json            // 页面配置JSON树
│          
└─src               // 建站平台前端SDK
    │  context.js           // 全局状态对象
    │  global.js            // 全局配置依赖
    │  reducer.js           // 全局状态管理
    │  
    ├─edit              // 编辑器
    │  │  compile.js        // 编译配置树为组件树
    │  │  board.js          // 编辑器可视区域面板
    │  │  index.js          // 编辑器总入口
    │  │  menu.js           // 编辑器组件菜单
    │  │  option.js         // 编辑器属性操作面板
    │  │  record.js         // 操作历史记录管理
    │  │  tree.js           // 搭建树层级展示
    │  │  search.js     // 搜索页面配置树方法
    │  │  
    │  └─style          // 编辑器样式
    │          
    └─page          // 预览页
        compile.js  // 渲染组件配置
        index.js        // 预览页总入口

结语


此项目对可视化建站的整体前后端流程有一个完整实现。基于此基础上,可以根据需要拓展定制化的编辑器功能、页面渲染功能等。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335