芯位社区开发记录
官方说:只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。
上一个项目中使用了自定义指令控制按钮权限,这里写一下自定义指令的用法。
芯位社区一期功能开发完毕了。这个项目算是深度参与,负责了其中绝大部分功能。这两天正好工期赶的比较靠前,预计是 15 号开发完成,前天实际上就已经做完提测了。
昨天摸鱼摸了一整天,开开心心的休息。今天闲来无事,写写项目开发笔记。
这个项目从需求评审开始就比较操蛋。项目经理陈鸿宇是新来的,虽然看得出来已经在尽力了解芯位相关的项目结构了,但是用户系统太他妈复杂了,再加上登录,用户数据等等东西之前改来改去的,反正我自己也不了解,新来的项目经理更是头晕目眩。需求评审的时候各种需求逻辑都不通。
项目本身又是比较复杂的社交类,然后他直接受不了辞职跑路了,深得土木老哥的真传。不过跑了也好,毕竟这个项目的产品顶头老大才是最大的傻逼。
这就不得不说这个全公司公认的傻逼了。不知道怎么就爬到了 产品总监的位置。人话是听不懂的,技术是完全不了解的,作为产品总监,公司的产品是一知半解的,设计出来的东西自然也是一坨屎的。
最最恶心的是,项目开工大半个月了,有产品相关的问题找他,一拍脑袋把原型改了一大堆。我操你妈的这一个月你一眼原型都没看过是吧,一期都快收尾了开始改?
总之就是顶着这么个傻逼干完了项目。
吐槽结束,开始技术分析。
项目分用户端和管理端,实际上是两个项目。管理端全部我写,用户端两个前端负责。此外用户端的入口在蜜线,目前没有独立的入口。用户体系使用蜜线的用户,在点击入口按钮的时候,调用接口将用户信息同步到社区。
1.社区公约
就是这种经典的在登陆位置用户查看和同意的社区公约。我一直以为是个弹框组件,公约直接放到项目中,在组件的 template 里面写死。
实际上采用的方法是,先把公约文本做成一个 html 文件,上传到阿里云 OSS 获取文件链接,点击打开链接
window.open(
"https://cdnobs.xinwei-edu.com/resources/xinwei/html/mx/communityPrivacy.html",
"_self"
);
如果要做成弹框的形式,可以放进 iframe 里面.
2.项目结构
除了特殊项目外,目前公司的项目都是使用统一的项目模板。之前的项目总结写过了,这里就没什么好说的了。
3. 话题银河动画
话题银河页面:

