整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

哇噻,简直是个天才,无需scroll事件就能监听到元

哇噻,简直是个天才,无需scroll事件就能监听到元素滚动

噻,简直是个天才,无需scroll事件就能监听到元素滚动

1. 前言

最近在做 toolTip 弹窗相关组件封装,实现的效果就是可以通过hover或点击在元素的上面或者下面能够出现一个弹框,类似下面这样

这时我遇到一个问题,因为我想当这个弹窗快要滚出屏幕之外时能够从由上面弹出变到由下面弹出,本来想着直接监听 scroll 事件就能搞定的,但是仔细一想 scroll 事件到底要绑定到那个 DOM 上呢? 因为很多时候滚动条出现的元素并不是最外层的 body 或者 html 可能是任意一个元素上的滚动条。这个时候就无法通过绑定 scroll 事件来监听元素滚动了。

2. 问题分析

我脑海中首先 IntersectionObserver 这个 API,但是这个 API 只能用来 监测目标元素与视窗(viewport)的交叉状态,也就是当我的元素滚出或者滚入的时候可以触发该监听的回调。

js复制代码new IntersectionObserver((event)=> {
        refresh();
      }, {
       // threshold 用来表示元素在视窗中显示的交叉比例显示
       // 设置的是 0 即表示元素完全移出视窗,1 或者完全进入视窗时触发回调
       // 0表示元素本身在视口中的占比0%, 1表示元素本身在视口中的占比为100%
       // 0.1表示元素本身在视口中的占比1%,0.9表示元素本身在视口中的占比为90%
        threshold: [0, 1, 0.1, 0.9]
      });

这样就可以在元素快要移出屏幕,或者移入屏幕时触发回调了,但是这样会有一个问题

当弹窗移出屏幕时,可以很轻松的监听到,并把弹窗移动到下方,但是当弹窗滚入的时候就有问题了

可以看到完全进入之后,这个时候由于顶部空间不够,还需要继续往下滚才能将弹窗由底部移动到顶部。但是已经无法再触发 IntersectionObserver 和视口交叉的回调事件了,因为元素已经完全在视窗内了。 也就是说用这种方案,元素一旦滚出去之后,再回来的时候就无法复原了。

3. 把问题抛给别人

既然自己很难解决,那就看看别人是怎么解决这个问题的吧,我直接上 饿了么UI 上看看它的弹窗组件是怎么做的,于是我找到了 floating-ui 也就是原来的 popper.js 现在改名字了。

在文档中,我找到自动更新这块,也就是 floating-ui 通过监听器来实现自动更新弹窗位置。 到这里就可以看看 floating-ui 的源码了。

js
复制代码import {autoUpdate} from '@floating-ui/dom';

可以看到这个方法是放在 'floating-ui/dom'下面的

源码地址:github.com/floating-ui…

进入 floating-ui 的 github 地址,找到 packagesdom 下的 src 目录下,就可以看到想要的 autoUpdate.ts 自动更新的具体实现了。

4. 天才的想法

抛去其它不重要的东西,实现自动更新主要就是其中的 refresh 方法,先看一下代码

js复制代码function refresh(skip=false, threshold=1) {
    // 清理操作,清理上一次定时器和监听
    cleanup();

    // 获取元素的位置和尺寸信息
    const {
        left,
        top,
        width,
        height
    }=element.getBoundingClientRect();
        
        if (!skip) {
          // 这里更新弹窗的位置
          onMove();
        }
        
    // 如果元素的宽度或高度不存在,则直接返回
    if (!width || !height) {
        return;
    }

    // 计算元素相对于视口四个方向的偏移量
    const insetTop=Math.floor(top);
    const insetRight=Math.floor(root.clientWidth - (left + width));
    const insetBottom=Math.floor(root.clientHeight - (top + height));
    const insetLeft=Math.floor(left);
  // 这里就是元素的位置
    const rootMargin=`${-insetTop}px ${-insetRight}px ${-insetBottom}px ${-insetLeft}px`;

    // 定义 IntersectionObserver 的选项
    const options={
        rootMargin,
        threshold: Math.max(0, Math.min(1, threshold)) || 1,
    };

    let isFirstUpdate=true;

    // 处理 IntersectionObserver 的观察结果
    function handleObserve(entries) {
                // 这里事件会把元素和视口交叉的比例返回
        const ratio=entries[0].intersectionRatio;
        // 判断新的视口比例和老的是否一致,如果一致说明没有变化
        if (ratio !==threshold) {
            if (!isFirstUpdate) {
                return refresh();
            }

            if (!ratio) {
                    // 即元素完全不可见时,也就是ratio=0时,代码设置了一个定时器。
                    // 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
                    // 这次传递一个非常小的阈值 `1e-7`。这样可以在元素完全不可见时,保证重新触发监听
                timeoutId=setTimeout(()=> {
                    refresh(false, 1e-7);
                }, 100);
            } else {
                refresh(false, ratio);
            }
        }

        isFirstUpdate=false;
    }

        // 创建 IntersectionObserver 对象并开始观察元素
        io=new IntersectionObserver(handleObserve, options);
        // 监听元素
        io.observe(element);
}

