浏览器在解析HTML的过程中,从上往下,遇到一个标签渲染一个。当顶部遇到link的时候,css不会阻塞Html的解析
但是如果顶部存放link标签,当HTML解析成DOM树的时候,需要与css样式表结合生成render tree。
此时浏览器的操作:解析hmtl=》触发DomcontentLoad =>解析样式表 =》重新计算样式 =》更新图层=》绘制
放在顶部的link,Html最后呈现的时候需要依赖他。
而如果link标签放在底部,那么html解析城dom树并且生成render tree,paintine到页面上不需要依赖link。
浏览器的操作是:解析HTMl => 重新计算样式 =》布局=》绘制=》复合图层=》渲染
等Link的css请求回来后,浏览器需要继续 解析样式表 =》 解析HTMl => 重新计算样式 =》触发相关事件
所以css放在底部,可能会导致重绘效果。
<body>
<div class="div">123123div>
<script>
let i = 0
while(i < 1000000000){
i++
}
console.log(i);
script>
<div class="div">123123div>
body>
上面这段代码,浏览器的操作时:
解析hmtl=>解析样式表(Js执行需要等待css加载完毕)=>js执行=>重新计算样式=》布局=》绘制=》遇到下面的div=》又开始解析Html=> 重新计算样式=>布局=》绘制
js会阻塞html解析,也会阻塞渲染,并且,js要等待上面的css加载完毕,保证js里面可以操作样式。
<div class="div">123123div>
<script src="./1.js">
script>
<div>123123div>
将js抽离到文件去,通过script加载。
资源请求回来之后,默认会进行link script的预加载,所以css和js脚本是并行加载的。虽然执行步骤是跟上面一样的,但是css和js文件时并行请求的。
解析前遇到link和scirpt,会进行并行加载css和js文件。
html会生成字节流->分词器->tokens->根据token生成节点->插入到DOM树种。
css放在顶部,dom渲染会依赖css。放在底部,dom初次渲染不依赖,但是css加载完毕会引起dom的重绘。css不阻塞html解析
内嵌在html的js会阻塞html解析,并且会等待当前脚本之上的样式表解析完毕后才会执行js(保证js可以操作css),然后才会继续解析html。js依赖css的加载。
js一般放在底部,为的是操作完整的dom和不影响html的解析。
往大了看,浏览器就是帮助我们发送请求,然后将响应资源加载出来的软件。我们可以自己模拟一个客户端。模拟获取数据并且解析html为dom树,和css解析成styleSheet的实现。
首先通过http模块创建一个服务器
const Http = require("http");
const fs = require("fs");
const path = require("path");
const server = new Http.createServer();
server.on("request", (req, res) => {
console.log(req.headers);
fs.createReadStream("./1.html").pipe(res);
});
server.listen(3000);
//这是要相应的内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.div {
color: red;
}
</style>
</head>
<body>
<div class="div">123123</div>
<script>
let i = 0
while (i < 100000) {
i++
}
console.log(i);
</script>
<div>123123</div>
</body>
</html>
接着模拟客户端发送请求:我们知道http是基于tcp连接的。我们只要自己实现httpi请求头,并且创建tcp连接,将数据发送出去即可。
const net = require("net");
//创建一个HttpRequest类,用来发送请求
class HttpRequest {
constructor(options = {}) {
this.host = options.host;
this.method = options.method || "GET";
this.port = options.port;
this.path = options.path;
this.headers = options.headers;
}
send() {
return new Promise((resolve, reject) => {
// 构建http请求
const rows = [];
rows.push(`${this.method} ${this.path} HTTP/1.1`); //模拟浏览器的请求行
// 处理请求体的heades
Object.keys(this.headers).forEach((item) => {
rows.push(`${item}: ${this.headers[item]}`);
});
// 处理请求头
// GET / HTTP/1.1
// xxx:xx
//
//
const data = rows.join("\r\n") + "\r\n\r\n"; //加上换行符
console.log("data", data);
// 通过tcp传输
const socket = net.createConnection(
{
host: this.host,
port: this.port,
},
() => {
console.log("创建连接成功");
// 创建连接成功之后,传输http数据
socket.write(data);
}
);
let responseData = [];
// 也是一个可读流,tcp传输是分段的。监听服务器数据返回,返回的不只有文件内容,还有响应头
socket.on("data", function (chunk) {
responseData.push(chunk);
});
socket.on("end", () => {
responseData = Buffer.concat(responseData);
let [headers, body] = responseData.toString().split("220");
resolve({
headers,
body,
});
});
});
}
}
接着我们发送请求
async function request() {
const request = new HttpRequest({
host: "127.0.0.1",
method: "GET",
port: 3000,
path: "/",
headers: {
name: "lin",
age: 12,
},
});
// 发送请求,响应行,响应头,响应体
let { headers, body } = await request.send();
// 处理body,对html解析生成dom树,对css文本解析,生成styleSheets。
}
关键就是自己实现http请求头,并且通过net创建了tcp连接,将数据发送出去。然后通过可读流,获取返回数据,返回的数据不仅有文件内容,还有响应头,如下:
HTTP/1.1 200 OK
Date: Tue, 22 Feb 2022 14:50:37 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked
220
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.div {
color: red;
}
</style>
</head>
<body>
<div class="div">123123</div>
<script>
let i = 0
while (i < 100000) {
i++
}
console.log(i);
</script>
<div>123123</div>
</body>
</html>
0
这就是具体的响应内容。我们通过分割获取到html的部分。
const HtmlParser = require("htmlparser2");
// 发送请求,响应行,响应头,响应体
let { headers, body } = await request.send();
// html解析城dom tree,就是做词法分析最后生成dom tree
// 解析后需要生城tree,典型的栈型结构
let stack = [{ type: "document", children: [] }];
// 浏览器根据响应内容来解析文件
const parser = new HtmlParser.Parser({
// document html header body
// 遇到一个tag,获取他的tagName和属性
onopentag(name, attributes) {
let parent = stack[stack.length - 1];
//
let element = {
tagName: name,
attributes,
children: [],
parent,
};
parent.children.push(element);
stack.push(element); // 当前的tag可能也有儿子
},
// 获取tag的内容
ontext(text) {
let parent = stack[stack.length - 1]; // 因为上面的tag已经作为一个元素Push进stack了,这里直接获取,将text放入到children就行
let textNode = {
type: "text",
text,
};
parent.children.push(textNode);
},
// 关闭tag,遇到闭合的,就将其从栈中取出,到最后的html退出,stack此时剩一个document,他是一颗树,通过children跟parent将整个Html变成dom树。
onclosetag(name) {
//只考虑内联css
if(name === 'style'){
let parent = stack[stack.length - 1];
const cssText = parent.children[0].text
parserCss(cssText)
console.log('cssText',cssText);
}
// 遇到闭合,
stack.pop();
if (stack.length === 1) {
//console.dir(stack, { depth: null });
}
},
});
parser.write(body);
}
主要是通过一个栈结构,使用HtmlParser.parser,在遇到html标签的时候,将其压入栈中,然后匹配到内容的时候,将其存入对应元素的children,再到闭合标签的时候,将其推出栈。
比如document和header标签,遇到document标签,压入栈中,而下一个标签就是header,他会将header标签作为document的chidlren,并且将header标签压入栈中。然后遇到header标签的闭合标签,就将header标签推出栈,此时栈里只有一个document标签。他通过children连接到了header标签,以此类推,构建城整颗dom树。
等所有dom标签被解析后,最后栈里剩的就是一个document元素,他没有闭合标签。打印的结果应该是:
[
<ref *4> {
type: 'document',
children: [
{ type: 'text', text: '\r\n' },
{ type: 'text', text: '\r\n' },
<ref *2> {
tagName: 'html',
attributes: { lang: 'en' },
children: [
{ type: 'text', text: '\r\n\r\n' },
<ref *1> {
tagName: 'head',
attributes: {},
children: [
{ type: 'text', text: '\r\n ' },
{
tagName: 'meta',
attributes: { charset: 'UTF-8' },
children: [],
parent: [Circular *1]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'meta',
attributes: { 'http-equiv': 'X-UA-Compatible', content: 'IE=edge' },
children: [],
parent: [Circular *1]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0'
},
children: [],
parent: [Circular *1]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'title',
attributes: {},
children: [ { type: 'text', text: 'Document' } ],
parent: [Circular *1]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'style',
attributes: {},
children: [
{
type: 'text',
text: '\r\n' +
' .div {\r\n' +
' color: red;\r\n' +
' }\r\n' +
' '
}
],
parent: [Circular *1]
},
{ type: 'text', text: '\r\n\r\n' }
],
parent: [Circular *2]
},
{ type: 'text', text: '\r\n\r\n' },
<ref *3> {
tagName: 'body',
attributes: {},
children: [
{ type: 'text', text: '\r\n ' },
{
tagName: 'div',
attributes: { class: 'div' },
children: [ { type: 'text', text: '123123' } ],
parent: [Circular *3]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'script',
attributes: {},
children: [
{
type: 'text',
text: '\r\n' +
' let i = 0\r\n' +
' while (i < 100000) {\r\n' +
' i++\r\n' +
' }\r\n' +
' console.log(i);\r\n' +
' '
}
],
parent: [Circular *3]
},
{ type: 'text', text: '\r\n ' },
{
tagName: 'div',
attributes: {},
children: [ { type: 'text', text: '123123' } ],
parent: [Circular *3]
},
{ type: 'text', text: '\r\n' }
],
parent: [Circular *2]
},
{ type: 'text', text: '\r\n\r\n' }
],
parent: [Circular *4]
}
]
}
]
可以看到形成了一颗以document开始的树结构。
假设我们的css只有内联,那么在Html匹配到style标签的时候,就应该处理css了、
const parser = new HtmlParser.Parser({ // document html header body // 遇到一个tag,获取他的tagName和属性 onopentag(name, attributes) { //匹配标签 }, // 获取tag的内容 ontext(text) { let parent = stack[stack.length - 1]; // 因为上面的tag已经作为一个元素Push进stack了,这里直接获取,将text放入到children就行 let textNode = { type: "text", text, }; parent.children.push(textNode); }, // 关闭tag,遇到闭合的,就将其从栈中取出,到最后的html退出,stack此时剩一个document,他是一颗树,通过children跟parent将整个Html变成dom树。 onclosetag(name) { //只考虑内联css if(name === 'style'){ let parent = stack[stack.length - 1]; const cssText = parent.children[0].text parserCss(cssText) } // 遇到闭合, stack.pop(); if (stack.length === 1) { console.dir(stack, { depth: null }); } }, });
在遇到闭合标签的时候,在ontext阶段已经在text内部存入style这个元素的children,直接取出放入parserCss即可。
const css = require("css"); //解析cssfunction parserCss(styleText){ const ast = css.parse(styleText) //解析成styleSheet console.log('ast', ast);}
然后通过css这个包进行解析。
结果应该是
.div { color: red; }// 转为{ type: 'stylesheet', stylesheet: { source: undefined, rules: [ { type: 'rule', selectors: [ '.div' ], declarations: [ { type: 'declaration', property: 'color', value: 'red', position: Position { start: { line: 3, column: 13 }, end: { line: 3, column: 23 }, source: undefined } } ], position: Position { start: { line: 2, column: 9 }, end: { line: 4, column: 10 }, source: undefined } } ], parsingErrors: [] }}
可以看到css文本被转为了一个styleSheeel对象。
步骤:
清除上一次请求 =》重定向 =》fetchStart(真正开始请求)=》Appcache检查缓存 =》 DNS解析 => 建立TCP连接 => 发送请求request => Response响应请求 => 处理资源 => onLoad
期间有很多的值,比如resetStart记载了开始请求的时间,responseEnd记载了请求结束的时间。
// 打印performance.timing{connectEnd: 1645577680989connectStart: 1645577680989domComplete: 1645577681183domContentLoadedEventEnd: 1645577681165domContentLoadedEventStart: 1645577681165 //DomContentLoad事件开始触发domInteractive: 1645577681085domLoading: 1645577681013domainLookupEnd: 1645577680989domainLookupStart: 1645577680989fetchStart: 1645577680989loadEventEnd: 1645577681183loadEventStart: 1645577681183 // load事件开始触发navigationStart: 1645577680989redirectEnd: 0redirectStart: 0requestStart: 1645577680989 // 请求开始responseEnd: 1645577680990 responseStart: 1645577680989 // 请求结束secureConnectionStart: 0unloadEventEnd: 1645577681010unloadEventStart: 1645577681010[[Prototype]]: PerformanceTiming}
通过这些值可以做性能监控。
DCL表示DomContentLoad事件的触发,FP表示有像素画到页面上了,就触发。 FCP表示首次内容绘制,L表示onLoad事件的触发时间。LCP表示可见区域最大的内容绘制的时间(就是你页面上需要绘制最久的dom元素。)
上面这些值可以通过performance.timing里的值计算。浏览器默认帮助我们计算好了
<!-- 需要等待所有的事件执行完毕才会计算 --> <script> setTimeout(() => { let { fetchStart, //开始访问 requestStart, responseStart, responseEnd, domInteractive, //dom树构建完毕,可以交互的时间点 domContentLoadedEventEnd, //dom加载完毕 + domContentLoad触发结束 loadEventStart, // 所有资源加载完毕 } = performance.timing; let TTFB = responseStart - requestStart //从请求到数据返回第一个字节的所消耗的时间 let TTI = domInteractive - fetchStart //从开始访问,到dom可以交互所消耗的时间 let DCL = domContentLoadedEventEnd - fetchStart // DOM从请求数据,到dom加载完毕 let L = loadEventStart - fetchStart // 所有资源加载完毕所用的时长 console.log('TTFB,', TTFB); console.log('TTI,', TTI); console.log('DCL,', DCL); console.log('L,', L); }, 3000);
FP, FCP可以通过performance.getEntriesByType(‘paint’)获取具体时间
const paint = performance.getEntriesByType('paint') console.log('paint', paint);结果是[ { "name": "first-paint", FP(只是花了像素而已) "entryType": "paint", "startTime": 20.700000000186265, "duration": 0 }, { "name": "first-contentful-paint", FCP(必须有内容) "entryType": "paint", "startTime": 20.700000000186265, "duration": 0 }]
可以看到时间是差不多的。
FMP表示有意义的绘制,如
<div elementtiming="meaningful">haadiv>
表示是有意义的div,然后
PerformanceObserver观察性能测量事件,监听新的性能条目。
const observer = new PerformanceObserver((entryList, observer) => { console.log(entryList.getEntries()); observer.disconnect() //首屏监测完毕后直接结束。 }) observer.observe({ entryTypes: ['element'] })//打印结果[ { "name": "text-paint", "entryType": "element", "startTime": 47.59999999962747, "duration": 0, "renderTime": 47.59999999962747, "loadTime": 0, "intersectionRect": { "x": 8, "y": 49.60000228881836, "width": 28, "height": 20.80000114440918, "top": 49.60000228881836, "right": 36, "bottom": 70.40000343322754, "left": 8 }, "identifier": "meaningful", "naturalWidth": 0, "naturalHeight": 0, "id": "", "url": "" }]
可获取该元素的渲染时间,作为FMP的时间。
检测LCP,最大内容渲染时间
// LCP new PerformanceObserver((entryList, observer) => { console.log('lcp', entryList.getEntries()); observer.disconnect() }).observe({ entryTypes: ['largest-contentful-paint'] })
结果是
[ { "name": "", "entryType": "largest-contentful-paint", "startTime": 40.7, "duration": 0, "size": 2545, "renderTime": 40.7, "loadTime": 0, "id": "", "url": "" }]
通过这个api也可以获取到LCP的时间。
FID时间,首次输入延迟
// FID new PerformanceObserver((entryList, observer) => { console.log('FID', entryList.getEntries()); observer.disconnect() }).observe({ type: ['first-input'], buffered: true })//当点击input标签后,打印[ { "name": "mousedown", "entryType": "first-input", "startTime": 1000.0999999996275, "duration": 16, "processingStart": 1001.0999999996275, "processingEnd": 1001.0999999996275, "cancelable": true }]
这几个就是首屏渲染的关键时间点,最好能记住!