站外商详的重构与优化|得物技术
来源:互联网
时间:2026-06-16 13:51:06
### 站外商详重构:从uni-app到源码搭建,性能提升与架构升级的全过程
在移动端电商场景里,站外商详(H5/小程序)一直是个“看起来功能单一、但实际影响面很广”的页面。过去,它依赖老接口detailV3,在样式和功能上始终与最新版的客户端存在差距——App里已经做到类目差异化配置了,站外端的用户看到的却还是老一套。这种割裂感,不光影响用户体验,也拖了业务转化的后腿。
从唤端数据能直观感受到问题:App商品详情页分享后的唤端成功率超过75%,说明这些用户带着明确的目标进入App做购买,是高价值人群。而站外商详日均PV稳居站外流量前三,它的唤端价值同样高,但受限于uni-app多端同构方案的SPA架构,前端性能一直不理想。
看看旧版的性能数据对比大盘就清楚了:
- 平均fmp:2.75s
- 75分位平均fmp:2.74s——对比源码搭建大盘的1.29s,多了1.45s
- 平均lcp:3.29s
- 75分位平均lcp:4.06s——对比源码搭建大盘的1.46s,多了2.6s
性能差,转化率自然受影响。所以这次重构的核心目标很明确:一是接入得物后台最新版的商详数据API,让站外商详与App商详不再割裂;二是大幅提升前端性能,给用户更好的秒开体验。
一、背景
(以上已包含背景内容,此处保留章节标题结构) 站外商详(H5/小程序)一直以来采用detailV3老接口数据,在样式和功能上,不能与最新版的客户端同步对齐,各个端之间的使用体验存在差异。 从唤端数据看,App商品详情页分享后的唤端成功率非常高,能够达到75%以上,代表着这些用户都是带有明确目标和意愿来App内部进入购买商品,ROI好的高净值用户。而对于日均pv占据站外流量TOP3的站外商详来说,唤端价值相对比较高的同时,uni-app多端同构方案的SPA架构限制了H5与小程序页面性能体验天花板,长期以来站外商详的性能指标在前端平台的性能统计大盘下比较靠后。 从性能数据监控并对比源码搭建页面的性能数据看,旧版的商详性能数据并不理想,对用户在站外商详页的转化率有一定影响。综上背景,我们决定重构站外商详,一方面可以接入得物后台最新版本的商详数据API,便于后续需求迭代,避免站外商详和App商详体验的持续割裂现象;另一方面可以同时提高站外商详的前端性能,带给用户更好的使用体验。二、技术方案
本次站外商详升级到创新商详版本,放弃了原项目的uni-app多端同构方案,同时采用营销侧的技术基建——源码搭建。这保证了站外秒开性能和用户体验,同时又保证了代码层面的同构开发。下面详细介绍本次重构的核心设计。源码搭建
源码搭建是得物前端平台基于SSR架构的C端基建。简单说,就是利用页面搭建器现有的开发组件能力快速生产页面,业务开发者不需要关心公用组件、体验、性能和稳定性基础建设,只需要在建立好的页面仓库中开发业务代码。流水线构建会自动帮助开发构建上传,开发效率很高。首屏性能保障方案
重构有一个核心诉求:提高前端页面加载性能。而提高秒开体验的核心就是SSR——在Node端请求服务端数据并渲染出HTML结构直出给浏览器。但这里有个矛盾:商详数据是电商平台的核心数据,尤其是得物出价相关数据一直受黑产爬虫关注,风控要求接口数据必须加密。 数据加密后,Node端无法解密,就没法直出HTML,相当于降级为SPA,用户体验大打折扣。如果数据不加密,Node可以解析数据直接渲染,但数据安全又没了保障。 怎么解决?关键在于拆分。把首屏数据从完整接口中分离出来——这部分数据和敏感数据无关,不需要加密。具体流程: - 拆分出首屏数据接口,SSR阶段只请求这个接口,渲染HTML结构返回给浏览器 - 浏览器端运行时(用户已打开页面),再通过风控请求完整的加密数据接口,渲染到页面 这样一来,首屏渲染速度和风控加密需求就都兼顾了。 简要流程如下图所示: <iframe allowfullscreen="" frameborder="0" src="https://mp.weixin.qq.com/mp/readtemplate?t=pages/video_player_tmpl&action=mpvideo&auto=0&vid=wxv_3770452736794607621"></iframe>同构与多环境运行
重构不能因为换了技术方案就放弃多端同构优势。H5环境可以直接访问SSR架构下的新商详,那小程序环境怎么办?很简单:小程序的webview组件可以替代原生页面。一套代码,通过SSR+webview的配合,实现多环境运行。设计图如下:
风险控制/止损策略
站外商详是PV高、包含完整交易链路的页面,冒烟点和线上故障不可接受。因此设计了多层止损策略。 **故障降级页面——旧版商详** 新版上线后,旧版页面暂时不下线,路径和代码保持不变,可以作为降级页面。一旦新版出问题,能无缝切回旧版。 **SSR故障降级** 如果SSR侧的请求不可用,影响的只是简版数据接口的渲染,只会降低秒开性能,不会中断正常业务流程。 **灰度策略** 结合前端配置中心,逐步灰度放量,命中灰度的用户跳转新版商详。灰度配置本身也是紧急回滚手段——遇到故障时关闭放量,所有用户切回旧版。三、一些针对性重构
在整体重构过程中,识别出几个关键模块需要针对性改造,目的是适配架构变化并提升可拓展性。重点介绍请求拦截器和埋点Hook的重构设计。请求拦截器的重构
新版商详需要在Node.js、微信小程序、移动端浏览器等多种场景运行,未来还可能增加支付宝小程序等。为了保障后续拓展性和可维护性,重构了请求拦截器模块。 1. **RequestInceptor类型定义** 通过定义层面区分环境,避免在node环境下访问window等类型错误:export interface RequestInceptor2. **具体实现** 每个RequestInceptor都是一个函数,根据环境返回不同的处理逻辑:> { (): { nodeEnv: (config: T, runtimeConfig?: RunTimeConfig) => Promise | T; clientEnv: (config: T, runtimeConfig?: Pick ) => Promise | T; }; }
const h5CommonHeaders: RequestInceptor = () => ({
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 => {
await yunDunLoad;
return config;
},
});
3. **inceptorsLoader和requestInceptorsCreator**
`inceptorsLoader`接收初始配置和拦截器数组,按顺序依次处理;`requestInceptorsCreator`是工厂函数,组合常见的拦截器。
const inceptorsLoader = async (initialConfig, inceptors) => {
const promiseList = map(inceptors, interceptor => {
return async config => {
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 => fn(config)), Promise.resolve(initialConfig));
return promiseListResult;
};
export const requestInceptorsCreator = config =>
inceptorsLoader(config, [
h5CommonHeaders,
yunDunSDK,
]);
4. **使用方式**
通过工厂函数,灵活添加、删除或修改拦截器,保证特定顺序执行。调用时传入初始配置,得到优化后的请求配置,然后传给HTTP客户端(如axios)发起请求。
埋点Hook的重构
埋点开发一直是前端同学的痛点——大量逻辑与业务/视图强绑定,还要写一堆模板式代码。本次基于React Hook重构了埋点上报的应用层逻辑。 **埋点Hook实现层** 1. `generateTrackConfig`函数核心代码:
const generateTrackConfig = (trackSend) => {
return function createTrackConfig() {
const names = trackSend.map(item => item.name);
const extractEventData = (current) => {
const nameSplit = current.split('_');
const [page, block] = nameSplit.slice(-2);
const isBlockTypePresent = /\d+/.test(page);
const event = nameSplit.slice(0, isBlockTypePresent ? -2 : -1).join('_');
return { event, current_page: isBlockTypePresent ? page : block, block_type: isBlockTypePresent ? block : '' };
};
return names.reduce((total, current) => {
const eventData = extractEventData(current);
total[current] = (platform, { transParams }) => createEventObject(eventData, platform, transParams);
return total;
}, {});
};
};
2. `useProTrack`函数核心代码:
export const useProTrack = (
{ props, functionalRef },
trackSendProps
) => {
useWithReactFunctionalTrack({
functionalRef: functionalRef,
functionalProps: functionalPropsRef.current,
useEffect,
createTrackConfig: generateTrackConfig(trackSendRef.current),
});
useEffect(() => {
ObserveTrackRef.current = new IntersectionObserver(handleIntersection);
return () => { ObserveTrackRef.current?.disconnect(); };
}, []);
const trackFuncMemo = useMemo(() => {
return trackSendRef.current.reduce((result, item) => {
result[item.name] = (trackParams, options) => {
if (!options?.ele) {
track(item.name)(trackParams);
return;
}
const { ele } = options;
startToObserveMap.current[item.name]();
};
return result;
}, {});
}, []);
return trackFuncMemo;
};
**埋点Hook应用层**
1. 从埋点任务中复制埋点名,不要手动拼写:
2. 组件内引用useProTrack并注册埋点名:
const proTrack = useProTrack({ props, functionalRef }, [
{ name: 'trackEvent_1234' },
{ name: 'trackEvent_2345' },
]);
3. 主动上报例子:
proTrack.trackEvent_1234({
button_title: '我知道了',
});
埋点名会有完整类型提示。
4. 曝光上报例子:
在HTML元素的ref钩子中注册,语法相比主动曝光多了一个配置。 **重构收益** - 节省前端工时,减少心智负担:无需手动配置trackConfig文件,无需感知/调用埋点SDK内部 - 减少流程,避免埋点验证返工:复制埋点系统埋点名即可创建函数,不再担心event/current_page/block_type写错,TS类型提示完备trackFn?.trackEvent_1234_3210({ trade_type_list: tradeTypeListStr, }, { ele })}>