对浏览器加载资源有很多不确定性, 例如
- css/font的资源的优先级比img高, 资源的优先级是怎么确定的呢?
- 资源的优先级又是如何影响到加载的先后顺序的?
- 有几种情况可能会导致资源被阻止加载?
通过源码可以找到答案。 此次源码解读基于Chromium64
下面通过加载资源的步骤, 一次说明。
1. 开始加载
通过以下命令打开chromium, 同时打开一个网页
chrominm -- renderer-startup-dialog https://www.baidu.com
Chrome会在DocumentLoader.cpp里通过以下代码去加载:
main_resource_ =
RawResource::FetchMainResource(fetch_params, fetcher(), substitute_data_);
页面资源属于MainRescouce, chrome把Rescource归为以下几种:
enum Type : uint8_t {
kMainResource,
kImage,
kCSSStyleSheet,
kScript,
kFont,
kRaw,
kSVGDocument,
kXSLStyleSheet,
kLinkPrefetch,
kTextTrack,
kImaportResource,
kMedia,
kManifest,
kMock
}
除了常见的image/css/js/font之外,我们发现还有像textTranck的资源,这个是什么东西呢?这个是video的字母, 使用webvtt格:
<video controls poster="/images/sample.gif">
<source src='sample.mp4' type='vide/mp4'>
<track kind='captions' src='sampleCaption.vtt' srclang='en'>
</video>
还有动态请求ajax属于Raw类型。 因为ajax可以请求多种资源。
MainResource包括location即导航输入地址得到的页面、使用franme/iframe嵌套的、通过超链接点击的页面以及表单提交这几种。
接着交给稍微底层的RescurceFeche去加载, 所有的资源啊都是通过它加载的:
fetcher->RequestResource(
paras, RawResourceFactory(Resource::kMainResource), substitute-data
)
2. 预处理请求
每个请求会生成一个RescourceRequest对象, 这个对象包含了http请求的所有信息:
包括url, http header、 http body等, 还有请求的优先级信息等:
然后会更具页面的加载策略对这个请求做一些预处理, 如下代码:
PrepareRequestResult result = prepareRequest(params, factory, substityt_data, identifier, blocked_reason);
if(result == kAbort)
return nullptr;
if(result == kBlock)
return ResourceForBlockedRequest(params, factory, blocked_reason);
prepareRequest会做两件事情,一件事检查请求是否合法, 第二件是把请求做修改。 如果检查合法性返回kAbort或者kBlock, 说明资源已经废弃了或被阻止了, 就不去加载了。
被block的原因可能有一下几种:
enum class ResourceRequestBlockedReason {
kCSP, //csp内容安全策略检查
kMixedContent, //mixed content
kOrigin, //secure origin
kInspector, //devtools的检查器
kSubresourceFilter,
kOther,
kNone
}
源码你面会在这个函数做合法性检查:
blocked_reason = Context().CanRequest(/*参数省略*/);
if(blocked_reason != ResourceRequestBlockedReason::kNone) {
return kBlock;
}
CanRequest函数会相应的检查一下内容:
1. csp(content security policy)内容安全策略检查
csp是减少xss攻击一个策略。 如果我们只允许加载自己域的图片的话, 可以加上这个meta标签:
<meta http-equiv='Content-Security-Policy' content='img-src "self";'>
或者是后端设置这个http响应头。
self表示本域, 如果加载其他域的图片浏览器将会报错:
所以这个可以防止一些xss注入的跨域请求。
源码里面会检查该请求是否符合csp的设定要求:
const ContentSecurityPolicy* scp = GetContentSecurityPolicy();
if(csp && !csp->AllowRequest(
request_context, rul, options.content_security_policy_nonce,
options.integrity_metadata, options.parser_disposition,
redirect_status, reporting_policy, check_header_type)){
return ResourceRequesttBlocedReason::KCSP;
}
如果有csp并且AllowRequest没有通过的话就会返回堵塞的原因。 具体的检查过程是根据不同的资源类型去获取该类资源的CSP设定进行比较。
接着会根据CSP的要求改变请求:
ModifyRequestForCSP(request);
主要是升级http为https
(2). upgrade-insecure-request
如果设定了一下csp规则:
<meta http-equiv="Content-Security-Policy" content='upgrad-insecure-request'>
那么会将网页的http请求强制升级为https, 这是通过改变request对象实现的:
url.SetProtocol("https");
if(url.prot()==80)
url.SetPort(443);
resource_request.SetURL(url)
包括改变url的协议和端口号
(3)Mixed Content混合内容block
在https的网站请求http的内容就是Mixed Content, 例如加载一个http的js脚本, 这种请求通常会被浏览器堵塞掉, 因为http是没有加密的, 容易受到中间人的攻击, 如修改js的内容, 从而控制整个https的页面, 而图片之类的资源即使内容被修改可能只是展示问题, 所以默认么有block掉。 源码里面会检查Mixed Content的内容:
if(shouldBlockFetchByMixedContentCheck(request_context, frame_type, resource_requ3st.GetRedirectStatus(),url, reporting_policy))
return ResourceRequestBlockedReason::kMixedContent;
在源码里面, 一下4种资源是optionally-blockable(被动混合内容):
case WebUrlRequest::kRequestContextAudio:
case WebURLRequest::kRequestContextFavicon:
case WebURLquest::kRequestContextIage:
case WebURLRequest::kREquestContextVideo:
return WebMixedContextContextType::kOptionallyBlockable;
什么叫被动混合内容呢?
W3c文档是这样说的:那些不会打破页面重要部分, 风险比较低的, 但是使用频率又比较高的Mixed Content内容。
而剩下的其他所有几乎 都是blockable的, 包括js/css/frame/XMLHttpRequest等:
我们注意到img srcset 里的资源也是默认会被阻止的, 即下面的img会被block:
<img srcset="http://fedren.com.test-1x.png 1x, htt://fedren.com/text-2x.png 2x /">
3. 资源优先级
(1)计算资源加载优先级
通过调用一下函数设定:
resource_request.SetPriority(ComputLoadPriority(
resource_type, params.GetResourceRequest(), ResourcePriority::kNotVisible,
params.Defer(), params.GetSpeculativePreloadType(),
params.Isl=LInkPreload()))
我们来看看这个函数里是怎么计算当前资源的优先级的。
首先每个资源都有一个默认的优先级, 这个优先级作为初始值:
ResourceLoadPriority priority = TypeToPriority(type);
不同类型的资源优先级是这么定义的:
ResourceLoadPriority TypeToPriority(Resource::Type type) {
switch(type) {
case Resource::kMainResource:
case Resoruce::KCSSStyleSheet;
case Resource::kFont:
return kResourceLoadPriorityVeryHigh;
case Resource::KXSStyleSheet:
DCHECK(RuntimeEnabledFeatures::XSLTEnabled());
case Resource::kRaw:
case Resource::kImportResource:
case Resource::kScript:
return kResourceLoadPriortyHigh;
case Resource::kMainifest::
case Resource::kMock:
return kResourceLoadPriorityMedium;
case Resource::kImage:
case Resource::kTextTrack:
case Resource::kMedia:
case Resource::kSVGDoucment:
return kResourceLoadPriorityLow;
case Resource::kLinkPrefetch:
return kResourceLoadPriorityVeryLow;
}
return kResourceLoadPriorityUnresolved;
}
可以看到优先级总共分为五级: very-high、high、medium、low、very-low,其中MainRescource页面、css、字体这三个的优先级是最高的,然后是script,ajax这种, 而图片、音频的默认优先级是比较低的, 最低的事prefetch预加载的资源。
什么是预加载的资源呢? 有时候你可能需要让一些资源先加载好等着用, 例如用户输入出错的时候咋输入框右边显示一个x的图片, 如果等要显示的时候再去加载就会有延时, 这个时候可以用一个link标签:
<link rel="prefetch" href="image.png">
浏览器空闲的时候就会去加载。另外还可以与解析DNS:
<link rel="preconnect" href="https://cdn.chime.me">
预建立TCP链接:
<link rel="preconnect" href="https;??cdn.chime.me">
后面这两个不属于加载资源, 这里顺便提一下。
注意上面的switch-case设定资源优先级有一个顺序, 如果既是script都是prefetch的话, 得到的优先级是high, 而不是prefetch的事very high, 因为prefetch是最后一个判断。 所以在设定了资源默认的优先级之后,会在对一些情况做一些调整, 主要是对prefetch/preload的资源。 包括:
a)降低preload的字体的优先级
如下代码:
if (type == Resource::kFont&&is_link_preload)
priority = kResourceLoadPrioritHigh
会把预加载字体的优先级从very-high变为high
b)降低defer/asyncde script的优先级
如下代码:
if(type == Resourc::kScript) {
if(FetchParameters::kLazyLoad == defer_option) {
priority = kResourceLoadPriorityLow;
}
}
script如果是defer的话, 那么它的优先级会变成最低。
c)页面底部preload的script优先级变成medium
如下代码:
if(type ==Resource::kScript) {
if(FetchP昂让meters::kLazyLoad == defer_option) {
priority = kResourceLoadPriorityLow;
}else if(speculative_preload_type == FetchParameters::SpeculativePreloadType::kInDoucment && image_fetched_) {
priority=kResourceLoadPriorityMedium;
}
}
如果是defer的script那么优先级调成最低(上面第三小点),否则如果是preload的script, 并且如果页面已经加载了一张图片就认为这个script是页面偏底部的位置, 就把它的优先级调成medium.。 通过一个flag决定是否已经加载过第一张图片了:
if(type == Resoucre::kImage && !is_link_preload) {
image_fetched_ = true;
}
资源在第一张非preload的图片前认为是early, 而后面认为是late, late的script的优先级会偏低。
什么是preload呢? preload不同于prefetch的, 在早期浏览器,script资源都是阻塞加载的, 当页面遇到一个script, 那么要等这个script下载和执行完了,才会继续解析剩下的dom结构, 也就是说sscript是串行加载的, 摒弃会堵塞其他资源的加载,这样会导致页面整体的加载速度慢, 所以早在2008年的时候浏览器除了一个推测加载策略, 即遇到script的时候, dom会停止构建, 但是会继续去搜索页面需要加载的资源, 如看下后续的html有没有img/script标签, 先进行预加载, 而不是等到dom的时候才去架子啊, 这样大大提高了页面整体的加载速度。
d)把同步即堵塞加载的资源的优先级调成最高
如下代码:
return stc::max(priority, resource_request.Priority());
如果是同步加载的资源, 那么它的request对象里面的优先级是最高的, 所以本来是high的ajax同步请求在最后return的时候会变成very-high.
这里是取了两个值的最大值, 第一个值是上面进行各种判断得到depriority, 第二个在初始这个ResourceRequest对象本身就有的一个优先级属性,返回最大值后再重新设置resource_request的优先级属性。
在构建resource request对象时所有的资源都是最低的, 这个可以从构建函数你知道:
ResourceReques::ResoucreReques(Conset KURL& url):url_(url),serviec_worker_mode_(WebURLRequest::ServiceWorkerMode:KAll),priority_(kResourceLoadPriorityLowest)
但是同步请求在初始化的时候会先设置成最高的:
void FetchParameters::MakerSynchronouse(){
resource_request_.SetPriority(kResourceLoadPriorityHightest);
resource_request.SetTimeoutInterval(10);
options_.synchronous_policy = kRequestSynchronously;
}
以上就是基本的资源加载优先级策略。
(2)转换成Net的优先级
这是在渲染线程里面进行的, 上面提到的资源优先级在发请求之前会被转化成Net的优先级:
resource_request->prioirty = ConverWebKitPriorityToNetPriority(request.GetPriority());
资源优先级对应Net的优先级如下:
画成一个表:
Net Priority是请求资源的时候使用的, 这个实在chrome的io线程里面进行的, 我在《js与多线程》的Chrome的多线程模型里面提到, 每个页面都有Renderer线程负责渲染页面, 而浏览器有io线程, 用来负责请求资源等。 为什么io线程不是放在每个页面里面而是放在浏览器框架呢?因为这样的好处是如果两个页面页面请求了相同资源的话, 如果有缓存的话就能避免重复请求了。
上面的都是在渲染线程里面debug操作得到的数据, 为了能够观察资源请求的过程, 需要切换到io线程, 而这个两个线程间的通信是通过chrome封装的mojo框架进行的。 在renderer线程会发一个消息个io线程通知它:
mojo::Message message(
internal::kURLLoaderFactory_CreateLoaderAndStart_Name, kFlags, 0,0, nullptr);
//对这个message进行各种设置后, 调接受者的Accept函数
ignore_result(receiver_->Accept(&message));
XCode里面可以看到这是在渲染线程RendererMain里操作的:
要切换到Chrome的IO线程, 把debug的方式改一下, 如果选择Chromium程序:
之前是使用Attach to Process把渲染进程的PID传进来, 因为每个页面都是独立的一个进程, 现在要改成debug chromium进程。 然后在content/browser/loader/resource_scheduler.cc这个文件里的ShouldStartRequest函数里大断电, 接着在Chromium里面打开一个网页, 就可以看到断点生效了。在XCode里面可以看到当前线程名称叫chrome_IOThread:
这与上面的描述一致。 IO线程是如何利用优先级决定要不要开始加载资源的呢?
(3)资源加载
上面提到的ShouldStartRequest这个函数时判断当前资源是否能开始加载了, 如果能的话就准备加载了, 如果不能的话就继续把它放到pending request队列里面, 如下代码所示:
void ScheduleRequest(const net::URLRequest& url_request, SchedduledResourceRequest* request) {
SetRequestAttributes(request, DetermineRequestAttributes(request));
ShouldStartReqResult should_start = ShouldStartRequest(request);
if(should_start == START_REQUEST){
StartRequest(request, STRAT_SYNC, RequestStartTrigger::NONE);
}eles {
pending_request_.Insert(request);
}
}
一旦受到Mojo加载资源的消息就会调用上面的ScheduleRequest函数, 除了受到 消息之外, 还有一个地方也会调用:
void LoadAnyStartablePendingRequests(RequestStartTrigger trigger) {
// We iterate through all the pending requests, starting with the highest
// priority one.
RequestQueue::NetQueue::iterator request_iter =
pending_requests_.GetNextHighestIterator();
while (request_iter != pending_requests_.End()) {
ScheduledResourceRequest* request = *request_iter;
ShouldStartReqResult query_result = ShouldStartRequest(request);
if (query_result == START_REQUEST) {
pending_requests_.Erase(request);
StartRequest(request, START_ASYNC, trigger);
}
}
这个函数的特点是遍历pending requests, 每次取出优先级最高的一个request, 然后调用shouldRequest判断是否能运行了, 如果能的话就把它 从pending requests 里面删掉, 然后运行。
而这个函数会有三个地方会调用, 一个是io线程的循环判断,只要还有未完成的任务, 就会触发加载, 第一个是当有请求完成时会调用, 第三个是插入body标签的时候。 所以主要总共有三个地方会触发加载:
- 收到来自渲染线程IPC::Mojo的请求加载资源的消息
- 每个请求完成之后, 触发加载pending request 你还未加载的请求
- io线程定时循环未完成的任务, 触发加载
知道了触发加载机制之后, 接着研究具体优先加载的过程,用一下html做demo:
<!DOCType html>
<html>
<head>
<meta charset="utf-8">
<link rel="icon" href="4.png">
<img src="0.png">
<img src="1.png">
<link rel="stylesheet" href="1.css">
<link rel="stylesheet" href="2.css">
<link rel="stylesheet" href="3.css">
<link rel="stylesheet" href="4.css">
<link rel="stylesheet" href="5.css">
<link rel="stylesheet" href="6.css">
<link rel="stylesheet" href="7.css">
</head>
<body>
<p>hello</p>
<img src="2.png">
<img src="3.png">
<img src="4.png">
<img src="5.png">
<img src="6.png">
<img src="7.png">
<img src="8.png">
<img src="9.png">
<script src="1.js"></script>
<script src="2.js"></script>
<script src="3.js"></script>
<img src="3.png">
<script>
!function(){
let xhr = new XMLHttpRequest();
xhr.open("GET", "https://baidu.com");
xhr.send();
document.write("hi");
}();
</script>
<link rel="stylesheet" href="9.css">
</body>
</html>
然后把Chrome的网络熟读调为fase 3G, 让加载速度降低, 以便更好的观察这个过程, 结果如下:
从上图可以发现一下特点:
- 每个域每次最后同时加载6个资源(http/1.1)
- css具有最高的优先级, 最先加载吗即使放在最后面9.css也是比前面资源先开始加载
- js比图片优先加载, 即使出现的比图片晚
- 只有等css都加载完了, 才能加载 其他的资源, 即使这个时候没有达到6个限制
- head里面的非高优先化级的资源最多先加载一张(0.png)
- xhr的资源虽然具有高优先级, 但是由于它是排在3.js后面的, js的执行时同步的, 所以它排的比较靠后, 如果把它排在1.js前面, 那么它也会比图片先加载、
什么会这样呢?我们从源码寻找答案。
首先认清几个概念, 请求可分为delayable和none-delayable两种:
statice const net ::RequestPriority
kDelayablePriorityThreshould = net::MEDIUM;
在优先级在Medium以下的为delayable,即可推迟的, 而大于等于medium的为不可delayable的。从刚刚我们总结的表可以看出:css/js是不可推迟的,而图片, preload的js为可推迟加载:
还有一种是layout-blocking的请求:
// The priority level above which resources are considered layout-blocking if
// the html_body has not started.
static const net::RequestPriority
kLayoutBlockingPriorityThreshold = net::MEDIUM;
这是当还没有渲染body标签, 并且优先级在Medium之上的如css的请求。
然后, 上面提到的ShouldStartPequest函数, 这个函数时规划资源加载顺序最重要的函数, 从源码注释可以知道它大概的过程:
// ShouldStartRequest is the main scheduling algorithm.
//
// Requests are evaluated on five attributes:
//
// 1. Non-delayable requests:
// * Synchronous requests.
// * Non-HTTP[S] requests.
//
// 2. Requests to request-priority-capable origin servers.
//
// 3. High-priority requests:
// * Higher priority requests (> net::LOW).
//
// 4. Layout-blocking requests:
// * High-priority requests (> net::MEDIUM) initiated before the renderer has
// a <body>.
//
// 5. Low priority requests
//
// The following rules are followed:
//
// All types of requests:
// * Non-delayable, High-priority and request-priority capable requests are
// issued immediately.
// * Low priority requests are delayable.
// * While kInFlightNonDelayableRequestCountPerClientThreshold(=1)
// layout-blocking requests are loading or the body tag has not yet been
// parsed, limit the number of delayable requests that may be in flight
// to kMaxNumDelayableWhileLayoutBlockingPerClient(=1).
// * If no high priority or layout-blocking requests are in flight, start
// loading delayable requests.
// * Never exceed 10 delayable requests in flight per client.
// * Never exceed 6 delayable requests for a given host.
从上面的注释可以得到以下信息:
- 高优先级的资源(>=Medium)、同步请求和非http(s)的请求能够立刻加载
- 只要有一个layout blocking的资源在加载, 最多只能加载一个delayable的资源, 这个就解释了为什么0.png能够先加载
- 只有当layout blocking和high priority的资源加载完了, 才能开始加载delayable的资源, 这个就解释了为什么要等css加载完了才能加载其他的js/图片。
- 同时加载的delayable资源同一个域只能由6个, 同一个client即同一个页面最多只能有10
- 个,否则要进行排队。
注意这里说的开始加载,并不是说能够开始请求建立连接了。 源码里面叫
in flight,在飞行中, 而不是叫in request之类的, 能够进行in filght的请求是指那些不用queue的请求, 如下图:
白色条是指queue的时间段, 而灰色的事已经in filght了, 但受到同域只能最后只能建立6个tcp链接等的影响而进入的stalled状态, 绿色是ttfb从开始建立tcp连接到收到第一个字节的时间, 蓝色是下载的时间。
我们已经解释了大部分加载的特点的原因, 对着上面那张图片可以重述一次:
- 由于1.css 到9.css这几个css文件是high priority或者是none delayable的, 所以马上in flight, 但是还受到了同一个域最多只能有6个的限制, 所以6/7/9.css这三个进入stalled的状态
- 1.css到5.css是layout-blocking的, 所以最多只能再加载一个delayable的0.png,在它相邻的1.png就得排队了
- 等到high priority和layout的资源7.css/9.css/1.js加载完了, 就开始加载delayable的资源, 主要是preload的js和图片
这里有个问题, 为什么1.js是high priority的而2.js和3.js却是delayable的?为此在源码的ShouldStartRequest函数里面添加一些代码, 把每次判断请求的一些关键信息打印出来:
LOG(INFO) << "url: " << url_request.url().spec() << " priority: " << url_request.priority()
<< " has_html_body_: " << has_html_body_ << " delayable: "
<< RequestAttributesAreSet(request->attributes(), kAttributeDelayable);
把打印出来的信息按顺序画成以下表格:
1.js的优先级一开始是Low的,即是delayable的,但是后面又变成了Medium就不是delayable了,是high priority,为什么它的优先级能够提高呢?一开始是Low是因为它是推测加载的,所以是优先级比较低,但是当DOM构建到那里的时候它就不是preload的,变成正常的JS加载了,所以它的优先级变成了Medium,这个可以从has_html_body标签进行推测,而2.js要等到1.js下载和解析完,它能算是正常加载,否则还是推测加载,因此它的优先级没有得到提高。