當前位置:
首頁 > 科技 > 域名劫持資源重載入方案

域名劫持資源重載入方案

前言

今日早讀文章來自@今日頭條技術團隊授權分享。

正文從這開始~

痛點:

在部分用戶的網路環境中,頁面CDN域名被劫持,導致前端資源無法正常載入,而頁面主域名正常,導致頁面可以訪問,但是功能不正常。

背景:

通常來說,主域名一般都是眾所周知的域名,運營商一般不會劫持(本文特指劫持後導致無法載入,注入這些不在本文考慮範圍內),主域名被劫持的可能性小。因為被劫持後,用戶無法訪問自然能夠很明顯感知到這是網路問題(鑒於掛掉情況可能性較小)。而CDN域名一般鮮為人知,運營商由於商業目的可能會劫持部分域名,於是就會導致頁面html結構出來了,樣式和交互都不正常,用戶可能會認為這是產品的問題,很少會認為這是網路問題。

解決方案:

CDN和主域名都同時承載資源,優先CDN載入,在CDN載入失敗的情況下,切換到主域名再次載入資源。

難點:

如何捕捉資源404、503等載入錯誤。

js 如何準確重載入(與其說是難點,倒不如說是可討論的地方)。

1. 如何捕捉資源404、503等載入錯誤

1.1 在script或者link加onerror捕捉

在每個script或者link標籤中添加onerror屬性,捕捉載入錯誤。

註: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來準確捕捉載入錯誤,並且能夠比較好的協調資源載入。

以下為簡要代碼,以做理解,使用該方案時請根據各自業務而定

constloadScript=(url)=>{

returnnewPromise((resolve,reject)=>{

constscript=document.createElement( script );

script.src=url;

script.onload=()=>{

resolve();

};

script.onerror=()=>{

reject();

};

document.appendChild(script);

});

};

constloadOne=(mainDomain,secondaryDomain,url)=>{

returnloadScript(`//:${mainDomain}/${url}`)

.then(()=>{},()=>{

returnloadScript(`//:${secondaryDomain}/${url}`);

});

}

