前言
这篇博客,我使用node.js环境在代码逻辑层面描述浏览器从接收一个URL到渲染出一个网页这里面究竟都发生了什么。
我们知道在浏览器中把一个URL渲染出一个网页可以简化为五个步骤:
- 从URL到HTML
- 从HTML到DOM树
- 从DOM树到CSSOM树
- 从CSSOM树到布局树
- 从布局树到渲染显示
从URL到HTML
这一步我们是建立在HTTP请求的基础上,即服务器发送一个HTTP的URL请求,这个请求里面包含HTML文本信息,然后客户端通过接收到的数据进行解析,最终得到服务器所发送的HTML文本。
server.js:
const http = require('http');
http.createServer((request, response) => {
let body = [];
request.on('error', (err) => {
console.error(err);
}).on('data', (chunk) => {
console.log(chunk);
body.push(chunk);
}).on('end', () => {
body = Buffer.concat(body).toString();
console.log('body:', body);
response.writeHead(200, { 'Content-type': 'text/html' });
response.end(
`<html maaa=a >
<head class="my-head">
<style>
#container {
width:500px;
height:300px;
display:flex;
flex-wrap: wrap;
justify-content: space-around;
align-items: center;
background-color:rgb(255,255,255);
}
#container #my-id {
width:200px;
height:100px;
background-color:rgb(255,0,0);
}
#container .c1 {
width: 400px;
height: 100px;
background-color:rgb(0,255,0);
}
</style>
</head>
<body>
<div id="container">
<div id="my-id"></div>
<div class="c1" ></div>
</div>
</body>
</html>`
);
});
}).listen(8088);
client.js:
async function main() {
const request = new Request({
method: "POST",
host: "127.0.0.1",
port: "8088",
path: "/",
headers: {
["X-Foo2"]: "customed"
},
body: {
name: "rookie"
}
});
const response = await request.send();
console.log(response);
}
request.send()里面包含了对HTTP协议的解析算法,这个算法的基本思路是通过有限状态机进行处理。最终,response.body即是服务器所发送的HTML文本内容。
从HTML到DOM树
这一步我们需要对response.body进行解析,返回一个DOM树。
由于在HTML标准中以及规定了HTML的状态,所以解析HTML文本的思路也是通过有限状态机进行实现。(官方链接)在解析的过程中,我们通过在不同的状态机中执行不同的逻辑,把每一次需要保存的数据(token)都返回到一个回调函数中,token不仅包括标签的类型也包含该标签所具有的属性和文本内容。最后,全局定义一个栈,将此回调函数中的所有有效标签合成一棵DOM树。
parser.js
let stack = [{
type: 'document',
children: []
}];
function emit(token) {
let top = stack[stack.length - 1];
if (token.type == 'startTag') {
let element = {
type: 'element',
children: [],
attributes: []
};
element.tagName = token.tagName;
for (let p in token) {
if (p != 'type' && p != 'tagName') {
element.attributes.push({
name: p,
value: token[p]
});
}
}
top.children.push(element);
element.parent = top;
if (!token.isSelfClosing) {
stack.push(element);
}
currentTextNode = null;
} else if (token.type == 'endTag') {
if (top.tagName != token.tagName) {
throw new Error("Tag start end doesn't match!");
} else {
stack.pop();
}
currentTextNode = null;
} else if (token.type === 'text') {
if (currentTextNode == null) {
currentTextNode = {
type: 'text',
content: ''
}
top.children.push(currentTextNode);
}
currentTextNode.content += token.content;
}
}
...
module.exports.parserHTML = function parserHTML(html) {
let state = data;
for (let c of html) {
state = state(c);
}
state = state(EOF);
return stack[0];
}
最终,得到的stack[0]就是我们所需要的DOM树。
从DOM树到CSSOM树
这一步分为两小步,第一步收集style标签中的CSS规则,第二步将CSS规则添加到DOM树中形成CSSOM树。这两步都在emit函数中进行,收集CSS的时机为在endTag中为遇到style标签时,而应用CSS规则的时机在startTag为DOM元素时。
更新后的emit函数:
function emit(token) {
let top = stack[stack.length - 1];
if (token.type == 'startTag') {
let element = {
type: 'element',
children: [],
attributes: []
};
element.tagName = token.tagName;
for (let p in token) {
if (p != 'type' && p != 'tagName') {
element.attributes.push({
name: p,
value: token[p]
});
}
}
computeCSS(element);
top.children.push(element);
element.parent = top;
if (!token.isSelfClosing) {
stack.push(element);
}
currentTextNode = null;
} else if (token.type == 'endTag') {
if (top.tagName != token.tagName) {
throw new Error("Tag start end doesn't match!");
} else {
if (top.tagName === 'style') {
addCSSRules(top.children[0].content);
}
stack.pop();
}
currentTextNode = null;
} else if (token.type === 'text') {
if (currentTextNode == null) {
currentTextNode = {
type: 'text',
content: ''
}
top.children.push(currentTextNode);
}
currentTextNode.content += token.content;
}
}
我们通过npm的css包来处理原始的css规则,然后通过一个全局的rules进行保存,最后在computeCSS中使用并添加到DOM树中。(computedStyle)
let rules = [];
function addCSSRules(text) {
const ast = css.parse(text);
console.log(JSON.stringify(ast, null, " "));
rules.push(...ast.stylesheet.rules);
}
function computeCSS(element) {
const elements = stack.slice().reverse();// div dov #myid=>#myid div div
if (!element.computedStyle) {
element.computedStyle = {};
}
for (let rule of rules) {
const selectorPars = rule.selectors[0].split(" ").reverse();
if (!matchSelector(element, selectorPars[0])) {
continue;
}
let matched = false;
let j = 1;
for (let i = 0; i < elements.length; i++) {
if (matchSelector(elements[i], selectorPars[j])) {
j++;
}
if (j >= selectorPars.length) {
matched = true;
break;
}
}
if (matched) {
const sp = specificity(rule.selectors[0]);
const computedStyle = element.computedStyle;
for (let declaration of rule.declarations) {
if (!computedStyle[declaration.property]) {
computedStyle[declaration.property] = {};
}
if (!computedStyle[declaration.property].specificity) {
computedStyle[declaration.property].specificity = sp;
computedStyle[declaration.property].value = declaration.value;
} else if (!compare(computedStyle[declaration.property].specificity, sp)) {
computedStyle[declaration.property].specificity = sp;
computedStyle[declaration.property].value = declaration.value;
}
}
console.log(element.computedStyle);
}
}
}
到此我们得到的stack[0]就是一棵CSSOM树,其中DOM元素的computedStyle属性里保存了其CSS样式。
从CSSOM树到布局树
首先,布局的时机是在元素标签的endTag状态,将该元素的CSSOM传进layout函数。然后我们需要知道llayout函数里面究竟做了什么,这里,我已flex布局为例子进行描述。
在flex布局里,预想知道每个元素的位置,我们只需要知道每个元素的width,height,left/right,top/bottom即可。而layout函数里面就是就是确定每个DOM元素的这几个重要指标。
layout.js:
function getStyle(element) {
if (!element.style) {
element.style = {};
}
for (let prop in element.computedStyle) {
element.style[prop] = element.computedStyle[prop].value;
if (element.style[prop].toString().match(/px$/)) {
element.style[prop] = parseInt(element.style[prop]);
}
if (element.style[prop].toString().match(/^[0-9\.]+$/)) {
element.style[prop] = parseInt(element.style[prop]);
}
}
return element.style;
}
function layout(element) {
if (!element.computedStyle) {
return;
}
const elementStyle = getStyle(element);
if (elementStyle.display !== 'flex') {
return;
}
const items = element.children.filter(e => e.type === 'element');
items.sort((a, b) => (a.order || 0) - (b.order || 0));
const style = elementStyle;
['width', 'height'].forEach(size => {
if (style[size] === 'auto' || style[size] === '') {
style[size] = null;
}
});
if (!style["flex-direction"] || style["flex-direction"] === 'auto') {
style["flex-direction"] = 'row';
}
if (!style["align-items"] || style["align-items"] === 'auto') {
style["align-items"] = 'stretch';
}
if (!style["justify-content"] || style["justify-content"] === 'auto') {
style["justify-content"] = 'flex-start';
}
if (!style["flex-wrap"] || style["flex-wrap"] === 'auto') {
style["flex-wrap"] = 'nowrap'
}
if (!style["align-content"]|| style["align-content"]=== 'auto') {
style["align-content"]= 'stretch';
}
let mainSize; //主轴元素大小
let mainStart; //主轴元素起点
let mainEnd; //主轴元素终点
let mainSign; // 排布方向
let mainBase; // 记录那些已经显示设定大小的元素值
let crossSize;
let crossStart;
let crossEnd;
let crossSign;
let crossBase;
if (style["flex-direction"] === 'row') {
mainSize = 'width';
mainStart = 'left';
mainEnd = 'right';
mainSign = +1;
mainBase = 0;
crossSize = 'height';
crossStart = 'top';
crossEnd = 'bottom';
}
if (style["flex-direction"] === 'row-reverse') {
mainSize = 'width';
mainStart = 'right';
mainEnd = 'left';
mainSign = -1;
mainBase = style.width;
crossSize = 'height';
crossStart = 'top';
crossEnd = 'bottom';
}
if (style["flex-direction"] === 'column') {
mainSize = 'height';
mainStart = 'top';
mainEnd = 'bottom';
mainSign = +1;
mainBase = 0;
crossSize = 'width';
crossStart = 'left';
crossEnd = 'right';
}
if (style["flex-direction"] === 'column-reverse') {
mainSize = 'height';
mainStart = 'bottom';
mainEnd = 'top';
mainSign = -1;
mainBase = style.height;
crossSize = 'width';
crossStart = 'left';
crossEnd = 'right';
}
// 只受交叉轴的影响
if (style["flex-wrap"] === 'wrap-reverse') {
const tmp = crossStart;
crossStart = crossEnd;
crossEnd = tmp;
crossSize = -1;
} else {
crossBase = 0;
crossSign = 1;
}
let isAutoMainSize = false;
if (!style[mainSize]) { // auto sizing
elementStyle[mainSize] = 0;
items.forEach(item => {
const itemStyle = getStyle(item);
if (itemStyle[mainSize] !== null || itemStyle[mainSize] !== (void 0)) {
elementStyle[mainSize] = elementStyle[mainSize] + itemStyle[mainSize];
}
})
isAutoMainSize = true;
}
let flexLine = []; // 一行
const flexLines = [flexLine];
let mainSpace = elementStyle[mainSize];//剩余空间
let crossSpace = 0;// 交叉轴的高度(行高)
items.forEach(item => {
const itemStyle = getStyle(item);
if (itemStyle[mainSize] === null) {
itemStyle[mainSize] = 0;
}
if (itemStyle.flex) {// flex属性 如flex:1
flexLine.push(item);
} else if (style["flex-wrap"] === 'nowrap' && isAutoMainSize) {
mainSpace -= itemStyle[mainSize];
if (itemStyle[crossSize] !== null && itemStyle[crossSize] !== (void 0)) {
crossSpace = Math.max(crossSpace, itemStyle[crossSize]);
}
flexLine.push(item);
} else {
if (itemStyle[mainSize] > style[mainSize]) { // 子元素比父元素大
itemStyle[mainSign] = style[mainSize];
}
if (mainSpace < itemStyle[mainSize]) {
flexLine.mainSpace = mainSpace;
flexLine.crossSpace = crossSpace;
flexLine = [];
flexLine.push(item);
flexLines.push(flexLine);
mainSpace = style[mainSize];
crossSpace = 0;
} else {
flexLine.push(item);
}
if (itemStyle[crossSize] !== null && itemStyle[crossSize] !== (void 0)) {
crossSpace = Math.max(crossSpace, itemStyle[crossSize]);
}
mainSpace -= itemStyle[mainSize];
}
});
flexLine.mainSpace = mainSpace; // 最后一行的剩余空间
if (style["flex-wrap"] === 'nowrap' || isAutoMainSize) {
flexLine.crossSpace = (style[crossSize] !== (void 0)) ? style[crossSize] : crossSpace;
} else {
flexLine.crossSpace = crossSpace;
}
if (mainSpace < 0) {
// 等比压缩 (只可能是单行即最后一行)
const scale = style[mainSize] / (style[mainSize] - mainSpace);
let currentMain = mainBase;
items.forEach(item => {
const itemStyle = getStyle(item);
if (itemStyle.flex) {
itemStyle[mainSize] = 0;
}
itemStyle[mainSize] = itemStyle[mainSize] * scale;
itemStyle[mainStart] = currentMain;
itemStyle[mainEnd] = itemStyle[mainStart] + mainSign * itemStyle[mainSize];
currentMain = itemStyle[mainEnd];
});
} else {
// 多行
flexLines.forEach(items => {
let mainSpace = items.mainSpace;
let flexTotal = 0;
items.forEach(item => {
const itemStyle = getStyle(item);
if ((itemStyle.flex !== null) && (itemStyle.flex !== (void 0))) {
flexTotal += itemStyle.flex;
}
});
if (flexTotal > 0) {
// 均匀分配
let currentMain = mainBase;
items.forEach(item => {
const itemStyle = getStyle(item);
if (itemStyle.flex) {
itemStyle[mainSize] = (mainSpace / flexTotal) * itemStyle.flex;
}
itemStyle[mainStart] = currentMain;
itemStyle[mainEnd] = itemStyle[mainStart] + mainSign * itemStyle[mainSize];
currentMain = itemStyle[mainEnd];
});
} else {
// 根据justifyContent进行分配
let currentMain;
let step; //间隔
if (style["justify-content"] === 'flex-start') {
currentMain = mainBase;
step = 0;
}
if (style["justify-content"] === 'flex-end') {
currentMain = mainSpace * mainSign + mainBase;
step = 0;
}
if (style["justify-content"] === 'center') {
currentMain = mainSpace / 2 * mainSign + mainBase;
step = 0;
}
if (style["justify-content"] === 'space-between') {
currentMain = mainBase;
step = mainSpace / (items.length - 1) * mainSign;
}
if (style["justify-content"] === 'space-around') {
step = mainSpace / items.length * mainSign;
currentMain = step / 2 + mainBase;
}
items.forEach(item => {
const itemStyle = getStyle(item);
itemStyle[mainStart] = currentMain;
itemStyle[mainEnd] = itemStyle[mainStart] + mainSign * itemStyle[mainSize];
currentMain = itemStyle[mainEnd] + step;
})
}
})
}
//计算交叉轴尺寸
if (!style[crossSize]) { // 自动填充
crossSpace = 0;
elementStyle[crossSize] = 0;
flexLines.forEach(flexLine => {
elementStyle[crossSize] = elementStyle[crossSize] + flexLine.crossSpace;
});
} else {
crossSpace = style[crossSize];
flexLines.forEach(flexLine => {
crossSpace -= flexLine.crossSpace;
});
}
// 分配 crossSpace
if (style["flex-wrap"] === 'wrap-reverse') {
crossBase = style[crossSize];
} else {
crossBase = 0;
}
let step;
if (style["align-content"]=== 'flex-start') {
crossBase += 0;
step = 0;
}
if (style["align-content"]=== 'flex-end') {
crossBase += crossSign * crossSpace;
step = 0;
}
if (style["align-content"]=== 'center') {
crossBase += crossSign * crossSpace / 2;
step = 0;
}
if (style["align-content"]=== 'space-between') {
crossBase += 0;
step = crossSpace / (flexLines.length - 1);
}
if (style["align-content"]=== 'space-around') {
crossBase += crossSign * step / 2;
step = crossSpace / (flexLines.length);
}
if (style["align-content"]=== 'stretch') {
crossBase += 0;
step = 0;
}
flexLines.forEach(items => {
const lineCrossSize = style["align-content"]=== 'stretch'
? items.crossSpace + crossSpace / flexLines.length
: items.crossSpace;
items.forEach(item => {
const itemStyle = getStyle(item);
const align = itemStyle["align-self"] || style["align-items"];
if (itemStyle[crossSize] === null) {
itemStyle[crossSize] = (align === 'stretch') ? lineCrossSize : 0;
}
if (align === 'flex-start') {
itemStyle[crossStart] = crossBase;
itemStyle[crossEnd] = itemStyle[crossStart] + crossSign * itemStyle[crossSize];
}
if (align === 'flex-end') {
itemStyle[crossEnd] = crossBase + crossSign * lineCrossSize;
itemStyle[crossStart] = itemStyle[crossEnd] - crossSign * itemStyle[crossSize];
}
if (align === 'center') {
itemStyle[crossStart] = crossBase + crossSign * (lineCrossSize - itemStyle[crossSize]) / 2;
itemStyle[crossEnd] = itemStyle[crossStart] + crossSign * itemStyle[crossSize];
}
if (align === 'stretch') {
itemStyle[crossStart] = crossBase;
itemStyle[crossEnd] = crossBase + crossSign * ((itemStyle[crossSize] !== null && itemStyle[crossSize] !== (void 0)) ? itemStyle[crossSize] : lineCrossSize);
itemStyle[crossSize] = crossSign * (itemStyle[crossEnd] - itemStyle[crossStart]);
}
});
crossBase += crossSign*(lineCrossSize+step);
})
}
module.exports = layout;
到此,client.js里面解析得到的就是一棵布局树。
从布局树到渲染显示
这一步的逻辑比较简单直接,我们通过使用各种外界的js库将已完成的布局树绘制出来,这里我只使用npm的images库。
最新的main函数:
async function main() {
const request = new Request({
method: "POST",
host: "127.0.0.1",
port: "8088",
path: "/",
headers: {
["X-Foo2"]: "customed"
},
body: {
name: "rookie"
}
});
const response = await request.send();
console.log(response);
const dom = parser.parserHTML(response.body);
// console.log(JSON.stringify(dom, null, " "));
const viewport = images(800, 600);
render(viewport, dom);
viewport.save('viewport.jpg');
}
render.js:
const images = require('images');
function render(viewport, element) {
if (element.style) {
const img = images(element.style.width, element.style.height);
if (element.style['background-color']) {
const color = element.style['background-color'] || 'rgb(0,0,0)';
color.match(/rgb\((\d+),(\d+),(\d+)\)/);
img.fill(Number(RegExp.$1), Number(RegExp.$2), Number(RegExp.$3));
viewport.draw(img, element.style.left || 0, element.style.top || 0);
}
}
if (element.children) {
for (let child of element.children) {
render(viewport, child);
}
}
}
module.exports = render;
最后将代码运行起来,我们可以得到一张viewport.jpg的图片:
同时,我们可以在浏览器打开此项目服务器的端口,得到如下界面:(将背景色调黑)
最终我们得到了一个和浏览器解析一模一样的结果!
结语
到此,我们手动实现了一个简易的浏览器的解析渲染过程,但是实际中的浏览器原理比这里所描述的要复杂得多,后续我应该还会继续学习并更新此代码(完整代码地址),有兴趣的朋友也可以尝试着自己手动玩玩。