走进京东618大促“产星”之路
近期项目遇到关于视频的需求,文章很多内容非常受益,转载保存
活动介绍
活动背景
今年618我们承接两个活动——产业带和星店长。
本次产业带会场延续之前的“源头好物”为主题,以1日1主推形式通过短视频和直播展现;帮助买家直达原产地优质货源,帮助卖家提升竞争力,降低竞争成本。
今年618星店长有了更加精彩的玩法方式,星店长结合明星与品牌,撬动粉丝经济流量转化,配合站内外强势曝光,以明星专属推荐商品等维度赋能品牌曝光;联动扩大京东618声量,助力品牌及业务将明星权益落地转化,星店长的影响力引爆了热度高涨的消费力,打开了粉丝经济更多的想象空间。
本篇文章向大家介绍在开发两个活动是遇到的问题和总结的经验,带大家走进京东618大促”产星“之路。
技术栈
首先在框架的选择上,选用的是 Vue, 并且使用 TypeScript 作为主要开发语言,在前端应用复杂度不断飙升的大背景下,应对组件不易维护且难于扩展的问题。其次在组件库方面,我们采用团队自主研发的一套京东风格的移动端组件库—— NutUI 组件库 。
NutUI 组件库
基于 Vue 的 UI 组件库,我们选择了部门自主研发的开源组件库 NutUI 。NutUI 是一套京东风格的移动端组件库,开发和服务于移动 Web 界面的企业级前中后台产品。现在已经升级到 3.0+ ,不仅支持 Vue 3,还增加「小程序多端适配」的新功能。
活动痛点
- 开发时间紧迫,上线日期先确认,开发测试倒排期,严重压缩开发时间
- 数据结构复杂,数据体量大,数据嵌套层级多
- 需求变动频繁,PRD 变更次数多,导致需要反复核对确认 PRD
- 依赖数据配置,活动数据依赖广告组和商品组的素材
应对措施
规范化
规范开发流程
- 需求评审阶段,增加多方评审机制,避免修改方案的情况。
- 在开发前完成素材配置,避免等待素材,造成前端时间空窗。
- 按照提需流程(评审+开发+测试)。
- 严格评估需求变动的必要性和风险。
- 遇到问题及时同步各方,避免堵塞开发进度。
组件化
NutUI 是一套京东风格的移动端组件库,支持最新的 vue3 语法,支持 H5、小程序使用。在本次活动开发中,我们也用了 NutUI 不少组件(例如: Dialog、Coupon、Video、TabPanel、Infiniteloading、Popup、Price、Toast、Magic等)。
自动化
1. Upload-oss-tools
下载 插件 后按照文档说明进行配置,这样我们就可以不用配置 host ,同时增加了测试覆盖范围,在手机兼容测试中也是节约了很多时间,不用再到每台手机上去配置,这样每位同学就可以轻松访问开发环境,从而提高了我们内部的协作效率。同时它简单易上手,几行命令就可以轻轻松松完成操作,插件支持文件批量上传,解决平台只能单文件操作的问题。
我们不只能像浏览 主站 一样直接访问项目链接,而且也可以把它当作一个小型的存储空间,将我们平时的一些文件、代码等上传备份,文件的上传类型可以是 word 、execl 等。
它也可以当作一个容灾处理的方案,在项目上线后如果我们的服务器出现故障,无法访问资源的时候,我们可以把资源链接指向 OSS 的路径,这样即使后端服务接口无法访问,我们也可以保证页面展示信息完整。
如果您已经使用它了,相信您已经通过配置对项目实现了“一键部署”的功能。
2. 图片压缩
在质量相同的情况下,WebP 格式图像的体积要比 JPEG 格式图像小 40%。
接口下发的图片链接是由“域名/业务名/s800x800 _图片名.jpg”构成的,例如: https://m.360buyimg.com/babel/s800x800_jfs/t1/157993/20/14850/102931/605d8d9bEb844fc8d/4df66b613a527ca4.jpg!q70.jpg
jfs
前面的数字代表图片的宽高,通过在链接里自定义尺寸达到压缩图片的目的。需要注意的是自定义的尺寸尽量与原图尺寸比例保持一致。
于是我们定义了可以改变图片尺寸的方法,代码如下:
ImgSetFunction(url: string, params: imgParams = {}) {
if (!url) {
return null;
}
if (url.indexOf('jfs') != 0) {
if (url.indexOf('jfs') == -1) {
// 链接中没有jfs,直接返回
return url;
} else {
if (url.indexOf('_jfs') != -1) {
// 链接已经被压缩过了
return url;
} else {
// 有jfs,但不在开头,拼接压缩
if (params.width && params.height) {
const flag = url.indexOf('jfs');
const len = url.length;
return (
url.substring(0, flag) +
's' +
params.width +
'x' +
params.height +
'_' +
url.substring(flag, len)
);
} else {
return url;
}
}
}
}
}
图片的后面加 !q10.jpg
表示访问图片质量是10的图片,这样就可以实现降低图片质量的大小。
3. 懒加载
对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。这样对于页面加载性能上会有很大的提升,也提高了用户体验。在vue项目中,我们经常使用 vue lazyload
插件来实现图片懒加载,但很多人不知道的是,这个插件也可以处理楼层懒加载!
在 vue lazyload
的 github 上有个组件懒加载的参数—— lazyComponent
,将需要懒加载的组件放在 vue-lazy-component
的下面,并且在回调函数 show
里对数据进行处理。
Vue.use(ImgSet)
.use(VueLazyload, {
lazyComponent: true,
preLoad: 1,
attempt: 1,
loading:
'//img13.360buyimg.com/imagetools/jfs/t1/10984/3/8320/7862/5c34009cE52c55a13/4c2804c7a0b43a8c.png'
});
<!-- T1品牌 -->
<lazy-component @show="t1Handler" v-if="t1Floor"></lazy-component>
<BrandArea :t1Data="t1Data" />
t1Handler() {
console.log('懒加载 ->「T1」楼层');
}
会场开发
在开发会场的时候,遇到了几个问题:
1. 楼层的排序可根据接口的顺序调整排序
比如有楼层 A、B、C,业务通过分析用户的点击率对楼层的排序进行调整。
而我们需要通过接口返回的楼层顺序对楼层的顺序进行动态加载。
在刚听到这个需求的时候是有点懵的,在浏览了 vue 的官网后,发现了 动态组件 & 异步组件 。
实现思路:目前活动的每个楼层都封装成一个组件,然后在 index 页面统一引入。有了动态组件这个东西之后,我们就可以根据 :is
绑定不同的值来渲染不同的组件。比如,拿到后台给我们返回的要渲染组件顺序的数组,我们通过循环数组,构建出一个最终我们想要的数据格式。
<div v-for="floor in moveFloor" :key="floor.key" class="active-box">
<component
v-bind="floor.props"
:is="floor.app"
/>
</div>
首先在 html
里面通过 <component>
声明了一块区域,并通过 :is="floor.app"
绑定了 floor.app。然后再定义一个数组,将是楼层A、B、C 的数据过滤出来,再进入 switch 循环语句,当楼层数据的 key
是 floorA 时, 意为当前的 <component>
将会被 commponentA
渲染。直到所有楼层数据全部执行完毕,componentA
、componentB
、componentC
按照接口的顺序渲染出来。
// 获取组件的顺序
getTempList() {
const moveList = this.floorData.filter((floor: any) => {
return floor.alias == 'floorA' ||
floor.alias == 'floorB' ||
floor.alias == 'floorC'
})
return moveList;
}
async createTempData() {
const result = await this.getTempList();
this.moveData = result.forEach((val: any) => {
let key = val.alias;
switch (key) {
case 'floorA':
val.component = 'commponentA'
val.options = this.DataA
break;
case 'floorB':
val.component = 'commponentB'
val.options = this.DataB
break;
case 'floorC':
val.component = 'commponentC'
val.options = this.DataC
break;
}
})
this.init()
}
异步组件可以通过 require
和 import
两种方式导入组件,下面是采用通过 import
来注册一个异步组件。关键点在于动态修改 () => import('')
里面的值。每个组件要传给子组件的值和接收子组件 emit
的事件也可以动态的绑定上去。
init() {
// 构建渲染页面组件的数组
this.moveFloor = this.moveData.map((value: any, index) => {
return {
app: () => import(`../components/${value.component}.vue`), // 异步组件
key: index,
props: {
data: value.options, // 传给子组件的 options
},
fn: {
change: this.changeTest // 接收来自子组件的 $emit 事件
}
};
});
}
到这里,就实现了页面组件的排列顺序就是根据接口返回的顺序排列的功能!
2. 视频自动播放
需求是这样的,首先是可以左右滑动的,然后当前页面上一个 item 里边有三个商品,其中的素材有直播类型,视频类型,商品图类型,展示顺序为直播>视频>商品图 ,视频播放逻辑,入场2S后自动播放,如果有两个视频在同一屏幕里,则默认播放第一个,用户上滑过第一个视频的一半,则第一个视频暂停,第二个视频开始播放,横滑,第二个 item 的视频播放为第一个item完全滑过的时候自动播放。如图中所示,第一个和第三个是视频,首先是第一个视频播放,当滑动超过第一个视频的一半高度时候,去播放第三个视频。
乍一听挺简单的,不就是去判断页面滑动的距离以及轮播滑动,然后去播放视频吗?
但是真实做起来的时候可是太麻烦了,首先不说视频自动播放的问题,光是这里的数据处理就已经让人头皮发麻了,首先我们需要的这个 list 数据要深入四五层才能拿到,然后还要做逻辑判断去展示直播,商品,视频三种不同类型,关键是还要做各种兜底,因为下发的数据指不定到哪个字段就出现问题了。
这里简单说下我处理视频播放这里的逻辑吧,我这里是首先去判断当前屏幕中有几个视频,然后用 getBoundingClientRect()
这个 api 去判断视频的位置进而去处理视频播放,至于滑动到下个页面视频播放的话,swiper 组件是提供了相应滑动的 api 的,我们调用相应的 api 就可以了。
简单介绍下 getBoundingClientRect()
let rect = object.getBoundingClientRect()
// 返回值类型
rect.top:元素上边到视窗上边的距离;
rect.right:元素右边到视窗左边的距离;
rect.bottom:元素下边到视窗上边的距离;
rect.left:元素左边到视窗左边的距离;
接下来才是重头戏,这里总结了一些做产业带视频自动播放的心得。
封面问题
video 标签提供了 poster 属性来展示封面图。但是在一些安卓手机下,该属性兼容性太差,各种展示黑屏。而且我们本次的需求是没有封面图可以用的。所以我们这里直接就用视频播放的第一帧来代替封面图啦,但是这样也会有一些问题,加入在ios的省电模式下,因为视频不能自动播放,所以会显示黑屏。
在正常的有封面图提供下,我们这里可以考虑在视频上层添加一个div来嵌入一张图片。可以采用 poster 属性。但是在 android 下,兼容性太差。可以考虑在视频上层加一个div图片。视频播放是显示video,隐藏该图片即可。但是经过测试,发现先隐藏video,再展示 video 时,会出现闪屏现象。这个是因为 video 为 display:none 时,video 是属于未激活状态。当重新设置 display:block 时,video 被激活。因此会出现闪屏。这里建议将视频设置宽度为 1 px。当播放时,可以将封面隐藏,再将视频的 width 设置成100%;
禁止全屏播放,隐藏播放控件
<video id="pageVideo"
x5-playsinline="true" //安卓需要设置的属性
playsinline="true" //ios需要设置的属性
webkit-playsinline="true" //ios10需要设置的属性
preload="auto" //预加载
loop //循环播放
poster="images/shipin.jpg"> //预设未播放封面
<source src="mp4/shipin.mp4" type="video/mp4">
</video>
我们可能去网上找一些解决方案,如何去禁止 video 在移动端全屏播放,隐藏视频的播放控件,这里推荐大家去使用 NutUI 的 video 组件,这些问题都已经帮助大家解决了,大家只需要去关注什么时候去触发视频播放就 ok 了。
还有一个问题就是在 uc 浏览器上,视频是全屏播放的,这个问题我在网上也浏览了很久,找各路大神帮忙解决,最后我找到了这样一则留言。
所以我们这次在处理站外浏览器视频自动播放的时候,判断如果是 uc 浏览器的话,采用降级方案,直接过滤掉视频素材,直接采用商品图素材。大家如果后续有更好的解决方案,可以一起沟通哦。
自动播放 我们这里建议交互设计的时候,是由用户主动触发触屏去播放视频,可以在用户出发滑屏或者 touch 的时候,用 video.play() 自动播放。
然后关于视频在切入后台,在切回来的自动播放逻辑来说,可以使用 visibilitychange
来实现
document.addEventListener('visibilitychange', () => {
if (document.visibilityState == 'hidden') {
this.pageHideHandle();
} else {
setTimeout(() => {
this.pageShowHandle();
}, 2000)
}
});
关于这里使用了 setTimeout 2s延时:在测试中发现,当回到页面后100%会执行 else 但在 IOS 上只是息屏 else 里的 play 事件能执行成功,但如果是点击 home 键或者切换到其他程序则需要加2000延时才可以成功执行播放事件。
visibilitychange
事件是浏览器新添加的一个事件,当浏览器的某个标签页切换到后台,或从后台切换到前台时就会触发该消息,现在主流的浏览器都支持该消息了,例如 Chrome, Firefox, IE10 等。虽然这只是一个简单的功能,但是能够广大的采用 HTML5 的开发者提供方便,比如用户正在浏览时,突然切换到后台去发一条短信或打一个电话,再切换到页面,那么开发者就需要捕捉对这些突发情形进行处理,当页面切换到后台时就暂停视频,从后台切换回来时,又能允许用户继续观看视频。这样我们就实现了用户切出页面,再切进来的时候,视频可以照常播放了。
3. "互动"的多状态展示
多状态弹框
星店长通过做任务的方式,让粉丝为爱豆助力,撬动粉丝经济流量转化,为了更好的提升粉丝的体验,星店长互动模块分为粉丝助力、做任务、抽盲盒、瓜分红包、奖品5大功能。
当接到任务开始梳理的时候,就被震惊到了,星店长的视觉稿共提供了31个页面状态,其中29个是为互动模块提供的。这么多的状态该如何处理。采用暴力方式,每种状态都用各自的弹框展示,多少个状态就多少个弹框。若采用这种方式,页面代码量可想而知。 所以对所有的弹框状态进行了整理分类:
- 瓜分红包:3种弹框,5种状态
- 抽盲盒:5种弹框,8种状态
- 做任务:2种弹框,2种状态
- 奖励记录:2种弹框,4种状态
- 助力分享:2种弹框,8种状态
- 信息提示弹框:1种,5种状态(未登录、未绑定手机号)
说实话,这还是第一次遇到这么多弹框的情况。按照功能进行分类后,将相同功能的弹框放在同一个 dialog 中,并与产品确认不同的后端 code 对应的不同弹框状态,重新定义每个 dialog 的状态值,与后端 code 值进行区分。
按钮多维度状态展示
按钮是互动页面必不可少的元素,不同的按钮状态代表直接影响用户接收到的信息,星店长互动模块的”开关“就靠一个按钮来决定。
这样一个简单的按钮,状态却需要从3个维度进行考虑:
-
整体活动维度(6.1-6.10)
-
互动阶段维度(6.2-6.5)
-
用户参与活动的资格维度(用户是否登录)
星店长上线时间是6.1-6.10,但是互动开启时间只有6.2-6.5,所以6.1互动未开启,6.6互动已结束,并且在互动开启阶段,不同的时间段,爱豆所获取的助力值都会影响”开关“的状态。光是想想脑袋就大的不行。不过经过与产品的不懈努力,终于梳理了下面这张图:
对于开发者来说,最难的任务不是要写多少代码,而是没有思路、思路不清楚。有这样一张图不管是在复杂的逻辑,小伙伴们都可以轻松搞定。
4. 弹幕输出入框,无法直接输入文字
问题描述:页面是可滚动的,页面上有需要输入值的 input 框。当打开键盘后,页面会被顶上去一段距离,当再关闭键盘就可能出现:页面依然是是顶上去的位置。
还有有一种情况:如果 input 存在于吸顶或吸底元素中时,用户点击输入框,输入法弹出后,fiexd 失效,页面中定位好的元素随屏幕滚动。我们可以使用 scrollIntoView 方法,将input输入框显示在可视区域。
// 输入框获得焦点时,元素移动到可视区域
inputOnFocus(e) {
setTimeout(function(){
e.target.scrollIntoView(true);
// true:元素的顶端将和其所在滚动区的可视区域的顶端对齐;
// false:底端对齐。
},200); // 增加延时是因为键盘弹起需要时间
}
我还遇到这种问题就是,页面的 input 获得焦点时,看不到光标,但是打印的时候输入的值是存在的,这可能是光标错位导致的,也可能是在 style 中 -webkit-user-select:none 导致 input 框在 iOS 中无法输入,光标不出现,我们可以增加下面的方法和样式。
user-select: text;
-webkit-user-select: text;
利用 scrollIntoView 使当前元素出现到指定位置,避免光标错位。
e.target.scrollIntoView(true);
e.target.scrollIntoViewIfNeeded();
某些时候在小程序中会出现软键盘弹起后不能自动收回;还有就是软键盘弹起后,若原输入框被遮挡,页面整体将会上移,然而当输入框失焦,软键盘收起后,页面未恢复,导致弹框里的按钮响应区域错位;这2个小问题可以使用focus、blue事件的回调来解决。
setTimeout(() => {
var scrollHeight = document.documentElement.scrollTop || document.body.scrollTop || 0;
window.scrollTo(0, Math.max(scrollHeight - 1, 0));
}, 100);
5. 异形轮播改造,swiper
我们在循环模式 loop = true 时轮播中 slide 增加点击事件,由于只复制页面没有复制点击事件,所以写在 slide 上的点击事件在页面循环一次回来遇到复制的页面时,点击事件就会失效。 我们要使用下面的 api 点击实现。
on: {
click: function() {
}
}
目前 swiper 只支持水平和垂直的轮播,无法满足其他异形的轮播方式,所以我们使用了 swiper 的能力,然后我们动态去改变他的坐标。我们目前需要展示 5 个,所以提前定义一个代表5个元素索引的数组,然后根据当前选中的索引,去获取相邻的几个 slide ,获取 slide 之后动态改成我们设计的坐标数据。
未来规划
我们现在在 NutUI 的基础上,开发一套针对大促活动的 Vue 业务组件库—— NutUI-Cat。可以提升开发效率,节省开发时间,便于其他开发者更好更快的参与到活动的开发中。除此之外,还有脚手架、功能模块和页面模板。下面一一向大家介绍:
脚手架
codmi-cli
是我们前端开发部自研的构建工具,通过使用 codmi-cli
搭建脚手架。
安装
# 1. 安装nrm管理多个源
npm install -g nrm --registry=http://registry.m.jd.com
# 2. 添加京东私源
nrm add jd http://registry.m.jd.com
# 3. 切换到jd源
nrm use jd
# 4. 安装 codmi 脚手架
npm install @jd/codmi-cli -g
创建
选择 Vue2 模板
codmi create testapp
cd testapp
`npm i` or `yarn`
启动项目
npm run start
并且未来脚手架将会涵盖以下功能:
组件
原子设计方法论是由国外设计师 Brad Frost 提出的,即界面是由原子、分子、模块和模板页面共同组成的。
用户界面设计中的原子,是构成界面的基本元素。两个原子即可组成一个分子,以按钮为例:包含了文字和图标。
除此之外,我们还把大促公共的功能模块封装成组件,并发布到 npm 。例如跳转、环境判断和图片优化等,方便大家直接安装使用。并且把将开发过的活动沉淀成页面模板,方便浏览。下面是我们为组件库分类分类与规划:
总结
每年的 618 大促和双 11 等活动,我们一直在开发新的活动页,为了实现之前模板的高效利用,我们将持续打磨 NutUI-Cat ,不断地丰富组件及模板,无论是开发还是产品业务等,当你看到页面模板时,能够很清楚的知道自己所需要的模板及组件,实现快速开发。
期待大家对我们新的组件库提出更好的想法和思路,欢迎大家参与共建。