总结本次高校暑假听歌PK项目中:踩到的坑、复用到的组件/工具及其优缺点、有价值的最佳实践。通过总结这些事项,提高通用组件在后续项目中的易用性和稳定性,避免新手后续项目中遇到同样的问题。最后,把本次项目中可沉淀为通用能力的方法进行总结。

背景

由于本人刚从B端业务组转到C端活动搭建业务组,对组内的一些搭建规范和工具组件都不是很了解,因此通过一次线上活动的搭建来熟悉组内的一些协作规范与工具的使用。站在活动搭建新手的角度,总结本次高校PK项目搭建过程中:踩到的坑、复用到的组件/工具及其优缺点、有价值的最佳实践。通过总结这些事项,提高通用组件在后续项目中的易用性和稳定性,避免新手后续项目中遇到同样的问题。最后,把本次项目中可沉淀为通用能力的方法进行总结,为活动搭建能力舔砖加瓦。

流程复盘:高校PK复盘-流程归因

规范/组件/性能

新手刚进入活动进行开发时,对组内约定规范、重要的组件方法性能优化的经验性写法等不太了解,在此做一些总结,以便后续有新手加入时可以快速了解。

约定规范

通用配置

如静态链接、枚举字段等固定字段,尽量统一放到utils下面的config或const文件中。这样可以方便统一管理,避免修改时遗漏等。

img

契约字段定义

一般一个完整的活动都由多个成员协作完成,由于前期不一定能全部投入,所以先投入的成员建议在对好接口契约后,可以对契约中已经约定字段在model或service里新建一个文件进行定义。方便后续接入的成员可以快速的进行对接。示例:https://g.hz.netease.com/cloudmusic-frontend/febase-projects/four-relation/-/blob/dev/src/models/index.ts

性能优化

图片资源

  • 图片压缩

    视觉稿中提供的切图推荐使用为3x大小的切图,不然在页面呈现出来的效果会不够高清。由于部分高清图一般较大可能会导致加载和渲染性能的问题,单张图片资源大小尽量控制在300kb以内。建议内测前统一使用https://tinypng.com/进行无损压缩。

  • 大图检测

    上线前需要对性能监控平台的-未处理大图模块过一遍,确保项目中大图都已经被处理;否则会触发性能监测平台告警。

    img

  • 图片裁剪

    对于请求接口中的头像或图片,服务端返回的图片链接往往可能是高清的,这会影响加载的速度和性能,此时可以使用utils中的setImageSuffix方法进行裁剪(setImageSuffix的原理是往服务端给的图片链接后面拼接参数,使用nos的能力获取到我们需要大小的图片)。

  • 请求重试

    类似于排名列表中展示头像这种要请求多张图片的场景,当网络波动时往往会出现请求图片失败的情况,此时可以使用框架中的CommonImg方法,对图片进行包裹,再结合setImageSuffix方法,不仅可以对图片大小进行裁剪,还可以起到兜底的作用:在图片加载失败时会触发3次重新加载请求,若最终无法请求到图片时展示兜底逻辑。

字体资源

通常在视觉稿中可能会出现多种字体,这些字体可以直接联系视觉让他导出给到我们。但是由于这些字体包体积较大(一般1~4M),出于加载性能考虑一般来说字体包总共大小建议尽量控制在10M以内,因此需要对字体包进行必要性裁剪和控制字体的种类。

字体裁剪参考:https://km.netease.com/v4/detail/blog/227640

预加载

活动通常会使用大量的图片承载,有些关键的场景,图片要考虑加载失败后重试以及失败后有兜底展示的图片。如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** 预加载函数 **/
import { useEffect } from 'react';
import { PreLoader } from '@music/activity-m2-loading';
import headerBg from '@assets/detailSchool/headerBg.jpg';
const loader = new PreLoader();
// 资源预加载
const usePreLoader = (): void => {
useEffect(() => {
// --- 图片资源预加载示例 ---
loader.add([headerBg]);
loader.start();
}, []);
};
export default usePreLoader;

/** 主会场页 **/
usePreLoader();

滑动节流

