如何设计实现H5营销页面搭建系统

3,186 阅读11分钟

背景

近几年,low codeno codepro code等越来越多的出现在我们的视野中。抱着不被卷的心态 🐶,我决定来深入探索一下。

我所在的是营销部门。每天/月都承载着大量的营销活动,本文也是我在探索可视化搭建过程中的一些心得体会

其实这些名词都与搭建相关。其中一个应用最广的场景就是营销。我们知道无论是淘宝、京东这些电商巨头,亦或是携程、去哪儿这些OTA,每天 APP 上都承接着无数的活动页面。

大致梳理一下营销活动的一些特点:

  • 页面类似: 页面布局和业务逻辑较固定
  • 需求高频: 每周甚至每天有多个这种需求
  • 迭代快速: 开发时间短, 上线时间紧
  • 开发耗时: 开发任务重复, 消耗各方的沟通时间和人力

不同于常规的业务开发,营销活动往往受影响的因素很多:节假日大促、政策规则等,所以往往可能是今天上午说的活动,明天就要上这种。如果单靠前端同学去维护,那怕不是要加无数的班(比如之前的我 😭)

每次来一个新活动,都靠前端同学去画页面,显然这种效率是极低的。如果排期宽裕点还行,如果遇到618双11怕不是要逼疯我们。。

楼层搭建

鉴于这种场景,内部也进行了很多的讨论。得出的一致结论就是:开发同学提供营销搭建后台,页面做成可配置化,配置的工作交给产品/运营同学。这样,基于楼层搭建营销页面的方案就应运而生了。

其实楼层搭建在营销页面的搭建中是一种比较常见的方式。 如上图是京东的一个活动页面,页面主要由三部分组成:头图楼层、优惠卷楼层、热销楼层。因为就像生活中的盖楼一样,所以在早期的营销搭建中,就有了楼层的概念。每个楼层其实就对应了一个具体的组件。 然后在具体楼层的编辑内容区域就可以去上传对应的数据了。

但这种方式有一个很大的缺点就是:不够直观。随着业务的快速迭代,也陆续得到了一些反馈。最终发现运营同学真正需要的是那种可以直接拖拽生成页面的,也就是可视化搭建

可视化搭建

楼层搭建的基础上进一步改造为可视化搭建,复杂度提升了很多。单纯的去看页面的不同呈现,可能仅仅就是加了一个拖拽的操作。但真正准备去落地的时候,发现其中的细节特别多,也包含了很多的设计理念在里面。

我们先来看一下原型图,然后仔细分析一下需要做的事情: 市面上大部分营销可视化搭建系统基本都是类似上图这样的页面呈现。左侧对应组件区域,中间是画布区域,右侧是属性区域。

大致操作流程就是拖动左侧的组件到中间的画布,选中组件,右侧属性面板就会展示与该组件关联的属性。编辑右侧属性,画布中对应的组件样式就会同步更新。页面拼接完成,可通过类似预览的操作进行页面预览。预览无误,即可通过发布按钮进行活动的发布。

流程梳理完,我们来看下项目的基础架构:

这里我基于原型对项目设计进行了功能的铺平,其实还是围绕组件画布属性面板这三块。

到这里,我们思考几个问题:

  • 画布区域如何渲染已添加到画布中的组件(组件库组件会很多,画布中可能只需添加几个组件,考虑如何做动态渲染)?
  • 组件从左侧拖入画布区域,选中组件,就可知道该组件关联的属性。组件 Schema 如何设计?
  • 画布区域和预览时组件的渲染是否可共用一套渲染逻辑?
  • 组件的数据如何去维护(考虑添加组件、删除组件、组件渲染/预览等场景)
  • 组件库如何维护(考虑新增组件满足业务需要的场景)

首先来看第一条,简单归纳就是动态加载组件

动态加载组件

如果你经常使用vue,那我想你对vue中的动态组件肯定不陌生:

<!-- 当 currentView 改变时组件就改变 -->
<component :is="currentView"></component>

市面上的大部分编辑器也都是利用了这个特性,大致实现思路就是:

  • 用一个数组componentData维护编辑器中的数据
  • 将组件拖动到画布中时,将此组件的数据pushcomponentData
  • 编辑器遍历(v-for)组件数据componentData,将组件依次渲染到画布中

由于我在的团队包括我自己一直都在使用react,这里着重来提下react组件动态加载的实现方式,框架使用的是umi

我在实现这部分功能时,在umiapi中找到了dynamic 封装一个异步组件:

