搭建一个落地页需要涉及到多方合作,需要不断地进行沟通协调。繁杂的流程需要耗费很多的时间,因此我们推动产品重新搭建了一个专门服务于软广投放流程的编辑器——星创,完成广告搭建在投放业务各系统中的闭环。
一、落地页技术架构
名词解释
模板:就是投放业务人员配置一个完整的落地页。
站外落地页: 投放在媒体,站外的用户看到的落地页。
站内落地页: 用户点击站外落地页,进入App站内看到的H5页面。
编辑器:是一个提供投放业务人员的搭建平台。由(画布+设置器)+生成器组成,搭建的数据源是运营常用落地页模板,模板的组成又分为基础模板、玩法模板、定制化模板,实体间遵循的通信协议是DSL。
技术架构图
落地页的大体功能可以参考下面整体的架构图。
整体的架构包含应用层、B端编辑器SDK、C端Node渲染层、以及基于nest.js数据服务存储层。
下面我将从基础框架、模版类型配置、模版渲染3个方面阐述落地页编辑器的技术选型思路。
基础框架搭建
我们是基于pnpm+monorepo+turbo重新搭建的一个新项目。因为我们涉及到3个端的应用,有SSR、nest.js后端服务、前端H5 editor编辑器应用。整体之前肯定会存在一些代码共用,通用的逻辑、通用的utils。如下面图所示。
再说这个架构的好处,就是在开发环境下,如果需要新增一个外投组件,如果不是这种架构,是普通的多项目架构的话,本地调试需要启动两个项目,这是bad case。再monorepo的架构下,只需要在compoents新增一个物料,然后node和B端H5复用这个物料代码,从components去引用这个组件就可以了。在开发环境调试的时候,只需要启动editor这个H5项目,而不是需要启动,H5项目和SSR项目,提高了开发体验。还有就是复用公共的基建,公共的配置、公共的打包。
二、模板JSON设计
这里说的模板,就是投放业务人员配置一个完整的落地页。
编辑器的低代码协议还是以JSON为主,主要设计了模板类型,模板基础配置,模板可变配置、模板通用能力配置。
如下面所示:
{
// 模板对应的类型
templateType: string,
// 模板变化的配置
defaultConfig: {
},
// 模板可变配置
variableConfig:{}
// 模板不变的配置
globalConfig: {
},
}
这里这么设计的考虑有下面👇🏻几点:
新增模板类型
关于为什么新增模板类型,我给出了下面关于投放业务的思考。
- 是为了渲染层对于不同模板类型走不同的渲染模式。比如静态化的广告落地页,不涉及请求接口的。统一走SSG或者ISR的渲染模式。如果是依赖接口请求的落地页,走SSR或者是react 18的流式渲染RSC。
因为我们需要支持多种渲染模式,所以在一开始做SSR技术选型的时候,优先选择了next 14版本,可以后面更好地支持业务。
- 不同的渲染模式,打包出来的产物不同,性能优化的策略侧重点也是不一样的。
- 之前投放地落地页链接都是xx-plus/xxx/xx,没法一眼看出当前落地页属于哪个类型,由于我们需要做精细化的落地页数据归因,所以我们存储了落地页的模板类型,所以最后投放链接都是这样。
https://cdn-xx.xx.com/xx-plus/{{模板类型}}/{{模板ID}}
- 批量修改场景:过去投放落地页会有在节假日进行批量换图、批量修改配置的需求。过去在哪吒中中需要投入大量的维护成本,我们将落地页的类型维度单独抽象出来,通过支持对类型的批量修改来进一步提效。
抽象模板配置
globalConfig: 主要是落地页的通用配置,是一个保留字段,主要是对落地页某个类型,同样添加某些功能,比如自动换端、自动全屏点击、一些投放策略的优化、 页面配置信息……
defaultConfig: 主要是模板的基础配置,就是运营新建一个模板,这里配置已经不需要填了,已经由框架侧内置,进一步节省运营的配置时间。
variableConfig: 主要是模板的可变配置,也就是运营经常会配置的。
我这里上一张图方便大家理解下:
如上图所示:
之前落地页都通过内部的A搭建器进行搭建。A搭建器为了保持通用性,提供大量配置,从实践上看,配置的数量和复杂性,对投放运营来说是非常大的心智负担。因此在设计星创编辑器时,我们将符合业务特点的经验配置直接做了内置,并且简化了大量配置。投放业务人员只需要做一些简单的配置,就可以完成落地页的搭建。
我举其中一个例子我们来看下对比,配置商品流落地页简化到了只需要选择自己的商品ID就已经完成了站内站外落地页搭建。
我们已经内置了站内落地页,创建一个站外落地页,会自动把站内落地页同时创建,然后塞到站外落地页的还原链接里。
模板渲染
实际渲染的数据是defaultConfig和variableConfig进行对象深度合并,形成最终落地页所SSR渲染需要的数据。对于落地页模板的通用能力,比如星创落地页需要支持自动换端,那么这个配置显然这是落地页的通用能力,对于这个配置我们是放在globalConfig中,开启了表示落地页支持自动换端。
我这里对容器的定义,就是根据模板类型,选择不同的渲染容器。目前支持的容器,有静态容器,和动态容器。
静态容器,顾名思义也就是在构建的时候,或者是根据接口数据能够知道当然渲染的模板数据。但是动态容器也就是没法在SSR侧知道渲染哪一个组件,比如我们的AB外投测试落地页,至于最终渲染哪个落地页需要在客户端才知道当前用户命中的是哪一个组件。
我整理了一些流程图方便大家理解:
三、后台权限设计
编辑器的node服务主要有4大块,投放业务人员搭建、内部接口http调用,SSR渲染调用,对应不同的网关。
如下图所示我在nest基础上了,用了gurad路由守卫,自定义user装饰器,为了方便获取用户信息,和打关键用户行为日志。
四、B端编辑器的选型思考
关于B端编辑器主要有2块,第一个是B端的画布、第二个是BC端组件配置如何映射,第三个编辑器的工具栏。
画布配置预览
这里由于我们的业务是外投的落地页C端页面,主要是移动端H5。所以整体的画布功能和PC端画布功能是取舍的。H5不需要特别复杂的功能,比如拖拽、样式、resize……唯一的要求做到所见就所得的就好了。
当时做画布配置预览的时候主要考虑了两种技术方案 ,一种是ifrmae去做整个编辑器,配置预览。第二种是组件、配置支持动态化。以远程组件的方式进行加载。我下面分别分析一下2种方案的优缺点。
动态远程组件
整体的流程大概分这几步:
1.先有一个组件。
2.将组件打包成UMD格式,可供浏览器使用(后面会介绍UMD),或者使用System.js去打包。
3.将其上传到CDN,然后还需要一个物料管理平台,主要管理组件的版本、支持注册、可回滚。
4.编辑器沙箱能力的考虑,防止污染全局。
5.SSR根据组件类型进行动态渲染。
伪代码如下:
const DynamicComponent = ({name, children, ...props}) => {
const Component = useMemo(() => {
return React.lazy(async () => fetchComponent(name))
}, [name])
return (
<Suspense
fallback={
<div style={{alignItems: 'center', justifyContent: 'center', flex: 1}}>
<span style={{fontSize: 50}}>Loading...</span>
</div>
}>
<Component {...props}>{children}</Component>
</Suspense>
)
}
export default React.memo(DynamicComponent)
这里使用到了React中的Suspense组件和React.lazy方法,关于他们的用法这里不做过多解释,整个DynamicComponent组件的含义是远程加载目标组件,这个过程该组件会渲染传入Suspense参数fallback之中的内容,最后会使用加载成功的组件进行替换。
优点:
- 动态性(当组件需要更新时,可以直接JS内容,就可以实现动态更新)。
- 不确定性(对于主应用而言,其不知道用户会拖拽多少个组件以及每个组件长什么样,它只需要将用户拖拽的JSON数组进行循环遍历,并渲染,然后将配置的属性传递过去就可以了,具体到每个组件具体是长什么样其不关心)。
劣势:
- 我们一个落地页的组件类型的是确定的,在编辑器配置落地页,只有选择模板类型,选择完模板类型,自动会加载模板对应的组件配置。不存在用户拖拽、选择。星创落地页产品的核心逻辑就是轻量。
Iframe
Iframe也就是中间预览的画布是SSR提高一个预览url。
只需要在node层对每个落地页类型,都新增一个预览preview路由和正式投放路由做区分。这么做的好处,预览路由和正式路由,在逻辑上解耦。可以做一些在node做一些定制化的业务预览逻辑。比如飞书审核的时候,设计看到的就是预览路由,不是真正的路由。审核没有通过的落地页,正式线上投放链接,看到是带有水印的。
目录结构如下图所示:
渲染的伪代码如下:
///预览路由
export default HocPreview(({ componentList }) => <Container componentList={componentList} />)
// 正式路由
export default HocApp(({ componentList }: { componentList: any }) => {
return <Container componentList={componentList} />
})
这里我是通过两个React HOC组件去做业务逻辑分离的处理。
好处:
- 天然的沙盒化因为iframe的天然隔离性,画布渲染器中的所有逻辑、样式不会影响编辑器本身。
- 利于多人编辑单人编辑时使用iframe进行通信,而多人编辑时可将iframe通信切换成WebSocket通信,设计时有异曲同工之妙。
- 编辑器页面打开快,体验好,不需要加载很多物料,预览的加载收敛到SSR去处理。
坏处:
- 可扩性特别差,新增落地页类型,都需要开发、测试。不支持定制化。
- 没法做到组件级别的回滚。
最终左右对比,从项目开发周期、上线时间、业务模型的综合考虑下,我还是选择了iframe作为画布预览方案。
画布通信
说画布通信前,我先说下编辑器全局数据流的技术选型。Hooks时代的react状态管理库,已经不是臃肿的redux,我们应该全面拥抱hooks,所以也就考虑了两种比较有代表性的状态管理库,Zustand和Valito。
Valtio 是围绕ES6的Proxy特性来进行设计的,它有以下几点核心的特性:
- 代理状态,基于Proxy。
- 响应式更新,对状态的操作都会通过代理进行,Valtio内部会跟踪并自动渲染。
- 细粒度渲染,Valtio的代理可以实现细粒度的依赖跟踪,这样只有组件实际使用的状态变化时才会重新渲染,避免了不必要的渲染,性能方面很不错。
- 简洁的API,Valtio尽可能地让开发者操作状态就像操作普通对象一样自然。
- 订阅/通知模式。
- 状态适用于组件之外,除了组件外,也支持用在某些逻辑上。
Zustand 的特性如下:
- 简洁性:Zustand通过一个干净直观的API简化了状态管理,减少了代码的复杂性。不需要应用外侧包裹一层Provider。
- 性能:Zustand高度优化,为你的应用提供卓越的性能。
- 可扩展性:随着你的项目增长,Zustand仍然易于使用并且扩展性好。
- 不变性:Zustand鼓励不变性,使跟踪状态变化和调试问题变得更容易。
- 灵活性:它不限于特定的框架。你可以在React、React Native或任何其他JavaScript环境中使用Zustand。
本身这两个状态库,各有千秋,没有谁好谁不好一说,只有更合适的业务的场景。考虑到我们是B端编辑器场景,Valito写法和vue相似,基于Proxy响应式的理念、以及学习成本最终使用了Valito作为我们的状态管理库。
我们再聊一下画布通信,由于选用了iframe做了画布渲染的工具,所以我们通信方案也是基于iframe的postMessage。大家可以看一下流程图:
核心代码渲染逻辑,大家可以参考一下。
** 高阶组件 基于iframe 进行封装 */
export const HocPreview = (Template: (componentList: any) => JSX.Element) => {
return ({ data }: { data: any }) => {
const [componentList, setComponentList] = useState(data?.componentList ?? [])
useEffect(() => {
// 在iframe中且没有hash值的时候,添加hover样式并通知父级
if (window.self !== window.top && !window.location.hash) {
const compElemList = document.querySelectorAll('.editor-box>div>*')
compElemList.forEach((item, index) => {
item.addEventListener('mouseenter', () => {
window.parent.postMessage(index, '*')
compElemList.forEach((citem) => {
citem.classList.remove('actived')
})
item.classList.add('actived')
})
})
}
window.addEventListener('message', ({ data }: { data: PostComponentMsg[] }) => {
setComponentList([...data])
})
// 去除预览页面的滚动条
document.querySelector('html')?.classList.add('no-scroll-bar')
}, [])
const handleClick = (e: SyntheticEvent) => {
if (window.self !== window.top) {
e.stopPropagation()
}
}
return (
<div className="editor-box" onClickCapture={handleClick}>
{/* <Mask /> */}
<Template componentList={componentList} />
</div>
)
}
}
B端组件配置
B端的form我们是基于antd的proCompoents我们选用的是SchemaForm。
SchemaForm是根据JSON Schema来生成表单的工具。SchemaForm会根据valueType来映射成不同的表单项。
除了内置valueType如图所示:
https://procomponents.ant.design/components/schema#valuetype-%E5%88%97%E8%A1%A8
我们还会根据业务类型新增一些自定义的valueType。
我们的模板都有一些通用的B端column配置,因此我们将这封装成hook,所以这些通用hook,可以当做我们元数据,任何几个hook组合形成,就能搭建一个B端配置。
所以后续新增任意外投模板,可以大大节约开发的时间,因为外投模板都可以复用之前的column配置。
五、收益
1.提升投放业务人员的体验,提供软广服务的多样性。
2.积累数据效果,迭代站外落地页的效果。
3.潜在钱效收益:过往流程过长会发生影响如期上线的问 题,大促节点如期上线抢占高下单率时机对钱效也有正向影响。
4.规避人工搭建跳转错误造成损失问题。
六、总结 & 规划
编辑器作为广告投放十分关键的一环,自研编辑器,离不开产品、团队同学、合作方的支持,才能推动落地。
关于编辑器后续的规划主要聚焦以下几个方面:
1.丰富外投落地页的通用能力,提高拉新和还原指标。
2.落地星创落地页数据洞察,从投放到站内承接,我们需要拿到更多的信息,能够得到更多的投放策略。
3.建立一套高效率的机制能快速实验和数据回收,帮助业务达成目标。
*文/Fly