scroll滚动事件建议增加节流函数throttle进行性能优化控制,否则可能会造成页面的卡顿。

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
import { throttle } from 'lodash';
useEffect(() => {
scrollTo(0, {
getContainer: () => scrollContainerRef.current as HTMLDivElement
});

const onScroll = throttle(() => {
if (scrollContainerRef.current && tabStoryRef.current) {
// 容器滚动的距离
const { y: containerPosition } = getScrollPosition(scrollContainerRef.current);
// 留言板距离顶部的距离
const { y: tabPosition } = getOffsetPosition(tabStoryRef.current,
scrollContainerRef.current);
tabOffsetPosition.current = tabPosition;

// 间隔距离切换
const DIFF_POSITION = 120;
if (containerPosition - tabPosition > DIFF_POSITION) {
setElevator(ElevatorType.TOP);
} else {
setElevator(ElevatorType.STORY);
}
}
}, 400);

document.addEventListener('scroll', onScroll, true);

return () => {
document.removeEventListener('scroll', onScroll, true);
};
}, []);

分享

  • 微信内唤端

    活动搭建一般是走的sg域名,但由于微信客户端的限制,只有在y域名下的页面才能使用唤端的功能,因此云音乐专门提供了y域名。对于需要分享出去通过微信传播的链接,我们可以将分享出去的域名换成y域名打头,示例如下:

    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
    // 首页分享出去:1.选择了学校
    const shareCodeUrl = getActivityUrl({
    yDomain: true,
    search: `userId=${userId}&nickname=${encodeURIComponent(nickname)}&avatar=${encodeURIComponent(avatar)}`
    });

    // y 域名
    export const yDomainUrl = 'https://y.music.163.com';

    /**
    * 拼接完整的 url
    * @param 路由 path
    * @param 参数 search
    * @param yDomain 是否使用 y 域名
    * @returns 拼接后的 url
    */
    export const getActivityUrl = (data?: InputUrl, strategyController?: any): string => {
    const { path = '', search = '', yDomain = false } = data || {};
    // 支持多链接模式
    let baseName = getBaseName();
    // 注意 baseName 会自带 '/'
    baseName = baseName && baseName.length > 1 ? baseName : '';

    const originLink = `${yDomain ? yDomainUrl : window.location.origin}${baseName}${path}?full_screen=true&nm_style=sbt${search}`;
    strategyController?.initLink?.(originLink);

    return strategyController?.shareLink || originLink;
    };
  • 分享默认配置

    活动模版中已经在layout中为我们预设了分享的相关配置(包括埋点的回调),主要使用@music/activity-share包实现,其代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 同步活动研发平台【链接分享配置】
    ShareConfig.updateShareData({
    name: ACTIVITY_NAME,
    title,
    link: shareLink,
    subTitle,
    picUrl: imgUrl,
    blogText: blogText || title,
    defaultText: defaultText || '',
    shareCb: (success: boolean, shareType: string) => {
    if (success) {
    onShareCallback(shareType); // bi 埋点
    // 【分享渠道】:分享按钮渠道pylon埋点
    shareChannelLog.link();
    }
    },
    onNavShareIconClick: () => !defaultActivityConfig.fullScreen && onShareLog(),
    });

    分享链接浮层唤起以后,用户触发了分享渠道点击,客户端会主动触发回调,即触发 shareCb 执行。云音乐app打开h5后,用户点击分享,触发容器右上角的分享按钮,客户端会执行回调,即执行 onNavShareIconClick 回调。因此,在完成业务定制的分享相关策略时,只需要关系分享逻辑,在需要的位置使用ShareConfig. updateShareData()更新链接和配置,再使用ShareConfig.share()就可以了;

  • 避免链接被封

    活动中常用的避免链接被封的方法有两种,1. 在链接中拼接随机字符串;2. 使用口令码分享;

    在链接中拼接随机字符串其原理是在路由后面拼随机字符串,达到混淆对方识别的效果(注意:是拼在路由上而不是参数里),在项目中可以调用如下方法实现:

    1
    2
    3
    import { useStrategy } from '@music/tl-mobile-sharestrategy';
    const { strategyController } = useStrategy();
    const newLink = strategyController?.initLink?.(originLink);

    结果如下:

    1
    https://y.music.163.com/st/pk-university/radsjl949?dlt=qwer&app_version=9.1.50&full_screen=true&userId=1802675598&area=%E5%85%A8%E5%9B%BD&userid=1802675598&nm_style=sbt

    其中,radsjl949就是拼接上的随机参数(随机数的长短可以通过配置中心进行配置),其原理是通过SPA + 静态资源部署实现。

    使用口令码是通过调用接口,将我们需要分享出去的url转换为口令码的形式,并调用手机的剪切板功能将换取到的口令码复制到剪切板。当用户获得口令码并打开App时就会自动识别并跳转到页面(注意:测试包不具备自动识别口令码并跳转到对应页面的能力,这一步需要在正式包上测);