refresh(true);

可以发现代码其实不复杂,主要实现还是依赖于IntersectionObserver,但是其中最重要的有几个点,我详细介绍一下

4.1 rootMargin

最重要的其实就是 rootMargin, rootMargin到底是做啥用的呢?

我上面说了 IntersectionObserver监测目标元素与视窗(viewport)的交叉状态,而这个 rootMargin 就是可以将这个视窗缩小。

比如我设置 rootMargin 为 "-50px -30px -20px -30px",注意这里 rootMarginmargin 类似,都是按照 上 右 下 左 来设置的

可以看到这样,当元素距离顶部 50px 就触发了事件。而不必等到元素完全滚动到视口。

既然这样,当我设置 rootMargin 就是该元素本身的位置,不就可以实现只要元素一滚动,元素就与视口发生了交叉,触发事件了吗?

4.2 循环监听事件

仅仅将视口缩小到该元素本身的位置还是不够,因为只要一滚动,元素的位置就发生了改变,即视口的位置也需要跟随着元素的位置变化进行变化

js复制代码if (ratio !==threshold) {
        if (!isFirstUpdate) {
           return refresh();
        }
        if (!ratio) {
            // 即元素完全不可见时,也就是ratio=0时,代码设置了一个定时器。
            // 这个定时器的作用是在短暂的延迟(100毫秒)后,再次调用 `refresh` 函数,
            // 这次传递一个非常小的阈值 `1e-7`。这样可以在元素在视口不可见时,保证可以重新触发监听
            timeoutId=setTimeout(()=> {
                    refresh(false, 1e-7);
            }, 100);
        } else {
                refresh(false, ratio);
        }
}

也就是这里,可以看到每一次元素视口交叉的比例变化后,都重新调用了 refresh 方法,根据当前元素和屏幕的新的距离,创建一个新的监听器。

这样的话也就实现了类似 scroll 的效果,通过不断变化的视口来确认元素的位置是否发生了变化

5. 结语

所以说有时候思路还是没有打开,刚看到这个实现思路确实惊到我了,没有想到借助 rootMargin 可以实现类似 scroll 监听的效果。很多时候得多看看别人的实现思路,学习学习大牛写的代码和实现方式,对自己实现类似的效果相当有帮助

floating-ui

作者:码头的薯条

链接:https://juejin.cn/post/7344164779630673946



在工作中,有时会遇到需要一些不能使用分页方式来加载列表数据的业务情况,对于此,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。

在高性能渲染十万条数据(时间分片)一文中,提到了可以使用时间分片的方式来对长列表进行渲染,但这种方式更适用于列表项的DOM结构十分简单的情况。本文会介绍使用虚拟列表的方式,来同时加载大量数据。

为什么需要使用虚拟列表

假设我们的长列表需要展示10000条记录,我们同时将10000条记录渲染到页面中,先来看看需要花费多长时间:

<button id="button">button</button><br>
<ul id="container"></ul>  
复制代码
document.getElementById('button').addEventListener('click',function(){
    // 记录任务开始时间
    let now=Date.now();
    // 插入一万条数据
    const total=10000;
    // 获取容器
    let ul=document.getElementById('container');
    // 将数据插入容器中
    for (let i=0; i < total; i++) {
        let li=document.createElement('li');
        li.innerText=~~(Math.random() * total)
        ul.appendChild(li);
    }
    console.log('JS运行时间:',Date.now() - now);
    setTimeout(()=>{
      console.log('总运行时间:',Date.now() - now);
    },0)

    // print JS运行时间: 38
    // print 总运行时间: 957 
  })
