实现:AB-testing 和组件设计

自从到了 marketing 团队,接触了大量 AB Testing 的需求。这里做一下简单总结。

什么是 AB Testing

产品经理、设计师在设计需求的实现时,可能会有多条路径或者说多个方案来达成一个业务目标。

在经过评审之后,一般会权衡利弊,得出一个平衡了用户体验、业务目标和开发成本的最终方案。

但也可能存在一种情况:方案 A、B、C 各有优劣,没办法通过主观判断和讨论得出几个方案谁更优、收益更好。

有一个方法是,同时上线 A、B、C,并将用户流量平均分配到这三者,在固定时间周期后通过埋点等数据来分析哪一个方案效果更好(收益,访问量,订单转化率,下载量等等指标),最后选择出最优方案。

这就是 AB Testing。

例子

marketing 团队大部分 AB 实验都是与 C 端展示相关的,例如文案的调整,模块的设计样式或相对位置等。

例如一个驱使用户下载 App 的 banner:


针对这一个简单的 banner,实验的情况就有多种多样:

  • 例如,在 HK 地区,展示不同的文案,统计最后 app 在 HK 的下载量,分析出哪一个文案更具吸引力

    1. “下载新用户享有 95 折扣”
    2. “下载后赠送 $5 折扣券”
  • 例如,修改 banner 的样式,对比不同 banner 带来的下载量,最后更新最优设计

    1. 样式 A:按钮蓝色,不带边距
    2. 样式 B: 按钮橙色,圆角,带阴影

实验的种类非常多,一般由产品经理来构思。并且需要确定:

  • 实验的人群,地区
  • 实验的平台 web / mweb
  • 实验的划分
  • 划分后每个实验组流量的分配量
  • 埋点,曝光 or 行为

关键点和注意事项

关键点:

  1. 流量划分
    每一个访问的用户,都会有一个设备 ID。通过设备 ID,将用户等量划分,并生成一个 cookie 值,记录用户命中的实验和分组。前端根据命中的实验展示不一样的效果。

  2. 实验目标
    实验只面向某个地区的群体,或者某类设备的群体。

  3. 数据采集
    当实验组、控制组被命中时,根据需要上报曝光或者行为事件。作为最后统计的依据。


需要注意的事项:

  1. 确保实验的变量唯一

  2. 试验周期不宜过长,一般控制在 2 到 3 周,最好不要超过一个月

  3. 分析实验结果时,排除其他因素在实验中的影响。

前端实现

前提:获取实验命中情况

流量划分和用户命中由后端计算。前端需要在 node 层请求 api 获取当前用户命中的分组情况(放在客户端请求可能会造成页面闪动和数据误报), 如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
router.get('/home', async function (ctx) {
let pageData = {}
// node
let experiments = await fetch.get('/experiments/group')
pageData.experiments = experiments
// experiments: {
// // 实验 1
// experiment1: {
// id: xxx,
// hitGroup: 'group1'
// },
// // 实验 2
// experiment2: {
// id: xxx,
// hitGroup: 'ctrl'
// }
// }

// process other data
// 渲染页面
ctx.render('home.html', pageData)
})

这段代码表示,用户命中了实验 1 的 group1 分组,命中了实验 2 的 ctrl 控制组。

首页 home.html 中 js 拿到实验命中情况,渲染出对应的样式。

旧有的实现

在旧的项目中,前端的技术栈是 渲染模板 handlebars + koa,后续引入了 Vue 的开发模式。

实验的实现方式没有做统一,公共代码只提供获取分组的方式,获取之后做什么完全由开发自己控制,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { ABTestTool } from '../ABTestingTool'

export function abtestExperiment1() {
ABTestTool({ notation: 'experiment1' }, (exp) => {
// 命中实验 group1
if (exp['experiment1'] === 'group1') {
// doSomething
}
// 命中实验 group2
if (exp['experiment1'] === 'group2') {
// doSomething
// 往往是直接修改样式
$('body').append(`<style>.theme_activities { display: flex;}</style>`)
}
// 命中实验 group3
if (exp['experiment1'] === 'group3') {
}
})
}

export { abtestExperiment1 }

亦或者使用了 Vue 的页面的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { ABTestTool } from '../ABTestingTool'
import hitComponent1 from 'com1.vue'
import hitComponent2 from 'com2.vue'

let hitComponent = {}
export function abtestExperiment1() {
ABTestTool({ notation: 'experiment1' }, (exp) => {
// 命中实验 group1
if (exp['experiment1'] === 'group1') {
// doSomething
hitComponent = hitComponent1
}
// 命中实验 group2
if (exp['experiment1'] === 'group2') {
// doSomething
hitComponent = hitComponent2
}
})
}