constload=(mainDomain,secondaryDomain,urls)=>{

constpromise=newPromise((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 不能檢測到載入失敗的資源,那麼不能被檢測到的,自然就是載入失敗的資源。通過這個原理可以準確捕捉到所有載入失敗的場景。

// 所有css+js資源

varallResources=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資源

varloadedResources=performance.getEntriesByType( resource )

.filter((res)=>{

varurl=res.name;

varurlWithoutParam=url.split( ? )[];

return[ script , link ].indexOf(res.initiatorType)!==-1&&

[/.css$/,/.js$/].some((reg)=>reg.test(urlWithoutParam));

})).map((res)=>res.name);

// 載入失敗的css+js資源

varfailedResources=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 來檢測

優點: 準確率高,瀏覽器兼容性好

缺點:僅僅適用於css資源,且對於跨域無效。

1.6 使用window.addEventListener來捕獲載入錯誤

當你看到此方法的標題時,或許你會覺得這個方法和1.1沒什麼區別。全局onerror不能捕捉到載入錯誤的原因1.1已經提及,那為什麼window.addEventListener卻能捕獲載入錯誤呢?

HTML中事件傳播機制有兩種,一個是冒泡,另一種是捕獲。

通過捕獲,能夠在全局捕獲到載入錯誤。

window.addEventListener( error ,()=>{

// to do your things.

},true);

從Webkit源碼來解釋一下為什麼

// Source/WebCore/dom/ScriptElement.cpp

voidScriptElement::dispatchErrorEvent()

{

m_element.dispatchEvent(Event::create(eventNames().errorEvent,false,false));

}

// Source/WebCore/dom/Event.h

classEvent:publicScriptWrappable,publicRefCounted{

public:

// ... 省略部分代碼

staticRefcreate(constAtomicString&type,bool canBubble,bool cancelable)

{

returnadoptRef(*newEvent(type,canBubble,cancelable));

}

// ... 省略部分代碼

}

可以看到,載入錯誤的event中 canBubble: false, cancelable: false。自然用通常的冒泡機制不能捕捉載入錯誤,需要用捕獲的方式來捕捉載入錯誤。同理代碼也可以在HTMLLinkElement.cpp等資源載入的場景中看到。

僅僅能夠捕獲載入錯誤還是不夠的,還需要區分載入錯誤和其他錯誤,因為該方法也能夠捕捉語法錯誤等一系列錯誤事件。

細心的你可能會發現,普通的錯誤會有message錯誤信息,而載入錯誤是沒有message錯誤信息。

// Source/WebCore/dom/ErrorEvent.cpp

ErrorEvent::ErrorEvent(ExecState&state,constAtomicString&type,constInit&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(constString&message,constString&fileName,unsigned lineNumber,unsigned columnNumber,JSC::Strongerror)

:Event(eventNames().errorEvent,false,true)

,m_message(message)

,m_fileName(fileName)

,m_lineNumber(lineNumber)

,m_columnNumber(columnNumber)

,m_error(error)

{

}

// Source/WebCode/dom/ScriptExecutionContext.cpp

bool ScriptExecutionContext::dispatchErrorEvent(constString&errorMessage,int lineNumber,int columnNumber,constString&sourceURL,JSC::Exception*exception,CachedScript*cachedScript)

{

// ... 省略部分代碼

ReferrorEvent=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:

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呢,執行順序也不用管了,瀏覽器都包了。

constload=(deps)=>{

document.write(

deps

.map(dep=>``)

.join( )

);

};

但是,一定要確保在DomContentLoaded之前,否則你將看到白刷刷的一片。那問題就來了,如何確保在loaded之前檢測完錯誤,並write依賴到body中。

那麼addEventListener則需要放到head里的最開始的地方(在任何資源載入之前即可),在body末尾插入依賴解析和載入。在html解析開始的時候開始監聽載入錯誤事件,在html解析將結束時開始依賴解析和載入。

consterrors=[];

window.addEventListener( error ,(e)=>{

if(!(einstanceofErrorEvent)){

errors.push(e);

}

},true);

// 解析錯誤,提取依賴,write依賴

以上

最終實現方案: window.addEventListener捕獲方式來完成檢測,document.write來完成載入和執行。

關於本文

作者:@今日頭條技術團隊

原文:https://techblog.toutiao.com/2017/05/09/cdn/

點擊展開全文

喜歡這篇文章嗎?立刻分享出去讓更多人知道吧!

本站內容充實豐富,博大精深,小編精選每日熱門資訊,隨時更新,點擊「搶先收到最新資訊」瀏覽吧!


請您繼續閱讀更多來自 前端早讀課 的精彩文章:

《前端架構設計》讀後感
代碼審查應該關注什麼之一
我理解的阿里無線前端「架構」
【第942期】圖說 WebAssembly

TAG:前端早讀課 |

您可能感興趣

以成員函數方式重載、以友元函數方式重載
通過重載避免隱式類型轉換
獵鷹重載火箭之夢想啟示錄
分數類,實現加減乘除操作符的重載(未考慮約數)
Python的模塊導入和重載
現階段輕卡向大貨廂重載發展成趨勢
我國重載鐵路橋樑轉體高度再次刷新
科達發布重載球形轉檯攝像機
Python入門基礎之面向對象四:運算符重載
現代戰機的攻擊力,真和載彈量沒多大關係!在反艦時,只看有多少重載掛架
運-5無人運輸機實現重載空投,中國軍事後勤再添智能利器
重車壓梁,重載列車助力寶成鐵路擊退洪峰!
首個重載貨車車聯網數據服務發布
SpaceX獵鷹重載成功發射,馬斯克送給外星人一輛特斯拉跑車
C++友元重載+運算符易錯點
NASA希望其商業合作夥伴提出為月球表面提供重載荷的新技術
一艘重載船舶在錫澄運河水域沉沒!2天2夜了,打撈工作仍在繼續
const 注意事項(初始化,重載,參數和返回值)
重載黃沙的渣土車剎車失靈,碰撞貨車後又衝出1公里,損失慘重,這可不是在玩漂移
遼寧號航母的重載起飛點能實施滿油彈滑躍起飛