一场由H5页面引起的前端数据结构讨论

作者:周周(沪江资深Web前端开发工程师)
本文为原创文章,转载请注明作者及出处

前言

近期在小D十周年活动之际,又看到了一个自家H5专题梦工厂生成的页面。

我与小D十年回忆 >>

回想起了一段往事,现在来看还蛮有趣的。主要是一个将业务逐步抽象成数据的过程,对于当时对数据设计等还不太敏感的自己有不小的促进作用。于是想通过本文分享下当初如何搭建可视化编辑页面系统中的一些开发设计思路,也希望对前端伙伴们在构建类似中大型应用时有一定帮助,可以更好地设计一些较复杂的数据结构。本文不会把具体的实现代码贴出来,更多的是背后为何要这样设计的一些思考过程,如何把一些业务映射到数据。有不足之处,还请轻拍。

让我们一起先来预览下编辑器的后台界面,编辑伙伴可以像在桌面应用中一样进行操作,最后直接生成一个h5页面。

背景

当时14年正值H5比较火热的时期,业务中很多时候会碰到一些重复类似的h5活动页面,开发几乎要变成 ctrl+c 和 ctrl+v 了,略显枯燥。后来涌现出了很多h5页面编辑应用,某秀、某KA等。某一天可爱的老大又在背后看着我们,突然来了一句:

我们要不也搞一个?

当时反应是万匹草泥马在头上奔过: 这个有点太复杂了,成本太高,还是不做了吧。不过冷静下来后,心想做完还可以解放一批开发同学,挑战也不小,所以心想

为什么不呢?

于是就开始了一个可视化编辑页面系统 Web IDE 的打造之旅。从计划开始做的那一刻起,其实是脑中一片空白,后来做了一些小DEMO后,才开始有了点思绪,也不是一蹴而就的。

先举个小例子**

场景: 前后端协作,需求是在页面上加一个异步请求的列表模块。

1.发送请求,处理数据。接口响应回来的数据可能是:

{
    ...,
    data: {
        list: [
            {id: 1, content: '这是第一条数据'},
            {id: 2, content: '这是第二条数据'},
            {id: 3, content: '这是第三条数据'}
        ],
        total: 4
    }
}

2.根据 list 中的数据,循环拼接出需要渲染的DOM结构

<li>1.这是第一条数据</li>
<li>2.这是第二条数据</li>
<li>3.这是第三条数据</li>

3.找到需要追加或者替换的节点,然后加到页面上。

在上面这个场景中,这类数据的结构可能是最常碰到的。整个过程可以理解为,先获取数据,再把数据转换到渲染视图里。看目前流行的框架,react,vue 等都帮我们省略了关注 DOM 的步骤,在这里我们先抛开一些框架的便利,回归到原始的步骤,由一个 renderer 方法充当。

+--------+      +----------+      +-------+
|  data  |  =>  | renderer |  =>  |  DOM  |
+--------+      +----------+      +-------+

伙伴要问了,这个流程比较熟悉,但如果做一个 Web IDE 不是就这么几步吧?

如何下笔

需求分析要详细

要下笔时,发现课题变得复杂了。很多时候就会像下面这张图中。

那先缓一缓,先尝试着拆解成一些小部件来分析。多观察,多联想,多分析。

制定目标

如何把一个H5页面转换成一个可编辑的状态。

现状分析

观察一个普通的H5活动页面,试着先抽取几个关键元素。试着滑动页面,大致可以猜测这是一个滑动组件,占满全屏的,交互可以划分为上下滑动,可能还有回调等等,这时可以发现一个H5上可能有一个或多个子页面(分页),这里面可能将会涉及到分页的操作。再看每个分页上,有图片,链接,文字等等一些元素,展现形式差异很大,样式都不一样,看起来很难统一,不像上面的小例子中可以比较容易地拼凑出,先放一放。但是有两个关键点浮现出来了,分页+元素,一个h5页面基本都是由很多页面和元素组成的,目标将转换成如何编辑分页,如何编辑元素

详细分析

再单独看一个页面详细地分析下:

可以发现页面中有很多元素,大致有:

  • 图片
  • 音频
  • 视频
  • 文字内容
  • 链接
  • 动画
  • 其他元素们

试着抽象一层,一个页面可能会变成下面的结构:

页面: [
    元素1,
    元素2,
    元素3
]

发现和“小例子”中的结构有点类似,一个块包含多个子块,照着类似的渲染方式,估计也能将元素们渲染到页面上,但样式却各不一样,元素之间的差异比较大,类型又不同,怎么想拼接 list 列表一样拼呢?试着比较下,上面“小例子”中的列表数据包含的是偏内容的数据,把 <li> 当做一个元素的话,分析下,这些元素的类型是一样的,数据里包含的是元素内容,而样式等一些其他属性或事件都是定义在了其他地方,并不在这个数据结构里。再回到元素本身上观察下,如何抽象,有哪些特征?

  • 类型

  • 内容

  • 位置

  • 大小

  • 颜色

  • 背景图

  • 链接

  • 其他特征

    元素: {
    类型,
    内容,
    位置,
    大小,
    ...
    }

