前言
最近做了一个需求:自定义首页。
用户或运营可以自己修改首页的布局,做到千人千面。
这个需求类似于当年的自定义QQ空间,不过怕是年轻一些的没玩过这个东西。
所以你也可以简单理解为是博客园的皮肤,只是不能写样式和代码,但是可以调整各个组件的布局。
明确需求
这并不是一个低代码页面设计器,不是给程序员用的。
只是一个自定义布局的页面而已,是面向客户或者运营的。
如果遇到类似的需求,大家可以针对具体的业务需求,从而一步步明确用户到底需要改变哪些布局属性。
强调这一点的必要在于,需求每明确和精简一次,都会极大简化后面的设计和开发。
可能用户不需要细致到像素级的宽高,而仅仅就是简单的几个字——对齐和能换位置,这在后续实现上有很大差别。
技术最终还是要用来解决问题,而不是创造问题。
给用户过多的自由度,给他一大堆设置选项,除了增加开发周期,更多地只会让他茫然无措。
不懂代码的用户甚至根本不会也不愿意使用你做的这个东西。
所以这个自定义布局页面最核心的一点在于:理解简单,操作简单。
整体流程图
我们将需求简化一下,做成一个如下的流程图:
我们需要一个设计器,去设计自定义布局的页面,设计完成后会将这个数据保存在服务端。
同时首页再从服务端获取数据,用一个渲染器去渲染页面。
按照这个思路,我们可以独立开发渲染器和设计器,只要保证两者之间的数据一致即可。
后续如果有业务变更,需要替换设计器和渲染器,也可以分开替换,而不需要同时替换。
不好的案例
四五年前我其实做过一个类似的需求。
当时的做法是:设计时记录下各个组件的宽高信息和样式,然后首页渲染时,根据相应的信息直接调整各个组件的位置,宽高和样式。
这种做法无疑是可以实现的,但是业务组件和布局信息耦合在了一起,新增业务组件或者调整UI时工作量很大。
对用户而言,调整布局时,需要自行确认宽高,不断进行适配,碰到需要适配不同分辨率屏幕的情况,不懂代码的用户可能心态直接就崩了。
而且,使用这种方式,在后续的业务迭代中,改动这块代码很容易使用户出现线上样式问题,导致开发根本就不敢动这块代码。
简易渲染器结构
为了解决上述问题,关键就在于将布局组件与业务组件分离。
我将所渲染的首页区域,作为一个容器组件。
然后将里面的各个内容区域组件分为布局组件和业务组件,两者完全分离后再由用户进行绑定。
这样在渲染的时候我们只用关心渲染简单的布局组件就行了,布局组件内部各种复杂的业务组件及其逻辑与我们毫无关系。
为了简单,布局组件推荐直接采用Ant Design的24列栅格设计,使用Row和Col进行布局,并不需要设定宽高,只用设定布局组件占几列就行了。
简易设计器结构
设计器分为两个区域:预览区和属性区。
左侧为预览区域:
有且仅有一个容器组件,右侧有一个新增按钮,点击后在下方新增一个布局组件。
布局组件可通过拖拽进行排序,这里我通过react-sortable-hoc来实现,技术细节无需赘述。
右侧为属性区域:
点击容器组件后,右侧属性区域显示容器组件的属性,比如各块之间的间距和整体的背景图。
点击布局组件后,右侧属性区域显示布局组件的属性,比如在栅格系统中占多少列,绑定哪个业务组件。
想象一下我们生成的数据结构,用TypeScript可以简化为:
// 容器组件属性
interface IContainerInfo{
//...各种属性
}
// 布局组件属性
interface ILayoutInfo{
//...各种属性
}
// 最终数据结构
interface IData{
container:IContainerInfo
layouts:ILayoutInfo[]
}
至于组件的排序,用layouts数组的索引即可。
复杂布局与树
上面的布局设定针对大多数简单布局而言,绰绰有余。
如果你的需求比较简单,比如移动端之类的首页布局,这个完全足够了。
但是对稍微复杂一点的布局而言,上面的方案是有问题的,比如下面这种布局:
使用一个Row和Col很难实现上面这个布局。
如果要实现,我们需要使用设计器生成下面的结构:
<Row>
<Col span={12}>
<Row>
<Col span={24}>组件A</Col>
<Col span={24}>组件B</Col>
</Row>
</Col>
<Col span={12}>
组件C
</Col>
</Row>
实际上这就是一个树形结构:
容器组件作为唯一根节点,无需改动。
之前绑定业务组件的布局组件,都可以理解为叶子节点,也无需改动。
唯一需要引入的是树枝节点。
为了渲染器能够渲染出目标结果,我们可以修改一下原先的数据结构为:
// 容器组件属性
interface IContainerInfo{
//...各种属性
}
// 布局组件属性
interface ILayoutInfo{
//...各种属性
isLeaf:boolean
children:ILayoutInfo[]
}
// 最终数据结构
interface IData{
container:IContainerInfo
layouts:ILayoutInfo[]
}
可以看到我们给布局组件引入了两个属性
- isLeaf 是否为叶子节点
- children 如果是树枝节点,那么children下面为一个布局组件属性数组
既然数据结构确定,反推到我们的设计器,那么就是加一个是否为叶子节点的复选框属性。
勾选该属性,为叶子节点,那么展示绑定业务组件的属性。
不勾选该属性,为树枝节点,展示子组件数量这个属性。
各位可能很疑惑为什么是子组件数量这个数字类型,而不是children之类的数据格式。
实际上,用户可以简单设定子组件数量,来设定树枝节点下子组件的个数,然后再点击左侧的子组件来进行进一步的设置。
这样在操作上来讲,会简单很多。
当然,实际开发中考虑的也需要更多,比如子组件数字增大,需要插入新的子组件,数字变小,需要删除子组件,设计器保存和加载时子组件数量这个属性与children这个属性的互相转换。
细节实现
上面的方案应该是可以满足绝大多数情况了,但是您可能在实际开发过程中遇到以下细节问题:
设计器中节点定位
在设计器中,点击左侧布局组件,在右侧展示属性这一功能,有些朋友可能会存在一点困惑:如何获取当前组件在树形结构中的位置?
我这里实现时,是给每一个节点给了一个code,这个code是从根节点到当前节点的index数组,用字符分隔后得到的字符串。
在点击布局组件时,可以在当前节点上拿到这个字符串,然后快速还原成index数组,从而在树形结构中存取数据。
设计器中业务组件的预览
组件预览的这块,在绑定业务组件后,不必直接加载业务组件来展示。
因为这里只调整布局,不需要业务组件内部的逻辑。
所以我推荐这里用一张图片,或者一些简化后的组件代替即可。
如非必要,设计器这里的预览业务组件和业务组件应该完全分离,用code关联即可,否则代码的可读性和可维护性都会受到挑战。
渲染器中业务组件隐藏的情况
通常我们可能会遇到一些隐藏业务组件的需求,比如如果是管理员,那么就在首页展示这个组件,否则隐藏。
我们通过上述手段实现的布局中,如果业务组件隐藏,包裹它的布局组件是不会隐藏的。
所呈现的效果就是,这个隐藏的业务组件附近,块之间的间隔会比正常间隔大。
解决这个问题,可以使用下面三种方法:
- 方法一(不推荐):取消块间隔这一属性,组件的间隔由业务组件实现。这样最简单,但是业务组件承担了过多的布局任务。
- 方法二(不推荐):在业务组件中判定需要隐藏后,触发布局组件传递进来的回调函数,从而隐藏布局组件。但是这种子组件控制父组件的方式并不好,可读性差。而且业务组件应该与布局组件解耦,不能直接控制布局组件。另外这种控制方法,对于隐藏操作而言只能生效一次。
- 方法三(推荐):在根节点组件中,设置控制逻辑。传递一个操作信息数组到每个子节点,如果布局组件的业务组件Code在这个操作信息数组中存在,并且操作为隐藏,那么就隐藏该布局组件。
推荐使用上述方法三,可以在渲染器上保持业务逻辑和布局逻辑始终分离开来,并且不仅仅能用于隐藏信息,还能用于其它的组件联动操作。
总结
以上便是我自己对这样一个自定义布局页面的思考和实现。
因为是公司的项目,所以无法开源。
但是如果参照上面的思路,实现起来应该也只是填充一些技术细节就可以了,希望对您起到帮助作用。
如果您有更好的方案,也希望不吝赐教。