abtestExperiment1()

// 拿到结果的 Vue 组件
export { hitComponent }

这些实现形式不统一,往往比较杂乱。实验完成后,剔除实验代码也没有统一的办法。

新的实现

handlebars + koa 旧项目的代码将逐渐被 nuxt 替代。强行统一实现的话投入产出比太低。

所以这里只考虑 nuxt 上的实现,旧项目的逻辑保持不变,逐步淘汰。

  1. 定制规范。因为实验代码原则是临时性质的,为了便于维护和代码整洁,代码的编写需要遵循统一的规范:
  • 通过需求单号标识实验代码、静态文件资源、多语言标记,并且合理添加带有单号标记的注释以便代码删除或回退
  • 实验代码尽量集中,最大限度的减少对主业务逻辑的侵染,理想情况下,实验代码应该是易移除,可插拔,位置集中,标识明晰的
  • 原则上不需要实验开发人员关注到数据上报发送,对于需求依赖的埋点,需要开发集中体现在实验代码中,并且事件命名带有实验标识,以在数据分析时,区别正常业务发的埋点。
  1. 思路

这里使用了高阶函数的思路,入参为一个配置,AB 函数内部计算命中哪一个实验,最后生成一个 Vue 组件。

代码模拟:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
<business-component /> ...
<!-- 1. 作为实验组件穿插在业务组件之间 -->
<abtest-xxxx></abtest-xxxx>
...
<!-- 2. 没有匹配到实验的业务组件会作为默认slot渲染 -->
<abtest-xxxx>
<business-component />
</abtest-xxxx>
...
</template>
<script>
// 基础函数
import AB, { IABTestingConfigs } from '~/components/common/ab-testing/index.ts'
// 声明组件
@Component({
components: {
// 这里使用了高阶函数的思路,入参为一个配置,AB函数内部计算命中哪一个实验
abtestXXXX: AB({
// 实验名
experimentName: 'Chat_Btn_Design_AB_1_Mweb',
// 分组名和对应的组件,这里使用动态引入,拆分为单文件按需加载
groupComponents: {
'实验组1名称': () => import('~/experiments/abtestXXXX/variant1.vue'),
'实验组2名称': () => import('~/experiments/abtestXXXX/variant2.vue') },
// 判断是否需要公共逻辑自动发送埋点数据
autoDataSend: true
} as IABTestingConfigs)
})
</script>

~/experiments/abtestXXXX/variant1.vue 实验代码中,需要说明实验的细节,开始结束时间,需求单,如

1
2
3
4
5
6
7
8
/**
* @Author: xxxx
* @Date: 2020/7/21 8:51 下午
* @Description: 首页 banner 验证不同文案的影响
* @Orion: https: xxxxx/xxxx/xxx
* @PM: 产品负责人
* @Backend: no
*/
  1. AB 函数的实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// config 就是上面的分组和组件的映射关系
export default function (config) {
const { experimentName, groupComponents } = config

// 组件,这里用 class 写法
class AB extends Vue {
// 命中的实验
get hitExperiment() {
// 从 store 拿到 命中的实验
const hitExp = store.state.itExperiment
// 从 store 拿到 命中的分组
const hitExpGroupName = hitExp.group.name
return {
// 返回一些必要的信息
id: 'xxx',
component: groupComponents
groupName: hitExp.group.name,
// ...
// ...
}
}
// mouted后发送埋点
mounted() {
// 发送埋点数据
autoDataSend && this.customTrack()
}
// 埋点数据
customTrack() {
if (this.hitExperiment) {
// 发送 mixpanel 埋点
// ....
this.sendTracking()
// .....
}
}
// 渲染组件
render(h: any) {
if (this.hitExperiment) {
// 根据命中的实验拿到组件
return h(this.hitExperiment.component, {
props: {
hitExperimentName: experimentName,
hitGroupName: this.hitExperiment.groupName,
hitExperimentId: this.hitExperiment.id,
hitGroupId: this.hitExperiment.groupId,
customTrack: this.customTrack,
},
attrs: this.$attrs,
on: {
...this.$listeners, // 处理事件
},
})
} else {
// 没有命中实验,返回 slot 的内容
return this.$slots.default ? h('div', this.$slots.default) : null
}
}
}
return AB
}

利用高阶组件,可以返回实际,命中的分组对应的组件,展示在页面中。

以上