整合營銷服務商

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

          免費咨詢熱線:

          純CSS實現波浪效果

          直以來,使用純 CSS 實現波浪效果都是十分困難的。

          因為實現波浪的曲線需要借助貝塞爾曲線。

          而使用純 CSS 的方式,實現貝塞爾曲線,額,暫時是沒有很好的方法。

          當然,借助其他力量(SVG、CANVAS),是可以很輕松的完成所謂的波浪效果的,先看看,非 CSS 方式實現的波浪效果。

          使用 SVG 實現波浪效果

          借助 SVG ,是很容易畫出三次貝塞爾曲線的。

          看看效果:

          代碼如下:

          <svg width="200px" height="200px" version="1.1" xmlns="http://www.w3.org/2000/svg">
              <text class="liquidFillGaugeText" text-anchor="middle" font-size="42px" transform="translate(100,120)" style="fill: #000">50.0%</text>
              <!-- Wave -->
              <g id="wave">
                  <path id="wave-2" fill="rgba(154, 205, 50, .8)" d="M 0 100 C 133.633 85.12 51.54 116.327 200 100 A 95 95 0 0 1 0 100 Z">
                  <animate dur="5s" repeatCount="indefinite" attributeName="d" attributeType="XML" values="M0 100 C90 28, 92 179, 200 100 A95 95 0 0 1 0 100 Z;
                                              M0 100 C145 100, 41 100, 200 100 A95 95 0 0 1 0 100 Z;
                                              M0 100 C90 28, 92 179, 200 100 A95 95 0 0 1 0 100 Z"></animate>
                  </path>
              </g>
              <circle cx="100" cy="100" r="80" stroke-width="10" stroke="white" fill="transparent"></circle>
              <circle cx="100" cy="100" r="90" stroke-width="20" stroke="yellowgreen" fill="none" class="percentage-pie-svg"></circle>
          </svg>
          

          畫出三次貝塞爾曲線的核心在于這一段。感興趣的可以自行去研究研究。

          使用 canvas 實現波浪效果

          使用 canvas 實現波浪效果的原理與 SVG 一樣,都是利用路徑繪制出三次貝塞爾曲線并賦予動畫效果。

          使用 canvas 的話,代碼如下:

          $(function() {
              let canvas = $("canvas");
              let ctx = canvas[0].getContext('2d');
              let radians = (Math.PI / 180) * 180;
              let startTime = Date.now();
              let time = 2000;
              let clockwise = 1;
              let cp1x, cp1y, cp2x, cp2y;
               
              // 初始狀態
              // ctx.bezierCurveTo(90, 28, 92, 179, 200, 100);
              // 末尾狀態
              // ctx.bezierCurveTo(145, 100, 41, 100, 200, 100);
               
              requestAnimationFrame(function waveDraw() { 
                  let t = Math.min(1.0, (Date.now() - startTime) / time);
                     
                  if(clockwise) {
                      cp1x = 90 + (55 * t);
                      cp1y = 28 + (72 * t);
                      cp2x = 92 - (51 * t);
                      cp2y = 179 - (79 * t);
                  } else {
                      cp1x = 145 - (55 * t);
                      cp1y = 100 - (72 * t);
                      cp2x = 41 + (51 * t);
                      cp2y = 100 + (79 * t);
                  }
                   
                  ctx.clearRect(0, 0, 200, 200);
                  ctx.beginPath();
                  ctx.moveTo(0, 100);
                  // 繪制三次貝塞爾曲線
                  ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, 200, 100);
                  // 繪制圓弧
                  ctx.arc(100, 100, 100, 0, radians, 0);
                  ctx.fillStyle = "rgba(154, 205, 50, .8)";
                  ctx.fill();
                  ctx.save(); 
                   
                  if( t == 1 ) {
                      startTime = Date.now();
                      clockwise = !clockwise;
                  }
           
                  requestAnimationFrame(waveDraw);
              });
          })
          

          主要是利用了動態繪制 ctx.bezierCurveTo() 三次貝塞爾曲線實現波浪的運動效果,感興趣的可以自行研究。

          純 CSS 實現波浪效果

          好,接下來才是本文的重點!使用純 CSS 的方式,實現波浪的效果。

          你 TM 在逗我?剛剛不是還說使用 CSS 無能為力嗎?

          是,我們沒有辦法直接繪制出三次貝塞爾曲線,但是我們可以利用一些討巧的方法,模擬達到波浪運動時的效果,姑且把下面這種方法看作一種奇技淫巧。

          原理

          原理十分簡單,我們都知道,一個正方形,給它添加 border-radius: 50%,將會得到一個圓形。

          border-radius:用來設置邊框圓角,當使用一個半徑時確定一個圓形。

          好的,如果 border-radius 沒到 50%,但是接近 50% ,我們會得到一個這樣的圖形:

          注意邊角,整個圖形給人的感覺是有點圓,卻不是很圓。額,這不是廢話嗎

          好的,那整這么個圖形又有什么用?還能變出波浪來不成?

          沒錯!就是這么神奇。:) 我們讓上面這個圖形滾動起來(rotate) ,看看效果:

          可能很多人看到這里還沒懂旋轉起來的意圖,仔細盯著一邊看,是會有類似波浪的起伏效果的。

          而我們的目的,就是要借助這個動態變換的起伏動畫,模擬制造出類似波浪的效果。

          實現

          當然,這里看到是全景實現圖,所以感覺并不明顯,OK,讓我們用一個個例子看看具體實現起來能達到什么樣的效果。

          我們利用上面原理可以做到的一種波浪運動背景效果圖:

          CodePen Demo -- Pure CSS Wave[1]

          后面漂浮的波浪效果,其實就是利用了上面的 border-radius: 45% 的橢圓形,只是放大了很多倍,視野之外的圖形都 overflow: hidden,只留下了一條邊的視野,并且增加了一些相應的 transform 變換。

          注意,這里背景是藍色靜止的,運動是白色的橢圓形。

          代碼也很簡單,SCSS 代碼如下:

          body {
              position: relative;
              align-items: center;
              min-height: 100vh;
              background-color: rgb(118, 218, 255);
              overflow: hidden;
           
              &:before, &:after {
                  content: "";
                  position: absolute;
                  left: 50%;
                  min-width: 300vw;
                  min-height: 300vw;
                  background-color: #fff;
                  animation-name: rotate;
                  animation-iteration-count: infinite;
                  animation-timing-function: linear;
              }
           
              &:before {
                  bottom: 15vh;
                  border-radius: 45%;
                  animation-duration: 10s;
              }
           
              &:after {
                  bottom: 12vh;
                  opacity: .5;
                  border-radius: 47%;
                  animation-duration: 10s;
              }
          }
           
          @keyframes rotate {
              0% {
                  transform: translate(-50%, 0) rotateZ(0deg);
              }
              50% {
                  transform: translate(-50%, -2%) rotateZ(180deg);
              }
              100% {
                  transform: translate(-50%, 0%) rotateZ(360deg);
              }
          }
          

          為了方便寫 DEMO,用到的長度單位是 VW 與 VH,不太了解這兩個單位的可以戳這里:vh、vw、vmin、vmax 知多少[2]

          可能有部分同學,還存在疑問,OK,那我們把上面的效果縮小 10 倍,將視野之外的動畫也補齊,那么其實生成波浪的原理是這樣的:

          圖中的虛線框就是我們實際的視野范圍。

          值得探討的點

          值得注意的是,要看到,這里我們生成波浪,并不是利用旋轉的橢圓本身,而是利用它去切割背景,產生波浪的效果。那為什么不直接使用旋轉的橢圓本身模擬波浪效果呢?因為中間高,兩邊低的效果不符合物理學原理,看上去十分別扭;

          可以點進去看看下面這個例子:

          CodePen Demo -- pure css wave[3]

          使用純 CSS 實現波浪進度圖

          好,既然掌握了這種方法,下面我們就使用純 CSS 實現上面最開始使用 SVG 或者 CANVAS 才能實現的波浪進度圖。

          HTML 結構如下:

          <div class="container">
              <div class="wave"></div>
          </div>
          
          .wave {
              position: relative;
              width: 200px;
              height: 200px;
              background-color: rgb(118, 218, 255);
              border-radius: 50%;
            
              &::before,
              &::after{
                  content: "";
                  position: absolute;
                  width: 400px;
                  height: 400px;
                  top: 0;
                  left: 50%;
                  background-color: rgba(255, 255, 255, .4);
                  border-radius: 45%;
                  transform: translate(-50%, -70%) rotate(0);
                  animation: rotate 6s linear infinite;
                  z-index: 10;
              }
               
              &::after {
                  border-radius: 47%;
                  background-color: rgba(255, 255, 255, .9);
                  transform: translate(-50%, -70%) rotate(0);
                  animation: rotate 10s linear -5s infinite;
                  z-index: 20;
              }
          }
           
          @keyframes rotate {
              50% {
                  transform: translate(-50%, -73%) rotate(180deg);
              } 100% {
                  transform: translate(-50%, -70%) rotate(360deg);
              }
          }
          

          效果圖:

          CodePen Demo -- Pure Css Wave Loading[4]

          雖然效果差了一點點,但是相較于要使用學習成本更高的 SVG 或者 CANVAS,這種純 CSS 方法無疑可使用的場景更多,學習成本更低!

          純 CSS 的充電效果

          還能實現類似這樣的充電效果:

          一些小技巧

          單純的讓一個 border-radius 接近 50 的橢圓形旋轉,動畫效果可能不是那么好,我們可以適當的添加一些其他變換因素,讓動畫效果看上去更真實:

        1. 在動畫過程中,動態的改變 border-radius 的值;
        2. 在動畫過程中,利用 transform 對旋轉橢圓進行輕微的位移、變形;
        3. 上面也演示到了,多個橢圓同時轉動,賦予不同時長的動畫,并且添加輕微的透明度,讓整個效果更加逼真。
        4. 紹一些你可能沒用過的SVG小技巧。

          在平時開發中,很多時候都會用到SVG。大部分情況我們都不必關注SVG里面到底是什么,直接當成圖片資源引入就行,比如常見的圖標資源

          我們可以通過多種方式使用這個特殊的圖片

          <img src="a.svg">
          
          .icon{
            background: url("a.svg")
          }
          

          甚至直接放到HTML

          <div>
            <svg>
            	...
            </svg>
          </div>
          

          這些都沒什么問題,但有時候,我們需要的是可以自適應尺寸的,比如像這樣的漸變邊框,尺寸會隨著文本內容的變化而變化,而不是固定尺寸,如下

          或者是這樣的虛線漸變邊框

          這樣的該如何用 SVG 動態實現呢,一起看看吧

          一、SVG導出的局限性

          SVG通常不是手寫的(能手寫任意路徑的都是大神),幾乎都是設計師借助軟件繪制生成的,比如設計都很喜歡的Figma(對前端非常友好,可以嘗試一下)

          比如前面提到的漸變邊框,在Figma中就是這樣

          對于設計師來說,漸變邊框很容易,只需要選擇邊框類型就行了

          對于 CSS 來說,這還算一個比較麻煩的事,通常我們需要額外嵌套一層漸變背景,通過遮蓋或者mask裁切的方式實現,有興趣的可以嘗試一下,這里暫不展開。

          那么,這個設計可以直接通過導出SVG實現嗎?

          先試試,Figma中可以直接將這個邊框復制成SVG格式

          下面是這段復制出來的SVG代碼(大概還是能看得懂一些的...)

          <svg width="41" height="25" viewBox="0 0 41 25" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="1" y="1" width="39" height="23" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
            <defs>
            <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
            	<stop stop-color="#FFD75A"/>
            	<stop offset="1" stop-color="#ED424B"/>
            </linearGradient>
            </defs>
          </svg>
          

          我們嘗試讓這段SVG尺寸跟隨button的大小,就行這樣

          <style>
            svg{
              position: absolute;
              inset: 0;
            }
          </style>
          <button>
            CSS
            <svg>...</svg>
          </button>
          

          在內容不定的情況下,就變成了這樣

          很顯然不行,因為生成的SVG寬高是固定的,沒法跟隨文本內容自適應尺寸

          既然 SVG很擅長漸變邊框,而 CSS很擅長自適應,那么,有沒有取長補短的辦法呢?

          當然也是有的!不過需要“改造”一下,接著往下看

          二、SVG 自適應尺寸

          首先我們把上面的那段SVG拿過來

          <svg width="41" height="25" viewBox="0 0 41 25" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="1" y="1" width="39" height="23" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
            <defs>
            <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
            	<stop stop-color="#FFD75A"/>
            	<stop offset="1" stop-color="#ED424B"/>
            </linearGradient>
            </defs>
          </svg>
          

          有沒有發現這里很多數值都固定的?如果想實現自適應,我們就需要將這些值改成百分比形式,注意看這個rect,有個xy坐標,我們現在寬高都是100%了,所以這里的坐標也要改成0,不然就撐出去了

          <svg width="100%" height="100%" viewBox="0 0 100% 100%" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="0" y="0" width="100%" height="100%" rx="4" stroke="url(#paint0_linear_1_2)" stroke-linecap="round"/>
            <defs>
            <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
            	<stop stop-color="#FFD75A"/>
            	<stop offset="1" stop-color="#ED424B"/>
            </linearGradient>
            </defs>
          </svg>
          

          為了驗證這個 SVG的自適應,我們將這個SVG放在一個div

          <div style="width: 100px;height: 80px;">
            <svg>...</svg>
          </div>
          
          <div style="width: 200px;height: 180px;">
            <svg>...</svg>
          </div>
          

          效果如下

          是不是已經自適應了?

          不過還是有點問題,仔細觀察,圓角處有些不自然,感覺被裁剪了一樣

          造成這種現象的原因有兩個:

          1. SVG描邊是居中描邊,并且不可修改
          2. SVG默認是超出隱藏的,也就是自帶overflow:hidden

          我們把邊框改大一點就可以很明顯的觀察到描邊是居中的

          由于是居中的,所以在不做修改的情況下,我們看到的其實只有原邊框的一半,利用這個原理我們其實可以實現常說的0.5px邊框,有興趣的可以參考我之前這篇文章:使用svg描邊來實現移動端1px

          在這里,我再介紹一種新的方式,那就是利用 CSS calc !

          沒錯,在 SVG中也可以使用CSS函數,比如我們這里邊框是4px,那么坐標xy就應該是2,然后寬高應該是calc(100% - 4px),所以可以很自然的改成這樣

          <div style="width: 100px;height: 80px;">
            <svg width="100%" height="100%">
              <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);"  rx="4" stroke="url(#paint0_linear_1_2)" stroke-width="4" stroke-linecap="round"/>
               <defs>
              <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
                <stop stop-color="#FFD75A"/>
                <stop offset="1" stop-color="#ED424B"/>
              </linearGradient>
              </defs>
            </svg>
          </div>
          

          非常完美了,不會有任何裁剪!(大家也可以復制上面這段代碼放在 HTML 中驗證)

          這樣就“輕易”實現了SVG的尺寸自適應

          這里小結一下

          1. SVG的尺寸改為`百分比
          2. 由于是居中描邊,所以要修正一下坐標和大小

          除此之外,還能直接加上style樣式,就像這樣

          <svg width="100%" height="100%" viewBox="0 0 100% 100%" fill="none" xmlns="http://www.w3.org/2000/svg">
            <style>
              rect{
                width: calc(100% - 4px);
                height: calc(100% - 4px);
              }
            </style>
            <rect x="2" y="2" width="100%" height="100%" rx="4" stroke="url(#paint0_linear_1_2)" stroke-width="4" stroke-linecap="round"/>
            <defs>
              <linearGradient id="paint0_linear_1_2" x1="0" y1="0" x2="1" y2="0">
                <stop stop-color="#FFD75A"/>
                <stop offset="1" stop-color="#ED424B"/>
              </linearGradient>
            </defs>
          </svg>
          

          雖然看著多,但后面作用更大,可以添加更多的 CSS 樣式

          三、SVG 在 HTML 中的應用

          其實前面的這段 SVG 可以直接放到 HTML 中用了,比如

          <button>
            <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
              <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" rx="16" stroke-width="2" stroke="url(#paint0_linear_3269_5233)"/>
              <defs>
                <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
                  <stop stop-color="#FFD75A"/>
                  <stop offset="1" stop-color="#ED424B"/>
                </linearGradient>
              </defs>
            </svg>
            CSS
          </button>
          

          我們需要將這個 SVG撐滿整個button,所以可以直接絕對定位

          button{
            position: relative;
          }
          button>svg{
            position: absolute;
            inset: 0;
          }
          

          這樣就得到了一個自適應尺寸的、帶有漸變邊框的按鈕,效果如下

          你也可以訪問在線鏈接:buton with SVG (juejin.cn)[1]

          四、SVG 在 CSS 中的應用

          不知道你有沒有這樣的感覺,把一大段 SVG放在 HTML不是特別優雅,總覺得太臃腫了。

          如果你有這種感覺,不妨將這段 SVG轉換成內聯CSS代碼。

          在這里可以借助張鑫旭老師的這個工具:SVG在線壓縮合并工具[2]

          我們將這段SVG粘貼過去,可以得到這樣的一段內聯SVG

          data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='100%25' height='100%25' style='width:calc(100%25 - 2px);height:calc(100%25 - 2px)' rx='16' stroke-width='2' stroke='url(%23paint0_linear_3269_5233)'/%3E%3Cdefs%3E%3ClinearGradient id='paint0_linear_3269_5233' y2='100%25' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%23FFD75A'/%3E%3Cstop offset='1' stop-color='%23ED424B'/%3E%3C/linearGradient%3E%3C/defs%3E%3C/svg%3E
          

          有了這段內聯SVG,我們可以直接用在background背景上

          button{
            background: url("data:image/svg+xml,%3Csvg fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Crect x='1' y='1' width='100%25' height='100%25' style='width:calc(100%25 - 2px);height:calc(100%25 - 2px)' rx='16' stroke-width='2' stroke='url(%23paint0_linear_3269_5233)'/%3E%3Cdefs%3E%3ClinearGradient id='paint0_linear_3269_5233' y2='100%25' gradientUnits='userSpaceOnUse'%3E%3Cstop stop-color='%23FFD75A'/%3E%3Cstop offset='1' stop-color='%23ED424B'/%3E%3C/linearGradient%3E%3C/defs%3E%3C/svg%3E")
          }
          

          HTML只需要干凈的button元素就夠了

          <button>CSS</button>
          <button>CSS & SVG</button>
          

          神奇的是,即便是轉為內聯了,SVG仍然保持著自適應特性,這樣也能實現同樣的效果,是不是好多了?

          你也可以訪問在線鏈接:button with SVG background (juejin.cn)[3]

          五、SVG 的獨特魅力

          如果說上面的效果 CSS 還能勉強模擬一下,那如果是這樣的虛線呢?

          對于 SVG 就非常容易了,只需要設置stroke-dasharray屬性就行,并且可以隨意更改虛線的間隔

          <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" rx="16" stroke-width="2" stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 4"/>
            <defs>
              <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
                <stop stop-color="#FFD75A"/>
                <stop offset="1" stop-color="#ED424B"/>
              </linearGradient>
            </defs>
          </svg>  
          

          還有這種虛線邊緣是圓角的情況,CSS就更加無能為力了

          SVG只需要設置stroke-linecap就行

          <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
            <rect x="2" y="2" width="100%" height="100%" style="width: calc(100% - 4px);height: calc(100% - 4px);" stroke-width="2" rx="16" stroke-linecap="round"  stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 6"/>
            <defs>
              <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
                <stop stop-color="#FFD75A"/>
                <stop offset="1" stop-color="#ED424B"/>
              </linearGradient>
            </defs>
          </svg> 
          

          更進一步,SVG還能實現虛線滾動動畫,CSS 應該是實現不了了

          看似復雜,其實只需要改變stroke-dashoffset屬性就行了,我們可以直接在SVG中插入CSS動畫

          <svg width="100%" height="100%" fill="none" xmlns="http://www.w3.org/2000/svg">
            <style>
              .rect{
                width: calc(100% - 4px);
                height: calc(100% - 4px);
                animation: move .3s infinite linear;
              }
              @keyframes move {
                0% { stroke-dashoffset: 0; }
                100% { stroke-dashoffset: 14; }
              }
            </style>
            <rect class="rect" x="2" y="2" width="100%" height="100%" stroke-width="2" rx="16" stroke-linecap="round"  stroke="url(#paint0_linear_3269_5233)"  stroke-dasharray="8 6"/>
            <defs>
              <linearGradient id="paint0_linear_3269_5233" x1="0" y1="0" x2="100%" y2="100%" gradientUnits="userSpaceOnUse">
                <stop stop-color="#FFD75A"/>
                <stop offset="1" stop-color="#ED424B"/>
              </linearGradient>
            </defs>
          </svg>  
          

          所有情況都可以將 SVG轉為內聯CSS直接用在背景上,極大的保證了HTML的簡潔性

          你也可以訪問在線鏈接:dot border with animation (juejin.cn)[4]

          六、總結一下

          以上就是本文的全部內容了,主要介紹了如何利用 SVGCSS各種的優勢來實現更加靈活的布局,下面總結一下

          1. 設計軟件導出的SVG都是固定尺寸的,不能自適應尺寸
          2. SVG很擅長漸變邊框,而CSS很擅長自適應尺寸,得想辦法取長補短
          3. SVG部分屬性支持百分比類型,可以實現尺寸自適應
          4. SVG描邊是居中描邊,并且不可修改,所以需要調整圓角矩形的坐標的大小
          5. SVG中也支持 CSS部分特性,比如calc計算函數
          6. SVG還支持內嵌style標簽,直接插入CSS代碼
          7. 可以將SVG轉為內聯CSS代碼,在支持SVG特性的同時極大的保證了HTML的整潔
          8. 借助SVG可以很輕松的實現漸變虛線邊框
          9. SVG中還支持CSS動畫,可以實現虛線滾動動畫

          你可能已經發現SVG并不是非常孤立的一門語言,現在還能和 CSSHTML聯動起來,充分發揮各自的優勢,這樣才能事半功倍 。

          [1]buton with SVG (juejin.cn): https://code.juejin.cn/pen/7341373491785236532

          [2]SVG在線壓縮合并工具: https://www.zhangxinxu.com/sp/svgo/

          [3]button with SVG background (juejin.cn): https://code.juejin.cn/pen/7341378448348643379

          [4]dot border with animation (juejin.cn): https://code.juejin.cn/pen/7341382517888876582

          作者:XboxYan

          來源-微信公眾號:前端偵探

          出處:https://mp.weixin.qq.com/s/VH2U-jqm3cXI0yQFrR3adQ

          流是一種控制訪問速率的策略,用于限制系統、服務或API接口的請求頻率或數量。它的目的是為了保護系統免受過多請求的影響,防止系統因過載而崩潰或變得不可用。限流是一種重要的性能優化和資源保護機制。


          限流的好處有以下幾個:

          • 保護系統穩定性:如果系統接受太多請求,超出了其處理能力,可能導致系統崩潰或響應時間急劇增加,從而影響用戶體驗。限流可以幫助控制請求速率,確保系統穩定運行。
          • 保護系統可用性:有些資源可能是有限的,如數據庫連接、網絡帶寬、內存等。通過限制對這些資源的訪問,可以防止它們被耗盡,從而保護系統的可用性。
          • 防止惡意攻擊:限流可以減少惡意攻擊和濫用系統資源的風險。例如,防止 DDoS(分布式拒絕服務)攻擊或惡意爬蟲訪問網站。
          • 公平分配資源:對于多個客戶或用戶,限流可以確保資源公平分配。每個客戶都有限制的訪問機會,而不會被某個客戶壟斷。
          • 避免雪崩效應:當系統中的一個組件或服務發生故障時,可能會導致大量請求涌入其他正常的組件或服務,進一步加劇系統負載,限流可以防止這種雪崩效應。

          限流分類

          限流的實現方案有很多種,磊哥這里稍微理了一下,限流的分類如下所示:

          1. 合法性驗證限流:比如驗證碼、IP 黑名單等,這些手段可以有效的防止惡意攻擊和爬蟲采集。
          2. 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以設置最大線程數(maxThreads),當并發超過最大線程數會排隊等待執行;而 Nginx 提供了兩種限流手段:一是控制速率,二是控制并發連接數。
          3. 服務端限流:比如我們在服務器端通過限流算法實現限流,此項也是我們本文介紹的重點。

          合法性驗證限流為最常規的業務代碼,就是普通的驗證碼和 IP 黑名單系統,本文就不做過多的敘述了,我們重點來看下后兩種限流的實現方案:容器限流和服務端限流。

          一、容器限流

          1.1 Tomcat 限流

          Tomcat 8.5 版本的最大線程數在 conf/server.xml 配置中,如下所示:

          <Connector port="8080" protocol="HTTP/1.1"
                    connectionTimeout="20000"
                    maxThreads="150"
                    redirectPort="8443" />

          其中 maxThreads 就是 Tomcat 的最大線程數,當請求的并發大于此值(maxThreads)時,請求就會排隊執行,這樣就完成了限流的目的。

          小貼士:maxThreads 的值可以適當的調大一些,此值默認為 150(Tomcat 版本 8.5.42),但這個值也不是越大越好,要看具體的硬件配置,需要注意的是每開啟一個線程需要耗用 1MB 的 JVM 內存空間用于作為線程棧之用,并且線程越多 GC 的負擔也越重。最后需要注意一下,操作系統對于進程中的線程數有一定的限制,Windows 每個進程中的線程數不允許超過 2000,Linux 每個進程中的線程數不允許超過 1000。

          1.2 Nginx 限流

          Nginx 提供了兩種限流手段:一是控制速率,二是控制并發連接數。

          控制速率

          我們需要使用 limit_req_zone 用來限制單位時間內的請求數,即速率限制,示例配置如下:

          limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
          server { 
              location / { 
                  limit_req zone=mylimit;
              }
          }

          以上配置表示,限制每個 IP 訪問的速度為 2r/s,因為 Nginx 的限流統計是基于毫秒的,我們設置的速度是 2r/s,轉換一下就是 500ms 內單個 IP 只允許通過 1 個請求,從 501ms 開始才允許通過第 2 個請求。

          我們使用單 IP 在 10ms 內發并發送了 6 個請求的執行結果如下:


          從以上結果可以看出他的執行符合我們的預期,只有 1 個執行成功了,其他的 5 個被拒絕了(第 2 個在 501ms 才會被正常執行)。

          速率限制升級版
          上面的速率控制雖然很精準但是應用于真實環境未免太苛刻了,真實情況下我們應該控制一個 IP 單位總時間內的總訪問次數,而不是像上面那么精確但毫秒,我們可以使用 burst 關鍵字開啟此設置,示例配置如下:

          limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
          server { 
              location / { 
                  limit_req zone=mylimit burst=4;
              }
          }

          burst=4 表示每個 IP 最多允許4個突發請求,如果單個 IP 在 10ms 內發送 6 次請求的結果如下:


          從以上結果可以看出,有 1 個請求被立即處理了,4 個請求被放到 burst 隊列里排隊執行了,另外 1 個請求被拒絕了。

          控制并發數

          利用 limit_conn_zone 和 limit_conn 兩個指令即可控制并發數,示例配置如下:

          limit_conn_zone $binary_remote_addr zone=perip:10m;
          limit_conn_zone $server_name zone=perserver:10m;
          server {
              ...
              limit_conn perip 10;
              limit_conn perserver 100;
          }

          其中 limit_conn perip 10 表示限制單個 IP 同時最多能持有 10 個連接;limit_conn perserver 100 表示 server 同時能處理并發連接的總數為 100 個。

          小貼士:只有當 request header 被后端處理后,這個連接才進行計數。

          二、服務端限流

          服務端限流需要配合限流的算法來執行,而算法相當于執行限流的“大腦”,用于指導限制方案的實現。


          有人看到「算法」兩個字可能就暈了,覺得很深奧,其實并不是,算法就相當于操作某個事務的具體實現步驟匯總,其實并不難懂,不要被它的表象給嚇到哦~


          限流的常見實現算法有以下三種:

          1. 時間窗口算法
          2. 漏桶算法
          3. 令牌算法

          接下來我們分別看來。

          2.1 時間窗口算法


          所謂的滑動時間算法指的是以當前時間為截止時間,往前取一定的時間,比如往前取 60s 的時間,在這 60s 之內運行最大的訪問數為 100,此時算法的執行邏輯為,先清除 60s 之前的所有請求記錄,再計算當前集合內請求數量是否大于設定的最大請求數 100,如果大于則執行限流拒絕策略,否則插入本次請求記錄并返回可以正常執行的標識給客戶端。


          滑動時間窗口如下圖所示:


          其中每一小個表示 10s,被紅色虛線包圍的時間段則為需要判斷的時間間隔,比如 60s 秒允許 100 次請求,那么紅色虛線部分則為 60s。


          我們可以借助 Redis 的有序集合 ZSet 來實現時間窗口算法限流,實現的過程是先使用 ZSet 的 key 存儲限流的 ID,score 用來存儲請求的時間,每次有請求訪問來了之后,先清空之前時間窗口的訪問量,統計現在時間窗口的個數和最大允許訪問量對比,如果大于等于最大訪問量則返回 false 執行限流操作,負責允許執行業務邏輯,并且在 ZSet 中添加一條有效的訪問記錄,具體實現代碼如下。


          我們借助 Jedis 包來操作 Redis,實現在 pom.xml 添加 Jedis 框架的引用,配置如下:

          <!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
          <dependency>
              <groupId>redis.clients</groupId>
              <artifactId>jedis</artifactId>
              <version>3.3.0</version>
          </dependency>

          具體的 Java 實現代碼如下:

          import redis.clients.jedis.Jedis;
          
          public class RedisLimit {
              // Redis 操作客戶端
              static Jedis jedis = new Jedis("127.0.0.1", 6379);
          
              public static void main(String[] args) throws InterruptedException {
                  for (int i = 0; i < 15; i++) {
                      boolean res = isPeriodLimiting("java", 3, 10);
                      if (res) {
                          System.out.println("正常執行請求:" + i);
                      } else {
                          System.out.println("被限流:" + i);
                      }
                  }
                  // 休眠 4s
                  Thread.sleep(4000);
                  // 超過最大執行時間之后,再從發起請求
                  boolean res = isPeriodLimiting("java", 3, 10);
                  if (res) {
                      System.out.println("休眠后,正常執行請求");
                  } else {
                      System.out.println("休眠后,被限流");
                  }
              }
          
              /**
               * 限流方法(滑動時間算法)
               * @param key      限流標識
               * @param period   限流時間范圍(單位:秒)
               * @param maxCount 最大運行訪問次數
               * @return
               */
              private static boolean isPeriodLimiting(String key, int period, int maxCount) {
                  long nowTs = System.currentTimeMillis(); // 當前時間戳
                  // 刪除非時間段內的請求數據(清除老訪問數據,比如 period=60 時,標識清除 60s 以前的請求記錄)
                  jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
                  long currCount = jedis.zcard(key); // 當前請求次數
                  if (currCount >= maxCount) {
                      // 超過最大請求次數,執行限流
                      return false;
                  }
                  // 未達到最大請求數,正常執行業務
                  jedis.zadd(key, nowTs, "" + nowTs); // 請求記錄 +1
                  return true;
              }
          }

          以上程序的執行結果為:

          正常執行請求:0
          正常執行請求:1
          正常執行請求:2
          正常執行請求:3
          正常執行請求:4
          正常執行請求:5
          正常執行請求:6
          正常執行請求:7
          正常執行請求:8
          正常執行請求:9
          被限流:10
          被限流:11
          被限流:12
          被限流:13
          被限流:14
          休眠后,正常執行請求

          此實現方式存在的缺點有兩個:

          • 使用 ZSet 存儲有每次的訪問記錄,如果數據量比較大時會占用大量的空間,比如 60s 允許 100W 訪問時;
          • 此代碼的執行非原子操作,先判斷后增加,中間空隙可穿插其他業務邏輯的執行,最終導致結果不準確。

          2.1 漏桶算法

          漏桶算法的靈感源于漏斗,如下圖所示:



          滑動時間算法有一個問題就是在一定范圍內,比如 60s 內只能有 10 個請求,當第一秒時就到達了 10 個請求,那么剩下的 59s 只能把所有的請求都給拒絕掉,而漏桶算法可以解決這個問題。


          漏桶算法類似于生活中的漏斗,無論上面的水流倒入漏斗有多大,也就是無論請求有多少,它都是以均勻的速度慢慢流出的。當上面的水流速度大于下面的流出速度時,漏斗會慢慢變滿,當漏斗滿了之后就會丟棄新來的請求;當上面的水流速度小于下面流出的速度的話,漏斗永遠不會被裝滿,并且可以一直流出。


          漏桶算法的實現步驟是,先聲明一個隊列用來保存請求,這個隊列相當于漏斗,當隊列容量滿了之后就放棄新來的請求,然后重新聲明一個線程定期從任務隊列中獲取一個或多個任務進行執行,這樣就實現了漏桶算法。


          上面我們演示 Nginx 的控制速率其實使用的就是漏桶算法,當然我們也可以借助 Redis 很方便的實現漏桶算法。


          我們可以使用 Redis 4.0 版本中提供的 Redis-Cell 模塊,該模塊使用的是漏斗算法,并且提供了原子的限流指令,而且依靠 Redis 這個天生的分布式程序就可以實現比較完美的限流了。

          Redis-Cell 實現限流的方法也很簡單,只需要使用一條指令 cl.throttle 即可,使用示例如下:

          > cl.throttle mylimit 15 30 60
          1)(integer)0 # 0 表示獲取成功,1 表示拒絕
          2)(integer)15 # 漏斗容量
          3)(integer)14 # 漏斗剩余容量
          4)(integer)-1 # 被拒絕之后,多長時間之后再試(單位:秒)-1 表示無需重試
          5)(integer)2 # 多久之后漏斗完全空出來

          其中 15 為漏斗的容量,30 / 60s 為漏斗的速率。

          2.3 令牌算法

          在令牌桶算法中有一個程序以某種恒定的速度生成令牌,并存入令牌桶中,而每個請求需要先獲取令牌才能執行,如果沒有獲取到令牌的請求可以選擇等待或者放棄執行,如下圖所示:



          我們可以使用 Google 開源的 guava 包,很方便的實現令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:

          <!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
          <dependency>
              <groupId>com.google.guava</groupId>
              <artifactId>guava</artifactId>
              <version>28.2-jre</version>
          </dependency>

          具體實現代碼如下:

          import com.google.common.util.concurrent.RateLimiter;
          
          import java.time.Instant;
          
          /**
           * Guava 實現限流
           */
          public class RateLimiterExample {
              public static void main(String[] args) {
                  // 每秒產生 10 個令牌(每 100 ms 產生一個)
                  RateLimiter rt = RateLimiter.create(10);
                  for (int i = 0; i < 11; i++) {
                      new Thread(() -> {
                          // 獲取 1 個令牌
                          rt.acquire();
                          System.out.println("正常執行方法,ts:" + Instant.now());
                      }).start();
                  }
              }
          }

          以上程序的執行結果為:

          正常執行方法,ts:2023-05-15T14:46:37.175Z
          正常執行方法,ts:2023-05-15T14:46:37.237Z
          正常執行方法,ts:2023-05-15T14:46:37.339Z
          正常執行方法,ts:2023-05-15T14:46:37.442Z
          正常執行方法,ts:2023-05-15T14:46:37.542Z
          正常執行方法,ts:2023-05-15T14:46:37.640Z
          正常執行方法,ts:2023-05-15T14:46:37.741Z
          正常執行方法,ts:2023-05-15T14:46:37.840Z
          正常執行方法,ts:2023-05-15T14:46:37.942Z
          正常執行方法,ts:2023-05-15T14:46:38.042Z
          正常執行方法,ts:2023-05-15T14:46:38.142Z

          從以上結果可以看出令牌確實是每 100ms 產生一個,而 acquire() 方法為阻塞等待獲取令牌,它可以傳遞一個 int 類型的參數,用于指定獲取令牌的個數。它的替代方法還有 tryAcquire(),此方法在沒有可用令牌時就會返回 false 這樣就不會阻塞等待了。當然 tryAcquire() 方法也可以設置超時時間,未超過最大等待時間會阻塞等待獲取令牌,如果超過了最大等待時間,還沒有可用的令牌就會返回 false。

          注意:使用 guava 實現的令牌算法屬于程序級別的單機限流方案,而上面使用 Redis-Cell 的是分布式的限流方案。

          小結


          本文提供了 6 種具體的實現限流的手段,他們分別是:Tomcat 使用 maxThreads 來實現限流;Nginx 提供了兩種限流方式,一是通過 limit_req_zone 和 burst 來實現速率限流,二是通過 limit_conn_zone 和 limit_conn 兩個指令控制并發連接的總數。最后我們講了時間窗口算法借助 Redis 的有序集合可以實現,還有漏桶算法可以使用 Redis-Cell 來實現,以及令牌算法可以解決 Google 的 guava 包來實現。


          需要注意的是借助 Redis 實現的限流方案可用于分布式系統,而 guava 實現的限流只能應用于單機環境。如果你嫌棄服務器端限流麻煩,甚至可以在不改代碼的情況下直接使用容器限流(Nginx 或 Tomcat),但前提是能滿足你的業務需求。


          好了,本節到這里就結束了,下期我們再會~


          參考 & 鳴謝

          https://www.cnblogs.com/biglittleant/p/8979915.html

          本文已收錄到我的面試小站 www.javacn.site,其中包含的內容有:Redis、JVM、并發、并發、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、設計模式、消息隊列等模塊。


          主站蜘蛛池模板: 中日韩一区二区三区| 成人精品一区二区激情| 日本高清一区二区三区| 国产精品成人一区二区| 无码人妻精品一区二区三区久久久| 人妻无码一区二区视频| 在线免费观看一区二区三区| 日产亚洲一区二区三区| 日本大香伊一区二区三区| 一区二区精品视频| 一区二区三区无码高清| 无码日本电影一区二区网站| 亚欧在线精品免费观看一区| 亚洲国产日韩一区高清在线| 狠狠色婷婷久久一区二区三区| 丰满人妻一区二区三区视频| 成人精品一区久久久久| 在线电影一区二区| 精产国品一区二区三产区| 波多野结衣久久一区二区| 亚洲午夜精品一区二区公牛电影院| 熟女大屁股白浆一区二区| 国产一区麻豆剧传媒果冻精品| 高清一区二区三区| 久久一区二区三区99| 日韩人妻无码一区二区三区久久99| 国产成人高清视频一区二区| 一区二区三区在线视频播放| 精品女同一区二区三区在线| 国产剧情一区二区| 亚洲日本一区二区一本一道| 久久久久人妻精品一区三寸蜜桃| 成人一区二区免费视频| 精品一区二区三区中文字幕| 少妇激情一区二区三区视频| 亚洲av色香蕉一区二区三区蜜桃| 怡红院一区二区在线观看| 无码人妻一区二区三区精品视频| 无人码一区二区三区视频| 亚洲AV美女一区二区三区| 3d动漫精品一区视频在线观看|