眾所周知JavaScript是一門單線程的語言,所以在JavaScript的世界中默認的情況下,同一個時間節點只能做一件事情,這樣的設定就造成了JavaScript這門語言的一些局限性,比如在我們的頁面中加載一些遠程數據時,如果按照單線程同步的方式運行,一旦有HTTP請求向服務器發送,就會出現等待數據返回之前網頁假死的效果出現。因為JavaScript在同一個時間只能做一件事,這就導致了頁面渲染和事件的執行,在這個過程中無法進行。顯然在實際的開發中我們并沒有遇見過這種情況。
基于以上的描述,我們知道在JavaScript的世界中,應該存在一種解決方案,來處理單線程造成的詬病。這就是同步【阻塞】和異步【非阻塞】執行模式的出現。
同步(阻塞):
同步的意思是JavaScript會嚴格按照單線程(從上到下、從左到右的方式)執行代碼邏輯,進行代碼的解釋和運行,所以在運行代碼時,不會出現先運行4、5行的代碼,再回頭運行1、3行的代碼這種情況。比如下列操作。
var a = 1
var b = 2
var c = a + b
//這個例子總c一定是3不會出現先執行第三行然后在執行第二行和第一行的情況
console.log(c)
接下來通過下列的案例升級一下代碼的運行場景:
var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
while(d2-d1<2000){
d2 = new Date().getTime()
}
//這段代碼在輸出結果之前網頁會進入一個類似假死的狀態
console.log(a+b)
當我們按照順序執行上面代碼時,我們的代碼在解釋執行到第4行時,還是正常的速度執行,但是在下一行就會進入一個持續的循環中。d2和d1在行級間的時間差僅僅是毫秒內的差別,所以在執行到while循環的時候d2-d1的值一定比2000小,那么這個循環會執行到什么時候呢?由于每次循環時,d2都會獲取一次當前的時間發生變化,直到d2-d1==2000等情況,這時也就是正好過了2秒的時間,我們的程序才能跳出循環,進而再輸出a+b的結果。那么這段程序的實際執行時間至少是2秒以上。這就導致了程序阻塞的出現,這也是為什么將同步的代碼運行機制叫做阻塞式運行的原因。
阻塞式運行的代碼,在遇到消耗時間的代碼片段時,之后的代碼都必須等待耗時的代碼運行完畢,才能得到執行資源,這就是單線程同步的特點。
異步(非阻塞):
在上面的闡述中,我們明白了單線程同步模型中的問題所在,接下來引入單線程異步模型的介紹。異步的意思就是和同步對立,所以異步模式的代碼是不會按照默認順序執行的。JavaScript執行引擎在工作時,仍然是按照從上到下從左到右的方式解釋和運行代碼。在解釋時,如果遇到異步模式的代碼,引擎會將當前的任務“掛起”并略過。也就是先不執行這段代碼,繼續向下運行非異步模式的代碼,那么什么時候來執行異步代碼呢?直到同步代碼全部執行完畢后,程序會將之前“掛起”的異步代碼按照“特定的順序”來進行執行,所以異步代碼并不會【阻塞】同步代碼的運行,并且異步代碼并不是代表進入新的線程同時執行,而是等待同步代碼執行完畢再進行工作。我們閱讀下面的代碼分析:
var a = 1
var b = 2
setTimeout(function(){
console.log('輸出了一些內容')
},2000)
//這段代碼會直接輸出3并且等待2秒左右的時間在輸出function內部的內容
console.log(a+b)
這段代碼的setTimeout定時任務規定了2秒之后執行一些內容,在運行當前程序執行到setTimeout時,并不會直接執行內部的回調函數,而是會先將內部的函數在另外一個位置(具體是什么位置下面會介紹)保存起來,然后繼續執行下面的console.log進行輸出,輸出之后代碼執行完畢,然后等待大概2秒左右,之前保存的函數再執行。
非阻塞式運行的代碼,程序運行到該代碼片段時,執行引擎會將程序保存到一個暫存區,等待所有同步代碼全部執行完畢后,非阻塞式的代碼會按照特定的執行順序,分步執行。這就是單線程異步的特點。
通俗的講:
通俗的講,同步和異步的關系是這樣的:
【同步的例子】:比如我們在核酸檢測站,進行核酸檢測這個流程就是同步的。每個人必須按照來的時間,先后進行排隊,而核酸檢測人員會按照排隊人的順序嚴格的進行逐一檢測,在第一個人沒有檢測完成前,第二個人就得無條件等待,這個就是一個阻塞流程。如果排隊過程中第一個人在檢測時出了問題,如棉簽斷了需要換棉簽,這樣更換時間就會追加到這個人身上,直到他順利的檢測完畢,第二個人才能輪到。如果在檢測中間棉簽沒有了,或者是錄入信息的系統崩潰了,整個隊列就進入無條件掛起狀態所有人都做不了了。這就是結合生活中的同步案例。
【異步的例子】:還是結合生活中,當我們進餐館吃飯時,這個場景就屬于一個完美的異步流程場景。每一桌來的客人會按照他們來的順序進行點單,假設只有一個服務員的情況,點單必須按照先后順序,但是服務員不需要等第一桌客人點好的菜出鍋上菜,就可以直接去收集第二桌第三桌客人的需求。這樣可能在十分鐘之內,服務員就將所有桌的客人點菜的菜單統計出來,并且發送給了后廚。之后的菜也不會按照點餐顧客的課桌順序,因為后廚收集到菜單之后可能有1,2,3桌的客人都點了鍋包肉,那么他可能會先一次出三份鍋包肉,這樣鍋包肉在上菜的時候1,2,3桌的客人都能得到,并且其他的菜也會亂序的逐一上菜,這個過程就是異步的。如果按照同步的模式點餐,默認在飯店點菜就會出現飯店在第一桌客人上滿菜之前第二桌之后的客人就只能等待連單都不能點的狀態。
總結:
JavaScript的運行順序就是完全單線程的異步模型:同步在前,異步在后。所有的異步任務都要等待當前的同步任務執行完畢之后才能執行。請看下面的案例:
var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
setTimeout(function(){
console.log('我是一個異步任務')
},1000)
while(d2-d1<2000){
d2 = new Date().getTime()
}
//這段代碼在輸出3之前會進入假死狀態,'我是一個異步任務'一定會在3之后輸出
console.log(a+b)
觀察上面的程序我們實際運行之后就會感受到單線程異步模型的執行順序了,并且這里我們會發現setTimeout設置的時間是1000毫秒但是在while的阻塞2000毫秒的循環之后并沒有等待1秒而是直接輸出了我是一個異步任務,這是因為setTimout的時間計算是從setTimeout()這個函數執行時開始計算的。
上面我們通過幾個簡單的例子大概了解了一下JS的運行順序,那么為什么是這個順序,這個順序的執行原理是什么樣的,我們應該如何更好更深的探究真相呢?這里需要介紹一下瀏覽器中一個Tab頁面的實際線程組成。
在了解線程組成前要了解一點,雖然瀏覽器是單線程執行JavaScript代碼的,但是瀏覽器實際是以多個線程協助操作來實現單線程異步模型的,具體線程組成如下:
按照真實的瀏覽器線程組成分析,我們會發現實際上運行JavaScript的線程其實并不是一個,但是為什么說JavaScript是一門單線程的語言呢?因為這些線程中實際參與代碼執行的線程并不是所有線程,比如GUI渲染線程為什么單獨存在,這個是防止我們在html網頁渲染一半的時候突然執行了一段阻塞式的JS代碼而導致網頁卡在一半停住這種效果。在JavaScript代碼運行的過程中實際執行程序時,同時只存在一個活動線程,這里實現同步異步就是靠多線程切換的形式來進行實現的。
所以我們通常分析時,將上面的細分線程歸納為下列兩條線程:
上圖是JavaScript運行時的一個工作流程和內存劃分的簡要描述,我們根據圖中可以得知主線程就是我們JavaScript執行代碼的線程,主線程代碼在運行時,會按照同步和異步代碼將其分成兩個去處,如果是同步代碼執行,就會直接將該任務放在一個叫做“函數執行棧”的空間進行執行,執行棧是典型的【棧結構】(先進后出),程序在運行的時候會將同步代碼按順序入棧,將異步代碼放到【工作線程】中暫時掛起,【工作線程】中保存的是定時任務函數、JS的交互事件、JS的網絡請求等耗時操作。
當【主線程】將代碼塊篩選完畢后,進入執行棧的函數會按照從外到內的順序依次運行,運行中涉及到的對象數據是在堆內存中進行保存和管理的。當執行棧內的任務全部執行完畢后,執行棧就會清空。執行棧清空后,“事件循環”就會工作,“事件循環”會檢測【任務隊列】中是否有要執行的任務,那么這個任務隊列的任務來源就是工作線程,程序運行期間,工作線程會把到期的定時任務、返回數據的http任務等【異步任務】按照先后順序插入到【任務隊列】中,等執行棧清空后,事件循環會訪問任務隊列,將任務隊列中存在的任務,按順序(先進先出)放在執行棧中繼續執行,直到任務隊列清空。
function task1(){
console.log('第一個任務')
}
function task2(){
console.log('第二個任務')
}
function task3(){
console.log('第三個任務')
}
function task4(){
console.log('第四個任務')
}
task1()
setTimeout(task2,1000)
setTimeout(task3,500)
task4()
剛才的文字閱讀可能在大腦中很難形成一個帶動畫的圖形界面來幫助我們分析JavaScript的實際運行思路,接下來我們將這段代碼肢解之后詳細的研究一下。
按照字面分析:
按照字面分析,我們創建了四個函數代表4個任務,函數本身都是同步代碼。在執行的時候會按照1,2,3,4進行解析,解析過程中我們發現任務2和任務3被setTimeout進行了定時托管,這樣就只能先運行任務1和任務4了。當任務1和任務4運行完畢之后500毫秒后運行任務3,1000毫米后運行任務2。
那么他們在實際運行時又是經歷了怎么樣的流程來運行的呢?大概的流程我們以圖解的形式分析一下。
圖解分析:
如上圖,在上述代碼剛開始運行的時候我們的主線程即將工作,按照順序從上到下進行解釋執行,此時執行棧、工作線程、任務隊列都是空的,事件循環也沒有工作。接下來我們分析下一個階段程序做了什么事情。
結合上圖可以看出程序在主線程執行之后就將任務1、4和任務2、3分別放進了兩個方向,任務1和任務4都是立即執行任務所以會按照1->4的順序進棧出棧(這里由于任務1和4是平行任務所以會先執行任務1的進出棧再執行任務4的進出棧),而任務2和任務3由于是異步任務就會進入工作線程掛起并開始計時,并不影響主線程運行,此時的任務隊列還是空置的。
我們發現同步任務的執行速度是飛快的,這樣一下執行棧已經空了,而任務2和任務3還沒有到時間,這樣我們的事件循環就會開始工作等待任務隊列中的任務進入,接下來就是執行異步任務的時候了。
我們發現任務隊列并不是一下子就會將任務2和任務三一起放進去,而是哪個計時器到時間了哪個放進去,這樣我們的事件循環就會發現隊列中的任務,并且將任務拿到執行棧中進行消費,此時會輸出任務3的內容。
到這就是最后一次執行,當執行完畢后工作線程中沒有計時任務,任務隊列的任務清空程序到此執行完畢。
我們通過圖解之后腦子里就會更清晰的能搞懂異步任務的執行方式了,這里采用最簡單的任務模型進行描繪復雜的任務在內存中的分配和走向是非常復雜的,我們有了這次的經驗之后就可以通過觀察代碼在大腦中先模擬一次執行,這樣可以更清晰的理解JS的運行機制。
執行棧是一個棧的數據結構,當我們運行單層函數時,執行棧執行的函數進棧后,會出棧銷毀然后下一個進棧下一個出棧,當有函數嵌套調用的時候棧中就會堆積棧幀,比如我們查看下面的例子:
function task1(){
console.log('task1執行')
task2()
console.log('task2執行完畢')
}
function task2(){
console.log('task2執行')
task3()
console.log('task3執行完畢')
}
function task3(){
console.log('task3執行')
}
task1()
console.log('task1執行完畢')
我們根據字面閱讀就能很簡單的分析出輸出的結果會是
/*
task1執行
task2執行
task3執行
task3執行完畢
task2執行完畢
task1執行完畢
*/
那么這種嵌套函數在執行棧中的操作流程是什么樣的呢?
第一次執行的時候調用task1函數執行到console.log的時候先進行輸出,接下來會遇到task2函數的調用會出現下面的情況:
執行到此時檢測到task2中還有調用task3的函數,那么就會繼續進入task3中執行,如下圖:
在執行完task3中的輸出之后task3內部沒有其他代碼,那么task3函數就算執行完畢那么就會發生出棧工作。
此時我們會發現task3出棧之后程序運行又會回到task2的函數中繼續他的執行。接下來會發生相同的事情。
再之后就剩下task1自己了,他在task2銷毀之后輸出task2執行完畢后他也會隨著出棧而銷毀。
當task1執行完畢之后它隨著銷毀最后一行輸出,就會進入執行棧執行并銷毀,銷毀之后執行棧和主線程清空。這個過程就會出現123321的這個順序,而且我們在打印輸出時,也能通過打印的順序來理解入棧和出棧的順序和流程。
關于上面的執行棧執行邏輯清楚后,我們就順便學習一下遞歸函數,遞歸函數是項目開發時經常涉及到的場景。我們經常會在未知深度的樹形結構,或其他合適的場景中使用遞歸。那么遞歸在面試中也會經常被問到風險問題,如果了解了執行棧的執行邏輯后,遞歸函數就可以看成是在一個函數中嵌套n層執行,那么在執行過程中會觸發大量的棧幀堆積,如果處理的數據過大,會導致執行棧的高度不夠放置新的棧幀,而造成棧溢出的錯誤。所以我們在做海量數據遞歸的時候一定要注意這個問題。
關于執行棧的深度:
執行棧的深度根據不同的瀏覽器和JS引擎有著不同的區別,我們這里就Chrome瀏覽器為例子來嘗試一下遞歸的溢出:
var i = 0;
function task(){
let index = i++
console.log(`遞歸了${index}次`)
task()
console.log(`第${index}次遞歸結束`)
}
task()
我們發現在遞歸了11378次之后會提示超過棧深度的錯誤,也就是我們無法在Chrome或者其他瀏覽器做太深層的遞歸操作。
發現問題后,我們再考慮如何能通過技術手段跨越遞歸的限制。可以將代碼做如下更改,這樣就不會出現遞歸問題了。
var i = 0;
function task(){
let index = i++
console.log(`遞歸了${index}次`)
setTimeout(function(){
task()
})
console.log(`第${index}次遞歸結束`)
}
task()
我們發現只是做了一個小小的改造,這樣就不會出現溢出的錯誤了。這是為什么呢?
在了解原因之前我們先看控制臺的輸出,結合控制臺輸出我們發現確實超過了界限也沒有報錯。
圖解原因:
這個是因為我們這里使用了異步任務去調用遞歸中的函數,那么這個函數在執行的時候就不只使用棧進行執行了。
先看沒有異步流程時候的執行圖例:
再看有了異步任務的遞歸:
有了異步任務之后我們的遞歸就不會疊加棧幀了,因為放入工作線程之后該函數就結束了,可以出棧銷毀,那么在執行棧中就永遠都是只有一個任務在運行,這樣就防止了棧幀的無限疊加,從而解決了無限遞歸的問題,不過異步遞歸的過程是無法保證運行速度的,在實際的工作場景中,如果考慮性能問題,還需要使用 while 循環等解決方案,來保證運行效率的問題,在實際工作場景中,盡量避免遞歸循環,因為遞歸循環就算控制在有限棧幀的疊加,其性能也遠遠不及指針循環。
在明確了事件循環模型以及JavaScript的執行流程后,我們認識了一個叫做任務隊列的容器,他的數據結構式隊列的結構。所有除同步任務外的代碼都會在工作線程中,按照他到達的時間節點有序的進入任務隊列,而且任務隊列中的異步任務又分為【宏任務】和【微任務】。
在了解【宏任務】和【微任務】前,還是哪生活中的實際場景舉個例子:
比如: 在去銀行辦理業務時,每個人都需要在進入銀行時找到取票機進行取票,這個操作會把來辦理業務的人按照取票的順序排成一個有序的隊列。假設銀行只開通了一個辦事窗口,窗口的工作人員會按照排隊的順序進行叫號,到達號碼的人就可以前往窗口辦理業務,在第一個人辦理業務的過程中,第二個以后的人都需要進行等待。
這個場景與JavaScript的異步任務隊列執行場景是一模一樣的,如果把每個辦業務的人當作JavaScript中的每一個異步的任務,那么取號就相當于將異步任務放入任務隊列。銀行的窗口就相當于【函數執行棧】,在叫號時代表將當前隊列的第一個任務放入【函數執行棧】運行。這時可能每個人在窗口辦理的業務內容各不相同,比如第一個人僅僅進行開卡的操作,這樣銀行工作人員就會為其執行開卡流程,這就相當于執行異步任務內部的代碼。
如果第一個人的銀行卡開通完畢,銀行的工作人員不會立即叫第二個人過來,而是會詢問第一個人,“您是否需要為剛才開通的卡辦理一些增值業務,比如做個活期儲蓄。”,這時相當于在原始開卡的業務流程中臨時追加了一個新的任務,按照JavaScript的執行順序,這個人的新任務應該回到取票機拿取一張新的號碼,并且在隊尾重新排隊,這樣工作的話辦事效率就會急劇下降。所以銀行實際的做法是在叫下一個人辦理業務前,如果前面的人臨時有新的業務要辦理,工作人員會繼續為其辦理業務,直到這個人的所有事情都辦理完畢。
從取卡到辦理追加業務完成的這個過程,就是微任務的實際體現。在JavaScript運行環境中,包括主線程代碼在內,可以理解為所有的任務內部都存在一個微任務隊列,在每下一個宏任務執行前,事件循環系統都會先檢測當前的代碼塊中是否包含已經注冊的微任務,并將隊列中的微任務優先執行完畢,進而執行下一個宏任務。所以實際的任務隊列的結構是這樣的,如圖:
由上述內容得知JavaScript中存在兩種異步任務,一種是宏任務一種是微任務,他們的特點如下:
宏任務:
宏任務是JavaScript中最原始的異步任務,包括setTimeout、setInterval、AJAX等,在代碼執行環境中按照同步代碼的順序,逐個進入工作線程掛起,再按照異步任務到達的時間節點,逐個進入異步任務隊列,最終按照隊列中的順序進入函數執行棧進行執行。
微任務:
微任務是隨著ECMA標準升級提出的新的異步任務,微任務在異步任務隊列的基礎上增加了【微任務】的概念,每一個宏任務執行前,程序會先檢測其中是否有當次事件循環未執行的微任務,優先清空本次的微任務后,再執行下一個宏任務,每一個宏任務內部可注冊當次任務的微任務隊列,再下一個宏任務執行前運行,微任務也是按照進入隊列的順序執行的。
總結:
在JavaScript的運行環境中,代碼的執行流程是這樣的:
# | 瀏覽器 | Node |
I/O | ? | ? |
setTimeout | ? | ? |
setInterval | ? | ? |
setImmediate | ? | ? |
requestAnimationFrame | ? | ? |
有些地方會列出來UI Rendering,說這個也是宏任務,可是在讀了HTML規范文檔以后,發現這很顯然是和微任務平行的一個操作步驟 requestAnimationFrame姑且也算是宏任務吧,requestAnimationFrame在MDN的定義為,下次頁面重繪前所執行的操作,而重繪也是作為宏任務的一個步驟來存在的,且該步驟晚于微任務的執行
# | 瀏覽器 | Node |
process.nextTick | ? | ? |
MutationObserver | ? | ? |
Promise.then catch finally | ? | ? |
代碼輸出順序問題1
setTimeout(function() {console.log('timer1')}, 0)
requestAnimationFrame(function(){
console.log('UI update')
})
setTimeout(function() {console.log('timer2')}, 0)
new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})
console.log('end')
解析:
本案例輸出的結果為:猜對我就告訴你,先思考,猜對之后結合運行結果分析。
按照同步先行,異步靠后的原則,閱讀代碼時,先分析同步代碼和異步代碼,Promise對象雖然是微任務,但是new Promise時的回調函數是同步執行的,所以優先輸出promise 1 和 promise 2。
在resolve執行時Promise對象的狀態變更為已完成,所以then函數的回調被注冊到微任務事件中,此時并不執行,所以接下來應該輸出end。
同步代碼執行結束后,觀察異步代碼的宏任務和微任務,在本次的同步代碼塊中注冊的微任務會優先執行,參考上文中描述的列表,Promise為微任務,setTimeout和requestAnimationFrame為宏任務,所以Promise的異步任務會在下一個宏任務執行前執行,所以promise then是第四個輸出的結果。
接下來參考setTimeout和requestAnimationFrame兩個宏任務,這里的運行結果是多種情況。如果三個宏任務都為setTimeout的話會按照代碼編寫的順序執行宏任務,而中間包含了一個requestAnimationFrame ,這里就要學習一下他們的執行時機了。setTimeout是在程序運行到setTimeout時立即注冊一個宏任務,所以兩個setTimeout的順序一定是固定的timer1和timer2會按照順序輸出。而requestAnimationFrame是請求下一次重繪事件,所以他的執行頻率要參考瀏覽器的刷新率。
參考如下代碼:
let i = 0;
let d = new Date().getTime()
let d1 = new Date().getTime()
function loop(){
d1 = new Date().getTime()
i++
//當間隔時間超過1秒時執行
if((d1-d)>=1000){
d = d1
console.log(i)
i = 0
console.log('經過了1秒')
}
requestAnimationFrame(loop)
}
loop()
該代碼在瀏覽器運行時,控制臺會每間隔1秒進行一次輸出,輸出的i就是loop函數執行的次數,如下圖:
這個輸出意味著requestAnimationFrame函數的執行頻率是每秒鐘60次左右,他是按照瀏覽器的刷新率來進行執行的,也就是當屏幕刷新一次時該函數就會觸發一次,相當于運行間隔是16毫秒左右。
繼續參考下列代碼:
let i = 0;
let d = new Date().getTime()
let d1 = new Date().getTime()
function loop(){
d1 = new Date().getTime()
i++
if((d1-d)>=1000){
d = d1
console.log(i)
i = 0
console.log('經過了1秒')
}
setTimeout(loop,0)
}
loop()
該代碼結構與上面的案例類似,循環是采用setTimeout進行控制的,所以參考運行結果,如圖:
根據運行結果得知,setTimeout(fn,0)的執行頻率是每秒執行200次左右,所以他的間隔是5毫秒左右。
由于這兩個異步的宏任務出發時機和執行頻率不同,會導致三個宏任務的觸發結果不同,如果我們打開網頁時,恰好趕上5毫秒內執行了網頁的重繪事件,requestAnimationFrame在工作線程中就會到達觸發時機優先進入任務隊列,所以此時會輸出:UI update->timer1->timer2。
而當打開網頁時上一次的重繪剛結束,下一次重繪的觸發是16毫秒后,此時setTimeout注冊的兩個任務在工作線程中就會優先到達觸發時機,這時輸出的結果是:timer1->timer2->UI update。
所以此案例的運行結果如下2圖所示:
代碼輸出順序問題2
document.addEventListener('click', function(){
Promise.resolve().then(()=> console.log(1));
console.log(2);
})
document.addEventListener('click', function(){
Promise.resolve().then(()=> console.log(3));
console.log(4);
})
解析:仍然是猜對了告訴你哈~,先運行一下試試吧。
這個案例代碼簡單易懂,但是很容易引起錯誤答案的出現。由于該事件是直接綁定在document上的,所以點擊網頁就會觸發該事件,在代碼運行時相當于按照順序注冊了兩個點擊事件,兩個點擊事件會被放在工作線程中實時監聽觸發時機,當元素被點擊時,兩個事件會按照先后的注冊順序放入異步任務隊列中進行執行,所以事件1和事件2會按照代碼編寫的順序觸發。
這里就會導致有人分析出錯誤答案:2,4,1,3。
為什么不是2,4,1,3呢?由于事件執行時并不會阻斷JS默認代碼的運行,所以事件任務也是異步任務,并且是宏任務,所以兩個事件相當于按順序執行的兩個宏任務。
這樣就會分出兩個運行環境,第一個事件執行時,console.log(2);是第一個宏任務中的同步代碼,所以他會立即執行,而Promise.resolve().then(()=> console.log(1));屬于微任務,他會在下一個宏任務觸發前執行,所以這里輸出2后會直接輸出1.
而下一個事件的內容是相同道理,所以輸出順序為:2,1,4,3。
關于事件循環模型今天就介紹到這里,在NodeJS中的事件循環模型和瀏覽器中是不一樣的,本文是以瀏覽器的事件循環模型為基礎進行介紹,事件循環系統在JavaScript異步編程中占據的比重是非常大的,在工作中可使用場景也是眾多的,掌握了事件循環模型就相當于,異步編程的能力上升了一個新的高度。
們都知道 Redis 提供了豐富的數據類型,常見的有五種:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
隨著 Redis 版本的更新,后面又支持了四種數據類型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
每種數據對象都各自的應用場景,你能說出它們各自的應用場景嗎?
面試過程中,這個問題也很常被問到,又比如會舉例一個應用場景來問你,讓你說使用哪種 Redis 數據類型來實現。
所以,這次我們就來學習 Redis 數據類型的使用以及應用場景。篇幅比較長,大家收藏慢慢看。
String 是最基本的 key-value 結構,key 是唯一標識,value 是具體的值,value其實不僅是字符串, 也可以是數字(整數或浮點數),value 最多可以容納的數據長度是 512M。
String 類型的底層的數據結構實現主要是 int 和 SDS(簡單動態字符串)。
SDS 和我們認識的 C 字符串不太一樣,之所以沒有使用 C 語言的字符串表示,因為 SDS 相比于 C 的原生字符串:
字符串對象的內部編碼(encoding)有 3 種 :int、raw和 embstr。
如果一個字符串對象保存的是整數值,并且這個整數值可以用long類型來表示,那么字符串對象會將整數值保存在字符串對象結構的ptr屬性里面(將void*轉換成 long),并將字符串對象的編碼設置為int。
如果字符串對象保存的是一個字符串,并且這個字符申的長度小于等于 32 字節,那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串,并將對象的編碼設置為embstr, embstr編碼是專門用于保存短字符串的一種優化編碼方式:
如果字符串對象保存的是一個字符串,并且這個字符串的長度大于 32 字節,那么字符串對象將使用一個簡單動態字符串(SDS)來保存這個字符串,并將對象的編碼設置為raw:
可以看到embstr和raw編碼都會使用SDS來保存值,但不同之處在于embstr會通過一次內存分配函數來分配一塊連續的內存空間來保存redisObject和SDS,而raw編碼會通過調用兩次內存分配函數來分別分配兩塊空間來保存redisObject和SDS。Redis這樣做會有很多好處:
但是 embstr 也有缺點的:
普通字符串的基本操作:
# 設置 key-value 類型的值
> SET name lin
OK
# 根據 key 獲得對應的 value
> GET name
"lin"
# 判斷某個 key 是否存在
> EXISTS name
(integer) 1
# 返回 key 所儲存的字符串值的長度
> STRLEN name
(integer) 3
# 刪除某個 key 對應的值
> DEL name
(integer) 1
批量設置 :
# 批量設置 key-value 類型的值
> MSET key1 value1 key2 value2
OK
# 批量獲取多個 key 對應的 value
> MGET key1 key2
1) "value1"
2) "value2"
計數器(字符串的內容為整數的時候可以使用):
# 設置 key-value 類型的值
> SET number 0
OK
# 將 key 中儲存的數字值增一
> INCR number
(integer) 1
# 將key中存儲的數字值加 10
> INCRBY number 10
(integer) 11
# 將 key 中儲存的數字值減一
> DECR number
(integer) 10
# 將key中存儲的數字值鍵 10
> DECRBY number 10
(integer) 0
過期(默認為永不過期):
# 設置 key 在 60 秒后過期(該方法是針對已經存在的key設置過期時間)
> EXPIRE name 60
(integer) 1
# 查看數據還有多久過期
> TTL name
(integer) 51
#設置 key-value 類型的值,并設置該key的過期時間為 60 秒
> SET key value EX 60
OK
> SETEX key 60 value
OK
不存在就插入:
# 不存在就插入(not exists)
>SETNX key value
(integer) 1
使用 String 來緩存對象有兩種方式:
因為 Redis 處理命令是單線程,所以執行命令的過程是原子的。因此 String 數據類型適合計數場景,比如計算訪問次數、點贊、轉發、庫存數量等等。
比如計算文章的閱讀量:
# 初始化文章的閱讀量
> SET aritcle:readcount:1001 0
OK
#閱讀量+1
> INCR aritcle:readcount:1001
(integer) 1
#閱讀量+1
> INCR aritcle:readcount:1001
(integer) 2
#閱讀量+1
> INCR aritcle:readcount:1001
(integer) 3
# 獲取對應文章的閱讀量
> GET aritcle:readcount:1001
"3"
SET 命令有個 NX 參數可以實現「key不存在才插入」,可以用它來實現分布式鎖:
一般而言,還會對分布式鎖加上過期時間,分布式鎖的命令如下:
SET lock_key unique_value NX PX 10000
而解鎖的過程就是將 lock_key 鍵刪除,但不能亂刪,要保證執行操作的客戶端就是加鎖的客戶端。所以,解鎖的時候,我們要先判斷鎖的 unique_value 是否為加鎖客戶端,是的話,才將 lock_key 鍵刪除。
可以看到,解鎖是有兩個操作,這時就需要 Lua 腳本來保證解鎖的原子性,因為 Redis 在執行 Lua 腳本時,可以以原子性的方式執行,保證了鎖釋放操作的原子性。
// 釋放鎖時,先比較 unique_value 是否相等,避免鎖的誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
這樣一來,就通過使用 SET 命令和 Lua 腳本在 Redis 單節點上完成了分布式鎖的加鎖和解鎖。
List 列表是簡單的字符串列表,按照插入順序排序,可以從頭部或尾部向 List 列表添加元素。
列表的最大長度為 2^32 - 1,也即每個列表支持超過 40 億個元素。
List 類型的底層數據結構是由雙向鏈表或壓縮列表實現的:
但是在 Redis 3.2 版本之后,List 數據類型底層數據結構就只由 quicklist 實現了,替代了雙向鏈表和壓縮列表。
# 將一個或多個值value插入到key列表的表頭(最左邊),最后的值在最前面
LPUSH key value [value ...]
# 將一個或多個值value插入到key列表的表尾(最右邊)
RPUSH key value [value ...]
# 移除并返回key列表的頭元素
LPOP key
# 移除并返回key列表的尾元素
RPOP key
# 返回列表key中指定區間內的元素,區間以偏移量start和stop指定,從0開始
LRANGE key start stop
# 從key列表表頭彈出一個元素,沒有就阻塞timeout秒,如果timeout=0則一直阻塞
BLPOP key [key ...] timeout
# 從key列表表尾彈出一個元素,沒有就阻塞timeout秒,如果timeout=0則一直阻塞
BRPOP key [key ...] timeout
消息隊列在存取消息時,必須要滿足三個需求,分別是消息保序、處理重復的消息和保證消息可靠性。
Redis 的 List 和 Stream 兩種數據類型,就可以滿足消息隊列的這三個需求。我們先來了解下基于 List 的消息隊列實現方法,后面在介紹 Stream 數據類型時候,在詳細說說 Stream。
1、如何滿足消息保序需求?
List 本身就是按先進先出的順序對數據進行存取的,所以,如果使用 List 作為消息隊列保存消息的話,就已經能滿足消息保序的需求了。
List 可以使用 LPUSH + RPOP (或者反過來,RPUSH+LPOP)命令實現消息隊列。
不過,在消費者讀取數據時,有一個潛在的性能風險點。
在生產者往 List 中寫入數據時,List 并不會主動地通知消費者有新消息寫入,如果消費者想要及時處理消息,就需要在程序中不停地調用 RPOP 命令(比如使用一個while(1)循環)。如果有新消息寫入,RPOP命令就會返回結果,否則,RPOP命令返回空值,再繼續循環。
所以,即使沒有新消息寫入List,消費者也要不停地調用 RPOP 命令,這就會導致消費者程序的 CPU 一直消耗在執行 RPOP 命令上,帶來不必要的性能損失。
為了解決這個問題,Redis提供了 BRPOP 命令。BRPOP命令也稱為阻塞式讀取,客戶端在沒有讀到隊列數據時,自動阻塞,直到有新的數據寫入隊列,再開始讀取新數據。和消費者程序自己不停地調用RPOP命令相比,這種方式能節省CPU開銷。
2、如何處理重復的消息?
消費者要實現重復消息的判斷,需要 2 個方面的要求:
但是 List 并不會為每個消息生成 ID 號,所以我們需要自行為每個消息生成一個全局唯一ID,生成之后,我們在用 LPUSH 命令把消息插入 List 時,需要在消息中包含這個全局唯一 ID。
例如,我們執行以下命令,就把一條全局 ID 為 111000102、庫存量為 99 的消息插入了消息隊列:
> LPUSH mq "111000102:stock:99"
(integer) 1
3、如何保證消息可靠性?
當消費者程序從 List 中讀取一條消息后,List 就不會再留存這條消息了。所以,如果消費者程序在處理消息的過程出現了故障或宕機,就會導致消息沒有處理完成,那么,消費者程序再次啟動后,就沒法再次從 List 中讀取消息了。
為了留存消息,List 類型提供了 BRPOPLPUSH 命令,這個命令的作用是讓消費者程序從一個 List 中讀取消息,同時,Redis 會把這個消息再插入到另一個 List(可以叫作備份 List)留存。
這樣一來,如果消費者程序讀了消息但沒能正常處理,等它重啟后,就可以從備份 List 中重新讀取消息并進行處理了。
好了,到這里可以知道基于 List 類型的消息隊列,滿足消息隊列的三大需求(消息保序、處理重復的消息和保證消息可靠性)。
但是,在用 List 做消息隊列時,如果生產者消息發送很快,而消費者處理消息的速度比較慢,這就導致 List 中的消息越積越多,給 Redis 的內存帶來很大壓力。
要解決這個問題,就要啟動多個消費者程序組成一個消費組,一起分擔處理 List 中的消息。但是,List 類型并不支持消費組的實現。
這就要說起 Redis 從 5.0 版本開始提供的 Stream 數據類型了,Stream 同樣能夠滿足消息隊列的三大需求,而且它還支持「消費組」形式的消息讀取。
Hash 是一個鍵值對(key - value)集合,其中 value 的形式入:value=[{field1,value1},...{fieldN,valueN}]。Hash 特別適合用于存儲對象。
Hash 與 String 對象的區別如下圖所示:
Hash 類型的底層數據結構是由壓縮列表或哈希表實現的:
在 Redis 7.0 中,壓縮列表數據結構已經廢棄了,交由 listpack 數據結構來實現了。
# 存儲一個哈希表key的鍵值
HSET key field value
# 獲取哈希表key對應的field鍵值
HGET key field
# 在一個哈希表key中存儲多個鍵值對
HMSET key field value [field value...]
# 批量獲取哈希表key中多個field鍵值
HMGET key field [field ...]
# 刪除哈希表key中的field鍵值
HDEL key field [field ...]
# 返回哈希表key中field的數量
HLEN key
# 返回哈希表key中所有的鍵值
HGETALL key
# 為哈希表key中field鍵的值加上增量n
HINCRBY key field n
Hash 類型的 (key,field, value) 的結構與對象的(對象id, 屬性, 值)的結構相似,也可以用來存儲對象。
我們以用戶信息為例,它在關系型數據庫中的結構是這樣的:
我們可以使用如下命令,將用戶對象的信息存儲到 Hash 類型:
# 存儲一個哈希表uid:1的鍵值
> HSET uid:1 name Tom age 15
2
# 存儲一個哈希表uid:2的鍵值
> HSET uid:2 name Jerry age 13
2
# 獲取哈希表用戶id為1中所有的鍵值
> HGETALL uid:1
1) "name"
2) "Tom"
3) "age"
4) "15"
Redis Hash 存儲其結構如下圖:
在介紹 String 類型的應用場景時有所介紹,String + Json也是存儲對象的一種方式,那么存儲對象時,到底用 String + json 還是用 Hash 呢?
一般對象用 String + Json 存儲,對象中某些頻繁變化的屬性可以考慮抽出來用 Hash 類型存儲。
以用戶 id 為 key,商品 id 為 field,商品數量為 value,恰好構成了購物車的3個要素,如下圖所示。
涉及的命令如下:
當前僅僅是將商品ID存儲到了Redis 中,在回顯商品具體信息的時候,還需要拿著商品 id 查詢一次數據庫,獲取完整的商品的信息。
Set 類型是一個無序并唯一的鍵值集合,它的存儲順序不會按照插入的先后順序進行存儲。
一個集合最多可以存儲 2^32-1 個元素。概念和數學中個的集合基本類似,可以交集,并集,差集等等,所以 Set 類型除了支持集合內的增刪改查,同時還支持多個集合取交集、并集、差集。
Set 類型和 List 類型的區別如下:
Set 類型的底層數據結構是由哈希表或整數集合實現的:
Set常用操作:
# 往集合key中存入元素,元素存在則忽略,若key不存在則新建
SADD key member [member ...]
# 從集合key中刪除元素
SREM key member [member ...]
# 獲取集合key中所有元素
SMEMBERS key
# 獲取集合key中的元素個數
SCARD key
# 判斷member元素是否存在于集合key中
SISMEMBER key member
# 從集合key中隨機選出count個元素,元素不從key中刪除
SRANDMEMBER key [count]
# 從集合key中隨機選出count個元素,元素從key中刪除
SPOP key [count]
Set運算操作:
# 交集運算
SINTER key [key ...]
# 將交集結果存入新集合destination中
SINTERSTORE destination key [key ...]
# 并集運算
SUNION key [key ...]
# 將并集結果存入新集合destination中
SUNIONSTORE destination key [key ...]
# 差集運算
SDIFF key [key ...]
# 將差集結果存入新集合destination中
SDIFFSTORE destination key [key ...]
集合的主要幾個特性,無序、不可重復、支持并交差等操作。
因此 Set 類型比較適合用來數據去重和保障數據的唯一性,還可以用來統計多個集合的交集、錯集和并集等,當我們存儲的數據是無序并且需要去重的情況下,比較適合使用集合類型進行存儲。
但是要提醒你一下,這里有一個潛在的風險。Set 的差集、并集和交集的計算復雜度較高,在數據量較大的情況下,如果直接執行這些計算,會導致 Redis 實例阻塞。
在主從集群中,為了避免主庫因為 Set 做聚合計算(交集、差集、并集)時導致主庫被阻塞,我們可以選擇一個從庫完成聚合統計,或者把數據返回給客戶端,由客戶端來完成聚合統計。
Set 類型可以保證一個用戶只能點一個贊,這里舉例子一個場景,key 是文章id,value 是用戶id。
uid:1 、uid:2、uid:3 三個用戶分別對 article:1 文章點贊了。
# uid:1 用戶對文章 article:1 點贊
> SADD article:1 uid:1
(integer) 1
# uid:2 用戶對文章 article:1 點贊
> SADD article:1 uid:2
(integer) 1
# uid:3 用戶對文章 article:1 點贊
> SADD article:1 uid:3
(integer) 1
uid:1 取消了對 article:1 文章點贊。
> SREM article:1 uid:1
(integer) 1
獲取 article:1 文章所有點贊用戶 :
> SMEMBERS article:1
1) "uid:3"
2) "uid:2"
獲取 article:1 文章的點贊用戶數量:
> SCARD article:1
(integer) 2
判斷用戶 uid:1 是否對文章 article:1 點贊了:
> SISMEMBER article:1 uid:1
(integer) 0 # 返回0說明沒點贊,返回1則說明點贊了
Set 類型支持交集運算,所以可以用來計算共同關注的好友、公眾號等。
key 可以是用戶id,value 則是已關注的公眾號的id。
uid:1 用戶關注公眾號 id 為 5、6、7、8、9,uid:2 用戶關注公眾號 id 為 7、8、9、10、11。
# uid:1 用戶關注公眾號 id 為 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2 用戶關注公眾號 id 為 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5
uid:1 和 uid:2 共同關注的公眾號:
# 獲取共同關注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"
給 uid:2 推薦 uid:1 關注的公眾號:
> SDIFF uid:1 uid:2
1) "5"
2) "6"
驗證某個公眾號是否同時被 uid:1 或 uid:2 關注:
> SISMEMBER uid:1 5
(integer) 1 # 返回0,說明關注了
> SISMEMBER uid:2 5
(integer) 0 # 返回0,說明沒關注
存儲某活動中中獎的用戶名 ,Set 類型因為有去重功能,可以保證同一個用戶不會中獎兩次。
key為抽獎活動名,value為員工名稱,把所有員工名稱放入抽獎箱 :
>SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark
(integer) 5
如果允許重復中獎,可以使用 SRANDMEMBER 命令。
# 抽取 1 個一等獎:
> SRANDMEMBER lucky 1
1) "Tom"
# 抽取 2 個二等獎:
> SRANDMEMBER lucky 2
1) "Mark"
2) "Jerry"
# 抽取 3 個三等獎:
> SRANDMEMBER lucky 3
1) "Sary"
2) "Tom"
3) "Jerry"
如果不允許重復中獎,可以使用 SPOP 命令。
# 抽取一等獎1個
> SPOP lucky 1
1) "Sary"
# 抽取二等獎2個
> SPOP lucky 2
1) "Jerry"
2) "Mark"
# 抽取三等獎3個
> SPOP lucky 3
1) "John"
2) "Sean"
3) "Lindy"
Zset 類型(有序集合類型)相比于 Set 類型多了一個排序屬性 score(分值),對于有序集合 ZSet 來說,每個存儲元素相當于有兩個值組成的,一個是有序結合的元素值,一個是排序值。
有序集合保留了集合不能有重復成員的特性(分值可以重復),但不同的是,有序集合中的元素可以排序。
Zset 類型的底層數據結構是由壓縮列表或跳表實現的:
在 Redis 7.0 中,壓縮列表數據結構已經廢棄了,交由 listpack 數據結構來實現了。
Zset 常用操作:
# 往有序集合key中加入帶分值元素
ZADD key score member [[score member]...]
# 往有序集合key中刪除元素
ZREM key member [member...]
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素個數
ZCARD key
# 為有序集合key中元素member的分值加上increment
ZINCRBY key increment member
# 正序獲取有序集合key從start下標到stop下標的元素
ZRANGE key start stop [WITHSCORES]
# 倒序獲取有序集合key從start下標到stop下標的元素
ZREVRANGE key start stop [WITHSCORES]
# 返回有序集合中指定分數區間內的成員,分數由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
# 返回指定成員區間內的成員,按字典正序排列, 分數必須相同。
ZRANGEBYLEX key min max [LIMIT offset count]
# 返回指定成員區間內的成員,按字典倒序排列, 分數必須相同
ZREVRANGEBYLEX key max min [LIMIT offset count]
Zset 運算操作(相比于 Set 類型,ZSet 類型沒有支持差集運算):
# 并集計算(相同元素分值相加),numberkeys一共多少個key,WEIGHTS每個key對應的分值乘積
ZUNIONSTORE destkey numberkeys key [key...]
# 交集計算(相同元素分值相加),numberkeys一共多少個key,WEIGHTS每個key對應的分值乘積
ZINTERSTORE destkey numberkeys key [key...]
Zset 類型(Sorted Set,有序集合) 可以根據元素的權重來排序,我們可以自己來決定每個元素的權重值。比如說,我們可以根據元素插入 Sorted Set 的時間確定權重值,先插入的元素權重小,后插入的元素權重大。
在面對需要展示最新列表、排行榜等場景時,如果數據更新頻繁或者需要分頁顯示,可以優先考慮使用 Sorted Set。
有序集合比較典型的使用場景就是排行榜。例如學生成績的排名榜、游戲積分排行榜、視頻播放排名、電商系統中商品的銷量排名等。
我們以博文點贊排名為例,小林發表了五篇博文,分別獲得贊為 200、40、100、50、150。
# arcticle:1 文章獲得了200個贊
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章獲得了40個贊
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章獲得了100個贊
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章獲得了50個贊
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章獲得了150個贊
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1
文章 arcticle:4 新增一個贊,可以使用 ZINCRBY 命令(為有序集合key中元素member的分值加上increment):
> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"
查看某篇文章的贊數,可以使用 ZSCORE 命令(返回有序集合key中元素個數):
> ZSCORE user:xiaolin:ranking arcticle:4
"50"
獲取小林文章贊數最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序獲取有序集合 key 從start下標到stop下標的元素):
# WITHSCORES 表示把 score 也顯示出來
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"
獲取小林 100 贊到 200 贊的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分數區間內的成員,分數由低到高排序):
> ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"
使用有序集合的 ZRANGEBYLEX 或 ZREVRANGEBYLEX 可以幫助我們實現電話號碼或姓名的排序,我們以 ZRANGEBYLEX (返回指定成員區間內的成員,按 key 正序排列,分數必須相同)為例。
注意:不要在分數不一致的 SortSet 集合中去使用 ZRANGEBYLEX和 ZREVRANGEBYLEX 指令,因為獲取的結果會不準確。
1、電話排序
我們可以將電話號碼存儲到 SortSet 中,然后根據需要來獲取號段:
> ZADD phone 0 13100111100 0 13110114300 0 13132110901
(integer) 3
> ZADD phone 0 13200111100 0 13210414300 0 13252110901
(integer) 3
> ZADD phone 0 13300111100 0 13310414300 0 13352110901
(integer) 3
獲取所有號碼:
> ZRANGEBYLEX phone - +
1) "13100111100"
2) "13110114300"
3) "13132110901"
4) "13200111100"
5) "13210414300"
6) "13252110901"
7) "13300111100"
8) "13310414300"
9) "13352110901"
獲取 132 號段的號碼:
> ZRANGEBYLEX phone [132 (133
1) "13200111100"
2) "13210414300"
3) "13252110901"
獲取132、133號段的號碼:
> ZRANGEBYLEX phone [132 (134
1) "13200111100"
2) "13210414300"
3) "13252110901"
4) "13300111100"
5) "13310414300"
6) "13352110901"
2、姓名排序
> zadd names 0 Toumas 0 Jake 0 Bluetuo 0 Gaodeng 0 Aimini 0 Aidehua
(integer) 6
獲取所有人的名字:
> ZRANGEBYLEX names - +
1) "Aidehua"
2) "Aimini"
3) "Bluetuo"
4) "Gaodeng"
5) "Jake"
6) "Toumas"
獲取名字中大寫字母A開頭的所有人:
> ZRANGEBYLEX names [A (B
1) "Aidehua"
2) "Aimini"
獲取名字中大寫字母 C 到 Z 的所有人:
> ZRANGEBYLEX names [C [Z
1) "Gaodeng"
2) "Jake"
3) "Toumas"
Bitmap,即位圖,是一串連續的二進制數組(0和1),可以通過偏移量(offset)定位元素。BitMap通過最小的單位bit來進行0|1的設置,表示某個元素的值或者狀態,時間復雜度為O(1)。
由于 bit 是計算機中最小的單位,使用它進行儲存將非常節省空間,特別適合一些數據量大且使用二值統計的場景。
Bitmap 本身是用 String 類型作為底層數據結構實現的一種統計二值狀態的數據類型。
String 類型是會保存為二進制的字節數組,所以,Redis 就把字節數組的每個 bit 位利用起來,用來表示一個元素的二值狀態,你可以把 Bitmap 看作是一個 bit 數組。
bitmap 基本操作:
# 設置值,其中value只能是 0 和 1
SETBIT key offset value
# 獲取值
GETBIT key offset
# 獲取指定范圍內值為 1 的個數
# start 和 end 以字節為單位
BITCOUNT key start end
bitmap 運算操作:
# BitMap間的運算
# operations 位移操作符,枚舉值
AND 與運算 &
OR 或運算 |
XOR 異或 ^
NOT 取反 ~
# result 計算的結果,會存儲在該key中
# key1 … keyn 參與運算的key,可以有多個,空格分割,not運算只能一個key
# 當 BITOP 處理不同長度的字符串時,較短的那個字符串所缺少的部分會被看作 0。返回值是保存到 destkey 的字符串的長度(以字節byte為單位),和輸入 key 中最長的字符串長度相等。
BITOP [operations] [result] [key1] [keyn…]
# 返回指定key中第一次出現指定value(0/1)的位置
BITPOS [key] [value]
Bitmap 類型非常適合二值狀態統計的場景,這里的二值狀態就是指集合元素的取值就只有 0 和 1 兩種,在記錄海量數據時,Bitmap 能夠有效地節省內存空間。
在簽到打卡的場景中,我們只用記錄簽到(1)或未簽到(0),所以它就是非常典型的二值狀態。
簽到統計時,每個用戶一天的簽到用 1 個 bit 位就能表示,一個月(假設是 31 天)的簽到情況用 31 個 bit 位就可以,而一年的簽到也只需要用 365 個 bit 位,根本不用太復雜的集合類型。
假設我們要統計 ID 100 的用戶在 2022 年 6 月份的簽到情況,就可以按照下面的步驟進行操作。
第一步,執行下面的命令,記錄該用戶 6 月 3 號已簽到。
SETBIT uid:sign:100:202206 2 1
第二步,檢查該用戶 6 月 3 日是否簽到。
GETBIT uid:sign:100:202206 2
第三步,統計該用戶在 6 月份的簽到次數。
BITCOUNT uid:sign:100:202206
這樣,我們就知道該用戶在 6 月份的簽到情況了。
如何統計這個月首次打卡時間呢?
Redis 提供了 BITPOS key bitValue [start] [end]指令,返回數據表示 Bitmap 中第一個值為 bitValue 的 offset 位置。
在默認情況下, 命令將檢測整個位圖, 用戶可以通過可選的 start 參數和 end 參數指定要檢測的范圍。所以我們可以通過執行這條命令來獲取 userID = 100 在 2022 年 6 月份首次打卡日期:
BITPOS uid:sign:100:202206 1
需要注意的是,因為 offset 從 0 開始的,所以我們需要將返回的 value + 1 。
Bitmap 提供了 GETBIT、SETBIT 操作,通過一個偏移值 offset 對 bit 數組的 offset 位置的 bit 位進行讀寫操作,需要注意的是 offset 從 0 開始。
只需要一個 key = login_status 表示存儲用戶登陸狀態集合數據, 將用戶 ID 作為 offset,在線就設置為 1,下線設置 0。通過 GETBIT判斷對應的用戶是否在線。50000 萬 用戶只需要 6 MB 的空間。
假如我們要判斷 ID = 10086 的用戶的登陸情況:
第一步,執行以下指令,表示用戶已登錄。
SETBIT login_status 10086 1
第二步,檢查該用戶是否登陸,返回值 1 表示已登錄。
GETBIT login_status 10086
第三步,登出,將 offset 對應的 value 設置成 0。
SETBIT login_status 10086 0
如何統計出這連續 7 天連續打卡用戶總數呢?
我們把每天的日期作為 Bitmap 的 key,userId 作為 offset,若是打卡則將 offset 位置的 bit 設置成 1。
key 對應的集合的每個 bit 位的數據則是一個用戶在該日期的打卡記錄。
一共有 7 個這樣的 Bitmap,如果我們能對這 7 個 Bitmap 的對應的 bit 位做 『與』運算。同樣的 UserID offset 都是一樣的,當一個 userID 在 7 個 Bitmap 對應對應的 offset 位置的 bit = 1 就說明該用戶 7 天連續打卡。
結果保存到一個新 Bitmap 中,我們再通過 BITCOUNT 統計 bit = 1 的個數便得到了連續打卡 3 天的用戶總數了。
Redis 提供了 BITOP operation destkey key [key ...]這個指令用于對一個或者多個 key 的 Bitmap 進行位元操作。
舉個例子,比如將三個 bitmap 進行 AND 操作,并將結果保存到 destmap 中,接著對 destmap 執行 BITCOUNT 統計。
# 與操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 統計 bit 位 = 1 的個數
BITCOUNT destmap
即使一天產生一個億的數據,Bitmap 占用的內存也不大,大約占 12 MB 的內存(10^8/8/1024/1024),7 天的 Bitmap 的內存開銷約為 84 MB。同時我們最好給 Bitmap 設置過期時間,讓 Redis 刪除過期的打卡數據,節省內存。
Redis HyperLogLog 是 Redis 2.8.9 版本新增的數據類型,是一種用于「統計基數」的數據集合類型,基數統計就是指統計一個集合中不重復的元素個數。但要注意,HyperLogLog 是統計規則是基于概率完成的,不是非常準確,標準誤算率是 0.81%。
所以,簡單來說 HyperLogLog 提供不精確的去重計數。
HyperLogLog 的優點是,在輸入元素的數量或者體積非常非常大時,計算基數所需的內存空間總是固定的、并且是很小的。
在 Redis 里面,每個 HyperLogLog 鍵只需要花費 12 KB 內存,就可以計算接近 2^64 個不同元素的基數,和元素越多就越耗費內存的 Set 和 Hash 類型相比,HyperLogLog 就非常節省空間。
這什么概念?舉個例子給大家對比一下。
用 Java 語言來說,一般 long 類型占用 8 字節,而 1 字節有 8 位,即:1 byte = 8 bit,即 long 數據類型最大可以表示的數是:2^63-1。對應上面的2^64個數,假設此時有2^63-1這么多個數,從 0 ~ 2^63-1,按照long以及1k = 1024 字節的規則來計算內存總數,就是:((2^63-1) * 8/1024)K,這是很龐大的一個數,存儲空間遠遠超過12K,而HyperLogLog 卻可以用 12K 就能統計完。
HyperLogLog 的實現涉及到很多數學問題,太費腦子了,我也沒有搞懂。
HyperLogLog 命令很少,就三個。
# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]
# 返回給定 HyperLogLog 的基數估算值。
PFCOUNT key [key ...]
# 將多個 HyperLogLog 合并為一個 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]
Redis HyperLogLog 優勢在于只需要花費 12 KB 內存,就可以計算接近 2^64 個元素的基數,和元素越多就越耗費內存的 Set 和 Hash 類型相比,HyperLogLog 就非常節省空間。
所以,非常適合統計百萬級以上的網頁 UV 的場景。
在統計 UV 時,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把訪問頁面的每個用戶都添加到 HyperLogLog 中。
PFADD page1:uv user1 user2 user3 user4 user5
接下來,就可以用 PFCOUNT 命令直接獲得 page1 的 UV 值了,這個命令的作用就是返回 HyperLogLog 的統計結果。
PFCOUNT page1:uv
不過,有一點需要你注意一下,HyperLogLog 的統計規則是基于概率完成的,所以它給出的統計結果是有一定誤差的,標準誤算率是 0.81%。
這也就意味著,你使用 HyperLogLog 統計的 UV 是 100 萬,但實際的 UV 可能是 101 萬。雖然誤差率不算大,但是,如果你需要精確統計結果的話,最好還是繼續用 Set 或 Hash 類型。
Redis GEO 是 Redis 3.2 版本新增的數據類型,主要用于存儲地理位置信息,并對存儲的信息進行操作。
在日常生活中,我們越來越依賴搜索“附近的餐館”、在打車軟件上叫車,這些都離不開基于位置信息服務(Location-Based Service,LBS)的應用。LBS 應用訪問的數據是和人或物關聯的一組經緯度信息,而且要能查詢相鄰的經緯度范圍,GEO 就非常適合應用在 LBS 服務的場景中。
GEO 本身并沒有設計新的底層數據結構,而是直接使用了 Sorted Set 集合類型。
GEO 類型使用 GeoHash 編碼方法實現了經緯度到 Sorted Set 中元素權重分數的轉換,這其中的兩個關鍵機制就是「對二維地圖做區間劃分」和「對區間進行編碼」。一組經緯度落在某個區間后,就用區間的編碼值來表示,并把編碼值作為 Sorted Set 元素的權重分數。
這樣一來,我們就可以把經緯度保存到 Sorted Set 中,利用 Sorted Set 提供的“按權重進行有序范圍查找”的特性,實現 LBS 服務中頻繁使用的“搜索附近”的需求。
# 存儲指定的地理空間位置,可以將一個或多個經度(longitude)、緯度(latitude)、位置名稱(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]
# 從給定的 key 里返回所有指定名稱(member)的位置(經度和緯度),不存在的返回 nil。
GEOPOS key member [member ...]
# 返回兩個給定位置之間的距離。
GEODIST key member1 member2 [m|km|ft|mi]
# 根據用戶給定的經緯度坐標來獲取指定范圍內的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
這里以滴滴叫車的場景為例,介紹下具體如何使用 GEO 命令:GEOADD 和 GEORADIUS 這兩個命令。
假設車輛 ID 是 33,經緯度位置是(116.034579,39.030452),我們可以用一個 GEO 集合保存所有車輛的經緯度,集合 key 是 cars:locations。
執行下面的這個命令,就可以把 ID 號為 33 的車輛的當前經緯度位置存入 GEO 集合中:
GEOADD cars:locations 116.034579 39.030452 33
當用戶想要尋找自己附近的網約車時,LBS 應用就可以使用 GEORADIUS 命令。
例如,LBS 應用執行下面的命令時,Redis 會根據輸入的用戶的經緯度信息(116.054579,39.030452 ),查找以這個經緯度為中心的 5 公里內的車輛信息,并返回給 LBS 應用。
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
Redis Stream 是 Redis 5.0 版本新增加的數據類型,Redis 專門為消息隊列設計的數據類型。
在前面介紹 List 類型實現的消息隊列,有兩個問題:1. 生產者需要自行實現全局唯一 ID;2. 不能以消費組形式消費數據。
基于 Stream 類型的消息隊列就解決上面的問題,它不僅支持自動生成全局唯一 ID,而且支持以消費組形式消費數據。
Stream 消息隊列操作命令:
生產者通過 XADD 命令插入一條消息:
# * 表示讓 Redis 為插入的數據自動生成一個全局唯一的 ID
# 往名稱為 mymq 的消息隊列中插入一條消息,消息的鍵是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"
插入成功后會返回全局唯一的 ID:"1654254953808-0"。消息的全局唯一 ID 由兩部分組成:
消費者通過 XREAD 命令從消息隊列中讀取消息時,可以指定一個消息 ID,并從這個消息 ID 的下一條消息開始進行讀取(注意是輸入消息 ID 的下一條信息開始讀取,不是查詢輸入ID的消息)。
# 從 ID 號為 1654254953807-0 的消息開始,讀取后續的所有消息(示例中一共 1 條)。
> XREAD Stream mymq 1654254953807-0
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
如果想要實現阻塞讀(當沒有數據時,阻塞住),可以調用 XRAED 時設定 block 配置項,實現類似于 BRPOP 的阻塞讀取操作。
比如,下面這命令,設置了 block 10000 的配置項,10000 的單位是毫秒,表明 XREAD 在讀取最新消息時,如果沒有消息到來,XREAD 將阻塞 10000 毫秒(即 10 秒),然后再返回。
# 命令最后的“$”符號表示讀取最新的消息
> XREAD block 10000 Stream mymq $
(nil)
(10.00s)
前面介紹的這些操作 List 也支持的,接下來看看 Stream 特有的功能。
Stream 可以以使用 XGROUP 創建消費組,創建消費組之后,Stream 可以使用 XREADGROUP 命令讓消費組內的消費者讀取消息。
創建一個名為 group1 的消費組,這個消費組消費的消息隊列是 mymq:
# 創建一個名為 group1 的消費組
> XGROUP create mymq group1 0
OK
消費組 group1 內的消費者 consumer1 從 mymq 消息隊列中讀取所有消息的命令如下:
# 命令最后的參數“>”,表示從第一條尚未被消費的消息開始讀取。
> XREADGROUP group group1 consumer1 Stream mymq >
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
消息隊列中的消息一旦被消費組里的一個消費者讀取了,就不能再被該消費組內的其他消費者讀取了。
比如說,我們執行完剛才的 XREADGROUP 命令后,再執行一次同樣的命令,此時讀到的就是空值了:
> XREADGROUP group group1 consumer1 Stream mymq >
(nil)
使用消費組的目的是讓組內的多個消費者共同分擔讀取消息,所以,我們通常會讓每個消費者讀取部分消息,從而實現消息讀取負載在多個消費者間是均衡分布的。
例如,我們執行下列命令,讓 group2 中的 consumer1、2、3 各自讀取一條消息。
# 讓 group2 中的 consumer1 從 mymq 消息隊列中消費一條消息
> XREADGROUP group group2 consumer1 count 1 Stream mymq >
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
# 讓 group2 中的 consumer2 從 mymq 消息隊列中消費一條消息
> XREADGROUP group group2 consumer2 count 1 Stream mymq >
1) 1) "mymq"
2) 1) 1) "1654256265584-0"
2) 1) "name"
2) "xiaolincoding"
# 讓 group2 中的 consumer3 從 mymq 消息隊列中消費一條消息
> XREADGROUP group group2 consumer3 count 1 Stream mymq >
1) 1) "mymq"
2) 1) 1) "1654256271337-0"
2) 1) "name"
2) "Tom"
基于 Stream 實現的消息隊列,如何保證消費者在發生故障或宕機再次重啟后,仍然可以讀取未處理完的消息?
Streams 會自動使用內部隊列(也稱為 PENDING List)留存消費組里每個消費者讀取的消息,直到消費者使用 XACK 命令通知 Streams“消息已經處理完成”。
如果消費者沒有成功處理消息,它就不會給 Streams 發送 XACK 命令,消息仍然會留存。此時,消費者可以在重啟后,用 XPENDING 命令查看已讀取、但尚未確認處理完成的消息。
例如,我們來查看一下 group2 中各個消費者已讀取、但尚未確認的消息個數,命令如下:
127.0.0.1:6379> XPENDING mymq group2
1) (integer) 3
2) "1654254953808-0" # 表示 group2 中所有消費者讀取的消息最小 ID
3) "1654256271337-0" # 表示 group2 中所有消費者讀取的消息最大 ID
4) 1) 1) "consumer1"
2) "1"
2) 1) "consumer2"
2) "1"
3) 1) "consumer3"
2) "1"
如果想查看某個消費者具體讀取了哪些數據,可以執行下面的命令:
# 查看 group2 里 consumer2 已從 mymq 消息隊列中讀取了哪些消息
> XPENDING mymq group2 - + 10 consumer2
1) 1) "1654256265584-0"
2) "consumer2"
3) (integer) 410700
4) (integer) 1
可以看到,consumer2 已讀取的消息的 ID 是 1654256265584-0。
一旦消息 1654256265584-0 被 consumer2 處理了,consumer2 就可以使用 XACK 命令通知 Streams,然后這條消息就會被刪除。
> XACK mymq group2 1654256265584-0
(integer) 1
當我們再使用 XPENDING 命令查看時,就可以看到,consumer2 已經沒有已讀取、但尚未確認處理的消息了。
> XPENDING mymq group2 - + 10 consumer2
(empty array)
好了,基于 Stream 實現的消息隊列就說到這里了,小結一下:
Redis 基于 Stream 消息隊列與專業的消息隊列有哪些差距?
一個專業的消息隊列,必須要做到兩大塊:
1、Redis Stream 消息會丟失嗎?
使用一個消息隊列,其實就分為三大塊:生產者、隊列中間件、消費者,所以要保證消息就是保證三個環節都不能丟失數據。
Redis Stream 消息隊列能不能保證三個環節都不丟失數據?
可以看到,Redis 在隊列中間件環節無法保證消息不丟。像 RabbitMQ 或 Kafka 這類專業的隊列中間件,在使用時是部署一個集群,生產者在發布消息時,隊列中間件通常會寫「多個節點」,也就是有多個副本,這樣一來,即便其中一個節點掛了,也能保證集群的數據不丟失。
2、Redis Stream 消息可堆積嗎?
Redis 的數據都存儲在內存中,這就意味著一旦發生消息積壓,則會導致 Redis 的內存持續增長,如果超過機器內存上限,就會面臨被 OOM 的風險。所以 Redis 的 Stream 提供了可以指定隊列最大長度的功能,就是為了避免這種情況發生。
但 Kafka、RabbitMQ 專業的消息隊列它們的數據都是存儲在磁盤上,當消息積壓時,無非就是多占用一些磁盤空間。
因此,把 Redis 當作隊列來使用時,會面臨的 2 個問題:
所以,能不能將 Redis 作為消息隊列來使用,關鍵看你的業務場景:
參考資料:
Redis 常見的五種數據類型:**String(字符串),Hash(哈希),List(列表),Set(集合)及 Zset(sorted set:有序集合)**。
這五種數據類型都由多種數據結構實現的,主要是出于時間和空間的考慮,當數據量小的時候使用更簡單的數據結構,有利于節省內存,提高性能。
這五種數據類型與底層數據結構對應關系圖如下,左邊是 Redis 3.0版本的,也就是《Redis 設計與實現》這本書講解的版本,現在看還是有點過時了,右邊是現在 Github 最新的 Redis 代碼的。
可以看到,Redis 數據類型的底層數據結構隨著版本的更新也有所不同,比如:
Redis 五種數據類型的應用場景:
Redis 后續版本又支持四種數據類型,它們的應用場景如下:
針對 Redis 是否適合做消息隊列,關鍵看你的業務場景:
原文鏈接:https://mp.weixin.qq.com/s/r9_0xpRsp2ubgyvpiyMfuw
防御性編程是一種細致、謹慎的編程方法(習慣)。我們在寫代碼時常會有“以防萬一”的心態,把以防萬一有可能出現的情況提前考慮進去,規避以免以防萬一出現帶來的問題。
應用防御性編程技術,你可以偵測到可能被忽略的錯誤,防止可能會導致災難性后果的“小毛病”的出現,在時間的運行過程中為你節約大量的調試時間。
比如我們在寫下面這個效果時,如果只是按設計師設計效果來開發,我們就不會考慮標題內容過長的問題。但是在實際的應用中,數據是從后臺加載而來,標題的字數就有可能過長,過長之后就會導致標題溢出折行的效果如下圖,帶來不好的體驗。
如果站在防御式編程的角度來思考,那我們就會提前把這種問題規避掉。如果標題過長,我們可以使用...省略號來處理。而不是等到項目上線,實際問題發生時,再來修改代碼。
防御式CSS是一個片段的集合,可以幫助我們規避“以防萬一”產生的問題。
我們在CSS布局時,是按照設計師的效果來開發的,但是實際的網頁內容是動態的,網頁上的內容是可以改變的,如:文字數量,圖片尺寸、窗口大小等,再加上用戶的一些意想不到的行為和運行環境,從而造成CSS布局的效果并沒有按照我們預期的效果顯示。
我們可以通過添加某些CSS代碼,來避免這種情況帶來的問題。防御式CSS是實現項目穩定性建設重要但極其容易忽視的一環。
接下來我們分享9個應用場景下,具有防御式的CSS代碼。
我們設計時的理想效果是標題文字不超過8個字,正好顯示完整。但實際應用時,有可能標題內容過長造成換行顯示。我們可以添加文字溢出顯示..省略號來解決。
<style>
body,
h3 {
margin: 0;
padding: 0;
}
.box {
width: 150px;
height: 150px;
position: relative;
margin: 40px auto;
}
.box h3 {
height: 30px;
line-height: 30px;
font-weight: 100;
width: 100%;
background-color: rgba(0, 0, 0, 0.5);
font-size: 16px;
color: #fff;
position: absolute;
bottom: 0;
text-align: center;
/*以防萬一,標題過長時,用...省略號顯示*/
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<body>
<div class="box">
<img src="images/flex-06.jpg" alt="" />
<h3>"以防萬一"標題過長產生的問題</h3>
</div>
</body>
在這個效果中,我們并不希望標簽延伸到最右側,我們希望內容過長時,可以在一定的長度時就折行顯示。我們可以通過添 max-width屬性來避免這種“以防萬一”的問題。
同類的應用還有 min-width,在此就不演示了
<style>
.box {
width: 250px;
height: 250px;
position: relative;
}
.box span {
position: absolute;
background-color: rgba(119, 245, 197, 0.8);
line-height: 1.3;
font-size: 12px;
padding: 5px 10px;
border-radius: 0 50px 50px 0px;
left: 0px;
top: 5px;
/*以防萬一標簽內容過長,控制最大寬度,內容過多折行顯示*/
max-width: 70%;
}
</style>
</head>
<body>
<div class="box">
<img src="images/ms.jpg" alt="" />
<span>植物奶油 巧克力 草莓 榴蓮 花生 芝麻 小米 雞蛋</span>
</div>
</body>
我們預想的是用戶按1:1的大小來上傳頭像圖片,但實際用戶上傳的頭像比例是五花八門,就會造成圖片被拉伸或擠壓變形。我們可以添加Object-fit:cover來等比例裁剪圖片尺寸,這樣圖片就不會被拉伸或壓縮,不過會有一部分圖片被裁剪掉。
<style>
.box {
width: 200px;
height: 200px;
border-radius: 50%;
overflow: hidden;
}
.box img {
width: 100%;
height: 100%;
/*保持圖片原有尺寸來裁剪*/
object-fit: cover;
}
</style>
<body>
<div class="box">
<img src="images/tx2.png" alt="" />
</div>
</body>
當圖片上有文字時,如果圖片加載失敗,而外層容器的背景色和文字顏色接近,那么文字的展示效果就不理想;此時我們可以給圖片加上對應的背景色。
這個效果大家只需做個了解就好。通常如果圖片上有文字,設計師在設計效果圖時都會在圖片和文字中間加上一層黑色的半透明遮罩層,這樣即使圖片加載不出來,也不影響文字的展示效果。
<style>
.box {
width: 250px;
height: 156px;
position: relative;
}
.box img {
width: 100%;
height: 100%;
object-fit: cover;
/*"以防萬一"圖片加載失敗,加上背景色,保證文字能正常顯示*/
background-color: #666;
}
.box h3 {
width: 100%;
font-size: 20px;
text-align: center;
position: absolute;
top: 40px;
color: #fff;
}
</style>
<body>
<div class="box">
<img src="images/rotate3.webp" alt="" />
<h3>美麗的風景圖</h3>
</div>
</body>
在內容比較長的情況下,可以通過設置 overflow-y控制滾動條是否展示。但是這里更推薦將overflow-y的值設置為 auto。如果你將overflow-y顯式設置為 scroll時,不管容器內容長短,滾動條都會展示出來,這種體驗是不好的
<style>
.box {
width: 160px;
padding: 20px;
height: 200px;
background-color: skyblue;
line-height: 2;
border-radius: 20px;
}
.box .content {
padding-right: 10px;
max-height: 100%;
/*以防萬一,用戶內容不足時,不需要顯示滾動條,只有內容溢出時才顯示*/
overflow-y: auto;
}
/* 整個滾動條*/
.content::-webkit-scrollbar {
width: 5px;
padding: 0px 20px;
}
/* 滾動條軌道*/
.content::-webkit-scrollbar-track {
border-radius: 10px;
background-color: #000;
margin: 20px 0px;
}
/*滾動條上的滾動滑塊*/
.content::-webkit-scrollbar-thumb {
width: 14px;
border-radius: 10px;
background-color: #ddd;
}
</style>
<body>
<div class="box">
<div class="content">
在內容比較長的情況下,可以通過設置
overflow-y控制滾動條是否展示。但是這里更推薦將
</div>
</div>
</body>
當內容不足時不會出現滾動條,文字占據的寬度要寬些。當內容溢出出現滾動條時,因為滾動條要占據一部分空間,則會造成文字占據的空間變窄,因而會造成重排。我們可以元素添加scrollbar-gutter:stable;來避免這個問題。
scrollbar-gutter屬性有三個值
屬性值 | 描述 |
auto | 就是默認表現。沒有滾動條的時候,內容盡可能占據寬度,有了滾動條,可用寬度減小 |
stable | 如果 overflow 屬性計算值不是 visible,則提前預留好空白區域,這樣滾動條出現的時候,整個結構和布局都是穩定的。 |
both-edges | 讓容器左右兩側同時預留好空白區域,目的是讓局部絕對居中對稱。 |
沒有加scrollbar-gutter時,未出現滾動條和出現滾動條之間的差別
加上scrollbar-gutter:stable;后,出現滾動條和沒有出現滾動,前后文字顯示效果沒有差異
<style>
.box {
width: 160px;
padding: 20px;
height: 200px;
background-color: skyblue;
line-height: 2;
border-radius: 20px;
}
.box .content {
max-height: 100%;
/*以防萬一,用戶內容不足時,不需要顯示滾動條,只有內容溢出時才顯示*/
overflow-y: auto;
/*預留好滾動條位置,必免引起重排*/
scrollbar-gutter: stable;
}
.content::-webkit-scrollbar {
width: 10px;
}
.content::-webkit-scrollbar-track {
border-radius: 10px;
background-color: #000;
margin: 20px 0px;
}
.content::-webkit-scrollbar-thumb {
width: 14px;
border-radius: 10px;
background-color: #ddd;
}
</style>
<body>
<div class="box">
<div class="content">
當內容不足時不會出現滾動條,文字占據的寬度要寬些。當內容溢出出現滾動條時,因為滾動條要占據一部分空間,則會造成文字占據的空間變窄,因而會造成重排。
</div>
</div>
</body>
我們會發現當子元素滾動到頂部或底部繼續滾動滾輪時,會導致父元素的滾動,但這種行為有時會影響頁面體驗。在子元素上應用overscroll-behavior: contain就可以禁止掉這一行為。
<style>
body {
height: 2000px;
}
.box {
height: 400px;
width: 200px;
margin: 0px auto;
overflow-y: auto;
background-color: skyblue;
/*當滾動到滾動條底部和頂部時,會觸發父元素的滾動條滾動*/
overscroll-behavior-y: contain;
}
</style>
<body>
<div class="box">
<p>1</p>
<p>2</p>
<p>3</p>
<p>4</p>
<p>5</p>
<p>6</p>
<p>7</p>
<p>8</p>
<p>9</p>
<p>10</p>
<p>11</p>
<p>12</p>
<p>13</p>
<p>14</p>
<p>15</p>
<p>16</p>
<p>17</p>
<p>18</p>
<p>19</p>
<p>20</p>
<p>21</p>
<p>22</p>
<p>23</p>
<p>24</p>
<p>25</p>
<p>26</p>
<p>27</p>
<p>28</p>
<p>29</p>
<p>30</p>
<p>31</p>
<p>32</p>
</div>
</body>
如果我們每一行顯示的個數為n,那我們可以最后一行子項的后面加上n-2個span元素,span元素的寬度和其它子項元素寬度一樣,但不用設置高度。
為什么是添加n-2個span元素呢?
當最后一行只有1個子元素時,他會默認靠左,不用處理
當最后一行子元素正好時,我們就不用關心這個問題。
所以要去掉這兩種情況,只需要加n-2個span元素就好
<style>
.container {
width: 500px;
display: flex; /*彈性布局*/
justify-content: space-between; /*兩端對齊*/
flex-wrap: wrap; /*超出部分換行*/
}
.item {
width: 120px;
height: 100px;
background-color: pink;
margin-top: 10px;
}
.container span {
width: 120px; /*span寬和子項寬一樣*/
}
</style>
</head>
<body>
<div class="container">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
<div class="item">4</div>
<div class="item">5</div>
<div class="item">6</div>
<div class="item">7</div>
<!--以防萬一,子項個數不夠,最后一排出現兩端對齊,達不到預期效果-->
<span></span>
<span></span>
</div>
</body>
當我們想盡可能多的在一行顯示子項的個數時,有可能會出現子項個數不滿一行的情況。那這個時候利用網格布局,使用auto-fill和auto-fit就會是兩個完全不同的效果。
以下情況只會出現在子項內容不能占滿一行時。也就是說萬一內容不能占滿一行,則使用auto-fill就會出現空白問題。我們把auto-fill改成auto-fit就解決了這個問題
<style>
.container {
width: 100%;
display: grid;
/*grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));*/
/*以防萬一,子項不足占據一行時*/
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-rows: 250px;
grid-auto-flow: row;
grid-auto-rows: 250px;
gap: 10px;
}
.container .item:nth-child(even) {
background-color: skyblue;
}
.container .item:nth-child(odd) {
background-color: pink;
}
</style>
<body>
<div class="container">
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
<div class="item"></div>
</div>
</body>
為幫助到一部分同學不走彎路,真正達到一線互聯網大廠前端項目研發要求,首次實力寵粉,打造了《30天挑戰學習計劃》,內容如下:
HTML/HTML5,CSS/CSS3,JavaScript,真實企業項目開發,云服務器部署上線,從入門到精通
共4大完整的項目開發 !一行一行代碼帶領實踐開發,實際企業開發怎么做我們就是怎么做。從學習一開始就進入工作狀態,省得浪費時間。
從學習一開始就同步使用 Git 進行項目代碼的版本的管理,Markdown 記錄學習筆記,包括真實大廠項目的開發標準和設計規范,命名規范,項目代碼規范,SEO優化規范
從藍湖UI設計稿 到 PC端,移動端,多端響應式開發項目開發
這些內容在《30天挑戰學習計劃》中每一個細節都有講到,包含視頻+圖文教程+項目資料素材等。只為實力寵粉,真正一次掌握企業項目開發必備技能,不走彎路 !
過程中【不涉及】任何費用和利益,非誠勿擾 。
點擊進入:30 天挑戰學習計劃 Web 前端從入門到實戰 | arry老師的博客-艾編程
*請認真填寫需求信息,我們會在24小時內與您取得聯系。