簡單的演算法-解決頁面指令碼非同步載入順序問題

lanzhiheng發表於2019-01-20

這幾天稍微掃了一下CoffeeScript的部分原始碼,發現了一條挺有意思的演算法,它解決了頁面非同步載入指令碼時遇到的順序問題。只是當初都沒想過可以這樣優雅地去處理這方面的問題。非同步載入的指令碼之間可能會有依賴關係,因此載入順序就異常重要了。

一. 場景分析-同步與非同步

1. 同步載入

假如瀏覽器需要引入多個JavaScript資源,我們一般會在頁面上嵌入如下程式碼

<
body>
<
script src="http://xxxxx.com/global.js">
<
/script>
<
script>
<
!-- Global 全域性變數是從global.js指令碼中引入 -->
window.Global.user_id = "x223345333445"
<
/script>
<
script src="http://xxxxx.com/main.js">
<
/script>
<
/body>
複製程式碼

預設情況下script標籤裡面的資源會自動載入,並且這個過程是同步的,我們並不需要擔心第一個script標籤請求完成之前瀏覽器就去執行第二個script標籤中的程式碼(超時或者是加上了aync這些屬性的情況要另當別論了)。這種場景我姑且稱之為指令碼的同步載入。

2. 非同步載入

上述場景中script標籤相當於自動加上了type="text/javascript"這樣一個屬性值,瀏覽器會自動識別這類資源並進行載入。但是如果載入的不是JavaScript資源呢?假設我們要載入CoffeeScript資源,或許就會把程式碼寫成這樣

<
body>
<
script type="text/coffeescript" src="http://xxxxx.com/global.coffee">
<
/script>
<
script type="text/coffeescript">
<
!-- Global 全域性變數是從global.coffee指令碼中引入 -->
window.Global.user_id = "x223345333445" <
/script type="text/coffeescript">
<
script src="http://xxxxx.com/main.coffee">
<
/script>
<
/body>
複製程式碼

這種情況下,瀏覽器就不會自動載入script標籤裡面的資源了,畢竟瀏覽器無法直接解析這種型別的指令碼。

如果要載入這類資源我們則需要手動編寫程式碼來遍歷所有包含屬性值type="text/coffeescript"script標籤,如果是帶有src屬性則非同步請求資源,如果沒有src屬性則直接獲取標籤包裹的內容。通過特殊的指令碼來執行載入好的CoffeeScript程式碼。

這種場景中第一和第三個標籤都需要通過傳送請求來獲取資源,這就會導致一種現象,如果不加特殊處理第二個標籤裡面的程式碼會比另外兩個指令碼先執行,而這個時候變數Global還沒有被定義,就會導致指令碼出錯。這種就是非同步載入指令碼的場景,非同步雖好,它不會堵塞頁面,不過要處理各個指令碼之間的依賴關係也是個頭疼的問題。

二. 解決方案

在不考慮使用打包工具的情況下我暫且提出這三個解決方案

1. 回撥

回撥無疑是最為簡單粗暴的方式,以上的案例中只有3個JavaScript資源,構建一條完整的回撥鏈似乎沒什麼問題。不過還是會使程式碼變得難懂,且噁心。回撥很簡單這裡就不貼程式碼了。

如果我把script標籤增加到10個,並且其中包含幾個內嵌指令碼的話你應該不會再想用回撥來解決了吧?案例如下

<
body>
... <
script type="text/coffeescript" src="http://xxxxx.com/extern1.coffee">
<
/script>
<
script type="text/coffeescript">
<
!-- 內嵌指令碼 -->
<
/script>
<
script type="text/coffeescript" src="http://xxxxx.com/extern2.coffee">
<
/script>
<
script type="text/coffeescript" src="http://xxxxx.com/extern3.coffee">
<
/script>
<
script type="text/coffeescript">
<
!-- 內嵌指令碼 -->
<
/script>
<
script type="text/coffeescript" src="http://xxxxx.com/extern4.coffee">
<
/script>
<
script type="text/coffeescript" src="http://xxxxx.com/extern5.coffee">
<
/script>
<
script type="text/coffeescript">
<
!-- 內嵌指令碼 -->
<
/script>
<
script type="text/coffeescript" src="http://xxxxx.com/extern6.coffee">
<
/script>
<
script type="text/coffeescript">
<
!-- 內嵌指令碼 -->
<
/script>
<
/body>
複製程式碼