复制代码

当我们点击按钮,会同时向页面中加入一万条记录,通过控制台的输出,我们可以粗略的统计到,JS的运行时间为38ms,但渲染完成后的总时间为957ms。

简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单来统计JS运行时间和总渲染时间:

  • 在 JS 的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
  • 第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
  • 第二个console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的

关于Event Loop的详细内容请参见这篇文章-->

然后,我们通过Chrome的Performance工具来详细的分析这段代码的性能瓶颈在哪里:



从Performance可以看出,代码从执行到渲染结束,共消耗了960.8ms,其中的主要时间消耗如下:

  • Event(click) : 40.84ms
  • Recalculate Style : 105.08ms
  • Layout : 731.56ms
  • Update Layer Tree : 58.87ms
  • Paint : 15.32ms

从这里我们可以看出,我们的代码的执行过程中,消耗时间最多的两个阶段是Recalculate Style和Layout。

  • Recalculate Style:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,确定每个元素具体的样式。
  • Layout:布局,知道元素应用哪些规则之后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。

在实际的工作中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。

那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style和Layout阶段消耗大量的时间。

而虚拟列表就是解决这一问题的一种实现。

什么是虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

假设有1万条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。



说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。

假设滚动发生,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项至`第13项。



实现

虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

  • 计算当前可视区域起始数据索引(startIndex)
  • 计算当前可视区域结束数据索引(endIndex)
  • 计算当前可视区域的数据,并渲染到页面中
  • 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上



由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将Html结构设计成如下结构:

<div class="infinite-list-container">
    <div class="infinite-list-phantom"></div>
    <div class="infinite-list">
      <!-- item-1 -->
      <!-- item-2 -->
      <!-- ...... -->
      <!-- item-n -->
    </div>
</div>
复制代码
  • infinite-list-container 为可视区域的容器
  • infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list 为列表项的渲染区域

接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop

  • 假定可视区域高度固定,称之为screenHeight
  • 假定列表每项高度固定,称之为itemSize
  • 假定列表数据称之为listData
  • 假定当前滚动位置称之为scrollTop

则可推算出:

  • 列表总高度listHeight=listData.length * itemSize
  • 可显示的列表项数visibleCount=Math.ceil(screenHeight / itemSize)
  • 数据的起始索引startIndex=Math.floor(scrollTop / itemSize)
  • 数据的结束索引endIndex=startIndex + visibleCount
  • 列表显示数据为visibleData=listData.slice(startIndex,endIndex)

当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。

  • 偏移量startOffset=scrollTop - (scrollTop % itemSize);

最终的简易代码如下:

<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items"
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
      >{{ item.value }}</div>
    </div>
  </div>
</template>
复制代码
export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //每项高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表总高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight=this.$el.clientHeight;
    this.start=0;
    this.end=this.start + this.visibleCount;
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //结束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //当前滚动位置
      let scrollTop=this.$refs.list.scrollTop;
      //此时的开始索引
      this.start=Math.floor(scrollTop / this.itemSize);
      //此时的结束索引
      this.end=this.start + this.visibleCount;
      //此时的偏移量
      this.startOffset=scrollTop - (scrollTop % this.itemSize);
    }
  }
};
复制代码

点击查看在线DEMO及完整代码

最终效果如下:



列表项动态高度

在之前的实现中,列表项的高度是固定的,因为高度固定,所以可以很轻易的获取列表项的整体高度以及滚动时的显示数据与对应的偏移量。而实际应用的时候,当列表中包含文本之类的可变内容,会导致列表项的高度并不相同。

比如这种情况:



在虚拟列表中应用动态高度的解决方案一般有如下三种:

1.对组件属性itemSize进行扩展,支持传递类型为数字、数组、函数

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number

这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或可以通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。

2.将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内。

由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。

3.以预估高度先行渲染,然后获取真实高度并缓存。

这是我选择的实现方式,可以避免前两种方案的不足。

接下来,来看如何简易的实现:

定义组件属性estimatedItemSize,用于接收预估高度

props: {
  //预估高度
  estimatedItemSize:{
    type:Number
  }
}
复制代码

定义positions,用于列表项渲染后存储每一项的高度以及位置信息,

this.positions=[
  // {
  //   top:0,
  //   bottom:100,
  //   height:100
  // }
];
复制代码

并在初始时根据estimatedItemSize对positions进行初始化。

initPositions(){
  this.positions=this.listData.map((item,index)=>{
    return {
      index,
      height:this.estimatedItemSize,
      top:index * this.estimatedItemSize,
      bottom:(index + 1) * this.estimatedItemSize
    }
  })
}
复制代码

由于列表项高度不定,并且我们维护了positions,用于记录每一项的位置,而列表高度实际就等于列表中最后一项的底部距离列表顶部的位置。

//列表总高度
listHeight(){
  return this.positions[this.positions.length - 1].bottom;
}
复制代码

由于需要在渲染完成后,获取列表每项的位置信息并缓存,所以使用钩子函数updated来实现:

updated(){
  let nodes=this.$refs.items;
  nodes.forEach((node)=>{
    let rect=node.getBoundingClientRect();
    let height=rect.height;
    let index=+node.id.slice(1)
    let oldHeight=this.positions[index].height;
    let dValue=oldHeight - height;
    //存在差值
    if(dValue){
      this.positions[index].bottom=this.positions[index].bottom - dValue;
      this.positions[index].height=height;
      for(let k=index + 1;k<this.positions.length; k++){
        this.positions[k].top=this.positions[k-1].bottom;
        this.positions[k].bottom=this.positions[k].bottom - dValue;
      }
    }
  })
}
复制代码

滚动后获取列表开始索引的方法修改为通过缓存获取:

//获取列表起始索引
getStartIndex(scrollTop=0){
  let item=this.positions.find(i=> i && i.bottom > scrollTop);
  return item.index;
}
复制代码

由于我们的缓存数据,本身就是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式来降低检索次数:

//获取列表起始索引
getStartIndex(scrollTop=0){
  //二分法查找
  return this.binarySearch(this.positions,scrollTop)
},
//二分法查找
binarySearch(list,value){
  let start=0;
  let end=list.length - 1;
  let tempIndex=null;
  while(start <=end){
    let midIndex=parseInt((start + end)/2);
    let midValue=list[midIndex].bottom;
    if(midValue===value){
      return midIndex + 1;
    }else if(midValue < value){
      start=midIndex + 1;
    }else if(midValue > value){
      if(tempIndex===null || tempIndex > midIndex){
        tempIndex=midIndex;
      }
      end=end - 1;
    }
  }
  return tempIndex;
},
复制代码

滚动后将偏移量的获取方式变更:

scrollEvent() {
  //...省略
  if(this.start >=1){
    this.startOffset=this.positions[this.start - 1].bottom
  }else{
    this.startOffset=0;
  }
}
复制代码

通过faker.js 来创建一些随机数据

let data=[];
for (let id=0; id < 10000; id++) {
  data.push({
    id,
    value: faker.lorem.sentences() // 长文本
  })
}
复制代码

点击查看在线DEMO及完整代码

最终效果如下:



从演示效果上看,我们实现了基于文字内容动态撑高列表项情况下的虚拟列表,但是我们可能会发现,当滚动过快时,会出现短暂的白屏现象。

为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,在滚动时给予一些缓冲,所以将屏幕分为三个区域:

  • 可视区域上方:above
  • 可视区域:screen
  • 可视区域下方:below



定义组件属性bufferScale,用于接收缓冲区数据与可视区数据的比例

props: {
  //缓冲区比例
  bufferScale:{
    type:Number,
    default:1
  }
}
复制代码

可视区上方渲染条数aboveCount获取方式如下:

aboveCount(){
  return Math.min(this.start,this.bufferScale * this.visibleCount)
}
复制代码

可视区下方渲染条数belowCount获取方式如下:

belowCount(){
  return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}
复制代码

真实渲染数据visibleData获取方式如下:

visibleData(){
  let start=this.start - this.aboveCount;
  let end=this.end + this.belowCount;
  return this._listData.slice(start, end);
}
复制代码

点击查看在线DEMO及完整代码

最终效果如下:


基于这个方案,个人开发了一个基于Vue2.x的虚拟列表组件:vue-virtual-listview,可点击查看完整代码。

面向未来

在前文中我们使用监听scroll事件的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。

可以使用IntersectionObserver替换监听scroll事件,IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。

遗留问题

我们虽然实现了根据列表项动态高度下的虚拟列表,但如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证我们在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。

这种情况下,如果我们能监听列表项的大小变化就能获取其真正的高度了。我们可以使用ResizeObserver来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。

不过遗憾的是,在撰写本文的时候,仅有少数浏览器支持ResizeObserver。

参考

  • 浅说虚拟列表的实现原理
  • react-virtualized组件的虚拟列表实现
  • React和无限列表
  • 再谈前端虚拟列表的实现

数据驱动视图的框架下,你最头疼的事情是什么?没错,就是获取dom。大部分业务逻辑都可以在数据层面进行处理,但有些情况就不得不去获取真实的dom,比如获取元素的宽高

dom.offsetHeight

或者调用某些dom方法等

dom.scrollTop=100

通常在框架里,比如说vue中,会如何获取真实 dom 呢?我想大家可能都用过这样一个方法nextTick,用于在数据更新后获取 dom,如下

this.show=true
this.$nextTick(()=> (
  document.getElementById('xx').scrollTop=100
))

用过的都知道,这个方式非常不靠谱,经常会出现诸如类似这样的错误

Cannot read property 'scrollTo' of undefined

碰到这种情况,很多同学可能会用定时器,如果500不行,那就换1000,只要延时够长,总能获取到真实dom的。

this.show=true
settimeout(()=> (
  document.getElementById('xx').scrollTop=0
),500)

或许这些框架底层有其他解决方式,不过我并不精通这些,那么,从原生角度,有什么比较好的方式去解决这些问题呢?换句话说,如何确保元素渲染时机呢?

一、如何监听元素渲染?

元素监听最官方的方式是MutationObserver,这个API天生就是为了 dom变化检测而生的。

https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

功能非常强大,几乎能监听到 dom的所有变化,包括上面提到的元素渲染成功。

但是,正是因为过于强大,所以它的api就变得极其繁琐,下面是MDN里的一段例子

// 选择需要观察变动的节点
const targetNode=document.getElementById("some-id");

// 观察器的配置(需要观察什么变动)
const config={ attributes: true, childList: true, subtree: true };

// 当观察到变动时执行的回调函数
const callback=function (mutationsList, observer) {
  // Use traditional 'for loops' for IE 11
  for (let mutation of mutationsList) {
    if (mutation.type==="childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type==="attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};

// 创建一个观察器实例并传入回调函数
const observer=new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

我相信,除非特殊需求,没人会愿意写上这样一堆代码吧,定时器不比这个“香”多了?

那么,有没有一些简洁的、靠谱的监听方法呢?

其实,文章标题已经暴露了,没错,我们可以用 CSS 动画来监听元素渲染。

原理其实很简单,给元素一个动画,动画会在元素添加到页面时自动播放,进而触发animation*相关事件。

代码也很简单,先定义一个无关紧要的 CSS 动画,不能影响视觉效果,比如

@keyframes appear{
  to {
    opacity: .99;
  }
}

然后给需要监听的元素上添加这个动画

div{
  animation: appear .1s;
}

最后,只需要在这个元素或者及其父级上监听动画开始时机就行了,如果有多个元素,建议放在共同父级上

parent.addEventListener('animationstart', (ev)=> {
  if (ev.animationName=='appear') {
    // 元素出现了,可以获取dom信息了
  }
})

下面来看几个实际例子

二、多行文本展开收起

没错,又是这个例子。

前不久,尝试用 CSS 容器实现了这个效果,有兴趣的可以参考这篇文章:

尝试借助CSS @container实现多行文本展开收起

虽然最后实现了,但是dom结构及其复杂,如下

<div class="text-wrap">
  <div class="text" title="欢迎关注前端侦探,这里有一些有趣的、你可能不知道的HTML、CSS、JS小技巧技巧。">
    <div class="text-size">
      <div class="text-flex">
        <div class="text-content">
          <label class="expand"><input type="checkbox" hidden></label>
          欢迎关注前端侦探,这里有一些有趣的、你可能不知道的HTML、CSS、JS小技巧技巧。
        </div>
      </div>
    </div>
  </div>
  <div class="text-content text-place">
    欢迎关注前端侦探,这里有一些有趣的、你可能不知道的HTML、CSS、JS小技巧技巧。
  </div>
</div>

很多重复的文本和多余的标签,这些都是为了配合容器查询添加的。

其实说到底,只是为了判断一下尺寸,其实 JS 是更好的选择,麻烦的只是获取尺寸的时机。如果通过 CSS 动画来监听,一切就都好办了。

我们先回到最基础的HTML结构

<div class="text-wrap">
  <div class="text-content">
    <label class="expand"><input type="checkbox" hidden></label>
    欢迎关注前端侦探,这里有一些有趣的、你可能不知道的HTML、CSS、JS小技巧技巧。
  </div>
</div>

这些结构是为了实现右下角的“展开”按钮必不可少的,如果不太清楚是如何布局的,可以回顾一下之前这篇文章:

CSS 实现多行文本“展开收起”

相关 CSS 如下

.text-wrap{
  display: flex;
  position: relative;
  width: 300px;
  padding: 8px;
  outline: 1px dashed #9747FF;
  border-radius: 4px;
  line-height: 1.5;
  text-align: justify;
  font-family: cursive;
}
.expand{
  font-size: 80%;
  padding: .2em .5em;
  background-color: #9747FF;
  color: #fff;
  border-radius: 4px;
  cursor: pointer;
  float: right;
  clear: both;
}
.expand::after{
  content: '展开';
}
.text-content{
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
}
.text-content::before{
  content: '';
  float: right;
  height: calc(100% - 24px);
}
.text-wrap:has(:checked) .text-content{
  -webkit-line-clamp: 999;
}
.text-wrap:has(:checked) .expand::after{
  content: '收起';
}

效果如下

通过前一节的原理,我们给文本容器添加一个无关紧要的动画

.text-content{
  /**/
  animation: appear .1s;
}
@keyframes appear {
  to {
    opacity: .99;
  }
}

然后,我们在父级上监听这个动画,我这里直接监听document,这里做的事情很简单,判断一下容器的滚动高度和实际高度,如果滚动高度超过实际高度,说明文本较多,超出了指定行数,这种情况就给容器添加一个特殊的属性

document.addEventListener('animationstart', (ev)=> {
  if (ev.animationName=='appear') {
    ev.target.dataset.mul=ev.target.scrollHeight > ev.target.offsetHeight;
  }
})

然后根据这个属性,判断“展开”按钮隐藏或者显示

.expand{
  /**/
  visibility: hidden;
}
.text-content[data-mul="true"] .expand{
  visibility: visible;
}

这样只有在文本较多时,“展开”按钮才会出现,效果如下

是不是要简单很多?完整代码可以参考以下链接

  • CSS els with animation (juejin.cn)[1]
  • CSS els with animation (codepen.io)[2]

三、文本超长时自动滚动

再来看一个例子,相信大家都碰到过。

先看效果吧,就是一个无限滚动的效果,类似与以前的marquee标签

首先来看HTML,并没有什么特别之处

<div class="marqee">
  <span class="text" title="这是一段可以自动滚动的文本">这是一段可以自动滚动的文本</span>
</div>

这里是首尾无缝衔接,所以需要两份文本,我这里用伪元素生成

.text::after{
  content: attr(title);
  padding: 0 20px;
}

单纯的滚动其实很容易,就一行 CSS,如下

.text{
  animation: move 2s linear infinite;
}
@keyframes move{
  to {
    transform: translateX(-50%);
  }
}

这样实现会有两个问题,效果如下

一是较少的文本也发生的滚动,二是滚动速度不一致。

所以,有必要借助 JS来修正一下。

还是上面的方式,我们直接用CSS动画来监听元素渲染

.marqee{
  /**/
  animation: appear .1s;
}
@keyframes appear {
  to {
    opacity: .99;
  }
}

然后监听动画开始事件,这里要做两件事,也就是为了修正前面提到的两个问题,一个是判断文本的真实宽度和容器宽度的关系,还有一个是获取判断文本宽度和容器宽度的比例关系,因为文本越长,需要滚动的时间也越长

document.addEventListener('animationstart', (ev)=> {
  if (ev.animationName=='appear') {
    ev.target.dataset.mul=ev.target.scrollWidth > ev.target.offsetWidth;
    ev.target.style.setProperty('--speed', ev.target.scrollWidth / ev.target.offsetWidth);
  }
})

拿到这些状态后,我们改一下前面的动画。

只有data-multrue的情况下,才执行动画,并且动画时长是和--speed成比例的,这样可以保证所有文本的速度是一致的

.marqee[data-mul="true"] .text{
  display: inline-block;
  animation: move calc(var(--speed) * 3s) linear infinite;
}

还有就是只有data-multrue的情况下才会生成双份文本

.marqee[data-mul="true"] .text::after{
  content: attr(title);
  padding: 0 20px;
}

这样判断以后,就能得到我们想要的效果了

完整代码可以参考以下链接

  • CSS marquee width animation (juejin.cn)[3]
  • CSS marquee width animation (codepen.io)[4]

四、元素锚定定位

最后再来一个例子,其实这个方式我平时用的很多了,一个任务列表页面,我们有时候会遇到这样的需求,在地址栏上传入一个 id,例如

https://xxx.com?id=5

然后,根据这个id自动锚定到这个任务上(让这个任务滚动到屏幕中间)

由于这个任务是通过接口返回渲染的,所以必须等待 dom渲染完全才能获取到。

传统的方式可能又要通过定时器了,这时可以考虑用动画监听的方式。

.item{
  /**/
  animation: appear .1s;
}
@keyframes appear {
  to {
    opacity: .99;
  }
}

然后我们只需要监听动画开始事件,判断一下元素的 id 是否和我们传入的一致,如果是一致就直接锚定就行了

const current_id='item_5';// 假设这个是url传进来的
document.addEventListener('animationstart', (ev)=> {
  if (ev.animationName=='appear' && ev.target.id===current_id) {
    ev.target.scrollIntoView({
      block: 'center'
    })
  }
})

这样就能准确无误的获取到锚定元素并且滚动定位了,效果如下

完整代码可以参考以下链接

  • CSS scrollIntoView with animation (juejin.cn)[5]
  • CSS scrollIntoView with animation (codepen.io)[6]

五、其他注意事项

在实际使用中,有一些要注意一下。

比如,在vue中也可以将这个监听直接绑定在父级模板上,这样会更方便

<div @animationstart="apear">
  
</div>

还有一点比较重要,很多时候我们用的的可能是CSS scoped,比如

<style scoped>
.item{
  /**/
  animation: appear .1s;
}
@keyframes appear {
  to {
    opacity: .99;
  }
}
</style>

如果是这种写法就需要注意了,因为在编译过程中,这个动画名称会加一些哈希后缀,类似于这样

所以,我们在animationstart判断时要改动一下,比如用startsWith

document.addEventListener('animationstart', (ev)=> {
  if (ev.animationName.startsWith('appear')) {
    // 
  }
})

这个需要额外注意一下

六、总结一下

是不是从来没有用过这些方式,赶紧试一试吧,相信会有不一样的感受,下面总结一下

  1. 在数据驱动视图的框架下,获取dom是一件比较头疼的事情
  2. 很多时候数据更新了,dom还没来得及更新,这时获取就出错了
  3. 元素监听最官方的方式是MutationObserver,但是比较复杂,一般情况下不会有人用
  4. 另辟蹊径,我们可以用 CSS 动画来监听元素渲染
  5. 原理非常简单,给元素一个动画,动画会在元素添加到页面时自动播放,进而触发animation*相关事件
  6. 利用这个技巧,我们可以很轻松的获取元素的dom相关信息已经触发相关事件
  7. 注意一下框架里的编译,可能会更改动画名称

总的来说,这是一个非常实用的小技巧,虽然没有纯 CSS那么“高级”,但是却是最“实用”的。

作者:XboxYan

来源:微信公众号:前端侦探

出处:https://mp.weixin.qq.com/s/KNIMdROilYYR6S1o7xze1g