开发过程中经常会用到类似vant或者element等UI库,但对这些的实现原理并不深刻,前端不能一直只会用别人封装好的东西,所以我准备模仿造一个UI库的轮子,最终目的是研究别人的源码学习一些思想,至于能不能真的用上就另说了。
在开发UI库之前,选择了vant
和element
源码进行研究,关于按需引入和全局引入如何实现的原理,后期再写文章补充。由于是要造一个移动端的UI库,所以主要以vant
源码进行模仿。这段时间研究了刷新组件,今天开发完毕,在此记录一下思路。
需求
下拉的时候,先下拉即可刷新
,超过一定高度之后显示 释放即可刷新
,释放时候回弹,显示加载中
,刷新完毕之后回到顶部。
开发思路
需要有一个主容器(黑色框pull-refresh
)包裹,内部的子容器(深蓝色pull-refresh_track
)每次根据滑动更改transhlateY
,头部(蓝色pull-refresh__head
)的内容是绝对定位,并且translateY(-100%)
,由于主容器overflow:hidden
,所以就看不到头部了,插槽显示的是列表内容。
布局
<template>
<div class="pull-refresh">
<div ref="track" class="pull-refresh__track" :style="trackStyle">
<div class="pull-refresh__head">{
{statusText}}</div>
<slot></slot>
</div>
</div>
</template>
pull-refresh{
overflow: hidden;
user-select: none;
&__track{
position: relative;
height: 100%;
transition-property: transform;
}
&__head{
position: absolute;
left: 0;
width: 100%;
height: @pull-refresh-head-height;
overflow: hidden;
color: @pull-refresh-head-text-color;
font-size: @pull-refresh-head-font-size;
line-height: @pull-refresh-head-height;
text-align: center;
transform: translateY(-100%);
color: #333;
}
}
绑定跟touch
相关事件
在mounted
生命周期中使用addEventListener
为touchstart
、touchmove
、touchend
绑定想要的事件handleTouchStart
、handleTouchMove
和handleTouchEnd
。
实现上下拉动的功能
我们先来实现第一个简单的功能,当你拉动屏幕的时候,蓝色的框,即子容器的translateY
发生改变,当你向下拉动的时候,头部会出现在视野(黑色的框框)中。
在开始触摸handleTouchStart
时记录当前的开始位置:
handleTouchStart(event: TouchEvent){
const touches = event.touches[0];
this.startX = touches.clientX;
this.startY = touches.clientY;
}
在触摸过程中不断更新distance
:
handleTouchMove(event: TouchEvent){
const touches = event.touches[0];
this.deltaX = touches.clientX - this.startX;
this.deltaY = touches.clientY - this.startY;
this.distance = this.deltaY;
}
最后更改一下pull-refresh__track
的translateY
:
get trackStyle(){
return {
transform: this.distance ? `translate3d(0,${
this.distance}px, 0)` : '',
}
}
将trackStyle
设置为pull-refresh__track
的style
。这样向下拖动的时候就可以看到pull-refresh__head
了。
当为垂直方向时才更新distance
现在横向拉动的时候也会带动屏幕拖动,不是我们想要的,我们必须实现:当拖动的方向为垂直时才更改distace
。
getDirection(x: number, y: number){
if(x > y && x > MIN_DISTANCE) return 'horizontal';
if(y > x && y > MIN_DISTANCE) return 'vertical';
return '';
}
handleTouchMove(event: TouchEvent){
const touches = event.touches[0];
this.deltaX = touches.clientX - this.startX;
this.deltaY = touches.clientY - this.startY;
this.offsetX = Math.abs(this.deltaX);
this.offsetY = Math.abs(this.deltaY);
this.direction = this.getDirection(this.offsetX, this.offsetY);
if(this.direction === 'vertical'){
this.distance = this.deltaY;
}
}
优化下拉的感觉
UI
库的下拉都会有一种你越拉越难往下拉动的感觉:
// 优化下拉距离
_optimizeDistance(distance: number){
// 你设置的pull-refresh__head的高度
const headHeight = +this.headHeight;
if(distance > headHeight) {
if(distance < headHeight * 2){
distance = headHeight + (distance - headHeight) / 2;
} else {
distance = headHeight * 1.5 + (distance - headHeight * 2) / 4;
}
}
return Math.round(distance);
}
handleTouchMove(event: TouchEvent){
// ...
if(this.direction === 'vertical'){
this.distance = this._optimizeDistance(this.deltaY);
}
}
更改下拉时候的文字
当向下拉动的距离小于pull-refresh_head
的高度时,显示下拉即可刷新
, 超过时显示释放即可刷新
。这两句话不是固定的可以由用户自定义。
@Prop({
default: '下拉即可刷新...'}) private pullingText!: string;
@Prop({
default: '释放即可刷新...'}) private loosingText!: string;
handleTouchMove(event: TouchEvent){
// ...
if(this.direction === 'vertical'){
this.distance = this._optimizeDistance(this.deltaY);
if(this.distance < this.headHeight){
this.statusText = this.pullingText;
}else {
this.statusText = this.loosingText;
}
}
}
touchEnd
事件实现
接下来我们释放的时候,就会触发touchEnd
,如果下拉即可刷新
即直接回到顶部,如果是释放即可刷新
,则将pull-refresh__track
的translateY
更改为headHeight
并显示加载中
并触发使用了这个刷新组件的父组件的刷新事件。
原先我们是直接更改statusText
,我们将它更改为computed
属性,它的返回值根据status
更改。
handleTouchMove(event: TouchEvent){
// ...
if(this.direction === 'vertical'){
this.distance = this._optimizeDistance(this.deltaY);
if(this.distance < this.headHeight){
this.status = 'pulling';
}else {
this.statusText = 'loosing';
}
}
}
get statusText(){
switch(this.status){
case 'pulling': return this.pullingText;
case 'loosing': return this.loosingText;
case 'loading': return this.loadingText;
default: return this.pullingText;
}
}
public handleTouchEnd(){
if(this.status === 'loosing'){
// 释放即可刷新,则先回到`headHeight`高度,然后触发父组件的刷新事件
this.$emit('input', true);
this.$nextTick(() => {
this.$emit('refresh');
})
this.distance = +this.headHeight;
}else{
// 下拉即可刷新时,则直接返回顶部
this.status = 'pulling';
this.distance = 0;
}
}
我们可以在调用这个父组件里这么写:
<template>
<div class="wrapper">
<pull-refresh @refresh="handleRefresh" v-model="isLoading">
<div class="pull-content">这是下拉刷新容器包裹的内容</div>
</pull-refresh>
</div>
</template>
<script lang='ts'>
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class extends Vue {
private isLoading: boolean = false;
private handleRefresh(){
setTimeout(() => {
this.isLoading = false;
}, 1000)
}
}
</script>
根据value
更新status
:
@Watch('value', {
immediate: true })
changeIsLoading(value: boolean){
// 说明正在刷新
if(value){
this.status = 'loading';
this.distance = +this.headHeight;
}else{
// 没有刷新
this._updateStatus(0);
}
}
我们可以封装一个函数_updateStatus
,在这个函数里,我们根据距离更新status
,然后更新distace
:
_updateStatus(distance: number, isLoading?:boolean){
if(isLoading) this.status = 'loading';
else if(distance < this.headHeight){
this.status = 'pulling';
}else {
this.status = 'loosing';
}
this.distance = distance;
}
试着把之前的代码重构一下吧~~
刷新过程中不可以再次刷新
现在已经大致完成了下拉刷新的功能,接下来进行优化,我们现在在刷新过程中又可以触发刷新过程,并不科学。
get enableRefresh(){
return !(this.status === 'loading' || this.disabled);
}
public handleTouchStart(event: TouchEvent){
if(!this.enableRefresh) return;
// ...
}
public handleTouchMove(event: TouchEvent){
if(!this.enableRefresh) return;
// ...
}
public handleTouchEnd(event: TouchEvent){
if(!this.enableRefresh) return;
// ...
}
只有在顶部的时候才可以下拉刷新
接下来我们要考虑一种很常见的情况,在常见的页面中,上面是banner
图,下面是列表,列表可以下拉刷新,我们应该等到页面在顶部的时候刷新,而不是随时随地都可以刷新。
mounted(){
// getScroller函数获得当前可以滚动的元素,如果没有则获得最近的可滚动父元素
this.scrollEl = getScroller(this.$el as HTMLElement);
}
判断是否在顶部this.isCeiling = getScrollTop(this.scrollEl) === 0;
touchemove
时要阻止默认行为
如果直接使用event.preventDefault
会包下面的错误
[Intervention] Ignored attempt to cancel a touchmove event with cancelable=false, for example because scrolling is in progress and cannot be interrupted.
应该修改为:
if(typeof event.cancelBubble !== 'boolean' || event.cancelable) {
event.preventDefault();
}
源码
封装了event
的event.ts
:
// event.ts
// addEventListener绑定
export const on = (target: EventTarget, event: string, handler: EventHandlerNonNull, passive = false) => {
target.addEventListener(event, handler, {
capture: false, passive});
}
// removeEventListener移除
export const off = (target: EventTarget, event: string, handler: EventHandlerNonNull) => {
target.removeEventListener(event, handler);
}
// 阻止冒泡
export const stopPropagation = (event: Event) => {
event.stopPropagation();
}
// 阻止默认行为
export const preventDefault = (event: Event, isStopPropagation?: boolean) => {
if(typeof event.cancelBubble !== 'boolean' || event.cancelable) {
event.preventDefault();
}
if(isStopPropagation){
stopPropagation(event);
}
}
封装了有关滚动的scroll.ts
:
// scroll.ts
type ScrollElement = HTMLElement | Window;
// 是否是window
function isWindow(val: unknown): val is Window {
return val === window;
}
// 获得滚动容器
const overflowScrollReg = /scroll|auto/i;
export const getScroller = (el: HTMLElement, root: ScrollElement = window) => {
let node = el;
// nodeType === 1代表是元素
while(
node &&
node.tagName !== 'HTML' &&
node.nodeType === 1 &&
node !== root
){
// 获得当前的overflow-y的值
const {
overflowY } = window.getComputedStyle(node);
// 判断overflow-y是否等于scroll或者auto
if(overflowScrollReg.test(overflowY)){
if(node.tagName !== 'BODY') return node;
// body元素可滚动的情况下,判断html元素是否可以滚动,可以则返回body
// 不这么写的话,默认返回的是window, 原因如下:
// 当只设置 html { overflow-y: scroll } 或 只设置 body { overflow-y: scroll } 时,滚动条及 scroll 事件都是直接作用到 window 上的。
// 只有同时设置 html, body { overflow-y: scroll } 时,body 才会拥有滚动条和事件
const {
overflowY: htmlOverflowY } = window.getComputedStyle(
node.parentElement as Element
);
if(overflowScrollReg.test(htmlOverflowY)) return node;
}
node = node.parentElement as HTMLElement;
}
return root;
}
// 获得滚动条距离顶部的距离
export const getScrollTop = (el: ScrollElement): number => {
const top = 'scrollTop' in el ? el.scrollTop : el.pageYOffset;
// ios由于有回弹的效果,所以到顶部时实际上的top可能会小于0
return Math.max(top, 0);
}
·由于以后用到touch
的地方很多,封装可以用mixinx
的形式:
// touch.ts
import {
Vue } from 'vue-property-decorator';
import {
on, off } from '../utils/dom/event';
const MIN_DISTANCE = 10;
export default class TouchMixins extends Vue {
public startX: number = 0;
public startY: number = 0;
public deltaX: number = 0;
public deltaY: number = 0;
public offsetX: number = 0;
public offsetY: number = 0;
public direction: string = '';
resetTouchStatus(){
this.direction = '';
this.deltaX = 0;
this.deltaY = 0;
this.offsetX = 0;
this.offsetY = 0;
}
getDirection(x: number, y: number){
if(x > y && x > MIN_DISTANCE) return 'horizontal';
if(y > x && y > MIN_DISTANCE) return 'vertical';
return '';
}
touchStart(event: TouchEvent){
this.resetTouchStatus();
const touches = event.touches[0];
this.startX = touches.clientX;
this.startY = touches.clientY;
}
toucheMove(event: TouchEvent) {
const touches = event.touches[0];
this.deltaX = touches.clientX - this.startX;
this.deltaY = touches.clientY - this.startY;
this.offsetX = Math.abs(this.deltaX);
this.offsetY = Math.abs(this.deltaY);
this.direction = this.getDirection(this.offsetX, this.offsetY);
}
bindTouchEvent(el: EventTarget){
// @ts-ignore
const {
handleTouchStart, handleTouchMove, handleTouchEnd } = this;
on(el, 'touchstart', handleTouchStart);
on(el, 'touchmove', handleTouchMove);
if(handleTouchEnd){
on(el, 'touchend', handleTouchEnd);
on(el, 'touchcancel', handleTouchEnd);
}
this.$once('hook:beforeDestory', () => {
off(el, 'touchstart', handleTouchStart);
off(el, 'touchmove', handleTouchMove);
if(handleTouchEnd){
off(el, 'touchend', handleTouchEnd);
off(el, 'touchcancel', handleTouchEnd);
}
})
}
}
刷新组件的源码:
<template>
<div class="ar-pull-refresh">
<div ref="track" class="ar-pull-refresh__track" :style="trackStyle">
<div class="ar-pull-refresh__head" :style="headStyle">{
{
statusText}}</div>
<slot></slot>
</div>
</div>
</template>
<script lang='ts'>
import {
Component, Prop, Ref, Watch } from 'vue-property-decorator';
import {
getScroller, getScrollTop } from '../../../src/utils/dom/scroll';
import {
preventDefault } from '../../../src/utils/dom/event';
import ToucheMixins from '../../../src/mixins/touch';
const DEFAULT_HEAD_HEIGHT = 50;
const TEXT_STATUS = ['pulling', 'loosing', 'success'];
type ScrollElement = HTMLElement | Window;
@Component({
name: 'ArPullRefresh'
})
export default class ArPullRefresh extends ToucheMixins {
@Ref() readonly track: any;
@Prop({
default: false}) private value!: boolean;
@Prop({
default: '下拉即可刷新...'}) private pullingText!: string;
@Prop({
default: '释放即可刷新...'}) private loosingText!: string;
@Prop({
default: '加载中...'}) private loadingText!: string;
@Prop({
default: '刷新成功'}) private successText!: string;
@Prop({
default: 300}) private animationDuration!: string | number;
@Prop({
default: DEFAULT_HEAD_HEIGHT}) private headHeight!: string | number;
@Prop({
default: false}) private disabled!: boolean;
private status: string = 'normal';
private scrollEl: any;
private distance: number = 0;
private duration: number = 0;
private isCeiling: boolean = true;
// 优化下拉距离
_optimizeDistance(distance: number){
const headHeight = +this.headHeight;
if(distance > headHeight) {
if(distance < headHeight * 2){
distance = headHeight + (distance - headHeight) / 2;
} else {
distance = headHeight * 1.5 + (distance - headHeight * 2) / 4;
}
}
return Math.round(distance);
}
// 设置status
_updateStatus(distance: number, isLoading?:boolean){
if(isLoading) this.status = 'loading';
else if(distance < this.headHeight){
this.status = 'pulling';
}else {
this.status = 'loosing';
}
this.distance = distance;
}
public checkPullStart(event: TouchEvent){
this.isCeiling = getScrollTop(this.scrollEl) === 0;
if(this.isCeiling){
this.duration = 0;
this.touchStart(event);
}
}
public handleTouchStart(event: TouchEvent){
if(this.enableRefresh) {
this.checkPullStart(event);
};
}
public handleTouchMove(event: TouchEvent){
if(!this.enableRefresh) return;
if(!this.isCeiling){
this.checkPullStart(event);
}
this.toucheMove(event);
if(this.isCeiling && this.deltaY > 0 && this.direction === 'vertical'){
preventDefault(event);
let distance = this._optimizeDistance(this.deltaY);
this._updateStatus(distance);
}
}
public handleTouchEnd(){
if(this.enableRefresh && this.isCeiling && this.deltaY){
this.duration = +this.animationDuration;
if(this.status === 'loosing'){
this.$emit('input', true);
this.$nextTick(() => {
this.$emit('refresh');
})
}else{
this._updateStatus(0);
}
}
}
get headStyle(){
return {
height: this.headHeight ? `${
this.headHeight}px` : `${
DEFAULT_HEAD_HEIGHT}px`
}
}
get trackStyle(){
return {
transitionDuration: `${
this.duration}ms`,
transform: this.distance ? `translate3d(0,${
this.distance}px, 0)` : '',
}
}
get statusText(){
switch(this.status){
case 'pulling': return this.pullingText;
case 'loosing': return this.loosingText;
case 'loading': return this.loadingText;
default: return this.pullingText;
}
}
get enableRefresh(){
return !(this.status === 'loading' || this.disabled);
}
@Watch('value', {
immediate: true })
changeIsLoading(value: boolean){
this.duration = +this.animationDuration;
if(value){
this._updateStatus(+this.headHeight, true);
}else{
this._updateStatus(0);
}
}
mounted(){
this.bindTouchEvent(this.track);
this.scrollEl = getScroller(this.$el as HTMLElement);
}
}
</script>
写在最后
感谢阅读~