其他

其他通用配置或方案请参考:活动通用 - 前端技术方案

踩坑总结

协议/框架问题

  • 非样式文件中写长度需要单独转换

    问题描述

    在项目中的TSX文件中通过style写的大小不会被默认转换为vw单位,因为框架自带的插件只识别样式文件。导致后续会出现适配问题。

    解决方法

    使用utils中的getRealPx方法包裹一下数值或者自己手写一个转换方法。

  • 第三方APP唤起

    问题描述

    使用orpheus://deeplink协议唤起新浪客户端时,发现点击取消或确认后再返回活动页面,发现页面无法点击或滑动。排查原因后发现是协议弹出的浮层在点击取消或确认后并未关闭或销毁。

    解决方法

    联系客户端同学帮忙处理。

组件问题

  • 内容过滤组件(@music/ct-content-filter

    问题描述

    使用 @music/ct-content-filter内容过滤组件时发现,对接口数据源进行过滤时会造成一些不需要过滤的必要字段被处理(如:id等)。针对这种情况,若对每个字段单独处理担心会影响性能,把过滤字段抽离出来又失去了原有的意义。

    解决方法

    建议增加入参用来指定需要过滤的字段;

  • fetch组件问题

    问题描述

    fetch的content-type不匹配会导致请求参数错误的、返回抛出乱码;

    问题原因

    fetch包升级后新版本后现有fetch组件方法中不支持入参使用applications/json格式,且当接口报错时抛出的不是json对象,导致捕获函数中拿不到message信息。

    解决方法

    判断入参中传入json: true时,使用fetch.postJson方法请求接口、抛出信息转为json后再抛出;

兼容性问题

  • fixed失效问题

问题描述

在调试页面时发现一个很奇怪的问题,当超出屏幕高度的页面进行下滑时,胶囊按钮和电梯按钮会自动消失;

解决方法

排查发现h5页面在部分测试包里运行时,滑动页面下滑会导致position: fixed失效。更换测试APP到最新的发布版就好了;

  • 数据兼容性处理

    问题描述

    由于以下代码没有做兼容性处理,导致数据返回空或不匹配的字段类型时出现白屏的情况。

    1
    2
    3
    4
    5
    6
    7
    8
    // 超过三位的数字后面补充万 
    export const formatNumberToWan = (number: number) => {
    if (!number) return 0;
    if (number < 10000) {
    return number.toString();
    }
    return `${(number / 10000).toFixed(1)}w`;
    };

解决方法

​ 对空数据做好判断;

​ 备注:需要充分考虑来源数据的可靠性,做好为空判定,特别是像接口返回的数据字段,避免出现异常的白屏

  • 分享组件报错

    问题描述

    iphone7 plus在点击分享时出现弹出一个崩溃,且必现;

    问题原因

    分享链接中存在中文字段,部分机型会自动转换但是部分机型不会自动做处理,因此导致抛出分享链接不正确的异常;

    解决方法

    对链接中有传入中文参数的地方先用encodeURIComponent处理,在使用的地方获取到了变量再用decodeURIComponent解析

组件/工具复盘

对高校PK项目中使用到的活动已有组件或方法进行盘点,站在新手角度对这些组件或方法进行评价。

  • 状态管理

    描述:活动模版自带 zustand

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐ ⭐ ⭐

  • 分享埋点

    描述:活动模版默认在框架中生成分享埋点函数和触发事件,在需要分享埋点的页面增加ShareLogBtn组件即可。点击分享后,对应页面的share事件就会被触发。

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐ ⭐ ⭐

  • 截屏埋点

    描述:活动模版默认在框架中生成截屏埋点函数和触发事件,使用方法有两种:1. 在routes中直接加点位信息;2. 在需要截屏的页面的div上加id(一般用当前页面的路由当做id);

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐ ⭐ ⭐

  • 站内音频播放

    描述:

    • mnb(‘nm.play.playSongs’, {}),minibar播放歌曲;
    • @music/rpc-audio-h5,最开始使用的播放组件,可正常播放、暂停、开始,切换;

    好用程度:⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐

    需求适配:⭐

    痛点描述:根据其他项目的经验,策划后续希望播放过的音频切换时保留在播放列表中,且活动页面不展示minbar,使用mnb(‘nm.play.playSongs’, {})设置insertBefore后会弹起黑胶,有问题。使用@music/rpc-audio-h5需要保留播放过的歌曲列表好像不满足需求;

  • 链接跳转

    描述:工程模版自带的utils中的openPage()方法,站内调用mnb.open打开链接,站外使用window.location.href打开链接。

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐ ⭐ ⭐

  • 口令码分享

    描述:通过调用接口生成口令码,并使用Android或IOS自带的copy能里进行复制,用户打开App后会对剪切板进行监听并唤起页面

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐

    痛点描述:活动中途策划反馈希望换掉,原因是有人不知道怎么操作

  • 轮播组件

    描述:https://swiperjs.com/react

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐

    痛点描述:视觉想要轮播的引导栏放在右侧,该组件好像无法配置

  • 社区评论组件

    描述:@music/ct-mobile-story-publishing,绑定话题id即可查看或发表评论;

    好用程度:⭐ ⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐ ⭐

    需求适配:⭐ ⭐ ⭐ ⭐ ⭐

  • 敏感字段处理

    描述:@music/ct-content-filter 用于对接口字段中存在的敏感字段做过滤;

    好用程度:⭐ ⭐ ⭐ ⭐

    易于理解:⭐ ⭐ ⭐ ⭐

    需求适配:⭐

    痛点描述:无法指定数组中需要过滤的字段,会影响非敏感字段

最佳实践

页面请求优化

高校pk首页是一个瀑布流页面,总共有大概4~5个请求。刚开始写的时候没有考虑请求加载性能的问题,所以直接用promise.all进行包裹,其写法如下:

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
const getBaseInfo = useCallback(async () => {
const [
trumpetInfo,
schoolDetail,
countryRankInfo,
interestRankInfo,
recommendRankInfo
] = await Promise.all([
Services.getMainTrumpet({ resourceType: 1 }), // 获取小喇叭信息
Services.getMainDetail(), // 获取学校详情信息
Services.getRankListDetail({ // 获取全国榜
statisticType: 1,
chartType: 2,
size: 10,
}),
Services.getInterestRankList(), // 获取趣味榜单
Services.getRecommendRankList() // 获取推荐榜单
]);
.....
setMainTrumpet(trumpetInfo);
setMainDetail(schoolDetail?.data || null);
setCountryRank(countryRankInfo?.data);
setRecomdRank(filterRecommend?.data);
setFunCardList(interestRankInfo?.data);
}

useEffect(() => {
if (isInApp) {
getBaseInfo();
}
}, []);

这种写法在语法和代码运行上虽然没有什么问题,但实际体验时会发现,页面在加载过程中会出现明显的先数据加载并展示的过程(尤其是在网速较慢的情况下),屏幕上会首先展示一些背景图,然后再慢慢将接口请求到的内容展示到屏幕上。原因是我们使用promise.all去请求了5个接口,等到所有接口的数据都返回后才进行setState的处理,在这期间会有一段”漫长“的请求等待过程。因此考虑按照屏幕展示布局对接口进行拆分,将直接会出现在首屏中展示的数据优先请求,其次在请求首屏下滑后页面的数据,以此往后类推。

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
// 获取学校详情信息
const getBaseInfo = useCallback(async () => {
const schoolDetail = await Services.getMainDetail();
if (schoolDetail?.data?.bindUniversity?.area) { // 如果有绑定高校,请求地区榜
......
}
setMainDetail(schoolDetail?.data || null);
}, []);

// 获取榜单、小喇叭信息
const getRankAndTrumpet = useCallback(async () => {
// 获取小喇叭信息
const trumpetInfo = await Services.getMainTrumpet({ resourceType: 1 });
.....
setMainTrumpet(mainTrumpetObj);
// 获取全国榜
const countryRankInfo = await Services.getRankListDetail({
statisticType: 1,
chartType: 2,
size: 10,
});
.....
setCountryRank(filterData);
// 获取推荐榜单
const recommendRankInfo = await Services.getRecommendRankList();
.....
setRecomdRank(filterRecommend);
}, []);

// 获取趣味榜单
const getFunList = useCallback(async () => {
// 获取趣味榜单
const interestRankInfo: any = await Services.getInterestRankList();
setFunCardList(interestRankInfo?.data?.map((item: any) => ({
...item,
rankDetailDtoList: item?.rankDetailDtoList?.slice(0, 3) || []
})) || []);
}, []);

useEffect(() => {
if (isInApp) {
getBaseInfo();
getRankAndTrumpet();
getFunList();
}
}, []);

这样处理后会发现原本有明显数据加载的展示的地方已经看不来了,几乎是直接进入后与样式和图片同时展示。但在进行翻页后再退回之间页面的某个位置时,会发现屏幕有明显的跳屏现象。原因是因为页面中的模块是根据接口请求的数据进行map渲染的。由于使用的是setState对页面数据进行存储,当进入到其他页面时,当前页面的数据就会被销毁,再次返回该页面时,数据又会有一个从0到1的加载过程。因此考虑使用全局状态来存储页面的数据,示例如下:

1
2
3
4
5
6
7
import useHomeStore, { HomeInfo } from '@models/home';
// 使用zustand来做全局状态管理,代替setState对数据进行存储
const [
.....
] = useHomeStore((state: HomeInfo) => [
.....
]);

这样实现后,之前渲染过程明显、页面跳屏的问题都得到了解决。虽然原理很简单,但在实际开发过程容易忽视,因此作为新手最佳实践放在此处避免遗忘。

swiper实现跑马灯

目前项目中没有通用的跑马灯组件,自己写或者从红石上找都比较耗时,因此可以直接借助轮播组件实现跑马灯的效果:https://swiperjs.com/react

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
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay } from 'swiper/modules';
import 'swiper/css';
const test = () => {
return (
<Swiper
speed={3000}
freeMode // 【关键👇🏻】设置为true则变为free模式
loop // 设置为true 则开启loop(无限循环)模式
autoplay={{ // 自动播放
delay: 0, // 【关键👇🏻】自动切换的时间间隔,单位ms
}}
modules={[Autoplay]}>
<SwiperSlide>
Slide222222222222222222222
</SwiperSlide>
<SwiperSlide>
Slide33
</SwiperSlide>
<SwiperSlide>
Slide444444444444444
</SwiperSlide>
</Swiper>
)
}
/**
* css部分
*/
.swiper-wrapper {
-webkit-transition-timing-function: linear; /*之前是ease-out*/
-moz-transition-timing-function: linear;
-ms-transition-timing-function: linear;
-o-transition-timing-function: linear;
transition-timing-function: linear;
}

可沉淀组件

  • 返回按钮

    可沉淀为通用组件,需支持:箭头颜色配置、箭头背景色配置、返回上一页判断(有上一页返回上一页,没有上一页关闭页面);

  • 口令码分享

    目前应该只在个别项目中使用过,并没有作为稳定的工具沉淀。由于没有规范的约定导致在本次活动的使用过程中出现业务方觉得不好用的情况。因此,可以考虑将口令码做为组件沉淀,活动自定义的提示语作为必传项,此外还可以增加提示弹窗,在复制口令码打开客户端后弹出跳转提示,以此提升用户体验。

  • fetch方法优化

    框架自动生成的fetch方法不支持入参使用applications/json、返回报错乱码;

  • 掉落动效

​ 在多个活动中用到,可考虑沉淀为一个物料组件;

  • 字体包动态裁剪

    字体包动态裁剪,可以考虑作为后续模板沉淀;