第七集: 从零开始实现 (懒加载 v -lazy) 与 ’ 骨架屏模板 ’ 组件
本集定位 :
第一部分: 骨架屏模板
第二部分: 图片的懒加载组件
- 问什么说是 ’ 骨架屏模板 ’, 上一集我有过一些思考, 总的来说, 骨架屏在 pc 端毕竟只是一个缓冲手段, 没必要为他消耗太多, 什么 100% 还原之类的, 本套 ui 并没有这方面的追求, 只是做到尽可能的优化即可, 所以本 ui 只是提供模板, 简单使用即可, 更多精力放在业务上.
- 其实本集最主要的是第二部分, 懒加载现在已经是项目不可或缺的优化了, 而对于这种有大部分实现方案的技术有必要自己写一份么? 答案是非常有必要, 就拿本人来说, 通过这个组件的书写发现了网上大部分人的做法完完全全是错的, 可能他们都是 copy 的某个人的????, 通过对其的书写能增强对 dom 元素的更深理解, 而且可以由此组件推导出更多工程上可用的优化方案, 归根结底我们都是爱学习的仔! 知其然当然也要知其所以然, 那么这次就让我们一起来探索这两个组件吧????.
一: 骨架屏模板
中心思想就是做出几个样子的模板, 然后用户每个页面选个模板就行, 那么需要做的就是这个模板的 dom 尽可能的少, 还有就是要有流光划过的效果, 以及渐隐的动画, 出现没必要有动画.
第一步: 画横线
一条一条的条纹, 如图所示.
初学者可能会使用循环 div 生成条纹, 而工作过的人都有体会, dom 是很吃性能的, 这里选择 box-shadow 属性, 不了解这个属性有多神奇的同学, 可以去看张鑫旭的 css 世界这个本书.
第二步: 画公共的图形
比如圆形, 方形 这里最开始使用的伪类来做, 但是很不方便动态的配置各种属性, 可能会导致组件的可扩展性降低很多, 所以最后没有选择使用伪类.
第三步: 画金属光泽
这个本来我的想法是, 三个元素重叠, 第一个元素为底色, 第二个元素在左侧, 一点点变宽, 第三个元素在右侧一点点变窄, 反复重复就会出现条形的漏出第一个元素, 但是这个方案在性能上并不高, 而且能做到的事情很多但都不适合这套组件, 最后否决了这个想法.
现在采用的是一个 dom 元素, 从左下角倾斜 45°的飞向右上角
具体动效, 可去观看我的个人网站
个人网站
奉上代码
这里有个很有趣的 bug, 就是:style 里面没法使用 {} 的形式定义 box-shadow 这个属性, 所以只能选择行间的形式.
<template>
<transition name="leave">
<div class="cc-ske">
<div class="cc-ske__box">
<div class="cc-ske__base"
:style="`box-shadow: ${myShadow}; height: ${height}px;`">
<!-- 模式一: 单圆 -->
<template v-if="type === 1">
<div class="cc-ske__round" />
</template>
<!-- 模式二: 多圆 -->
<template v-else-if="type === 2">
<div class="cc-ske__round"
v-for="i in distanceList"
:key='i'
:style="{top:i}" />
</template>
<!-- 模式三: 表格 -->
<template v-else-if="type === 3">
<div class="cc-ske__rec--big" />
</template>
<!-- 模式四: 复杂表格 -->
<template v-else-if="type === 4">
<div class="cc-ske__round"
v-for="i in distanceList"
:key='i'
:style="{left:i}" />
<div class="cc-ske__rec" />
</template>
</div>
</div>
<div class="across" />
</div>
</transition>
</template>
props: {
type: { // 允许用户选择模式, 也就是样子
type: Number,
default: 1
},
height: { // 灰色条纹的高度, 因为有的用户可能需要很细的条纹
type: Number,
default: 30
}
},
由于条纹可配置, 所以他的 box-shaow 属性就是动态生成的
initClass() {
let myShadow = "";
for (let i = 0; i < 30; i++) {let h = (this.height + 20) * i;
// 每次生成一组属性
myShadow += `0px ${h}px 0 0 #F6F6F6,`;
}
// 去掉 ','
this.myShadow = myShadow.slice(0, -1) + ";";
}
比如说模式 4 需要多个圆形, 那就做一个圆, 给这个圆 shadow 属性
distanceList() {
let n = this.n,
result = [];
while (n--) {result.unshift(n * 180 + "px");
}
return result;
}
具体的 css 代码
vue-cc-ui/src/style/Ske.scss
@import './common/var.scss';
@import './common/mixin.scss';
@import './common/extend.scss';
// 父级只负责被色的底与定位
@include b(ske) {
background-color: white;
@include position(fixed);
@include e(box) {
// 里面为了与父级有一定的间隙
overflow: hidden;
@include position(absolute, 30px);
}
@include e(base) {
background-color: #F6F6F6;
width: 100%;
z-index: -1; // 为了伪类能够被挡住
}
@include e(round) {
display: flex;
position: absolute;
align-items: center;
justify-content: center;
background-color: white;
left: 0px;
width: 180px;
height: 180px;
&::after {
content: '';
position: absolute;
background-color: #F6F6F6;
width: 70%;
height: 70%;
border-radius: 50%;
}
}
@include e(rec) {
position: absolute;
background-color: #F6F6F6;
left: 0px;
bottom: 0;
right: 0px;
height: 300px;
@at-root {@include m(big) {
position: absolute;
background-color: #F6F6F6;
border-right: 20px solid white;
top: 0px;
left: 0px;
width: 260px;
height: 100%;
}
}
}
.across {
// 透明的白色, 惊艳了
background-color: white;
animation: pass 2s infinite linear;
width: 30px;
opacity: 0.8;
height: 2000px;
}
}
@keyframes pass {
0% {transform: rotate(-45deg) translate(0px);
}
100% {transform: rotate(-45deg) translate(2000px);
}
}
模式 2,3,4 的展示
二. 重头戏 ’ 懒加载 ’
这个组件与其他的组件不同, 他只有指令的形式, 没有 dom 当然也就没有 style 一说,
他的调用必须要 use 而不能 统一调用, 因为他要传入配置项.
从怎么配置他入手
time: 图片出现多久开始加载;
error: 加载失败时的图片
loadingImg: 加载时的图片
有了这些配置我们才能够把这个组件做出来
Vue.use(lazy, {
time: 200,
error:'xxx.png',
loadingImg:'xxx.png'
});
还是来样子, 结构还是与之前的一个套路
vue-cc-ui/src/components/Lazy/main/lazy.js
很简易的搭建个壳子
分析: 这里面肯定要储存下有指令的函数, 这就肯定需要闭包, 涉及到事件绑定与检测 dom 距离 body 距离等等的方法函数, 所以很适合以类的形式去做, 语义化好, 符合设计模式.
// 先把架构搭好
class Lazy {
// 接收传过来的参数
install(Vue, options) {
this.vm = Vue;
this.list = new Set(); // 容纳所有被指令绑定的元素
this.timeEl = ''; // 延时器的实例 id 载体
this.error = options.error;
this.time = options.time;
this.loadingImg = options.loadingImg;
this.initDirective();
this.initScroll();}
// 当然在初始化的阶段要设置这个全局指令;
initDirective() {
this.vm.directive('lazy', {
// 指令怎么使用或是参数的意义不懂的同学可以去官网查阅, 很详细.
bind: (el, data) => {
// 如果用户配了加载图片, 那么统一改成加载状态
if(this.loadingImg){
// 涉及到属性的修改个人比较喜欢 setAttribute 而不是直接赋值, 更语义化.
el.setAttribute('src', this.loadingImg);
}
// 把绑定事件的 dom 放到组里面
// vue-lazy 源码里面是把 value 放在属性上, 而我这里是分开放的
this.list.add({oImg: el, path: data.value});
}
});
}
initScroll() {
// 不管怎么样, 默认先把 body 监控起来把
// 先触发一次, 第一屏
this.whetherHandle();
// 默认情况下只是绑定监控 body 的滚动, 这里面别忘了 bind 一下, 不然 this 会改变
window.addEventListener('scroll', this.whetherHandle.bind(this), false);
}
// 具体的渲染相关在这里做
whetherHandle(){}
}
export default new Lazy(); // 不传参的话这个 () 可以省略;
什么时候出发加载, 加载什么样的 img?
whetherHandle 函数的完善
// 并不是每次滚动都判断加载图片, 而是滚动停止后.
// 图片在规定时间内一直出现在用户眼前才加载.
clearTimeout(this.timeEl);
this.timeEl = setTimeout(() => {
// 具体的执行我放在下一个函数里面, 为了单一职责
this.handleScroll();}, this.time);
handleScroll
挑出真正还在叫在中的元素, 进行下一步操作;
handleScroll() {
// 要循环遍历我们绑定 lazy 的元素
for (let item of this.list) {
// 判断是不是加载中
if (this.isNoLoading(item.oImg)) {
// 只要不是加载中, 统统剔除出 Set.
this.list.delete(item);
} else {
// 只有还是 loading 中的元素才会进行真正的判断
this.handleSrc(item);
}
}
}
// 工具类, 判断是不是 loading 状态
isNoLoading(item){if(!item)return false
if(item && item.src === this.loadingImg) return false
return true
}
handleSrc
思路:
- 取得当前 body 的滚动偏移量, 与高度宽度;
- 取得目标元素距离 body 的距离 ( 这个很重要, 网上基本上大部分做的都不对 );
- 计算当前元素是否出现在视口上
- 赋值 src
// 处理该不该显示的问题
// 这里涉及的比较多, 先看我的思路, 然后再逐一解释每个工具类方法
handleSrc(item) {let { oImg, path} = item,
{top: top1, left: left1} = getHTMLScroll(oImg),
{top: top2, left: left2} = getScrollOffset(),
{width, height} = getViewportSize(),
// 漏出一半就开始加载他
height2 = oImg.offsetHeight / 2,
width2 = oImg.offsetWidth / 2;
if (top1 - top2 + height2 > 0 && top1 - top2 + height2 < height) {if (left1 - left2 + width2 > 0 && left1 - left2 + width2 < width) {oImg.onerror = ()=>{oImg.setAttribute('src', this.error);
}
oImg.setAttribute('src', path);
}
}
}
utils 里面的家庭成员
getScrollOffset: 获取 body 的上下左右滚动距离. 兼容性很好.
function getScrollOffset() {if (window.pageXOffset) {
return {
left: window.pageXOffset,
top: window.pageYOffset
};
} else {
// 问题: 为什么要相加
// 因为这两个属性只有一个有用, 另一个肯定是 0, 索性直接相加
return {
left: document.body.scrollLeft + document.documentElement.scrollLeft,
top: document.body.scrollTop + document.documentElement.scrollTop
};
}
}
getViewportSize: 获取视口的宽高
兼容是不是 ’ 怪异模式 ’
‘ 怪异模式 ’ 这个知识点有兴趣可以去查查
function getViewportSize() {if (window.innerHeight) {
return {
width: window.innerWidth,
height: window.innerHeight
};
} else {if (document.compatMode === 'BackCompat') {
return {
width: document.body.clientWidth,
height: document.body.clientHeight
};
} else {
return {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight
};
}
}
}
重头戏!!
getHTMLScroll: 获取元素到 body 的距离
网上有很多这方面的文章, 大多只是用 offsetParent 与 offsetTop 这两种属性来做的, 可以这么说他们都错了!!
想要知道为什么错以及有什么坑我们逐一探索.
- offsetParent 是什么?
与当前元素最近的经过定位 (position 不等于 static) 的父级元素
也就是说, 他并不是元素的父级, 而是第一个有定位的父级, 有个大坑下面会讲. - offsetTop 与 offsetLeft
如果父元素不是 body 元素且设置了 position 属性时,offsetLeft 为元素边框外侧到父元素边框内侧的距离
坑点:
- 看起来貌似很完美, 一个找最近的定位父级 a, 一个获取这个元素到 a 的距离, 但是他们全都忽略了滚动!! 比如你这个元素本来不在 ’ 视野 ’ 内, 但是他的父级滚动的时候把它暴露了出来, 那他也算是出现在视口内, 上面的方式就完全做不到这一点了, 而且还要顾及到多层父级都有滚动属性, 有的父级有滚动没定位, 有的有滚动有定位
- 这个 offsetParent 的一个坑, 很少有人兼容他, 说明囫囵吞枣得人很多, 在元素定位为 fixed 的时候, 浏览器会把它脱离定位, 也就是他是没有父级这一说的, 所以他的 offsetParent 是个 null, 也就是永远找不到 body 身上.
具体实现代码如下
export function getHTMLScroll(node) {if (!node) return;// 啥也没传就别玩了
let result = {top: 0, left: 0},
parent = node.offsetParent||node.parentNode,// 获取第一个定位元素, 防止 img 本身就是 fiexd 定位元素
children = node; // 记录下子集
let task = son => {
// 真正获取的袁术是父级, 而不是定位父级 !!!
let dom = son.parentNode;
if (!dom) return; // 没有就别玩了
// 这里是关键 --- 当本次获取的父级是第一个定位父级时
if (parent === dom) {
// 拿到父级的滚动偏移量
let domScrollTop = dom.scrollTop || 0,
domScrollLeft = dom.scrollLeft || 0;
// 用子集距离第一个定位父级的距离减去父级的滚动偏移
result.top += children.offsetTop - domScrollTop;
result.left += children.offsetLeft - domScrollLeft;
// 赋予新的子集
children = parent;
// 赋予新的定位父级
parent = dom.offsetParent; // 下一个父级
} else {
// 这里是关键 --- 当本次获取的父级是不是定位父级时
let domScrollTop = dom.scrollTop || 0,
domScrollLeft = dom.scrollLeft || 0;
// 不用子集的 offsetTop 这里不涉及定位距离的计算
result.top -= domScrollTop;
result.left -= domScrollLeft;
}
// 碰到 body 就结束了
if (dom.nodeName !== 'BODY') {task(dom);
}
};
task(node);
return result;
}
第一版忘写了, 自定义父级监听
很多时候并不是要监听 body, 而是监听指定的父级的 scroll 事件
用户在 dom 上写上指令 v-lazy-box, 就可以监听这个元素了,
this.vm.directive('lazy-box', {
bind: el => {
// 触发第一次监控, 因为可能 dom 是 v -if 状态, 不知道什么时候出现;
this.whetherHandle();
// 与之前相同
el.addEventListener('scroll', this.whetherHandle.bind(this), false);
}
});
end
至此才把懒加载写完, 真实累;
不自己做一遍, 自己测一遍各种情况, 真的不知道竟然这么麻烦, 但也学到了很多收获满满.
最后还是希望与各位同学一起进步, 早日成为真正的大牛, 实现自己的价值!!!
谢谢您的观看, 一起加油吧????
个人学习博客: 个人网站
github:github