不知不觉已经是第四次接手负责每年的大型 H5 活动,这也意味着 4 年啦啊啊啊啊,哎时间过得真是太快,也是应该做一点总结了。
每年都会有大型的 H5 项目上线,这一些项目的逻辑在一般的情况下,它们的差别不会很大,但是每一次都会有不同的样式、条件和玩法。如果每一次大活动都是写死逻辑且不可复用,下一次 H5 项目过来又再写一次其实就是很没有必要的事情。
如果能把这一些做过的组件做成通用可配置的,即插即用。那么肯定是会极大的提高开发效率,同时项目的稳定性也有保证。只不过组件的代码逻辑就会比较复杂,开发难度会比较高,就单单组件内的一个按钮就需要考虑到这个按钮的颜色,大小,按钮内的字体各种样式和背景色以及这按钮是不是设计状态变化,若有还要考虑这一个状态变化的逻辑或者是一些联动的可能。所以,一个通用组件需要考虑和实现的逻辑就很多。
举个例子,比如今年的 H5 有一个 “我的奖品” 模块 ( 页面 or 弹窗 ),这个模块里面有的奖品的信息展示、时间的展示、数量的展示以及底部还有一些其他的按钮。以下是我截取两次不同的活动的“我的奖品”列表展示模块。
这两种样式的组件,大致的框架上都是一样的,点击侧边栏的 “我的奖励” 和 “我的背包”,只是展现形式和展示数据类型以及按钮的点击事件是不一样的。
所以,如果第三次 H5 ,或者以后的 H5 都写一遍这样的东西意义不大。所以,这样使用频率比较高的模块,就必须和业务方讨论。我们可以把这样的模块定出一个基本的交互和原型,统一做成一个通用列表展示组件,这个组件必须支持通用样式的展示,也需要支持特殊的样式展示,例如下面的情况
最左边的也就是正常的列表样式,右边的就是一些特殊的卡片和文字样式,所以,一个组件需要考虑的东西有很多。
这里叫列表展示组件而不叫“我的奖品组件”的原因是:我们只需要通过参数控制它需要展示什么的内容、标题是什么、按钮名称是什么、点击之后的逻辑是什么,而不是只局限于我的奖品列表,它也可以用于其他数据的展示。同时,这样的通用组件可以适用于各种 H5 。组件拿来即用或者用于一些 H5 自动生成的平台,只要根据文档传参数就可以了。
通过 config 控制具体的展示名字还有按钮id的标记区分事件,data 初始化组件的列表,与业务区分开来这里就变成了一个很纯粹的列表展示组件,可以展示任意的数据,只要按照格式传参就行。这里只是写一个很简单的 DEMO ,后面会提到入参和函数绑定。
拿到设计稿之后找出通用的模块,再根据类似模块之间的差异定出一个通用的规则。下面是本次大型 H5 的设计稿总览:
第一大类:分组赛,资格赛,弹窗,规则和投票等
第二大类:冲刺赛,总决赛,PK模块等
此次大型 H5 的分为了几个大阶段,分组赛,资格赛,冲刺赛和总决赛。看上去是非常多内容的,所以需要找出相似的模块,再和业务侧沟通从这几个赛段来看,可以抽离成组件的是
这里就拿一部分的组件描述一下实现思路,全部写的话就太多了,而且有些地方逻辑实现上也是比较像的。
一个通用组件所需要的配置参数一般归纳为几种,最重要的是这个组件的所有需要使用的值,也就是这个初始化参数。其次,是这个组件的一些样式配置或者是全局参数辅助使用,还有一些情况需要特定的属于这个组件的 key 。当然,不是说样式和全局参等等是不重要的参数,而是根据业务的需求来定,可能样式的参数才是重点这个也是可能的,具体的还得从业务或者这个组件本身的性质考虑,只是在做组件的时候优先考虑功能的实现。以下是我封装通用组件的一个习惯,分别绑定的参数是 data, styleForm, commonStyle, global, componentKey。以下是一个组件绑定参数和方法的例子:
<template>
<head-section
:data="headData"
:global="global"
:styleForm="headConfig"
:commonStyle="headCommonStyle"
:componentKey="headComponentKey"
@methods="headMethods"
/>
</template>
<script>
export default {
data(){
return {
// 全局配置
global: {},
// 顶部组件配置
headData: {},
headConfig: {},
headCommonStyle: {},
headComponentKey: {},
}
}
}
</script>
data 是这个组件传入的初始化参数或者是渲染组件的所有数据,类型是 Object 。组件可以用初始化参数通过 ajax 获取数据也可以通过 class 执行初始化逻辑或者是直接将数据绑定在这个 data 中。
<script>
// 组件
export default {
props: {
data: {
type: Object,
default: () => ({
// list:[], example
// total: 10 example
}),
},
}
</script>
styleForm 这个是组件的配置信息,比如这个组件的一些背景、样式信息以及一些固定的数据不会发生变化的数据。数据格式类型是 Object 。
<script>
// 组件
export default {
props: {
styleForm: {
type: Object,
default: () => ({
// styles: {}, example
// bg: './images/xx.png' example
}),
},
}
</script>
commonForm 这个参数是通用的样式配置,比如,控制这个组件的宽、高以及背景色等等。这个我们在自己在独立开发的 H5 的时候,会按照这样的参数格式配置。目的是让组件更加通用,适用于不同的地方,比如一些 H5 的自动生成平台。
因为,在互联网大厂里 H5 的开发如果是比较简单的页面,是不会单独用人力去开发的,而是通过平台配置生成 H5。我们需要做的就是提供各种各样的组件,让业务同学去配置使用。所以,平台的配置是以每个功能模块划分,commonForm 可以接入他们的平台的接口数值,直接在平台上控制这个组件的宽、高、是否居中等等的基础样式。
<script>
// 组件
export default {
props: {
commonStyle: {
type: Object,
default: () => ({
// width: 300, example
// height: 20 example
}),
},
}
</script>
全局属性指的是这个项目的唯一标记,适用于项目中的任意一个地方。比如说这个项目的 id ,他可能在做上报操作或者在请求接口的时候需要带上这个参数。那么就由 global 这个参数统一接收。类型也是 Object 。
<script>
// 组件
export default {
props: {
global: {
type: Object,
default: () => ({
// page_id: 111, example
}),
},
}
</script>
componentKey 是组件的标记,主要用于在做区分组件的时候,使用比如上报数据。同时也可以用于一些非常特定的逻辑,提供临时的解决方法。举个非常简单例子:业务方需要画 10 个圆且背景都是白色,突然间提出要在第 9 个圆中某个位置加上一个黑色的点,其他不变。
这样既不合理也不通用还砍不掉的需求,临时的解决方法就是通过 key 写一个 if else ,之后再说。
<script>
// 组件
export default {
props: {
componentKey: {
type: [String,Number],
default: 1, // example
},
}
</script>
在组件内通过输出按钮 id 或者事件类型,由上一层组件进行执行特定逻辑,这样的好处是通用的样式和 DOM 与 JavaScript 分离,不含有业务逻辑一下次也可以直接复用这个组件,不需要再去改。
组件
<template>
<div class="head-section" style="padding: 0px 0px">
<div
class="lottery-btn"
@click="onClickBtn('lottery', 'normal')"
></div>
<div
class="nav-btn rule-btn"
@click="onClickBtn('rule', 'page')"
></div>
</div>
</template>
<script>
export default {
methods: {
onClickBtn(id, type = 'page', eventParams = {}) {
this.$emit('methods',{
id: type,
value: eventParams
});
},
},
}
</script>
父级组件
<script>
export default {
methods: {
headMethods($Event) {
const { id, value } = $Event;
const page = (params) => {
this.goPage();
};
const anchor = (params) => {
this.goAnchor();
};
const clickEventMap = {
'page': page,
'anchor': anchor,
};
// 区分不同事件类型 传参
clickEventMap[id](value);
},
goAnchor(params) {
// ...
},
goPage(params) {
// ...
}
},
}
</script>
首先从功能上看,这个组件只能适用于独立开发的 H5 ,它不适合 H5 生成平台。或者说这样的组件在 H5 生成平台完全没有意义。因为,左边的 icon 和右边按钮列表,他们在 H5 生成平台里面,这些按钮都是靠使用者自己手动配置的地方。例如:
左边的 icon 就是使用一个按钮组件拖拽进去,再加上一个跳转事件。
右边的 btn-list 可以看成是 3 个独立按钮,也和上面一样用一个按钮组件拖拽进去,加上一个跳转事件,再连续配置 3 次。
但是,这一次是独立开发,所以只能按照可复用定制模版的思路来实现。需要考虑的地方是:
如图:对应的每个模块使用一个 ID 作为区分,其中 btn-list 包含以 btn-x 为唯一的标记,内容就是控制这个按钮的背景,显隐和文案。
之后再通过 headData 来渲染 btn-list ,数据格式为:
<script>
export default {
data() {
return {
headData:[
{
id: 'btn-1',
value: {
url: '...'
}
},
{
id: 'btn-2',
value: {
url: '...'
}
}
....
]
}
},
}
</script>
它的核心思想就是通过 ID 关联数据,通过 ID 关联配置。这有点像是数据库里面的主键,可以根据这个主键可以查询或者关联查找其他的数据表。
写一个通用的方法,在后面如果有新增的按钮,可以直接通过传参 (第几个按钮) 控制按钮的位置。
@function head-nav-btn-top($number) {
$top: 15;
$boxHeight: 46;
@if($number == 1){
@return 385;
}
@return 385 + (($top + $boxHeight) * ($number - 1));
}
// .class
top: remit(head-nav-btn-top(1));
top: remit(head-nav-btn-top(2));
top: remit(head-nav-btn-top(3));
锚点跳转到参数指定位置
headMethods($Event){
const { id, value } = $Event;
if (id === 'lottery-btn') {
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
window.scrollTo(scrollTop, this.$refs[value].offsetTop);
}
},
倒计时组件在逻辑上是比较简单的,更多需要考虑倒计时的展示样式,因为在 H5 里面,每种设计的风格或者业务需求不一样,不可能一直沿用一种样式的倒计时,比如这样
所以需要考虑的点是:
在计算倒计时的方法上有两种,第一种是获取本地手机时间再写一个 inteval 函数递减计算,第二种是使用 interval 每秒都向服务器进行时间获取计算出剩余时间。
这里我一般会选择后面那种,因为,首先本地时间不一定是正确的,也有可能是人为的去修改了系统的时间,其次,本地的 interval 延迟时间不一定准确。比如我们设定 1000 毫秒执行,但是由于部分手机本身的原因,这里可能存在着误差,设定的是 1000 毫秒,而在实际的执行中,它相当于 800 毫秒。那么就会导致一个问题,本地的时间越算,误差越大,如果在页面中的时间停留较短那问题不大,但是如果在页面的停留时间很长,到了最后看上就是一个大大的 BUG 。所以,每次都读取服务器时间是比较靠谱的。
实现要点:
<template>
<div class="countdown-section">
<div
v-if="styleForm.type === 'normal'"
:style="[{ 'background-image': `url(${styleForm.bgUrl})` }]"
:class="['countdown-section-bg', `width-${styleForm.bgWidth}`]"
>
<div class="countdown-content">
<p class="time-front" v-text="styleForm.timeFront"></p>
<p class="time" v-text="countTime"></p>
<p class="time-end" v-text="styleFrom.timeEnd"></p>
</div>
</div>
</div>
</template>
<script>
export default {
methods: {
countdown() {
...
this.timeStr = {} //data
this.format = ['hours', 'minutes','seconds'] // props
for(let i = 0 ;i < format.length; i++ ){
this.timeStr = this.time[format[i]];
}
},
},
}
</script>
节点样式方面:让 countdown-content 的内容居中,倒计时前后可以配置任意文案,再给 clase="time" 加上一个宽度,这样的好处是避免了在数字变化的时,因倒计时数字切换发生的抖动而影响到了整个倒计时文案的抖动问题。另一个是,在倒计时外层再包一层 v-if 样式,这个是来拓展倒计时多种样式的功能。
逻辑方面:传入一个时间格式的配置项,比如是否需要展示天数或者秒数,使用一个循环指定数据更新。最后的时间由 computed 计算属性将 day, hours minutes , seconds 计算出来。同时,倒计时为零的时候支持配置一个方法,例如,最常见的操作就是刷新当前页面或者是执行跳转。
进度条组件和倒计时组件一样,属于逻辑比较简单而比较注重样式上的一些配置。进度条组件需要考虑的点是:
<template>
<div class="progress-content">
<div class="progress">
<div class="progress-line"
:style="{ width: `${currentProgress}%`, backgroundImage: `linear-gradient(
to right,
${styles.lineStyle.begin},
${styles.lineStyle.end}
)`}"></div>
<div class="progress-state">
<div v-for="(item, index) in styles.list"
:key="`${index}buttom`"
:style="[{ 'background-image': `url(${+index <= +current ? styles.dot.high: styles.dot.normal})` }]"
class="state"
>
<div v-if="item.topText" :style="styles.top[index]" :class="['top']" v-text="item.topText"></div>
<div v-if="item.bottomText" :style="styles.bottom[index]" class="buttom" v-text="item.bottomText"></div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
methods: {
},
}
</script>
首先,通过 props 进来的 styles参数, 获取到这个进度条的颜色,为了进度条能有更多的颜色配置,就是用渐变色来配置,只要传入一个开始和一个结束的色值。
节点的的样式和文案全部通过数组渲染,来达到通用配置的目,以下是我截取简易的配置数据
const progress = {
top: [
{
color: '#f5ddff',
},
{
color: '#d6a5ea',
},
....
],
bottom: [
{
color: '#d6a5ea',
},
{
color: '#d6a5ea',
},
....
],
dot: {
high: '',
normal: '',
},
list: [
{
topText: 'Switch',
bottomText: '0',
hidden: false
},
{
topText: '2部',
bottomText: '10000',
hidden: false
},
...
],
lineStyle:{
begin:'rgba(255, 166, 248, 1)',
end:'rgba(255, 58, 210, 1)'
}
};
吸底部组件和顶部组件一样,它不适用于 H5 自动生成平台。吸底组件和顶部组件它们更像是一个容器,在这个容器里面配置其他的组件,所以这里还是做成一个可复用的定制模版。需要考虑的一些点:
他们的数据格式是:
<script>
export default {
data() {
return {
// 数据源绑定
suspensionData: {
userSection: {
name: 'xxxx',
url: '....png',
},
textSection: [
{
'text': `已贡献助力票数: 600`
},
{
'text': `剩余助力票数: 400`
}
],
},
// 数据配置样式设定
suspensionConfig: {
bg: '....png',
btn: [
{
'id': 'get',
'url': '....png',
'text': '获取助力票'
},
{
'id': 'exchange',
'url': '....png',
'text': '兑换助力票'
}
]
},
},
},
}
</script>
中间的文字展示和右边的按钮使用 Array 的形式渲染
<template>
...
<div
v-if="btn && btn.length > 0"
:class="['item-right, `length-${btn.length}`']"
>
...
</div>
</template>
<style lang="scss" scoped>
...
.btn{
...
&.length-2{
justify-content: space-between;
}
&.length-1{
justify-content: space-evenly;
}
}
</style>
在样式上线配置好 length-x 情况下是居中还是均分的样式。中间的文字也是同样的方法,只是这里就多了一些细节的考虑,比如:字体容器的溢出处理和行间距的一些设定。
头像和昵称按理也可以做一些设定,但是这里根据实际的需求来说没有必要,所以这里就直接固定下来。
这个 H5 的投票功能相对简单,只有一个增加/减少和最大值。
在做这个组件之前,我其实更想把它做成这样的形式。如图:
它可展示图片,还可以展示选择票的类型,同时下面还可以配置拓展按钮也可以绑定执行事件,看上去非常的好。但是后来想了一下,还是觉得这样投票组件的逻辑会有点冗余,既然是一个投票组件应该不就有其他的东西。
所以我也在原来的基础上结合这个组件多加了一投票的类型选择。就是这样:
这样看上去逻辑简单,而且也确实多了是一个实用的功能。所以,这个组件需要考虑的点是:
<template>
...
<!-- 票数编辑区域 -->
<div class="ticket-section">
<div class="ticket-edit">
<input class="ticket-text" v-model="ticketInfo['count']"/>
<div :class="['ticket-add']" @click="ticketAdd()" >
<p class="add">+</p>
</div>
<div :class="['ticket-min']" @click="ticketMins">
<p class="min">-</p>
</div>
</div>
<div :class="['ticket-max', 'allow']">
<p class="max" @click="ticketAdd(true)">MAX</p>
</div>
</div>
<!-- 类型选择区域 -->
<div class="ticket-type-section">
<div v-if="typeList.length > 0" class="ticket-type-content">
<div v-for="(item,index) in typeList" :key="`type${index}`"
class="ticket-type-item">
<div :class="['box',item.active? 'active': '']"></div>
<div v-text="item.text"></div>
</div>
</div>
</div>
...
</template>
首先用一个数组渲染类型列表,编辑区域票数区域比较重要的的就是做好数字上的校验和统一管理检验失败的提示文案。
const tipsMap = {
error: '亲!剩余助力票不足,请重新输入!',
success: '助力成功!',
errorNum: '必须是一个数值,注意不能有空格',
errorMax: '亲,剩余助票不足,请重新输入',
errorZero: '亲,剩余助票不足,请去获取哦!',
};
const validCount = (num) => {
this.$set(this, 'showTips', false);
const regExp = /^\+?[1-9][0-9]*$/g;
if (+this.ticketInfo.count === 0) {
return false
}
if (!regExp.test(this.ticketInfo.count)) {
this.toast(tipsMap['errorNum'])
return false;
};
if (+this.ticketInfo.count > +this.ticketInfo.left){
this.toast(tipsMap['errorMax'])
return false;
};
// 检验通过
return true;
}
},
validCount(1000);
排行组件是这一个活动逻辑最复杂的一个,他除了需要支持到这个活动展示的列表数据,也需要支持到以后其他 H5 的数据展示,也就是支持拓展。
比如:在这个排行榜中,第一列是一个头像列表类型,第二列是一个文字类型,第三列也是一个头像类型,第四列是一个按钮类型。那么,在组件初始化的时候通过 config 配置定义好每一列的类型和样式。如图:
const rankConfig = {
init: [
{
type: 'headList',
key: 'head',
name: '超能',
tips: 'live',
style: {
width: '25%',
color: '#ffffff',
background: '#c69494',
}
},
{
type: 'text',
key: 'score',
name: '总助力值',
style: {
width: '25%',
color: '#ffffff',
background: '#e53de7',
}
},
....
]
};
这一段是部分配置
从功能上看他需要支持:
标题部分主要代码:
<template>
...
<div v-for="(item, index) in styles.init"
:style="item.style"
:class="['column-item', 'column-type']"
:key="`${index}column`">
<div class="item-title">
<p class="title-text" v-text="item.name"></p>
<slot class="title-tips" :name="`sub-${item.key}`"></slot>
</div>
</div>
</template>
使用配置项循环列出列表的标题,里面有个 icon 的提示图标,使用插槽的方式插入,这里需要用 sub-${item.key} 作为一个区分,需要显示 tips icon 的标题才展示。这里只能用 key 作为区分不能用 type,原因是一个列表里面是有可能有相同的 type 列的。
列表的渲染,这里需要各种类型的展示再抽离成一个小组件,比如将 text ,headList 等等抽离成一个小组件,需要的时候再引用。这样做的好处出逻辑分开容易维护,小组件容易拓展,排行榜的代码也不会过多,如图:
他的核心代码如下:
<template>
...
<div
class="column"
v-for="(item, index) in info.list"
:key="`${index}rankList`"
>
<div
v-for="(styleItem, styleIndex) in styles.init"
:key="`${styleIndex}rankConfig`"
:class="'column-item'"
>
<HeadList
v-if="styleItem.type === 'headList'"
@methods="onClickEvent(item.key, item)"
><HeadList>
<Text v-if="styleItem.type === 'text'"></Text>
<ListBtn v-if="styleItem.type === 'btn'"></ListBtn>
</div>
</div>
</template>
第一层循环遍历所有列表数据,第二层循环遍历配置表,根据类型渲染具体内容,之后每一个块的内容都通过小组件形式引入。
以上就是这个 H5 组件化开发的部分核心逻辑和思考。
emm,打算找一期把这几个大的 H5 截图贴出来,就作为一个回忆了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。