找回密码
 会员注册
查看: 20|回复: 0

浅谈搭建平台-画布篇_UTF_8

[复制链接]

4

主题

0

回帖

13

积分

新手上路

积分
13
发表于 2024-10-1 18:00:31 | 显示全部楼层 |阅读模式
出品 | 西瓜视频前端技术团队作者 | 马进 什么是搭建平台 在介绍搭建平台之前,有一个无法绕过的主题,那就是 NCLC(No Code & Low Code)。NCLC 作为建站产品背后的核心概念,推动了一波又一波的技术浪潮。近些年围绕 NCLC 踊跃出了不同形态的产品,如项目管理 Meego、低代码系统 Retool、在线文档 Notion 等,甚至还有人将 NCLC 细分为了 12 个发展赛道。image图片来源:https://pinver.medium.com/decoding-the-no-code-low-code-startup-universe-and-its-players-4b5e0221d58b其中业界内发展最为迅猛、最有代表性的产品形态莫过于 建站类。关于建站类,相信大家都能说出一些耳熟能详的产品,如早期的 Dreamweaver、获取上亿投资的SaaS Webflow 以及 国内的各个大厂的搭建平台等。搭建平台之所以如此流行,原因不外乎于解决了企业的两大痛点:开发效率与人员转型。开发效率:搭建平台本身也是一款软件,与常见的 IDE 相比,都是为提高生产力服务;不同的是,它更注重于“复用与组合”,而非实际的业务逻辑。通俗来说,用组件拖拽与配置 代替了 传统的编程开发。想象一下,我们需要开发一款海报形态的活动页面,必不可少的需要堆图片、堆动效,如果走传统的编程可能会花费数天。而如果走搭建,只需生成一次物料,拖拽并配置物料 就搞定了。简而言之,搭建平台提供了一种全新的开发方式。面向特定场景下,开发的复杂度可以很好地被掩盖。如 电商活动、营销宣传、中后台等。人员转型:在日新月异的市场中,数字化转型获得了很多企业的一致认可,其中 NCLC 作为人员数字化背后的核心概念,允许企业员工在没有编程经验的情况下构建应用。在搭建平台的用户中,除了研发同学,更常见的用户是没有编程技能的运营同学,ta们通过搭建的方式生成所需的活动页面,在没有研发参与的情况下,真正按照自己的想法去实现。搭建平台大幅降低了使用者的门槛,避免浪费过多的开发资源,进一步提升了 企业中的流程效率。相信大家对搭建平台已经有一定的了解了,那么搭建平台由哪些概念组成呢? 衍生概念 在发展的过程中,搭建平台 衍生出了众多的概念,让人望而却步,但核心可以用一句话来概括:搭建平台 = 编辑器(画布+设置器)+ 生成器,而这些实体的数据源是物料,实体间遵循的通信协议是 UIDL。编辑器与生成器独立通过 UIDL 解耦,做到 搭建的页面类型 只与使用的物料相关。编辑器 只负责前端技术栈物料的渲染,生产出 UIDL 规范。生成器 消费 UIDL,再根据约定好的模板项目,生产出页面。该设计属于典型的分层架构,将系统分成若干个水平层,每一层都有清晰的角色和分工,不需要知道其他层的细节,层与层之间通过接口 & 协议通信。因为篇幅原因,本文的重心是 画布,分为架构设计与画布设计 两个章节来展开,设置器、生成器 可期待后续系列。术语描述备注UIDL用于描述 搭建页面时的所有与 UI 相关的可结构化信息。UIDL 常见包含的信息有:1、UIDL 版本2、项目的元信息3、Schema 结构4、使用的物料列表具体设计可查看:下文的 UIDL 规范。teleporthq 对 UIDL 的定义Schema用于描述 搭建页面时渲染页面结构的 DSL,为树状结构,每个节点都是一个组件实例。在 Schema 结构中,节点分为三种:1、根节点:一般隐藏且固定2、父节点:一般为容器形态的组件3、子节点:一般为不限形态的组件物料用于描述 搭建页面时所需的组件等一系列可丰富页面功能、样式的实体。业界内也有喜欢将其称之为区块,大部分都是对页面组成实体的总称。组件用于描述 搭建页面时 最小粒度的可操作单元,组件也是组成页面的基本单元。从使用场景出发,组件可分为两种:1、通用组件,如文字、图片2、业务组件,如关注按钮编辑器用于描述一个包含了 画布、设置器、设置项、图层、物料区相关概念的全局模块。编辑器 等于 搭建页面时 直面用户的工作台。画布用于描述包含 物料使用、物料编辑、物料渲染等一系列有关搭建动作的模块。画布属于编辑器设计中的核心难点。设置器用于描述 编辑器内 配置组件相关属性的面板。1、组件样式设置2、组件资源设置3、组件动作设置...生成器用于描述 生产 搭建后页面的功能模块生产器通常根据使用物料的形式分为两种:1、运行时生产:搭建的页面会将物料的 bundle 拼接在一起,拼接之后 页面能做到独立运行。 优点:可做到秒级的发布预览,提升了搭建的用户体验。 缺点:生产的页面形态需可加载动态组件,会导致页面性能差。2、源码生产:会将物料下载或安装到本地,并生成入口文件,再经历打包发布 生产出页面。 优点:对生产的页面形态无要求,页面性能高。 缺点:产出页面需要经历打包、发布,无法做到秒级,降级了搭建的用户体验。 架构设计 该 part 介绍了 搭建平台中 核心的架构设计。IOC 架构各个功能模块之间会以 IOC 架构进行依赖耦合,具体来说 每个模块都需定义使用规范,统一在入口进行模块之间的注入绑定。这意味着模块之间的依赖关系由容器在运行期决定。该设计优势在于功能模块之间的依赖以规范为主,不关心具体的实现;并且功能模块之间可以独立迭代,利于后续扩展。举个例子:每一种类型的终端会有独立的物料区、生成器的模板,以及相同的画布功能模块,在使用 IOC 架构之后,功能模块会以 底层基座 加扩展模块的方式进行聚合,如 TV 端画布与 移动端画布的明显差别在于画布分辨率、组件的选中态(TV 端存在焦点的概念),而这些差别 与 画布是附加的关系。如此设计的好处是当出现新的终端时,可以快速适配。总的来说,IOC 架构是一种可模块和可扩展的设计方案,让整个系统更加可插件化。延伸阅读:InversifyJS 的最佳实践。编辑渲染分层用户搭建时需要在组件上渲染出各种交互态,这些状态不能直接与组件本身耦合。搭建平台会把画布进一步分为 渲染层与编辑层,彼此之间通过通信协议进行解耦。编辑层本质上是画布中真实渲染的元素;渲染层则是在编辑层之上的一层蒙版,其中蒙版是由 与真实元素 相同大小定位的虚拟元素组成的。当用户编辑页面时,会直接操作到编辑层的元素,而后续的交互 会在渲染层上进行。举例:当用户选中组件时,被选中的组件位于编辑层;选中组件后会出现 8 个锚点,这些锚点是在渲染层中挂载的。这种设计带来的优势非常明显:可提效页面预览预览页面时只需移除掉 画布编辑器的蒙版即可,真正的秒级预览。便于画布的交互体验组件的提示线、锚点只在渲染层中产出,不会影响编辑层的真实组件;并且当拖拽组件时 只需增删相应的渲染层组件即可。真正的解耦设计可枚举出画布编辑的规范与画布渲染的规范,梳理每一个行为与其所需的输入信息,让每一个行为都有足够详细的描述,真正做到分层解耦。interfaceComponentAddAction{}interfaceComponentDragAction{}...interfaceComponentFocusRenderer{}interfaceComponentMirrorRenderer{}...画布渲染规范用于梳理 画布上的渲染动作,大概形态如下:画布编辑规范主要用于梳理 画布上的编辑动作,大概形态如下:业界中更激进的做法是将编辑与渲染通过 iframe 分层,具体来说是 渲染层是一个独立的路由,内嵌到编辑器中,编辑层会在渲染层之上建立一个等大小的蒙版。用户在操作画布时,蒙版会首先捕获用户的操作,再通过 iframe 的同源协议进行交互行为的通信,渲染层接收消息后实时做到响应。通过 iframe 实现编辑渲染分层的好处有:天然的沙盒化因为 iframe 的天然隔离性,画布渲染器中的所有逻辑、样式不会影响编辑器本身。利于多人编辑单人编辑时使用 iframe 进行通信,而多人编辑时可将 iframe 通信切换成 WebSocket 通信,设计时有异曲同工之妙。需要注意的是,如果想保障 编辑层与渲染层 相应组件之间的定位、大小相同,需要在 resize、scroll 事件中进行渲染层的重新渲染。下文以画布代称编辑层与渲染层,不再做区分。事件体系事件体系是进一步解耦的设计方案,编辑器中会建设一套完善的事件体系,对应着每个编辑动作、渲染动作、全局动作的生命周期或者具体的回调动作。每个功能模块都可消费相应的事件。image.png如此设计的好处是利于扩展,当添加新 feature 时,可以很容易地通过挂载事件来做到。针对事件体系,举两个典型案例:当 页面加载时 会触发 init 事件,平台上的不同功能模块消费该事件:侧边栏加载物料、画布渲染 Schema、物料管理 加载基础依赖等。当 拖拽组件到画布时 会触发 dragEnd 事件,平台上的不同功能模块消费该事件:物料管理加载拖拽的物料,设置器解析渲染拖拽物料的设置规范、画布移除拖拽镜像等。规范设计在平台实践时,切记两点:围绕落地场景来设计产品思路,不可盲目堆功能。规范先行,规范是贯穿编辑器的核心概念。下文介绍两个重点规范:UIDL 规范 与 物料规范。UIDL 规范UIDL:用于描述 搭建页面时的所有与 UI 相关的可结构化信息。UIDL 最初是由 teleporthq 公司提出:Alluserinterfacesservethesamepurpose:allowforaninteractionbetweenahumanandamachine.Functionallyspeaking,thevocabularyofhuman-machineinteractioniswelldefined.Nomatterthemediumorthetechnologyusedbehindit,auserinterfacewilllikelybebuiltwithadozenatomicvisualelementssuchas:titles,paragraphs,inputs,images,videos,links,buttons,etc.,andacoupleofmeaningfulcompositionsoftheseelementssuchaslists,tables,formsandmenus.However,overtimethenumberofchannelshasincreaseddramatically(web,mobile,tablets,tv,AR/VR)andthenumberoftechnologiesusedforeachofthosechannelsaswell.Thishasresultedinanincreasedhumantimecostofbuildingauserinterfaceanddistributingittoeachchannelwhileprovidingnoextravaluefortheenduser.Thisiswhywehavedecidedtosearchforasolutionwhichwouldallowustofocusmoreonthewhatandworrylessaboutthehow.Likeothersbeforeus,wedecidedtoworkonauniversalformatthatcoulddescribeallthepossiblescenariosforagivenuserinterface.Thisformatallowsusto:generatethesameuserinterfacewithvarioustoolsandframeworkstransitionfromonecodeoutputtoanotherwithouteffortenableefficientandadvancedprogrammaticmanipulationWehavenamedouruniversalformat"UserInterfaceDefinitionLanguage"(UIDL).Itisrepresentedbyahuman-readableJSONdocument,aformatsupportednativelybymanyprogramminglanguages.摘录自:**https://docs.teleporthq.io/guides/what-is-this.html#uidl一般而言,UIDL 可由四部分组成:UIDL 元信息项目元信息页面 Schema 规范使用的物料以及物料的配置interfaceComponentProps{id:string[key:string]:any}interfaceSchema{id:stringtype:MaterialIdname:stringchildren:Schema[]props:ComponentProp}interfaceUIDL{meta:{version:string//UIDL元信息}project:{//项目元信息id:stringtitle:stringversion:stringauthor:Userurl:string}schema:Schema//页面Schema规范materials:{//使用的物料及其配置components:Array}}物料规范物料:用于描述 搭建页面时所需的组件等一系列可丰富页面功能、样式的实体。一个物料有多种属性,每一个属性都可成为分类的条件。终端划分:移动端 Web、小程序、TV 端...形态划分:组件、插件、动作功能划分:基础组件、容器组件、玩法组件...其中终端、业务线 是物料外在的属性,形态、功能 是物料内在的属性。我们在确定一个具体物料时,会首先通过物料的外在属性过滤出当前页面上可用的物料,然后再通过物料的内在属性 明确应该使用的物料,所以在设计物料标识时也应尽可能的体现出关键的属性。 画布设计 该 part 介绍了画布中的核心难点 以及 对应的实现方案。为了让大家更好地理解文章脉络,我们举一个页面生产的案例:有个男人叫小帅(请自行脑补),他接到了某活动的需求,需要产出一个宣传页面,页面的大概样子如下:image.png于是他开始打开某搭建平台,首先从物料区拖拽了三个组件:图片、文本、按钮,并按照设计格式放置好。然后他开始进行各个组件的配置,图片组件上传设计图,文本组件配置介绍文案,按钮组件配置跳转链接。配置完成后,他开始了活动发布,等待发布完成后,得到了一个可投放的页面链接,到此 任务完成。来自一个真实的案例:image根据小帅的操作过程,我们不难得到以下关键流程:生成页面 = (1)添加组件 - (2)拖拽组件 - (3)选择组件 - (3)配置组件(设置器)- (4)活动发布(生成器)。因为文章篇幅,设置器、生成器 本文暂不展开,可期待后续系列。其中前三个核心环节均是在画布中完成的,那么这些核心环节的背后发生了什么呢?添加组件添加组件的核心 有两点:组件的产物加载到搭建平台生成组件的 Schema Node,并在拖拽结束时 插入到 UIDL(下一章节)Schema 生成当添加组件时,第一步是生成 组件的 Schema Node,为后续 UIDL 服务,生成时会解析当前组件设置器的规范,伪代码如下:genSchema(component:ComponentMaterial):Schema{constchildren:Schema[]=[]//TODO:应该由设置器中的props填充constprops={}//TODO:应该由设置器中的默认样式填充conststyles:React.CSSProperties=SchemaService.defaultStylesreturn{id:this.genComponentId(),type:component.id,name:component.name,props,children,styles}}加载物料用户第一次进入搭建平台时,是不可能直接加载所有物料资源的,物料一般是在使用时加载,这是因为物料是可扩展,加载所有物料 会导致搭建体验下降。所以编辑器需要一种异步的物料加载方式,有利于渲染单页面资源 最优化。打包规范很明显,物料的加载方式是需要与打包类型 紧密配合的。在 Web 的模块化发展中,出现了四种使用较广泛的类型:AMD、CJS、UMD、ESM,其中 UMD 是在 AMD、CJS 上层的兼容方案,就不展开了。下面简单梳理了下各种模块化的优劣势与适用场景。AMDCJSESM优势1、异步加载,适合多模块化的场景2、RequireJS 规范兼容性好同步加载,写法直观1、支持同步、异步加载2、支持前端工程化的打包优化3、已经被标准化了,属于未来的模块规范劣势1、写法不直观2、存在 runtime 时的约定规范,如 define('字符串')3、不能很好地支持打包优化4、未被标准化1、不能支持浏览器环境2、规范较为独立3、未被标准化在 CJS 项目中会存在兼容性问题。如 知名开发者 sindresorhus 开源的大部分库,如果想使用,需要将项目转换 ESM。场景浏览器环境Node 环境浏览器、Node 环境现代前端模块化的发展趋势逐渐以 ESM 为主流,相比 AMD、CJS 有标准化组织支持,所以物料打包规范建议以 ESM 为目标。加载物料随着前端模块化的发展,模块加载方式也出现了一些更加成熟的解决方案,梳理如下:方案优势劣势systemJS1、支持完善的模块规范 (AMD\ESM\CJS)2、项目有持续在更新,并且有打包 工具的支持没有 Context 的概念存在一定兼容性Requirejs1、支持 AMD、CJS 的模块2、规范兼容性好写法不直观,需要 promisify 化 API综合来说,使用 systemJS 加载模块是个更好的选择,对现代打包工具有较好的支持,有一套规范可以实现复杂的依赖引用,而且还支持各种模块的规范。依赖分析除了加载物料本身还远远不够,物料在实现时会依赖很多通用的基础库、框架,如 按钮组件在 Web 端会依赖 React,具体依赖大概可分为以下三种类型:基础框架、库:React、React-DOM 等特定组件下所需的 框架、库:如 xg-player依赖的其他组件:如 按钮列表组件 需要 按钮组件这三种类型的依赖,如果每一个物料都打包的话,无疑会造成很大的物料体积冗余,从而进一步影响拖拽体验,所以需要根据不同类型做不同的打包优化处理。基础框架、库:会作为公共依赖存在,物料打包时不会打包进该产物,该产物会直接内置在 搭建平台侧。注意 这些公共依赖 需要在物料侧统一版本。特定组件下所需的 框架:会一起打包进物料,不进行处理。依赖的其他组件:物料打包时,会检查当前物料依赖的其他物料,并声明在物料信息的某一字段中(如 dependencies),物料在加载时 会先加载 依赖所需的物料。注意 物料加载方式是深度优先的,会存在多层依赖的场景。沙箱化每个物料作为单独的可执行单元,运行在同一 runtime 中,为了避免互相相互影响,沙箱化机制是必不可少的,我们可以参考以下微前端的常见沙箱化手段。根据 Web 的技术特性,沙箱可分为逻辑隔离与样式隔离。逻辑隔离逻辑隔离常见的方案有 Eval、Function 方案,其中 Eval 的安全性、性能与 Function 相比差距较大。业界中常见的微前端框架 逻辑隔离是 通过代理 全局对象 实现,大概的逻辑如下:constvarBox={};constfakeWindow=newProxy(window,{get(target,key){//敏感调用检查returnvarBox[key]||window[key]},set(target,key,value){//敏感调用检查varBox[key]=value;returntrue;}})constfn=newFunction('window',code);fn(fakeWindow);样式隔离样式隔离根据开发的层面也有不同的解决方案。在开发层,可以尝试使用 style-components 或者 css-module 方案,打包出具有唯一样式的组件。在构建层可以使用 Shadow Dom 包一层 或者在所有样式外 加一层组件类名。letelementRef=document.querySelect('#sub-app');letshadow=elementRef.attachShadow({mode:'open'});结束语到此,添加组件已完成,回顾一下:组件的 UIDL 已经生成完毕组件的资源已经被加载到了搭建平台。拖拽组件拖拽组件属于画布中的核心难点,该 part 将尽可能覆盖拖拽背后的技术难点。拖拽库在介绍拖拽之前,必不可少要做的是 拖拽事件的监听与拖拽信息的收集,业界中有很多库都做了或多或少的事情,如 Github 的 draggable topic。那么拖拽库又做了什么呢?组件在拖拽时,会根据时间周期分为三个阶段,每个阶段都应该提供不同的信息供外部消费:拖拽开始 - 注册事件:MouseDown是否已拖拽,需提供 拖拽的判定距离,不能太小不然会误判。触发拖拽开始的回调拖拽中 - 注册事件:MouseMove、Scroll应只针对于非固定布局的元素拖拽的方向拖拽的距离拖拽的位置触发拖拽中的回调拖拽到边界时容器应随之滚动拖拽时应显示拖拽中的光标拖拽结束 - 注册时间:MouseUp拖拽的位置触发拖拽结束的回调在拖拽组件时 预期可提供不同阶段的回调函数,再结合该引擎使用,做到底层隔离,聚焦业务逻辑。你可能好奇 为什么要使用 MouseDown + MouseMove + MouseUp 模拟拖拽事件,而不使用 DOM 原生的 Drag 事件?画布组件拖拽中需要实时拿到当前的位置、拖拽方向,即需要注册 MouseMove 事件,如此一来不如使用以上三个 Mouse 事件模拟更加方便。镜像组件组件从拖拽开始到拖拽结束 会有一个拖拽中的镜像组件,可以从以下两点提升搭建的体验。这样做的好处是:镜像组件能前置加载组件资源 ,拖拽时也能给用户实时的反馈。实现伪代码如下:letcomponentMap={}letmirror={move:xxx,destory:xxx}//鼠标摁下加载物料,生成镜像onMouseDown=(e)=>{constschema=genSchema(e)constschema=loadComponent(schema)mirror=renderMirror(schema)}//根据拖拽的位置移动镜像onMouseMove=(e)=>{mirror.move(e)}//拖拽结束销毁镜像onMouseUp=(e)=>{mirror.destory()}//加载组件资源loadComponent=(schema)=>{if(componentMap[schema.type])returncomponentMap[schema.type]=systemjs.loadModule(schema.url)}//根据已经获取的组件资源渲染镜像renderMirror=(schema)=>{constmirrorEl=document.createElement('div')document.body.appendChild(this.mirrorEl)constMirror=componentMap[schema.type]ReactDOM.render(,this.mirrorEl)}在鼠标点击时加载物料资源 或者 复用已缓存的物料资源在拖拽开始时渲染相应的组件拖拽中 实时同步位置,拖拽结束卸载组件组件锚点组件锚点 属于 拖拽组件中绕不开的一个概念。用知名绘图工具 draw.io 举例,选中组件后,会在组件周围出现 8 个锚点。image组件在拖拽时 可根据其锚点进行 参考线的显示,并且在选中组件时 也可拖拽锚点进行组件的缩放。组件锚点 一般分为 4个、6个、8个,常见为 8个,以组件左上角(X*Y)二维坐标举例:实现逻辑是生成 8 个相应位置的锚点,再贴在原生节点上,具体实现如下:constpointList=['t','r','b','l','lt','rt','lb','rb']constselectedComponent//选中的组件constpointStyle={width:5px;height:5px;border-radius:5px;cursor:point.split('').reverse().map(m=>this.directionKey[m]).join('')+'-resize',}constgenPointStyle=point=>{let{left,top}=selectComponent.styleif(point==='lt'){return({...pointStyle,left,top})}//其他点的处理逻辑...}{pointList.map(p=>())}锚点拖拽锚点在拖拽时可以随意缩放组件本身的大小,从而 达到快捷设置组件尺寸的目的。重点来了,组件本身的位置大小,在 2D 坐标系中由四个属性即可描述完整:Width、Height、Left、Top,简称为 Rect,该概念贯穿全文。image.png并且需要注意的是,组件的 8 个锚点在拖拽时 都是以 对点(左右) 为固定的,所以拖拽不同的锚点,达到的效果是不相同的。左上锚点:可更改组件的 Width、Height、Left、Top中上锚点:可更改组件的 Height、Top右上锚点:可更改组件的 Width、Height、Top左中锚点:可更改组件的 Width、Left右中锚点:可更改组件的 Width左下锚点:可更改组件的 Width、Height、Left中下锚点:可更改组件的 Height右下锚点:可更改组件的 Width、Height举例说明:假设鼠标摁下的端点坐标为 (x1,y1),鼠标移动时取任一时间切片的坐标为(x2,y2),锚点拖拽具体的实现逻辑如下::在鼠标点击时 记录点击的坐标(x1,y1)在鼠标移动时,拿到鼠标移动时的坐标(x2,y2)由两个坐标 相减的绝对值 得到拖拽的距离根据以上的规则 与 拖拽的距离,重新设置 组件的 Width、Height、Left、Top伪代码如下://伪代码letcomponent//当前拖拽的组件letstartPoint//起点constonMouseDown=e=>{startPoint=newPoint(e)}constonMouseMove=(e,point)=>{constdistanceX=e.x-startPoint.xconstdistanceY=e.y-startPoint.yif(point==='LT'){component.height-=distanceYcomponent.width-=distanceXcomponent.left+=distanceXcomponent.top+=distanceY}//其他点处理...}智能吸附智能吸附属于组件拖拽时的辅助功能,提升页面的搭建效率。智能吸附一般可分为三种:位置吸附、距离吸附与尺寸吸附。位置吸附位置吸附在很多编辑场景中都能见得到,如设计工具、搭建平台、绘图工具等。主要原理是,用户在拖拽某一组件时,如果检测到 拖拽中的组件位置 与 画布中其他组件的位置 小于吸附的阈值,则会智能吸附,从而使得 用户搭建页面更加准确。吸附的阈值一般为 1~5 px 常见为 3 px,如果阈值太大 则卡顿感会很明显。智能吸附的原理很简单,当检测到 拖拽中组件与其他组件的 不同锚点 X 坐标或 Y坐标之间的差绝对值小于阈值时,就会将拖拽中组件的 Rect 中的 Left/Top 直接设置为 被吸附组件的锚点坐标。并且当吸附后组件之间会显示参考线,参考线一般根据锚点位置分为 2 种类型 6 条参考线,分别是:X 轴参考线 - Left、Height 是变量,Width 一般为 较小值,Top 一般等于吸附位置的 TopY 轴参考线:Top、Width 是变量,Height 一般为 较小值,Left 一般等于吸附位置的 Leftimage.png参考线的显示与锚点显示的大概逻辑相同,也是通过已知的定位大小 与 较高的图层 渲染在画布上。伪代码如下:letdraggingComponent//拖拽中的组件letallComponents//画布中的所有组件//垂直参考线样式width:1px;height:100%;z-index:9e3;display:noneletverticalLineconstonMouseDown=e=>{draggingComponent=getComponentByMousePosition(e)allComponents=getAllComponents()//得到画布中的所有组件注册鼠标移动、鼠标抬起事件}constDiff=3constisNearly=(a,b)=>Math.abs(a-b){allComponent.forEach(c=>{const{left,top}=c.getBoundingClientRect()if(isNearly(e.left,left)){draggingComponent.style.left=leftverticalLine.style.left=leftverticalLine.style.display='block'}//同理针对top、top+top/2、其他点...})}距离吸附很多设计工具除了 位置吸附外,也提供了距离吸附。用户在拖拽组件时,画布会实时检测 组件之间是否已存在 一定误差范围内的相同边距,如果存在 则设置组件的位置,已达到 边距与已存在的边距一致。该功能在排版 边距相同的组件时会很有效。功能要点如下:因为搭建页面时,画布的组件只能分为两种:拖拽中的组件 与 静态放置的组件。 静态放置的相邻组件之间是可以计算出边距的,所以编辑器中可存在一个代表 包含了画布上所有相邻组件的边距状态。当拖拽组件时,会拿到拖拽组件与相邻组件之间的边距,如果该边距 命中了 已知的边距大小,则进行组件之间的边距渲染。为了防止 边距吸附引起的噪音过大,组件之间的边距计算,需组件在拖拽组件视图内,如下:image.png拖拽组件的 Bottom >= 兄弟组件的 Top拖拽组件的 Top = 兄弟组件的 Left拖拽组件的 Left =({id})=>{const[schema,version]=useSchema(id)constmoduleMap=useSelector((state:RootState)=>state.material.moduleMap)const{children=[],type,props,name}=schemaconstModule=moduleMap[type]return({children.map(child=>())})}在插入完成后,还可发起 事件通知,进行后置的操作,如:选中组件、销毁镜像、设置器更新等。插入提示组件在拖拽时会实时计算鼠标下的 DOM 节点是否为画布中的组件,如果是画布组件需给出可插入提示,从而提升搭建体验。实时计算 DOM 节点,可通过 document.elementsFromPoint 方法,该方法会返回鼠标下的所有 DOM 节点,后续可深度遍历 查找出具有特征标识的 画布组件。根据组件插入的方式 可知,插入动作分为 插入目标组件中 与 插入目标组件周围,这两种插入动作的提示重心是完全不同的。插入目标组件中,提示重心在于目标组件可包含。插入目标组件周围,提示重心在于插入的位置。关于提示的实现,可通过增加上层绝对定位的 DOM 节点,这里不具体展开了。结束语至此,组件已经成功被插入到画布中,回顾一下:使用 or 自研 拖拽库,作为底层能力,不同阶段的信息供外部消费。在拖拽时有 镜像组件、多种智能吸附的效果 来保障搭建的效率与体验。组件插入时,利用 UIDL 响应式渲染的特性,在画布中呈现最新的组件。选择组件该 part 将重点介绍 组件选择后的核心机制,与可交互的快捷功能。事件分发在组件选择后,有一个无法绕过的主题,那就是事件分发。由于画布中的组件是动态加载来的,如果想为组件绑定事件,需要在组件开发时进行内部的 props 消费,如把 onClick 透传到组件根节点上,但针对组件产物 进行事件绑定,这显然是不合理的。所以如果想绑定事件,只能从 DOM 层出发,解放方案有二:组件节点声明 组件的关键信息,并且在 Wapper 中为每个组件绑定事件。需要绑定 组件个数 * N 个事件事件触发更准确优势:劣势:绑定全局的事件,再根据点击的位置找到相应的元素,进行分发。需要一套完善的组件查找机制节省内存优势:劣势:为了实现的复杂度,选择方案一即可。回归到事件分发的本身,事件分发是为了绑定画布组件的事件出现,编辑器可以提供一个EventProvider ,该功能希望能做到不侵入原有组件、不影响组件功能、样式,并且还可为组件绑定各种事件。可借助 display:contents 可以实现该功能,如果大家对该属性感兴趣,也可以看下 iCSS前端趣闻 的一篇文章:冷知识!使用 display: contents 实现幽灵节点?具体逻辑如下:functionwithEventProvider (Component:React.ComponentType ):React.ComponentType {constWrapped:React.ComponentType =props=>{return({console.log(e,props)}}>)}constname=Component.displayNameComponent.name'Unknown'Wrapped.displayName=`withEventProvider(${name})`returnWrapped}快捷操作用户在选中组件后可进行以下快捷操作,希望提高搭建效率。删除组件用户在选中组件之后,可以使用快捷键或者右键菜单进行删除组件。具体逻辑为:删除目标组件在 Schema 中的相应节点,删除之后画布重新渲染即可。复制粘贴用户在选中组件之后,可以使用快捷键或者右键菜单进行进行复制组件。预期操作途径:选中组件 X -> Ctrl C -> 选中画布、组件 Y -> Ctrl V需要注意的点如下:在画布状态中需维护 已复制的组件 schema,并且复制的组件状态只是单个变量,非数组。当 X、Y 组件是同一组件时,需设置两个组件偏移量,不然很容易出现重叠的情况。插入组件时 应重置 复制组件的 Left、Top ,不然会出现偏移不符合目标容器的情况。具体逻辑类似于组件插入。剪切组件用户在选中组件之后,可以使用快捷键或者右键菜单进行进行剪切组件。剪切操作本质上是 复制粘贴操作 与 删除操作的组合,在用户复制组件后 需将复制的组件从画布中删除,剪切操作与复制操作中使用的是同一画布状态。文本编辑在很多搭建平台、设计工具中,文本内容可以进行双击编辑,使用起来符合直觉。组件在开发时可为编辑的文本 wrapper 增加声明属性,如 data-edit="propKey"constText:React.FC =({id,styles={},content='请输入文字'})=>{return({content})}组件在挂载到画布之后,会为所有声明了 data-edit 的 DOM 节点设置 contentEditable。用户在选中某一组件后,会判断当前位置是否存在 可编辑的文本,如存在 会在失去组件焦点后 同步设置组件的文本,已达到更新输入后的内容。组件旋转组件旋转功能常见于设计工具或 自由度较高的建站平台中,活动页搭建平台中使用的场景较少。旋转功能的具体原理简单来说是,拿到 鼠标点击时坐标与鼠标移动时坐标 针对组件中心点 各自对应的角度,将其相减就是旋转的角度。组件多选用户可通过拖拽鼠标完成多个组件的选择,实现针对多个组件的同时操作,提升效率。选择区域用户在画布上拖拽鼠标时 需实时高亮已选择的区域,该功能属于组件多选的基本能力。假设鼠标摁下的端点坐标为 (x1,y1),鼠标移动时取任一时间切片的坐标为(x2,y2),从视频中可以分析出以下三点:选择区域 是以 (x1,y1) 为不动点区域的宽 是 x2 与 x1 之间的距离,区域的高是 y2 与 y1 之间的距离区域的左上坐标为 (min(x1,x2),min(y1,y2))具体设计思路如下://伪代码letstartPoint=nullconstarea={width:0,height:0,x:0,y:0}constonMouseDown=e=>{//记录端点startPoint=newPoint(e)注册鼠标移动、鼠标抬起事件}constonMouseMove=e=>{area.width=Math.abs(e.x-startPoint.x)area.height=Math.abs(e.y-startPoint.y)area.x=Math.min(e.x,startPoint.x)area.y=Math.min(e.y,startPoint.y)}当鼠标抬起后 选择区域会缩小成 区域中组件组合形成的最小矩形。分析要点如下:选择轮廓区域中的组件 需被完全包含选择轮廓区域的宽高 由边界组件的位置限定选择轮廓区域的位置 等于边界组件的位置具体设计思路如下://伪代码//得到画布中的所有组件constcomponents=getAllComponentInCanvas()//得到选择区域中的组件constcomponentsInArea=components.filter(c=>isInArea(c,area))//得到多个组件之间的边界typeBoundary={left:number,right:number,top:number,bottom:number}constboundary:Boundary=getComponentsBoundary(componentsInArea)constoutlineArea={width:boundary.right-boundary.left,height:...,x:boundary.left,y:boundary.bottom}同时操作在选择多个组件后,用户可同时对选择的组件进行 移动 & 拉伸操作。常见的设计工具中为了提升移动性能,减少终端算力,会在拖拽开始前 生成选择轮廓的 DOM 节点,再把选择的多个组件插入其中,这样多个组件拖拽时 实质上是在拖拽最外层的 选择轮廓节点。在拖拽完成后,会隐藏 外层轮廓节点,再为每个组件重新计算最终的移动位置。不过对于搭建平台来说,因为多选的组件个数不会太多,同时一起移动 也不会太多负向影响。具体移动的逻辑可参考镜像组件,会为选择的每个组件都生成镜像组件再移动。需要注意的是 移动时的智能吸附 以及 组件插入 是根据选择轮廓的外层位置来判定的,可理解为 组件多选后 同时移动不再根据单个组件判定 而是根据整体轮廓来判定。同时拉伸与移动同理,都是根据单个组件拉伸的规则,同时操作在多个组件上。结束语至此,我们可以在选择组件后进行操作了,回顾一下:使用事件分发机制,感知到当前用户操作的组件。通过快捷操作,文本编辑、组件旋转、增删组件的方式 提升搭建的效率。可选择多个组件进行同时的移动编辑。 尾声 本文根据一个常见的搭建页面案例,梳理了搭建平台的核心实体-画布的技术难点。当然对于一个成熟的编辑器而言,除了画布外,还有 设置器 与 生成器,不过碍于篇幅,敬请期待后续文章。 关于我们 我们来自字节跳动,是旗下西瓜视频前端部门,负责西瓜视频的产品研发工作。我们致力于分享产品内的业务实践,为业界提供经验价值。包括但不限于营销搭建、互动玩法、工程能力、稳定性、Nodejs、中后台等方向。欢迎关注我们的微信号:xiguafe,阅读更多精品文章。image我们在招的岗位:https://job.toutiao.com/s/reAThAC。招聘的城市:北京/上海/厦门。欢迎大家加入我们,一起做有挑战的事情!谢谢你的阅读,希望能对你有所帮助,欢迎关注、点赞~- END -
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-12 20:09 , Processed in 0.906782 second(s), 25 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表