整合營銷服務商

          電腦端+手機端+微信端=數據同步管理

          免費咨詢熱線:

          哇噻,簡直是個天才,無需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


          主站蜘蛛池模板: 一区二区三区在线播放| 精品久久久久久中文字幕一区| 福利一区二区三区视频午夜观看| 日本在线视频一区二区三区| 国产suv精品一区二区6| 精品视频一区二区三区| 无码精品人妻一区二区三区中| 精品国产福利一区二区| 国产日韩一区二区三免费高清 | 国产av福利一区二区三巨| 精品无码国产一区二区三区51安| 亚洲国产欧美日韩精品一区二区三区 | 日韩精品一区二区三区中文精品| 亚洲一区二区三区在线 | 偷拍激情视频一区二区三区| 国产精品香蕉在线一区| 精品一区二区三区在线视频观看 | 日韩一区二区三区四区不卡| 一区二区三区视频在线观看| 精品无码AV一区二区三区不卡| 天美传媒一区二区三区| 国产亚洲一区二区三区在线不卡| 另类免费视频一区二区在线观看 | 91一区二区视频| 一区二区三区无码高清视频| 国产成人一区二区三区视频免费| 久久久久久人妻一区二区三区| 四虎精品亚洲一区二区三区| 精品欧洲av无码一区二区| 精品国产一区二区三区AV性色 | 鲁丝片一区二区三区免费| 亚洲AV无码国产一区二区三区| 国产一区二区三区在线视頻| 国内精品一区二区三区最新| 91精品一区二区综合在线| 中文字幕av无码一区二区三区电影| 无码日本电影一区二区网站| 无码人妻少妇色欲AV一区二区| 午夜肉伦伦影院久久精品免费看国产一区二区三区 | 国产产一区二区三区久久毛片国语 | 国产一在线精品一区在线观看|