當然,上面的只是示範程式碼,正常情況下我們不可能這樣去寫程式碼。這種情況如果用回撥去解決載入順序問題的話,估計是個人都會崩潰了。我們需要尋找更好的解決方案。

2. 填充佇列,佇列滿了再執行

為了非同步載入CoffeeScript資源,我先把虛擬碼寫成這樣

// 用於執行CoffeeScript程式碼function handleCoffeeScript(cfCodeString) { 
...
}// 用於非同步請求資源,返回Promisefunction ajax(url) {
}document.querySelectorAll('[type="text/coffeescript"]').forEach((item) =>
{
if (item.src) {
ajax(item.src).then((content) =>
{
handleCoffeeScript(content)
})
} else {
handleCoffeeScript(item.innerHTML)
}
})複製程式碼

這程式碼咋一看似乎沒什麼問題,尤其是這10個script標籤所涵蓋的CoffeScript程式碼的業務邏輯彼此間沒有任何依賴關係的時候,上訴程式碼完全可以直接使用。然而,一旦它們之間有依賴關係,這樣去載入指令碼就會報錯。我給10個指令碼分別編號1-10,假設所有指令碼都能夠順利載入,那麼會出現下面的情況

2, 5, 8, 10 // 同步的內嵌指令碼先載入執行1, 3, 4, 6, 7, 9 // 需要非同步請求的指令碼後執行複製程式碼

PS: 這只是一種情況,我們永遠無法保證先傳送的請求會先響應,畢竟每個介面的響應時間都不一樣,假設編號1中的資源比較大響應時間較長,那麼執行順序可能會變成3, 4, 1, 6, 7, 9。

通常為了解決這種問題我們需要維護一個。初始化一個特定長度的佇列,初始值值都是undefined。由於非同步指令碼都會在同步指令碼之後才能被執行,為此可以在每次非同步請求結束時都去檢測佇列是否已經滿了,如果滿了就證明所有指令碼都已經載入完畢。接著依次執行佇列中的每一項所包含的CoffeeScript資源。

// 用於執行CoffeeScript程式碼function handleCoffeeScript(cfCodeString) { 
...
}// 用於非同步請求資源,返回Promisefunction ajax(url) {
...
}const sources = document.querySelectorAll('[type="text/coffeescript"]')// 初始化佇列let queue = new Array(sources.length)// 檢測佇列是否已經滿了function checkQueueFull(queue) {
for(let i = 0;
i <
sources.length;
i ++) {
if (queue[i] === undefined) return false
} return true
}sources.forEach((item, i) =>
{
if (item.src) {
ajax(item.src).then((content) =>
{
// 佇列填充 queue[i] = content // 佇列如果塞滿的話則依次執行所有指令碼 if (checkQueueFull(queue)) {
queue.forEach(item =>
handleCoffeeScript(item))
}
})
} else {
// 佇列填充 queue[i] = item.innerHTML
}
})複製程式碼

這個指令碼確實能夠解決非同步載入資源時遇到的順序問題了,但是它顯得有點笨拙,它必須要等到所有指令碼載入完成後才能夠依次去執行所有指令碼

假設編號為5的資源並不是那麼重要,而且載入時間會比較長,這種方式就會導致所有資源都需要等待編號5的資源載入完畢之後才有機會執行,這會導致指令碼層面的堵塞。接下來我們進一步優化這個流程,看如何規避這種問題。