const DynamicComponent = (type, componentsType) => {
  return dynamic({
    loader: async function () {
      const { default: Component } = await import(
        `@/libs/${componentsType}/${type}`
      );
      return (props) => {
        return <Component {...props} />;
      };
    },
  });
};

然后在调用的时候,将组件数组传入即可:

const Editor = memo((props) => {
  const {
    componentData,
  } = props;
  return (
    <div>
      {componentData.map((value) => (
        <div
          key={value.id}
        >
          <DynamicComponent {...value} />
        </div>
      ))}
    </div>
  );
});

解决了第一个问题,我们来看第二个,也就是:组件 Schema该如何设计

组件 Schema 设计

这里涉及到组件、画布和属性区域三块的联动。主要包含组件强相关的表单属性以及初始值。

由于涉及到组件属性的字段限制及校验,为了规范和避免出错,建议项目使用 ts

这里以一个TabList组件为例,展示一下它的Schema结构:

const TabList = {
  formData: [
    {
      key: 'tabs',
      name: 'Tab名称',
      type: 'TitleList',
    },
    {
      key: 'layout',
      name: '布局方式',
      type: 'Select',
      options: [
        {
          key: 'single',
          text: '单列',
        },
        {
          key: 'double',
          text: '双列',
        },
      ],
    },
    {
      key: 'activeColor',
      name: '激活颜色',
      type: 'Color',
    },
    {
      key: 'color',
      name: '文字颜色',
      type: 'Color',
    },
    {
      key: 'fontSize',
      name: '文字大小',
      type: 'Number',
    },
  ],
  initialData: {
    tabs: [
      {
        id: uuid(6),
        title: '华北',
        list: [
          {
            icon:
              '',
            goCity: '烟台',
            backCity: '北京',
            goDate: '08-18',
            goWeek: '周三',
            airline: '中国联合航空',
            price: 357,
            disCount: '4',
          },
        ],
      },
    ],
    layout: 'single',
    color: 'rgba(153,153,153,1)',
    activeColor: 'rgba(0,102,204,1)',
    fontSize: 16,
  },
};

在组件初始化时就约定好其对应的结构,当将组件拖入画布区域后,我们可以拿到当前选中的组件数据,然后右侧的属性面板就可以渲染出对应的可编辑表单项。来看下右侧表单区域的代码:

const FormEditor = (props) => {
  const { formData, defaultValue } = props;
  console.log('FormEditor props', props);
  const [form] = Form.useForm();

  const handleFormChange = () => {
    console.log('表单更新',form.getFieldsValue());
  };

  return (
    <Form
      form={form}
      initialValues={defaultValue}
      onValuesChange={handleFormChange}
    >
      {formData.map((item, i) => {
        return (
          <React.Fragment key={i}>
            {item.type === 'Number' && (
              <Form.Item label={item.name} name={item.key}>
                <InputNumber max={item.range && item.range[1]} />
              </Form.Item>
            )}
            {item.type === 'Text' && (
              <Form.Item label={item.name} name={item.key}>
                <Input />
              </Form.Item>
            )}
            {item.type === 'TitleList' && (
              <Form.Item label={item.name} name={item.key}>
                <TitleList />
              </Form.Item>
            )}
            {item.type === 'Select' && (
              <Form.Item label={item.name} name={item.key}>
                <Select placeholder="请选择">
                  {item.options.map((v: any, i: number) => {
                    return (
                      <Option value={v.key} key={i}>
                        {v.text}
                      </Option>
                    );
                  })}
                </Select>
              </Form.Item>
            )}
          </React.Fragment>
        );
      })}
    </Form>
  );
};

表单区域具体表单项发生改变后就会触发onValuesChange,也就是ant design表单的字段值更新时触发回调事件。这时数据就会更新到store中。而画布的数据源就是store中的componentData进而页面会实时更新。来看下整体的数据流转图:

至此,第二个问题也就解决了。

接着看第三个问题:画布区域和预览时组件的渲染是否可共用一套渲染逻辑?

组件共享

我们可以把预览组件理解为画布区的静态版本或者快照版本。从页面呈现上来看并没有太大的差异,那么从代码设计上,这两部分当然就可以共享一个组件。我们把这个共享组件叫做RenderComponent.tsx,数据源为store中的componentData,然后结合DynamicComponent组件,就得到了如下代码:

const RenderComponent = memo((props) => {
  const {
    componentData,
  } = props;
  return (
    <>
      {componentData.map((value) => (
        <div
          key={value.id}
        >
          <DynamicComponent {...value} />
        </div>
      ))}
    </>
  );
});

