作为客服域访问量最大的页面之一,订单详情页在客服的日常工作中被用来查阅用户的订单信息,以此为进线的买卖家用户提供更好的购买服务,进而提升用户的满意度。无论是一二线客服还是客服管理者,都能在日常使用的系统中直接访问到详情页,因此客服订单详情页的入口也比较多,目前已经超过了10处。
随着得物业务的快速发展,客服订单详情页需要展示的信息越来越多,需要支持的操作也越来越多,在页面改版前,一个页面首屏就需要展示80条订单信息,具体数量会根据订单类型、交易状态、物流状态等因素而改变。一个页面最多情况会有200条信息、60个按钮以及20个订单标签(不含用户标签、商品标签),所以会出现一个现象,一个1920*1080分辨率的显示器(客服常用的分辨率),鼠标滚轮需要滚2次才能从页面的最上方滑到最下方,详情页信息截图如下所示。
另一方面,贸然调整反而会降低客服同学的工作效率。页面内容、交互变化太大对客服同学也存有不小的培训成本,因此秉持着变更影响最小化的原则,信息量一直保持着只增不减。
经过一段时间的沉淀与打磨,客服订单详情页无论是客服的使用体验,还是开发体验都得到了显著提升,订单相关的TS问题近半年一直保持清零状态。这期间做了很多尝试和渐进式优化,本文主要从以下三点具体聊聊对客服订单详情页体验升级做的一些思考和优化。
最早订单详情页只是客服工单系统中的一个页面,使用的是Vue2、ElementUI的技术栈。客服工单系统的定位是后台管理系统,使用门槛较高,更适合管理者使用,所以需要一个面向一二线客服的平台,章鱼工作台就诞生了。章鱼工作台是基于qiankun微应用搭建的,其子应用最开始使用Vue3、Vite和Ant Design,那时,如果要在章鱼工作台中访问客服订单详情页属于跨应用、跨技术栈通信,使用iframe是成本最小,也是初期最合适的一种方式,这也是页面复用的第一个阶段:在子应用使用多个iframe容器嵌入工单系统中的订单详情页。
所有订单详情页的使用方只需要在本地创建iframe容器,然后在嵌入订单详情页的访问地址时传一些必要的参数如订单号、求购单号就能够正常访问了,十分便捷。还可以支持一些高级配置供使用方选用,如支持通过url query传参的方式控制页面样式,当用于外部嵌入时隐藏主应用的菜单和顶部tab栏等等。
这个阶段,初步解决了页面复用的问题。但大家都知道iframe的弊端,一二线客服同学的日常工作中会出现大量的页面切换,这样一来,iframe的内存占用高、加载缓慢的缺点就被放大开来,这个阶段一二线客服经常会反馈页面卡顿,所以迫切需要进一步优化。
对客服工单系统、章鱼工作台两个平台的用户特点、作业行为进行分析,发现章鱼工作台的用户对页面的体验要求更高,于是对详情页做了第一次优化,步入了第二个阶段:将订单详情页迁移至章鱼工单工作台,其构建方式也由Vite变更为Webpack5,子应用之间利用Webpack5的Module Federation特性通过远程组件的方式进行数据通信。另一方面,尽管iframe的缺点明显,但仍是跨技术栈应用间页面通信的不错选择,将iframe控制在单实例容器中可以最大程度限制其对内存的占用。
这样一来,详情页的入口虽然有10多个,但通信方式都收拢成了三种:远程组件、单实例iframe、本地组件。所有场景都能覆盖到,后续各个入口有复杂交互的变更或者自定义事件,都能够在页面主体做到监控和收口,不需要页面的使用方做额外开发。
/** Vue3 */
/** 1. 详情接口响应后注册message事件 */
onMounted(() => {
/** 请求详情接口 */
fetchOrderDetail(() => {
/** query上打上iframe标签,用于确定注册时机 */
route.query.iframeRoute && watchParentMessage()
})
})
/** 2. 监听iframe父级携带数据变化,更新本地页面数据 */
const watchParentMessage = () => {
window.addEventListener('message', event => {
const orderNo = event.data.data.orderNo
if (event.data.type === 'ORDER_CHANGE' && orderNo) {
/** 更新订单信息*/
initStream(orderNo)
}
})
}
/** 3. 本地页面交互事件被远端触发,发送当前的数据给远端 */
window.parent.postMessage(
/** payload: 携带的数据*/
{
type: 'workbenchRoute',
params: {
/** 跳转退货详情页 */
name: 'refundDetail',
query: {
// 携带数据
},
},
},
/** orgin: 如果想要传递给任意窗口,可以将这个参数设置为'*' ,为了安全起见,不建议设置为'*'*/
'*'
)
/** Vue2 */
/** 1. 页面初始化时注册message事件*/
mounted() {
window.addEventListener('message', this.callBack, false)
},
/** 2. 监听本地订单单号变化,将新的数据传给远端*/
watch: {
orderNo(newOrderNo) {
/** 在iframe的contentWindow属性上挂载postMessage方法*/
detailIframeRef.contentWindow.postMessage(
/** payload: 携带的数据*/
{
type: 'ORDER_CHANGE',
data: {
orderNo:newOrderNo,
//其他数据
},
},
/** orgin: 如果想要传递给任意窗口,可以将这个参数设置为'*' ,为了安全起见,不建议设置为'*'*/
'*',
)
},
},
/** 3. 监听远端交互和数据变化,根据交互类型做不同的本地处理 */
method: {
/** callBack: 远端事件被触发后,处理本地回调逻辑 */
callBack(event) {
try {
if (event.data.type === 'workbenchRoute') {
switch (event.data.params.name) {
case 'orderdetail':
//跳转订单详情的handler
break
case 'detail':
//跳转工单详情的handler
break
// 其他交互
default:
//兜底处理
}
}
} catch(error) {
//异常处理
}
}
/** 配置webpack MF插件,将订单详情页exposes出去*/
new ModuleFederationPlugin({
filename: 'remoteEntry.js?[hash]',
library: { type: 'window', name: 'app_ticket' },
name: 'app_ticket',
shared: {
/** 需要共享的依赖 */
},
exposes: {
/** 提供订单详情远程组件*/
'./OrderDetail': './src/views/orderdetail/Index.tsx',
},
}),
/** 1.webpack配置远端应用 */
remotes: {
app_ticket: getRemoteUrl('app_ticket'),
},
/** 2. 使用defineAsyncComponent注册组件*/
'OrderDetail': defineAsyncComponent(() => import('app_ticket/OrderDetail')),
/** 3. 像本地组件一样使用远程组件*/
<OrderDetail orderNo={orderNo} {...props} />
页面使用iframe的首屏耗时平均在7076ms,非首屏在2594ms,而MF的首屏只需要1279ms,非首屏更是只需428ms,渲染时间降低了6倍。
单个页面的内存占用减少到了之前的1/10以内,关于模块联邦和远程组件的更多细节可以查看Module Federation 在得物客服工单业务中的最佳实践。
上一章节说到使用MF的方式解决了架构层面的卡顿问题,但无缓存下页面仍要2~3s甚至更久才能刷出订单信息,这时要怎么办?是的,可以改交互、拆接口。但如果数据依赖了大量外域服务、没有外域产研资源介入,且要在一周时间做到有效的优化,那还能做些什么呢?
由于一些历史原因,客服订单详情页需要同时展示100+的订单信息,所有的订单信息、订单操作涉及的字段接近200个,而这么多字段其中90%都在一个http接口里面,这个大接口包含了36个dubbo接口,这些接口来自交易正向、逆向、供应链、商家、商品、用户以及其他BU。并行的调用一定会出现短板效应,只要有一个接口RT(Reaction Time,响应时间)慢,就会拉慢整个http接口的响应速度,同时出现Timeout的概率也会上升,再加上页面本身对资源的渲染时间,无缓存下仍要2~3s甚至更久才能刷出订单信息。这个大接口的平均RT在500ms,P99线的RT达到了1.3s,下图就是生产环境下某一次的调用详情,耗时在782ms,降RT优化首屏渲染刻不容缓。
除了接口RT耗时高的问题,还有首屏接口并行调用的问题。90%的字段都在一个大接口里面,剩下10%都是在零零散散的一些小接口里,把这些小接口加起来页面首屏接口超过6个。我们知道一个Chrome页签最多并行处理6个http请求,如果有第7个接口就会进入到Stalled(熄火)状态,等待前面的某一个接口响应完毕后再发起请求,下图是一个示例,getTrackTicketInfo接口是页面首屏的第7个接口,255.14ms就是需要等待的时间。
根据上述问题现状,初步的方案就是接口先聚合再拆分,把所有接口的数据聚合到一块,然后再根据信息模块拆分成若干个接口,前端再根据业务场景和用户行为,去对拆分出来的接口的调用时机进行优化。但是,理想很丰满,现实很骨感,数据量摆在这,很难在短时间去做到这件事情。光把字段梳理全,数据来源理清楚就用掉了两天时间,再考虑到成本和收益后,我们的最终方案就是新增快慢接口,快接口的RT要在200ms以内,所有拖慢RT的数据都放到慢接口中,前端再根据接口的特性将所有接口分为2个梯队在不同时间进行调用,最大程度的减少页面的首屏渲染时间。
为了控制页面并行请求接口数量和页面数据渲染次数,将除了快慢接口之外,所有零散小接口分为如下两个梯队:不依赖详情大接口反参的首屏信息接口;依赖详情大接口反参的首屏信息接口、非首屏信息接口。
最终详情页首屏接口调用情况示意图如下,在能够聚合依赖大接口反参的首屏信息接口的情况下,页面只会渲染两次,第一次是在快详情接口和第一梯队接口请求回来之后(使用Promise.all控制数据的渲染时机),第二次就是在慢详情接口请求回来之后。
使用Promise.all来保证快详情接口和第一梯队的接口信息同时渲染,减少页面渲染次数,从而减少页面抖动的情况。Promise.all有个缺点就是其中有一个Promise异常,整个就会抛出异常,所以需要对Promise.all包裹的Promise进行二次封装,保证有一个Promise报错不会干扰其他接口的请求,具体代码实现方式如下:
/** 处理promise,保证promise.all使用时相互独立 */
export const handlerPromise = (api, params) => {
return new Promise(resolve => {
api(params)
.then(resolve)
.catch(() => resolve({ error: true }))
})
}
/** 详情页首屏请求函数 */
const fetchOrderDetail = (callback?) => {
/** 使用handlerPromise封装过的promise,保证有一个报错不干扰其他接口请求 */
Promise.all([quickDetail(), firstLevel1(), firstLevel2()])
.then(([quickDetailData, firstLevelData1, firstLevelData2]) => {
// 快详情接口
!quickDetailData?.error && quickDetailHandler(quickDetailData)
// 第一梯队接口调用:不依赖详情反参的首屏信息接口
!firstLevelData1?.error && firstLevelHandler1(firstLevelData1)
!firstLevelData2?.error && firstLevelHandler2(firstLevelData2)
// 执行回调
nextTick(callback)
})
.then(() => {
// 第二梯队接口调用:依赖详情反参的首屏信息接口、非首屏接口
secondLevelHandler1()
secondLevelHandler2()
})
// 执行慢接口
fetchSlowOrderDetail()
}
接口调用顺序确认后还有一点需要注意,因为慢接口响应耗时较高,在客服同学快速查询的工作场景中,可能会在慢接口还在pending,就已经切换到下一个订单了,页面单实例的场景下,这个时候如果不处理可能会将出现数据串台的情况,不属于该订单的数据显示了出来,关于这一点,需要在慢接口的handler上做如下处理。
/** 慢详情接口请求 */
const fetchSlowOrderDetail = () => {
slowLoading.value = true
orderApi
.getDetail(params)
.then(slowData => {
/** 防止快速切换订单导致的数据串台问题 */
if (slowData?.topInfo?.orderNo === orderNo.value) {
orderDetail.value = slowData
}
slowLoading.value = false
})
.catch(() => {
slowLoading.value = false
})
}
优化后的Waterfall图就如下所示,不会再出现灰色的stalled耗时了,而且在228ms后首屏的数据就已经请求回来了。
上面一点解决了首屏接口的调用问题,接下来是对大详情接口具体做的一些优化:
经过上述优化,快详情接口RT只需要平均190ms,从之前大接口的470ms下降了41%,首屏渲染时间从873ms降至376ms,下降了57%,95分位567ms,下降了62%。
首屏优化效果明显,很难再看到反馈详情页加载缓慢的VOC了,一定程度地提升了客服的平台体验满意度。
解决了页面卡顿和首屏加载慢的问题,但仍存在一些问题。这一次在产研运同学的通力协作下,如何进一步提升技术同学的开发体验和客服同学的使用体验呢。
虽然详情页中堆叠的字段已经多达200处,随着业务高速发展仍会存在部分信息缺失、不准确的情况,对客服日常作业产生了一定的负向影响。另一方面,在开发订单类需求中,约60%的都是配合外域或者内部进行字段增删改,如果建立了订单信息的编排能力,后续字段类需求将可配置,从而能够解放这部分需求的产研生产力,达到降本增效;同时若前端模块能够支持模块拔插能力,也能为后续订单信息模块复用到坐席辅助及其他客服工作台提供技术支撑。
将订单基本信息及关联信息通过统一Schema维护。大家知道Schema(结构化的数据类型)只要约定的足够复杂是可以用来描述所有场景的数据的,所以使用Schema第一步就是要控制好这个边界,在能够覆盖大部分业务场景前提下不能太复杂。首先可将订单信息做3层细分:信息块、信息组、信息元素。做到配置信息块、信息元素对于订单详情页的场景来说都不太合适,所以这里选择约定到信息组的格式。
上面图示中两组信息组可用下述Schema描述出来,利用数组的有序性,从左到右、从上到下对信息组进行渲染,实现订单信息的编排配置能力。
schemaData : [
{
label: '订单类型'
text:'普通现货'
children: [
{
id: 'orderTypeDetail'
text: '详情',
show: true,
toolType: 'linkBtn', /** linkBtn, primaryBtn, tag */
eventType: 'click', /** dbclick, hover*/
interactiveType: 'popover', /** modal, popover, message*/
children: [
//** popover弹出的内容 */
{ label: , text: '', children: //..}]
{ label: , text: ''}
]
},
{
text: '晚到必赔',
show: true
toolType: 'tag',
},
{
text: '退运服务',
show: true
toolType: 'tag',
},
]
},
{
id: 'tradeStatus'
label: '支付状态'
text:'已经支付'
children: [
{
text: '七天风控',
show: true
toolType: 'tag',
},
]
}
],
使用Schema可以满足大部分的字段渲染场景,但是对于一些复杂的交互和自定义的样式仍需要前端去实现,这个时候每个信息元素中的id就发挥了作用,前端可以根据id去绑定交互事件和自定义样式,具体实现如下:
/** 信息元素枚举*/
enum INFO_ElEMENT_MAP {
/** 订单类型详情按钮 */
ORDERTYPE_DETAIL: 'orderTypeDetail'
}
/** 信息元素*/
const infoElementMap = {
INFO_ElEMENT_MAP.ORDERTYPE_DETAIL: {
/** 绑定事件 */
onClick: () => {
orderTypeDetailClickHandler()
},
/** 绑定样式 */
className: [styles.marginLeft],
},
/** 其他需要添加复杂交互和样式*/
}
约定好了Schema和规范,前端再编写对应模版解析代码去渲染页面,对应渲染图如下所示。
最外层的渲染器代码如下:
const SchemaRender = () => {
//TODO 健壮性代码
return (
<div>
{schemaData.length ? (
<Row>
{schemaTemplate.map(item => {
// 分隔符
if (item.key === TemplateKeyEnum.dividedLine) {
return <Divider />
}
return (
<Col span={12}>
<InfoItem
key={item.key}
label={item.label}
text={item.text}
infoList={item.children.map(child => {
return {
text: popoverRender(child),
hide: !child.show,
}
})}
/>
</Col>
)
})}
</Row>
) : (
<Skeleton title={false} active paragraph={{ rows: 3 }} />
)}
</div>
)
}
最终,Schema加上模版渲染就能渲染出订单详情页的信息,后续此类型的需求除了和外域约定字段,就不再需要额外资源投入了。
实现了信息快速编排,还有信息模块高耦合的问题。其实不同角色的客服同学关注的信息是不一样的,所以新的订单详情页要根据客服的身份去展示不同的信息;而且随着屏幕大小的不同,所适合布局也不同。另一方面,工单详情、坐席辅助都需要展示订单信息的某一个信息模块(整个页面展示就太重了),这时就需要订单信息模块有可插拔的能力了。
初步方案是后端维护一个信息模块池,提供出一个接口,前端通过传一个标识,能够返回对应标识需要的模块组合,然后根据数据组合渲染数据。这个方案可以实现信息可拔插能力。
上述方案可以解决问题,也比较简单,但是控制数据的还是前端,这其实违背了最初建设信息编排能力的初衷。于是最终改为后端同学从客服同学的登录态拿到userId,根据id拿到其所在处理组,是买家处理组就返回买家版订单信息,卖家版就返回卖家版订单信息。另一方面,前端也根据屏幕大小做布局的自适应。
从改版以来近8个迭代的资源投入数据来看,订单需求开发成本降低了66.7%。
新版详情页改动较大需要根据客服所在处理组进行灰度,但是一线和二线的处理组分配又不太一样,所以需要根据入口来源判断使用哪套AB方案接口。另一方面,订单详情页的入口非常多,所以在每个入口做灰度不太现实,改动较大,所以选择收口到详情页主页面区分新老页面。
/** 获取来源区分IM、工单灰度组信息 */
watch(
() => props.platformCode,
code => {
try {
switch (code) {
case PLATFORM_TYPE.IM:
/** 一线灰度走一线灰度接口 */
isGray.value = true
break
case PLATFORM_TYPE.TICKET:
/** 二线灰度走二线灰度接口 */
isGray.value = true
break
default:
isGray.value = false
} catch {
isGray.value = false
}
},
{
immediate: true,
}
)
return () => isGray.value ? (
<>
<Button type="link" onClick={clickHandler} >
返回{isOld.value ? '新版' : '老版'}
</Button>
{isOld.value ? <OldDetail {...props} /> : <Detail {...props} />}
</>
) : <OldDetail {...props} />
根据培训进度开放灰度名单给客服使用新版页面,同时对新老版页面的数据进行监控 。支持可监控、可灰度、可回滚,确保了在页面大改动情况下的系统质量稳定。
为了体现订单信息优化的收益和价值,需要对客服同学在新老订单详情页的停留时间、跳出订单详情页次数进行比对。
使用监听路由的方法去计算页面停留时间。这里只对其中一种做分析,其他两种类似。
/** 数据上报 */
const uplog = (current, last, constant, type) => {
/** b: 只用考虑上一个路由是订单页面的情况 */
if (constant === last) {
const nowTime = getNowTime()
const stayTime = nowTime - stayOrderDetailTime.currentTime
/** c: 小于3s,大于30min视为无效数据 */
if (stayTime > 3000 && stayTime < 1000 * 60 * 30) {
orderDuLog('ORDER_STAY_TIME', {
orderNo: orderNo.value,
stayTime,
type,
})
}
stayOrderDetailTime.currentTime = nowTime
}
/** d:如果最近的路由是订单页面,则重置时间*/
if (constant === current) {
stayOrderDetailTime.currentTime = getNowTime()
}
}
/** 监听路由 */
watch(
() => {
return { name: route.name, id: route.params?.id }
},
throttle(
(currentRoute, lastRoute) => {
/** 从工单出发:只用考虑上一个路由是订单页面的情况 */
uplog(currentRoute?.name, lastRoute?.name, `${globalConfig.backstageCode}_orderdetail`, 'routeChange')
},
100,
{ leading: true, trailing: false }
),
{
deep: true,
immediate: true,
}
)
页面跳出次数就比较简单了,只需要在跳出事件的handler里加上数据上报方法,比如查看商品详情、退换货详情、用户分期信息、工单详情等等,最后计算跳出总量即可。
老版页面平均停留时间15.6s,新版页面的平均停留时间8.5s,客服每次查询信息的时长缩短7.1s,可根据详情页PV换算日均可降低客服同学工作时长。
同时,客服查询特定订单信息时需要跳到别的页面查询,这说明订单信息是有缺失和或者客服是对信息准确性是有怀疑的,老版页面跳出率11.4%(约8.7次访问跳出1次),新版页面跳出率7.92%(约12.6次访问跳出1次)。客服查看订单信息时跳出的率也下降3.48pp。
虽然客服订单详情页的使用体验已经得到提升,但是体验升级之路仍在继续:
*文 / 昌禾
本文属得物技术原创,更多精彩文章请看:得物技术官网
未经得物技术许可严禁转载,否则依法追究法律责任!
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。