痛點:
在部分使用者的網路環境中,頁面CDN域名被劫持,導致前端資源無法正常載入,而頁面主域名正常,導致頁面可以訪問,但是功能不正常。
背景:
通常來說,主域名一般都是眾所周知的域名,運營商一般不會劫持(本文特指劫持後導致無法載入,注入這些不在本文考慮範圍內),主域名被劫持的可能性小。因為被劫持後,使用者無法訪問自然能夠很明顯感知到這是網路問題(鑑於掛掉情況可能性較小)。而CDN域名一般鮮為人知,運營商由於商業目的可能會劫持部分域名,於是就會導致頁面html結構出來了,樣式和互動都不正常,使用者可能會認為這是產品的問題,很少會認為這是網路問題。
解決方案:
CDN和主域名都同時承載資源,優先CDN載入,在CDN載入失敗的情況下,切換到主域名再次載入資源。
難點:
- 如何捕捉資源404、503等載入錯誤。
- js 如何準確重載入(與其說是難點,倒不如說是可討論的地方)。
1. 如何捕捉資源404、503等載入錯誤
1.1 在script或者link加onerror捕捉
在每個script或者link標籤中新增onerror屬性,捕捉載入錯誤。
1 |
<script onerror="catachError(this)"/> |
注:window全域性onerror是無法捕捉404、503等script或者link載入錯誤的,因為onerror事件並不會冒泡上傳。
優點:能夠準確捕捉資源載入失敗的場景,並及時處理。
缺點:程式碼入侵性強,不能夠很好的複用。對於fis利用佔位來新增css或者js的方式支援比較困難。
1.2 使用 HTTP HEAD 請求 檢測資源是否存在
在html最後加一段js,使用 HEAD 請求對所需要檢測的資源進行檢測,如果返回404或者503,則觸發載入失敗重新載入機制。
注意:由於是用XHR傳送HEAD請求,需要CDN方面支援跨域。
- 這麼多資源,這麼多檢測請求會不會帶來額外的流量消耗和延遲?資源一般來說都加了快取,在正常網路下,HEAD請求會從本地快取中返回結果,不會真正傳送http請求到伺服器,不會有額外消耗流量,增大延遲。
但是資源載入失敗的情況下,會消耗額外的流量、增大延遲。尤其是在網路返回延遲比較大的情況下,延遲會比較大。(但是這種情況,挽救的意義不大)
- 錯誤捕捉一定準確嗎?在資源載入完成和檢測之間,如果網路情況出現變化,就有可能導致誤判。
當資源在載入時成功返回,而在檢測時載入失敗時會導致資源載入兩次,僅僅針對無快取資源而言。對於有快取資源,載入完成之後,網路情況出現變化,HEAD請求已經感知不到了,因為HEAD請求就會走本地快取,而不會傳送到網路當中。
當資源在載入時載入失敗,而在檢測時成功返回時,檢測無效。以上情況出現概率比較小,需要出現在資源載入成功與失敗之間的微小時差中產生,幾乎可以忽略不計。
優點:檢測程式碼能夠和業務程式碼很好的分離,能夠檢測到絕大部分資源載入失敗的場景。
缺點:
- 有一定可能造成誤判。
- 倘若網路情況比較差且資源載入失敗的情況下,延遲比較大。
1.3 使用 載入器 載入資源
編寫類似labjs、requirejs但是支援fallback切換其他域名的載入器來載入資源,能夠在js中利用onerror來準確捕捉載入錯誤,並且能夠比較好的協調資源載入。
1 2 3 4 5 |
load('mp.toutiao.com', 'cdn.com', [ 'a.js', 'b.js', 'c.js' ]); |
以下為簡要程式碼,以做理解,使用該方案時請根據各自業務而定
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
const loadScript = (url) => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.onload = () => { resolve(); }; script.onerror = () => { reject(); }; document.appendChild(script); }); }; const loadOne = (mainDomain, secondaryDomain, url) => { return loadScript(`//:${mainDomain}/${url}`) .then(() => {}, () => { return loadScript(`//:${secondaryDomain}/${url}`); }); } const load = (mainDomain, secondaryDomain, urls) => { const promise = new Promise((resolve, reject) => resolve()); urls.forEach(url => { promise.then(() => loadOne(mainDomain, secondaryDomain, url)); }); }; |
但和labjs和requirejs不同的是,labjs和requirejs一般用於管理依賴以及按需載入,而針對檢測錯誤重載入的載入器用於檢測載入錯誤並切換源重新載入,有更好的載入錯誤重新載入機制,需要覆蓋頁面中所有的css、js等資源。
優點:能夠準確捕捉錯誤,且能夠在載入失敗並重試新域名的情況下保證正確的js執行順序。
缺點:
- 有可能導致白屏時間變長,資源載入時間會變長css、js的載入都會在載入器執行後再進行載入,而且瀏覽器無法識別將要載入哪些資源,不能進行並行預載入,導致css、js載入比較慢。且對於js而言,需要按順序執行,載入器只能按順序序列載入,載入完一個再載入另一個,相對於瀏覽器的自動並行載入,js載入時間會變長。
- 對於後端吐模板的頁面,會出現暫時性佈局亂和閃屏的情況後端吐模板的頁面,一般來說,頁面響應之後,html大致結構就出來了。如果使用載入器載入,css的載入會晚於頁面顯示的時間,會導致暫時性的頁面佈局沒有樣式只有html結構的情況。
普通頁面來說,css一般放在head裡,當瀏覽器解析頁面的時候,解析到link的時候,瀏覽器會去載入資源,同時繼續解析html,生成DOM樹,等到css載入完成、Style Rules樹也完成後,頁面才會render,你就會看見一個有樣式的頁面。
而對於載入器載入css的情況,在css載入完成之前,頁面就顯示了,就是一丟丟沒有樣式的html。等到css載入完成後,頁面在repaint/reflow一下,閃屏一下,頁面才顯示正常。
- 對於沒有采用labjs、requirejs等載入器的專案而言,改動成本比較大。
1.4 使用 Resource Timing API 來檢測
Resource Timing API(chrome和firefox等)不能檢測到載入失敗的資源,只能獲取到載入成功的資源的載入時間資料。
既然Resource Timing API 不能檢測到載入失敗的資源,那麼不能被檢測到的,自然就是載入失敗的資源。通過這個原理可以準確捕捉到所有載入失敗的場景。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 所有css+js資源 var allResources = Array.from(document.getElementsByTagName('script')) .map(script => script.src) .concat( Array.from(document.getElementsByTagName('link')) .filter((link) => link.rel === 'stylesheet') .map((link) => link.href) ); // 載入成功的css+js資源 var loadedResources = performance.getEntriesByType('resource') .filter((res) => { var url = res.name; var urlWithoutParam = url.split('?')[0]; return ['script', 'link'].indexOf(res.initiatorType) !== -1 && [/.css$/, /.js$/].some((reg) => reg.test(urlWithoutParam)); })).map((res) => res.name); // 載入失敗的css+js資源 var failedResources = allResources.filter((url) => loadedResources.indexOf(url) === -1); |
至於IE
IE並不支援這個方案,因為在IE中,載入失敗的資源會被包含在PerformanceResourceTiming中,而chrome、firefox等其他瀏覽器大部分並不包含。且並不能很好地區分載入失敗和載入成功的資源(尤其是404)。
詳情請看Clarify presence of requests that don’t return a response
翻了下W3C文件 resource-timing-1
Aborted requests or requests that don’t return a response may be included as PerformanceResourceTiming objects in the Performance Timeline of the relevant context.
注意裡面有一個may,這就很尷尬了。
優點:準確率高,程式碼也比較容易分離,無延遲。
缺點:對於Safari以及IE 11一下不支援。
1.5 CSS資源可通過rules來檢測
1 2 3 |
var links = document.queryAllSelector('link'); var failedCss = Array.from(links) .map((link) => link.sheet && link.sheet.rules.length === 0); |
優點: 準確率高,瀏覽器相容性好
缺點:僅僅適用於css資源,且對於跨域無效。
1.6 使用window.addEventListener來捕獲載入錯誤
當你看到此方法的標題時,或許你會覺得這個方法和1.1沒什麼區別。全域性onerror不能捕捉到載入錯誤的原因1.1已經提及,那為什麼window.addEventListener卻能捕獲載入錯誤呢?
HTML中事件傳播機制有兩種,一個是冒泡,另一種是捕獲。
通過捕獲,能夠在全域性捕獲到載入錯誤。
1 2 3 |
window.addEventListener('error', () => { // to do your things. }, true); |
從Webkit原始碼來解釋一下為什麼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Source/WebCore/dom/ScriptElement.cpp void ScriptElement::dispatchErrorEvent() { m_element.dispatchEvent(Event::create(eventNames().errorEvent, false, false)); } // Source/WebCore/dom/Event.h class Event : public ScriptWrappable, public RefCounted { public: // ... 省略部分程式碼 static Ref create(const AtomicString& type, bool canBubble, bool cancelable) { return adoptRef(*new Event(type, canBubble, cancelable)); } // ... 省略部分程式碼 } |
可以看到,載入錯誤的event中 canBubble: false, cancelable: false。自然用通常的冒泡機制不能捕捉載入錯誤,需要用捕獲的方式來捕捉載入錯誤。同理程式碼也可以在HTMLLinkElement.cpp等資源載入的場景中看到。
僅僅能夠捕獲載入錯誤還是不夠的,還需要區分載入錯誤和其他錯誤,因為該方法也能夠捕捉語法錯誤等一系列錯誤事件。
細心的你可能會發現,普通的錯誤會有message錯誤資訊,而載入錯誤是沒有message錯誤資訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// Source/WebCore/dom/ErrorEvent.cpp ErrorEvent::ErrorEvent(ExecState& state, const AtomicString& type, const Init& initializer, IsTrusted isTrusted) : Event(type, initializer, isTrusted) , m_message(initializer.message) , m_fileName(initializer.filename) , m_lineNumber(initializer.lineno) , m_columnNumber(initializer.colno) , m_error(state.vm(), initializer.error) { } ErrorEvent::ErrorEvent(const String& message, const String& fileName, unsigned lineNumber, unsigned columnNumber, JSC::Strong error) : Event(eventNames().errorEvent, false, true) , m_message(message) , m_fileName(fileName) , m_lineNumber(lineNumber) , m_columnNumber(columnNumber) , m_error(error) { } |
1 2 3 4 5 6 7 |
// Source/WebCode/dom/ScriptExecutionContext.cpp bool ScriptExecutionContext::dispatchErrorEvent(const String& errorMessage, int lineNumber, int columnNumber, const String& sourceURL, JSC::Exception* exception, CachedScript* cachedScript) { // ... 省略部分程式碼 Ref errorEvent = ErrorEvent::create(message, sourceName, line, column, error); // ... 省略部分程式碼 } |
由以上程式碼聯絡js程式碼可以看出,ErrorEvent繼承與Event是顯然的,而且ErrorEvent比Event多了message、filename、lineno、colno、error這些資訊。執行錯誤、語法錯誤以及自定義丟擲的異常錯誤,都源自ErrorEvent,都包含了message等錯誤資訊。而載入錯誤並不是源自ErrorEvent,而是直接源自Event,不包含message等錯誤資訊。由!e instanceof ErrorEvent即可辨別出載入錯誤。再來看看W3C的說明
https://www.w3.org/TR/html5/document-metadata.html#the-link-element
Once the attempts to obtain the resource and its critical subresources are complete, the user agent must, if the loads were successful, queue a task to fire a simple event named load at the link element, or, if the resource or one of its critical subresources failed to completely load for any reason (e.g. DNS error, HTTP 404 response, a connection being prematurely closed, unsupported Content-Type), queue a task to *fire a simple event named error at the link element *. Non-network errors in processing the resource or its subresources (e.g. CSS parse errors, PNG decoding errors) are not failures for the purposes of this paragraph.
https://www.w3.org/TR/html5/scripting-1.html#the-script-element
If the load resulted in an error (for example a DNS error, or an HTTP 404 error)
Executing the script block must just consist of *firing a simple event named error *at the element.
Firing a simple event named e means that a trusted event with the name e, which does not bubble (except where otherwise stated) and is not cancelable (except where otherwise stated), and which uses the Event interface, must be created and dispatched at the given target.
注意firing a simple event named error 。通過搜尋查閱W3C文件,named error event都是由於資源載入失敗而丟擲的,根據檔案型別過濾出來css和js即可。
由此可見,可以區分載入錯誤和其他錯誤。
優點:準確
缺點:低版本IE瀏覽器存在相容性問題,但大部分瀏覽器支援情況較好
綜上所述
對比以上情況,擬採用window.addEventListener捕獲的方法來實現檢測資源載入失敗的情況。
- css載入失敗,則直接在原位置直接切換到主域名重載入
- js載入失敗,則載入原位置之後(包含該js)的所有js資源切換到主域名重載入
要做的不僅僅是這些
再把場景擴大一點,我們可能希望支援更多場景:
- 忽略某些js的載入錯誤或者在重新載入的時候忽略某些js
- 可能某個js載入失敗了,不需要載入剩餘的全部js,只需要載入某個js,或者其他不在頁面中的js。
總的來說,就是支援自定義重載入關係。
2. js 如何準確重載入
要做到準確重載入需要做到兩步:
- 正確解析js依賴關係
- 按照依賴順序準確載入js
2.1 正確解析js依賴關係
如果只是簡單的重新載入剩餘的js,這個倒不是什麼問題。但是如果要支援自定義重載入關係,那這裡就有點文章。資源依賴關係交叉瞭如何解決?有以下依賴關係:
箭頭表示依賴鏈關係,例如a->b,則說明b依賴於a,a需要在b之前執行。
很顯然a、b、e要d之前載入,且f要在d之前載入。這並不是簡簡單單的去重這麼簡單,在紅線鏈中,c在第3個位置,而在藍線中,c在第2個位置。如果各自都按照順序載入,那麼就會造成b和c同時載入。
那麼解析依賴關係的過程中應該標記一個層級關係。對於重複的依賴,比較依賴層級,選擇大者,並且更新子依賴的層級。
注:[n]表示層級n,層級遞增排序組成載入佇列,每個層級包含一個資源陣列,層級內資源無先後順序。
處理完成後,只需要對層級進行排個序,按照層級順序載入,依賴自然就OK了。從以上例子來說,a和e可並行載入,兩者先後順序並無相互影響,其次是b、c等。
2.2 按照依賴順序準確載入js
載入一個script很簡單,載入很多script也很簡單,載入很多有順序關係的script就有點文章了。
promise+script src載入
顯然這不是什麼難點,promise化連續載入就好了。
但是問題也接著來了。
- 引入promise程式碼量成本太高,大大增加體積,自己寫回撥程式碼量更少。
- 序列載入,序列執行,自然想到了,瀏覽器的並行載入,序列執行。promise載入一個資源的過程中,並不能同時載入另一個資源(其實有辦法,看下文),載入速度自然就是一個短板。
以上,用promise是不值得的。考慮第一個問題,自己寫回撥不就好了嘛。但是寫回撥第二個問題也依然存在。
XMLHttpRequest載入+eval執行
模擬瀏覽器載入和執行js的方案,把載入和執行分開,用XmlRequest並行載入資源,然後用eval按依賴順序執行程式碼。比較頭疼的是,302、301就尷尬了,還得自己處理。加上跨域,就更頭疼了(跨域需靜態資源提供方配合解決)。
document.write
哎,瀏覽器本來就有一套載入的方案,還得自己用xml http request寫一套多麻煩,何不直接document.write呢,執行順序也不用管了,瀏覽器都包了。
1 2 3 4 |
const load = (deps) => { document.write( deps .map(dep => ``) .join('') ); }; |
但是,一定要確保在DomContentLoaded之前,否則你將看到白刷刷的一片。那問題就來了,如何確保在loaded之前檢測完錯誤,並write依賴到body中。
那麼addEventListener則需要放到head裡的最開始的地方(在任何資源載入之前即可),在body末尾插入依賴解析和載入。在html解析開始的時候開始監聽載入錯誤事件,在html解析將結束時開始依賴解析和載入。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<html> <head> <script> const errors = []; window.addEventListener('error', (e) => { if (!(e instanceof ErrorEvent)) { errors.push(e); } }, true); </script> </head> <body> <script> // 解析錯誤,提取依賴,write依賴 </script> </body> </html> |
以上
最終實現方案: window.addEventListener捕獲方式來完成檢測,document.write來完成載入和執行。