元素上的属性比较庞大,但还是可以放在一个元素的对象里。设想如果把这些部分也放在该元素的数据结构上,不单单有内容数据,还有样式上的数据,属性上的数据等等,这样是否就可以渲染了。那么目标有新增,如何去编辑这“庞大”的属性集合。

试错

我们假设一段需要的带属性样式的元素DOM结构:

<div class="element someClass" style="someKeyA: someValueA;someKeyB: someValueB;" data-custom="someCustomData">
    <div class="content">
        content's context
    </div>
</div>

相比“小例子”中的 <li> 多了很多属性和结构,根据上面的 DOM 结构,用对象的形式抽象下,格式大致如下:

element: {
    style: {
        someKeyA: 'someValueA',
        someKeyB: 'someValueB',
    },
    class: ['someClass'],
    attribute: {
        custom: 'someCustomData',
    },
    content: {
        text: 'content\'s context'
    }
}

这样的话,我们就可以通过一个拼接方法来生成我们想要的结构。这样一个关于元素的数据结构设计就有了雏形。我们可以通过修改元素上一些属性的值,改变元素的外在表现。整个过程可以简化成数据的变化引起视图的变化,和现在很多前端框架数据驱动思想有几分相似。

整理

通过类似上面很多小 demo 的积累,最后可以整理拼装下,回到单个页面上,除了元素,可能还有一些其他设置,假想预留一些字段。

那么一个页面抽象下,格式大致如下:

page: {
    elements: [
        {
            style: ...,
            class: ...,
            attribute: ...,
            content: ...,
        },
        { element2 },
        { element3 },
        { element4 },
        { element5 },
    ],
    setting: {
        propertyA: {},
        propertyB: 'valueB',
        flagC: false,
    }
}

生成的 DOM 结构大致如下:

<div class="page" data-flagC="false" ...>
    <div class="element element1" ...></div>
    <div class="element element2" ...></div>
    ...
</div>

再把一个个单页拼起来,就变成了我们需要的H5页面,格式大致如下:

h5: {
    pages: [
        {
            elements: ...,
            setting: ...,
        },
        {page2},
        {page3},
        {page4},
    ],
    setting: {
        propertyA: {},
        propertyB: 'valueB',
        flagC: false,
    }
}

从一个个小元素组成一个页面,再由一个个页面组成h5活动页面。至此一个对于h5页面的抽象出来的数据结构雏形基本完成了。

上面的结构没有展开,展开后你会发现这个大对象可能上千上万行,接下来关注下数据和操作界面中的映射关系了,如何去操作这些数据,数据怎么展现,元素和页面的关系等等。

业务映射到数据

为何要操作数据,而不是去操作DOM?

这也是在前期开发中踩过的坑,照着“所见即所得”的模式,像富文本编辑器一样,输入修改完就是最终输出的内容,也未尝不可,实现时习惯使用 jQuery 的伙伴很容易会联想到直接操作 DOM,比如一个元素的定位,使用 jquery-ui 的 draggble 拖拖拽拽很方便的定位,最后产出的就是最后实际的HTML。但放在实际场景中后,会发现拓展性兼容性不太友好。特别是在后期再去操作一段成品的 DOM 结构会变得比较麻烦,比如一个定位的数据,成品中的数据会看起来比较“死”,在适配不同屏幕时,计算对应的值会比较累。而如果是操作数据的话,可以在渲染之前对数据进行些处理,最后的产出就变得比较灵活,将数据层和视图层抽离的比较独立,拓展起来也比较容易。

映射关系

如何把这些界面的业务抽象成数据操作,首先还是简单分析整理下。一个可视化编辑应用的操作有很多,这里只举几个类型的数据操作。用户通过操作(比如输入、拖拽、移动、点击等)来改变元素的属性值。

用脑图发散一下有哪些功能:

  • 页面的增删改查
  • 元素的增删改查
  • 历史记录的操作
  • 用户操作
  • 其他

让我们回到已经制定的目标上,如何编辑页面,如何编辑元素。下面举几个例子

页面编辑
一个H5由多个页面组成,由几个{元素}组成的[元素集合],此类关系通常可以用数组来表示。

将页面集合简单成抽象成数据的操作:

    +-------------+
    |             |
    +-------------+

    => pages: [], index: -1

新增页面时,在 pages 数组中 push 一个'page 1'的实例对象,再通过索引取到该实例数据, 然后通过渲染方法将对应的视图渲染到界面中,这个关系链就基本完成了。

    +-------------+
    |    page 1   |
    +-------------+

    => pages: [ page1 ], index: 0

交换页面顺序

    +-------------+
    |    page 2   |
    +-------------+
    |    page 1   |
    +-------------+

    => pages: [ page2, page1 ], index: 1