动画要求如下:
1.要实现一个话题气泡向上飘的效果,话题气泡在屏幕上随机分布(至少看起来随机均匀)。
2.同时要求有三种话题气泡,数量比例为 1:2:7,
3.页面初始加载时屏幕布满气泡并在 1S 内渐显出现
4.鼠标悬浮在气泡上时所有气泡停止飘动,鼠标移开时继续。
5.页面隐藏时所有气泡停止飘动,返回页面时继续。
为什么我要分成这 5 个任务呢,因为每个任务都需要单独解决。比如动画暂停看似调同一个函数,其实不行。接下来是基本动画效果实现和遇到的问题及解决办法。
1.基本动画效果实现
在最初版本中,漂浮动画的实现使用的是 CSS 动画效果:
@keyframes move {
0% {
top: 110%;
width: 70px;
height: 70px;
font-size: 8px;
}
50% {
top: 50%;
width: 140px;
height: 140px;
font-size: 16px;
}
100% {
top: -10%;
width: 70px;
height: 70px;
font-size: 8px;
}
}
而气泡的位置分布 UI 设计上采用的是一行三个气泡,左右位置随机生成但是不重复的方式,代码上,随机生成位置并不难,基本思路是用一个数组记录上一组生成的气泡的位置,然后用 Random 函数生成新位置,和上一行的位置比对,如果重叠则重新生成。
// 随机生成一个新位置(即气泡的left属性)
// lastLine上一行气泡的位置。thisLine新生成的本行的位置
const getOnePosition = (lastLine: any, thisLine: any) => {
for (let i = 0; i < 10000; i++) {
let lastLineAvailable = true;
let thisLineAvailable = true;
let left = Math.floor(Math.random() * 1060);
for (let j = 0; j < lastLine.length; j++) {
if (left - lastLine[j] < 140 && left - lastLine[j] > -140) {
lastLineAvailable = false;
break;
}
}
for (let j = 0; j < thisLine.length; j++) {
if (left - thisLine[j] < 140 && left - thisLine[j] > -140) {
thisLineAvailable = false;
break;
}
}
if (lastLineAvailable && thisLineAvailable) {
return left;
}
}
};
// 开始动画函数
const startAnimation = () => {
if (isPaused.value) {
return;
}
let lineCount = 3;
// 生成lineCount个位置
for (let i = 0; i < lineCount; i++) {
const left = getOnePosition(lastLinePosition.value, thisLinePosition.value);
// 一万次计算后找不到位置就跳过
if (left === undefined) {
continue;
}
showList.value.push({
value: getOneTopic(),
left: left,
});
thisLinePosition.value.push(left as number);
}
// 重置
lastTime = Date.now();
pauseTime = Date.now();
remainingTime = (710 / homeBoxHeight) * 3600;
lastLinePosition.value = thisLinePosition.value;
thisLinePosition.value = [];
while (showList.value.length > 50) {
showList.value.shift();
}
};
把位置和话题信息放进同一个对象中,然后将对象 push 进 showList,在页面中展示就好
<Topic
:class="['son', isPaused ? 'animation-paused' : '']"
v-for="item in showList"
:key="item"
:style="{ left: item.left + 'px' }"
:is-paused="isPaused"
:topic="item"
@click="clickTopic(item.value)"
@change-paused="changePaused"
/>
这样,基本的随机位置生成就做好了,漂浮的效果也基本实现。
这里产生了一个严重的问题,因为后面的需求里有页面隐藏后动画暂停。使用 CSS 做动画,isPaused 暂停动画会偶发性的无法成功暂停已生成气泡的动画效果。尤其是点击气泡打开新页面时,这种情况更频繁。推测跟浏览器的性能优化有关,在 safiri 浏览器上更为严重,于是改用 JS 控制动画。
在 Topic.vue 组件中,直接设置自身的浮动动画:
let floatStartTime: any; // 动画开始时间
let floatAnimationId: any;
const floatDuration = 30000; // 动画全过程 30秒
let pausedProgress: number | null = null; //暂停时记录动画执行进度
let currentProgress: number | null = null; //当前执行进度
// 动画函数,使用requestAnimationFrame实现
const floatAnimate = (timestamp) => {
// 对于首屏加载的动画,传入delay控制动画跳过的时间
if (!floatStartTime) floatStartTime = timestamp - props.delay * 1000;
// 计算动画当前进度
const elapsed = timestamp - floatStartTime;
let progress = elapsed / floatDuration;
currentProgress = progress; //记录当前执行进度
// 如果本次动画是暂停后重启的,则从暂停进度开始执行动画
if (pausedProgress != null) {
progress = pausedProgress; //载入执行进度
currentProgress = pausedProgress;
// 反求floatStartTime
floatStartTime = timestamp - progress * floatDuration;
pausedProgress = null; // 重置进度记录
}
// 根据动画进度,计算气泡的位置和大小
if (bubbleRef.value?.style?.top !== undefined) {
bubbleRef.value.style.top = 110 - progress * 120 + "%";
bubbleRef.value.style.width =
140 - Math.abs(progress - 0.5) * 2 * 70 + "px";
bubbleRef.value.style.height =
140 - Math.abs(progress - 0.5) * 2 * 70 + "px";
bubbleRef.value.style.fontSize =
16 - Math.abs(progress - 0.5) * 2 * 8 + "px";
}
// 继续动画
if (progress < 1) {
floatAnimationId = requestAnimationFrame(floatAnimate);
}
};
watch(
() => props.isPaused,
(newVal) => {
if (newVal) {
pausedProgress = currentProgress; // 动画暂停,记录当前进度
cancelAnimationFrame(floatAnimationId);
} else {
floatAnimationId = requestAnimationFrame(floatAnimate);
}
}
);
使用 requestAnimationFrame 函数实现动画效果,可以更好更精细的控制动画的执行,并且可以暂停和恢复动画。
2.气泡按照 1:2:7 的比例生成
这个倒是简单,只需要获取话题的时候,使用 Random 按照概率获取 一个 type,然后根据 type 生成气泡就好。 需要注意,三种话题的原始数据是三个数组,要给每个数组一个记录顺序的索引,不然会出现话题重复的问题。
const getOneTopic = () => {
// 按照1:2:7的概率生成随机数
const num = Math.random() * 10;
let topic;
if (num < 1) {
topic = hot30TopicList.value[hotIdx] || {};
Object.assign(topic, { type: 1 });
hotIdx++;
if (hotIdx >= hot30TopicList.value.length) {
hotIdx = 0;
}
} else if (num < 3) {
topic = new50TopicList.value[newIdx] || {};
Object.assign(topic, { type: 2 });
newIdx++;
if (newIdx >= new50TopicList.value.length) {
newIdx = 0;
}
} else {
topic = configTopicList.value[configIdx] || {};
Object.assign(topic, { type: 3 });
configIdx++;
if (configIdx >= configTopicList.value.length) {
configIdx = 0;
}
}
// 如果三个话题中都没有数据,使用pre话题兜底
if (!topic.id) {
topic = preTopicList.value[preIdx] || {};
Object.assign(topic, { type: 3 });
preIdx++;
if (preIdx >= preTopicList.value.length) {
preIdx = 0;
}
}
return topic;
};
3.页面初始加载时屏幕布满气泡并在 1S 内渐显出现
从上面的代码中明显可以看出,showList 数组最开始是空的,所以页面刚开始时是空的,气泡从底部向上漂浮,直到填充完整个屏幕。
目前的需求是,页面初始加载时屏幕布满气泡并在 0.3S 内渐显出现。
实际上并不困难,只要在 onMounted 钩子中,在动画开始前填充 showList 数组就好。但是上浮动画要跳过一部分,具体跳过时长要具体计算,否则所有的气泡都在同一行挤在一起了。
为了方便写渐显动画,给首屏加载的气泡单独放进一个数组 preShowList 中。
const getPreShowList = () => {
const homeBox = document.querySelector(".home-box") as HTMLElement;
homeBoxHeight = homeBox.clientHeight;
topicLineCount = Math.floor(30000 / ((baseHei / homeBoxHeight) * baseTime)); //首屏应加载的气泡行数
const delay = baseAnimationTime / topicLineCount; // 预设动画每一行需要快进的时间
// 获取预设气泡列表
for (let i = 0; i < topicLineCount; i++) {
let lineCount = 3;
// 生成lineCount个位置
for (let j = 0; j < lineCount; j++) {
const left = getOnePosition(lastLinePosition.value, thisLinePosition.value);
// 一万次计算后找不到位置就跳过
if (left === undefined) {
continue;
}
preShowList.value.push({
value: getOneTopic(),
left: left,
delay: Math.floor((delay * (topicLineCount - i) - 1700) / 1000),
});
thisLinePosition.value.push(left as number);
}
lastLinePosition.value = thisLinePosition.value;
thisLinePosition.value = [];
}
};
baseHei 和 baseTime 是设计稿中父盒子的高度,一行气泡出现的时间间隔(大概 3600ms)。
因为 preShowList 是同时生成的,动画也是同时开始,不处理的话所有气泡会同时出现堆积在一起,所以添加 delay 属性.delay 的值是根据预设动画每一行需要快进的时间计算的。在前面写过的动画中,使用 delay 属性快进气泡对应的动画。
<Topic
:class="['son', isPaused ? 'animation-paused' : '', { 'fade-in': isLoaded }]"
v-for="item in preShowList"
:key="item"
:style="{ left: item.left + 'px', opacity: isLoaded ? 1 : 0 }"
:topic="item"
:is-paused="isPaused"
:delay="item.delay"
@click="clickTopic(item.value)"
@change-paused="changePaused"
/>
1S 内渐显出现,所以要添加 CSS 属性transition: opacity 1s ease,然后在 setTimeout 中设置 isLoaded 为 true,用来控制添加 fade-in 类.fade-in { opacity: 1; }
4.动画暂停问题
动画暂停有两种情况:鼠标悬浮在气泡上,离开页面。
首先看鼠标悬浮暂停。因为鼠标悬浮在一个气泡上时,所有气泡动画都要暂停,并暂停生成新的气泡,所以要在父组件中定一个变量,用来控制所有气泡的暂停。鼠标悬停时子组件 emit 事件改变这个变量。
实际上,除了改变变量,还有个更棘手的问题。气泡的生成是 setInterval 控制的,暂停动画清除定时器,重启动画重新设置定时器。这就导致两次生成中间的时间拉长了,反映到视觉效果上,两行气泡之间的间隔变大了。
为了解决这个问题,需要一个变量记录上次生成时间和暂停时间,重启动画时用于补偿时间差。同时需要考虑用户频繁移动鼠标暂停重启动画。
首先看基本的动画暂停: 在 Topic.vue 组件的顶层添加鼠标移入移出事件
<!-- 添加事件 -->
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseOut"
// 触发事件
const emit = defineEmits(["changePaused"]);
const handleMouseEnter = () => {
emit("changePaused", true);
};
const handleMouseOut = () => {
emit("changePaused", false);
};
`
在父组件中监听子组件触发@change-paused="changePaused"
const changePaused = (val: boolean) => {
if (!allowChangePaused.value) return; // 点击跳转前不允许鼠标移动触发动画暂停或重启
if (val) {
stopFloat();
} else {
startFloat();
}
};
在 stopFloat 和 startFloat 函数中,改变 isPaused 的值,并清除或重新设置定时器。
const stopFloat = () => {
if (isPaused.value) return;
isPaused.value = true;
clearInterval(timer);
};
const startFloat = () => {
isPaused.value = false;
clearInterval(timer);
timer = setInterval(startAnimation, (710 / homeBoxHeight) * 3600);
};
将 isPaused.value 传入 Topic,在 Topic 组件中使用控制动画的暂停。这里可以看一下上面动画效果实现的代码。在 Topic 中监听 props.isPaused
watch(
() => props.isPaused,
(newVal) => {
if (newVal) {
pausedProgress = currentProgress; // 动画暂停,记录当前进度
cancelAnimationFrame(floatAnimationId);
} else {
floatAnimationId = requestAnimationFrame(floatAnimate);
}
}
);
这样,全流程就完成了:
鼠标悬浮 --> 子组件 emit 事件 --> 父组件改变 isPaused 值 --> 将值传入子组件 --> 子组件监听 isPaused 变化,暂停或重启动画。
暂停后重启,气泡间距过大的问题怎么解决呢?如果每次暂停 clearInterval,重新 setInterval,那么下一次生成气泡的时间间隔就会变大,气泡间距也会变大。假如每 5S 生成一行,上一行气泡生成 4S 后暂停,然后重启动画,5S 后生成下一行,这两行之间的间距就变成了 9S。如果用户反复暂停重启动画,下一行则一直都不会生成。
为了解决这个问题,需要一个变量记录距离下次动画生成还有多久。 动画重启时,使用
setTimeout(()=>{
setInterval()
})
这种方法补充时间差,并在补充时间差后正常启动动画
let lastTime: any = Date.now(); //上次气泡生成的时间戳,或上次动画重启时间
let pauseTime: any; //暂停时的时间戳
let outTimer: any; //补充动画定时器
let remainingTime: any; //剩余时间
const stopFloat = () => {
if (isPaused.value) return;
isPaused.value = true;
// 如果当前没有待生成的话题
pauseTime = Date.now(); //记录暂停时间
const fullTime = (710 / homeBoxHeight) * 3600; // 动画完整周期时间
if (!remainingTime) remainingTime = fullTime;
// 剩余时间应该是完成周期 - 每次重启到暂停的间隔(即动画持续时间)
remainingTime = remainingTime - (pauseTime - lastTime);
clearInterval(timer);
clearTimeout(outTimer);
};
const startFloat = () => {
isPaused.value = false;
// 填补上一次耽误的生成
// 更新动画启动时间
lastTime = Date.now();
clearTimeout(outTimer);
outTimer = setTimeout(() => {
startAnimation();
clearInterval(timer);
timer = setInterval(startAnimation, (710 / homeBoxHeight) * 3600);
}, remainingTime);
};
