站外商品详情页的重构与优化|得物技术分享
一、背景
站外商品详情页(H5/小程序)一直以来使用的是detailV3老接口数据,导致其在样式和功能上无法与最新版的客户端同步,各端之间的使用体验存在较大差异。从唤端数据来看,App商品详情页分享后的唤端成功率非常高,达到75%以上,这表明这些用户有明确的目标和意愿来App内购买商品,属于高净值用户。
然而,对于日均PV占据站外流量前三的站外商详而言,尽管唤端价值较高,但由于uni-app多端同构方案的SPA架构限制了H5与小程序的页面性能体验,其性能指标在前端平台的性能统计中表现较差。具体表现为:
- 平均FMP(首次有效绘制时间):2.75秒
- 75分位平均FMP:2.74秒,比源码搭建大盘的1.29秒多了1.45秒
- 平均LCP(最大内容绘制时间):3.29秒
- 75分位平均LCP:4.06秒,比源码搭建大盘的1.46秒多了2.6秒
这些性能问题直接影响了用户在站外商详页的转化率。因此,我们决定重构站外商详,一方面接入得物后台最新版本的商详数据API,便于后续需求迭代,避免站外商详和App商详体验的持续割裂;另一方面提高站外商详的前端性能,提升用户体验。
二、技术方案
本次站外商详升级到创新商详版本,放弃了原项目的uni-app多端同构方案,采用了营销侧的技术基建——源码搭建。通过这种方式,我们不仅提高了站外秒开性能和用户体验,还保证了代码层面的同构开发。
源码搭建
源码搭建是得物前端平台基于SSR(服务器端渲染)架构的C端基建。本次商详重构采用源码搭建来完成,以下是其简要介绍:
- 源码搭建:利用页面搭建器现有开发组件能力快速生成页面,业务开发无需关心公用组件、体验、性能和稳定性基础建设,只需在建立好的页面仓库中开发业务代码,集成的流水线构建会自动帮助开发构建上传。
首屏性能保障方案
本次重构的核心诉求是提高前端页面加载性能,而提高秒开体验的关键在于SSR:在Node端请求服务端数据并渲染出HTML结构直出给浏览器。然而,商详数据是电商平台的核心数据,尤其是得物出价相关数据一直受到黑产爬虫的关注,因此风控侧要求商详接口数据需要做加密处理。这就产生了用户体验与数据安全之间的矛盾:
- 如果数据加密,Node端无法解密数据,无法直出HTML结构,相当于降级为SPA,用户体验大打折扣。
- 如果数据不加密,Node端可以解析数据做到HTML直出,但数据安全无法保障。
为了解决这一矛盾,我们采取了以下方案:
- 将首屏数据(对用户体验影响最大的FMP、LCP元素渲染所需的数据)从完整接口中拆分出来,这部分数据与需要加密的敏感数据无关,因此不需要加密处理。
- 拆分出首屏数据接口,在SSR阶段只请求首屏数据接口,并渲染HTML结构返回到浏览器。
- 浏览器端运行时,再通过风控请求完整的加密数据接口,并渲染到页面。
通过拆分首屏接口和分离首屏数据渲染与完整数据渲染,我们同时保障了首屏渲染速度与风控侧的加密需求。
同构与多环境运行
我们重构的主要目的是提高性能并对接最新版服务端接口,但不能因此放弃以往uni-app架构下的多端同构优势。因此,我们需要设计一套新的运行流程来适配SSR下的新商详。
- 在H5环境下,我们可以直接访问SSR架构下的新商详。
- 小程序环境中,我们利用小程序的webview组件来替换原本的小程序原生页面,从而实现一份代码多环境运行。
风险控制/止损策略
对于PV较高且包含完整交易链路的站外商详,冒烟点和阻塞线上购买流程的故障是不可接受的。因此,我们设计了较为完备的止损策略:
- 故障降级页面——旧版商详:新版商详上线后,旧版页面暂时不会下线,路径和代码保持不变,作为降级页面,确保在新版商详出现问题时无缝切换回旧版商详。
- SSR故障降级:如果SSR侧的请求出现不可用现象,只会影响简版数据接口的渲染,不会中断正常业务流程。
- 灰度策略:结合前端配置中心,我们可以通过逐步灰度放量的方式对命中灰度的用户跳转新版商详,同时灰度配置也可作为紧急回滚手段,在遇到故障时及时关闭灰度放量,引导所有用户跳转旧版商详。
三、一些针对性重构
在商详页面的整体重构过程中,我们识别出了一些关键模块需要进行针对性的重构。这些模块的重构目标是确保它们能够有效适配商详页面的整体架构变化,提升可扩展性。接下来我们详细介绍其中请求拦截器与业务埋点Hook的重构设计。
请求拦截器的重构
新版商详需要在多种场景(Node.js / 微信小程序 / 移动端浏览器)运行代码,未来还可能有更多场景(如支付宝小程序等)加入运行环境。为了保障后续更多运行环境的拓展性和可维护性,我们重构了请求拦截器模块。
1. RequestInceptor类型定义:
通过从定义层面区分不同环境,可以有效保障拦截器运行在有效环境,避免一些类型错误(如在Node环境下访问window等)。
```typescript
export interface RequestInceptor
(): {
// Node环境的请求拦截
nodeEnv: (config: T, runtimeConfig?: RunTimeConfig) => Promise
// 浏览器环境的请求拦截
clientEnv: (config: T, runtimeConfig?: Pick
};
}
```
2. RequestInceptor的具体实现:
每个RequestInceptor都是一个函数,根据环境返回不同的处理逻辑。示例代码如下:
```typescript
const h5CommonHeaders: RequestInceptor = () => ({
// 不同环境下需要携带一些不同的request header
nodeEnv: config => {
config.headers['reqEnv'] = 'node';
return config;
},
clientEnv: async config => {
config.headers['appid_org'] = 'wxapp';
return config;
},
});
const yunDunSDK: RequestInceptor = () => ({
nodeEnv: config => config,
clientEnv: async config => {
// 只需要在浏览器环境加载的sdk
await yunDunLoad;
return config;
},
});
```
3. inceptorsLoader和requestInceptorsCreator共同实现了请求拦截器的处理流程:
- `inceptorsLoader` 函数接收两个参数:`initialConfig` 和 `interceptors`。
- `initialConfig` 是初始的请求配置,包含请求方法、URL、参数等。
```typescript
const inceptorsLoader = async (initialConfig: InitialConfig, interceptors: RequestInceptor[]) => {
const promiseList = map(interceptors, interceptor => {
return async (config: InitialConfig) => {
const { nodeEnv, clientEnv } = interceptor();
if (isInWindow) {
return clientEnv(config, config?.runTimeConfig);
} else {
return nodeEnv(config, config?.runTimeConfig);
}
};
});
const promiseListResult = await promiseList.reduce(
(promise, fn) =>
promise.then(config => {
return fn(config);
}),
Promise.resolve(initialConfig),
);
return promiseListResult;
};
```
通过这些针对性的重构,我们解决了现有迭代中的瓶颈,确保系统稳定性的同时,加速了开发的迭代过程。