数据存储/分发

至于第四个问题:组件的数据如何去维护(考虑添加组件、删除组件、组件渲染/预览等场景),其实在上面回答第二个问题的时候,已经提到了。全局有维护一个store

state:{
  // 所有添加到画布中的组件数据
  componentData:[],
  // 当前编辑的组件数据
  curComponent: {}
}

reducers:{
  // 添加组件到componentData
  addComponentData(){},
  // 编辑组件,更新componentData及curComponent
  editComponentData(){},
  // 删除组件
  delComponentData(){}
}

对于可视化编辑器这种大型前端项目,须有一个全局状态管理机制去做数据的存储和分发。这样对于数据状态的共享和同步也是很有帮助的。

组件开发/维护

来看上面提到的最后一个问题:组件库如何维护(考虑新增组件满足业务需要的场景)

这种目前有两种通用的做法:

  • 直接放在项目中
  • 抽成 npm 包,形成独立的第三方组件库

如果是项目初期,我感觉第一种做法也不是不可以,方便调试。但长远来看,营销场景下沉淀出来的组件绝对不会少,抽成第三方 npm 包才是明智的选择,同时要配合一个类似组件管理后台的管理系统,对组件做统一的管理。

回到组件本身而言,必须有严格的开发规范。每个组件原则上只是呈现上的不同,对于约定俗成地组件研发规范则必须遵守。至于如何去限制,可以通过文档(弱)或者 cli(强)去做。

模板

除了上面的几个问题,还有一个点没提到:模板。我们知道营销活动有一个很典型的特点:页面类似。如果运营/产品同学从零去生成一个页面也是挺耗费时间的,而且大部分活动都是归属于某一个大类下面的,我们可以把这些相似的活动抽成模板。基于模板创建就会省时省力很多。鉴于这部分内容还在开发迁移中,暂时就不展开细说了。

到这里,我感觉已经把可视化编辑器实现上最为复杂的几部分以问题的形式一一解答了。其实无论是组件动态加载还是组件schema的设计数据结构的设计组件库的维护等,每个团队都可以制定一套适合自己的规范,没有绝对的对错之分。

其实在这个编辑器的实现过程中,有很多不容我们忽略的底层实现细节。包括:

  • 拖拽
  • 组件图层层级
  • 放大/缩小
  • 撤销/重做
  • 吸附
  • 绑定事件/动画

这些细节我就不一一展开说了,推荐一篇文章:可视化拖拽组件库一些技术要点原理分析。文章对于上面提到的技术要点都有很详细的说明。

low code/no code/pro code

上面说了这么多,下面让我们回到文章最开始提到的low code/no code/pro code。我会结合我们的可视化编辑器来阐述一下这三者。

首先来看下运营/开发同学使用编辑器创建活动的大致流程:

no code

首先来简单说明一下,什么是no code:从字面上来看就是无代码,也就是不写代码。

从上面的流程图中,可以看到运营/产品同学通过可视化编辑器,不用写一行代码,就可以搭建出功能齐全的活动页面。这种对应的就是no code

low code

low code的定义则是低代码、少写代码。

在上面的流程图中,更多体现在前端同学开发组件库。需要写部分代码,整体通过拖拽的方式生成的方式。对应的就是low code

pro code

pro code的定义是纯代码,也就是不通过任何可视化工具,全靠开发手写的代码形式。在low codeno code出现之前,这种方式是最为普遍的研发方式。

在上面的流程图中,这部分并没有体现。但是在实际的业务开发中,这种场景却是经常存在的。可能当前的一个营销活动,交互复杂、链路长,那通过本文这种可视化编辑器是很难去定制的。只能通过开发去手动写代码的方式去满足业务需求。

可视化编辑器更多的是去满足规则类似的页面开发,首要职责是去减轻重复业务的开发

展望

至此,一个营销系统的搭建探索演进流程我就大致梳理完毕了。

但,这只是一个开始。本文更多的是侧重于前端侧的探索,也仅仅是向可视化编辑器迈出了第一步,只是一个更倾向于纯前端的项目,很多逻辑都还没有考虑。这里列一下后面要做的吧:

  • 模板市场
  • 数据中心
  • 埋点
  • 组件调试/预览
  • 缓存
  • 开放 api 能力
  • CDN
  • 跨端
  • ...

❤️ 爱心三连

1.如果觉得这篇文章还不错,来个分享、点赞、在看三连吧,让更多的人也看到~

2.关注公众号前端森林,定期为你推送新鲜干货好文。

3.特殊阶段,带好口罩,做好个人防护。