3. 填充佇列,讓指令碼儘可能早地去執行

為了優化這個過程,**除了上述的佇列我們還需要另外維護一個索引,每次非同步請求完成之後檢測當前索引所在位置的資源,如果這個資源已經載入好了,則執行當前位置的指令碼,索引自增,再檢測下一個索引所對應的資源是否能夠執行,以此類推,直到遇到某個不可用的資源則停止執行。當再次發生非同步請求的候重複上述過程,會根據索引值從之前停止的地方重新開啟檢測。**這一切可以以遞迴的方式實現,虛擬碼大概如下

// 用於執行CoffeeScript程式碼function handleCoffeeScript(cfCodeString) { 
...
}// 用於非同步請求資源,返回Promisefunction ajax(url) {
}const sources = document.querySelectorAll('[type="text/coffeescript"]')// 建立一個等長的佇列let queue = new Array(sources.length)// 指令碼執行索引let index = 0// 執行函式,採用遞迴的方式,檢測佇列中當前索引的資源是否可用,如果可用則呼叫`handleCoffeeScript`方法來處理相關的內容,遞增索引,並呼叫自身function execute() {
param = queue[index] if(param !== undefined) {
handleCoffeeScript(content) index ++ execute()
}
}sources.forEach((item, i) =>
{
if (item.src) {
ajax(item.src).then((content) =>
{
queue[i] = content // 每次指令碼載入完成都觸發執行指令碼,具體是否需要執行需要執行指令碼來判斷 execute()
})
} else {
queue[i] = item.innerHTML
}
})複製程式碼

我們來幻想一個比較極端的場景,假設1號和9號的非同步請求都是慢請求,9號指令碼的耗時比1號指令碼長許多(假設是5s),那麼載入程式執行起來會有以下表現

PS:簡單起見,我暫時用指令碼的狀態來對佇列中的每一項進行佔位。

  • 同步指令碼率先被載入進佇列,但先不執行
[undefined, "Available", undefined, undefined, "Available", undefined, undefined, "Available", undefined, "Available"]複製程式碼
  • 除了1號,9號指令碼之外,其他非同步指令碼都載入完成並塞進佇列中
[undefined, "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]複製程式碼
  • 1號指令碼載入完成
// 1號指令碼載入完畢["Available", "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]複製程式碼

後續指令碼會依次執行,但由於9號指令碼載入時間太長,所以在對應的位置會停止執行,並等待

// 然後依次執行["Executed", "Available", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"]["Executed", "Executed", "Available", "Available", "Available", "Available", "Available", "Available", undefined, "Available"].....["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", undefined, "Available"]複製程式碼
  • 等9號指令碼載入完畢,繼續執行餘下指令碼
["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Available", "Available"]["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Available"]["Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed", "Executed"]複製程式碼

這個指令碼的效能會比之前的指令碼好上一些了,起碼它不會等到所有資源都載入完畢之後才去執行。

一方面,2-10號的資源都需要依賴1號資源,它保證了1號資源載入並執行完畢之前不會執行任何其他的指令碼。另一方面,載入9號指令碼需要比較長的時間,而我們並不需要等到它載入完了才去執行其他指令碼,而是會讓在它之前的能夠執行的指令碼先行執行。只有10號指令碼會等待9號指令碼。

總結

這篇文章簡單地對非同步載入指令碼可能遇到的問題以及相關的解決方案做了個簡單的闡述,雖說真實環境可能再也不會遇到這種問題了,不過了解一下演算法還是有好處的,說不定哪天遇到類似的場景就派上用場了。

本文只是用JavaScript寫了些虛擬碼,演算法流程也只是用文字來簡單闡述,可能會導致有些地方表達不夠到位。如果想更全面地瞭解這個載入指令碼我建議直接看Coffeescript裡面的原始碼。

來源:https://juejin.im/post/5c43b8edf265da61715e9b04

相關文章