通过数组的两个值的顺序交换即可以实现两个页面的顺序交换,发现很多场景只需要通过一些数组最基本的操作就可以实现一些看起来复杂的功能,而困难的更多是如何找到这一层映射的关系。

元素操作

元素有多种属性组成,多个{属性键值对}组成的{元素},此类关系通常可以用对象键值对来表示。

在元素对象上不断拓展需要变化的属性,比如元素的尺寸位置:

element: {
    style: {
        'top',
        'left',
        'width',
        'height',
    },
    ...
}

可以设计如上图四个输入框,每个输入框对应每一个属性值,这样一个简单的元素属性编辑控件就好了,依次类推,每加一个可编辑属性就对应加一个编辑控件。基本上都是以key-value的形式来操作。整个过程简化成用户通过界面的输入修改操作数据,数据更改后视图对应重新渲染一遍。

根据不断的尝试和增加,最后结构变成了类似如下的格式:

element: {
    id: 1,
    role: {
        type,
        value
    },
    style: {
        'top',
        'left',
        'width',
        'height',
        'transform',
        ...
    },
    inner: {
        html: 'rich text',
        style:{
            'background-image',
            'background-color',
            'background-size',
            'opacity',
            'color',
            'font-size',
            'text-align',
            'border-radius',
            ...
        }
    },
    attribute:{
        'animation-sequence',
    }
}

+--------+--------+----------+---------+-------------+
|  id    |  role  |  style   |  inner  |  attribute  |
+--------+--------+----------+---------+-------------+
|   1    |  link  |    ...   |   ...   |     ...     |
+--------+--------+----------+---------+-------------+
|   2    |  text  |    ...   |   ...   |     ...     |
+--------+--------+----------+---------+-------------+

完善后,一个元素的结构已经变得相对庞大了,包含了非常多的属性,随之而来的也是非常多对应的属性编辑控件,也是相对比较复杂的地方。

历史操作

怎么抽象设计?这在平时业务场景里并不多。先分析下历史要什么?主要就是撤销和恢复,用户可以 ctrl+z 回到上一个状态。历史这个大集合里肯定有多个历史状态,由多个{历史}组成[历史集合],于是就想到了数组。新状态和老状态的区别是什么?可能就是有了新的操作,数据有了变化,那么把这时的数据保存起来,塞到历史里,相当于是一个 push 的操作,看起来可行。再假如需要回到上一个状态,可以设置个索引 index, 将 index 指向到前一个,就拿到了前一个状态。

                   | -- push
            +------v------+
  index --> |   status 3  |
            +-------------+
            |   status 2  |
            +-------------+
            |   status 1  |
            +------|------+
                   v -- shift

抽取几个关键点:

- 有多个状态        -> 数组
- 不同状态之间指向   -> 数组的索引值, 游标
- 可以做个步数限制   -> 数组的长度

场景:有一个新的操作,即将新的数据插入到历史中

history.push(statusNew);

场景:如果满了,将最先插入的数据拿出来

history.shift();

场景:撤销一步,将游标指向到前一个,取到前一个状态。重做一步同理。根据这时的数据重新渲染,那么界面上也就回到了前一步的状态。

cursor --;
callback(history[cursor]);

那么history的结构就可能长成如下:

[
    {status1},
    {status2},
    {status3},
    {statusNew},
]

这样一个简单的历史数据结构设计就完成了。

留个问题: 如果撤回到了上几步,然后继续操作,整个历史状态该怎么处理?

最后

最后再通过组装整合,一个可视化编辑器主要的功能大致就满足了。再重新看下操作界面上的数据,可以划分为两个部分,一个前台页面数据,一个后台交互数据,大致如下:

structure

回顾上面的过程,已经从一个简单的数据列表渲染到具有前后台复杂型数据交互的WebIDE,但从数据结构的设计形式上看,本质上变化其实并不是很大,只是<li>变成了<element>,<page>等,里面包含的数据量也增加了许多。会不会发现这个数据虽然看起来十分庞大复杂,但也有几分清晰简单。而你的角色更像是一位建筑设计师,把握整个结构框架,然后再管理一砖一瓦。

上述的过程在开发其他项目时同样适用,在开始设计时,要一下子脑补出整个设计是比较困难的,特别是对某一个事物从一无所知到有点概念,从0到1的过程,客观的说这并不容易。可以先试着抽离出几个关键步骤,写几个小模块,把关键路径走通,在初期十分有效,随后再这些看似零散的小组件拼装起来,往往这个雏形会比一开始想的清晰很多,如此反复,整个设计也会变得更加清晰饱满。数据的设计也是相对应的,由一个个小的数据组成,渐渐的便会形成一个比较庞大的数据,这时代码可能不是最关键的,而是如何合理有效清晰地管理这些数据,可能更像是后端数据库管理一样。往往需要经过不断的试错走些歪路的过程,最后会慢慢得心应手一点。好设计是不断迭代出来的,勇敢试错,不怕踩坑,有句话叫,坑踩的深才铭心刻骨。

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

推荐阅读更多精彩内容