Categories
程式開發

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算


我是三鑽,一個在《技術銀河》中等你們一起來終生漂泊學習。 點贊是力量,關注是認可,評論是關愛! 下期再見👋!

前言

上一部分我們完成了從HTTP 發送Request,到接收到Response,並且把Response 中的文本都解析出來。

這一部分我們主要講解如何做HTML 解析和CSS 計算這兩個部分。

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 1

根據我們上部分列出的一個完整的瀏覽器架構的話,藍色背景的部分就是我們目前已經完成的流程。

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 2

HTML 解析

HTML parse 模塊的文件拆分

思路:

為了方便文件管理,我們把parser 單獨拆分到文件中parser 接收HTML 文本作為參數,返回一棵DOM 樹

加入HTML Parser

上一篇文章中我們最後獲得了一個Response 對像這裡我們就考慮如何利用這個Response 中的body 內容所以我們應該從獲得Response 之後,把body 內容傳給parser 中的parseHTML 方法進行解析在真正的瀏覽器中,我們是應該逐段的傳給parser 處理,然後逐段的返回因為這裡我們的目標只是簡單實現瀏覽器工作的原理,所以我們只需要統一解析然後返回就好這樣我們更容易理解,代碼也更加清晰易懂

文件:client.js

// 这个是 client.js

// 1. 引入 parser.js
const parser = require('./parser.js');

// ...
//... 之前的代码在此处忽略
// ...

let response = await request.send();

// 2. 在 `请求方法` 中,获得 response 后加入 HTML 的解析代码
let dom = parser.parseHTML(response.body);

文件:parser.js

/**
* 解析器
* @filename parser.js
* @author 三钻
* @version v1.0.0
*/

module.exports.parseHTML = function (html) {
console.log(html); // 这里我们先 console.log 打印一下返回的 HTML 内容
};

用有效狀態機(FSM) 實現HTML的分析

我們用FSM 來實現HTML 的分析在HTML 標準中,已經規定了HTML 的狀態我們的瀏覽器只挑選其中一部分狀態,完成一個最簡版本

HTML 標準裡面已經把整個狀態機中的狀態都設計好了,我們直接就看HTML標準中給我們設計好的狀態:https://html.spec.whatwg.org/multipage/,我們直接翻到“ Tokenization” 查看列出的狀態,這裡就是所有HTML 的詞法。

有些同學在讀這個標準的時候會說“我看不懂”,“我太難了”,“我看懵了”。 其實我們看不懂是因為這裡面的標準是寫給瀏覽器實現者去看的,但是用實現我們的瀏覽器的狀態機之後,我們就可以看懂了,而且發現這裡面寫的非常像我們的代碼。 這個標準中寫的就是偽代碼。 我們只需要把這裡面的偽代碼寫成真實代碼就可以了。

在HTML 中有80個狀態,但是在我們這裡,因為只需要走一遍瀏覽器工作的流程,我們就不一一實現了,我們在其中挑選一部分來實現即可。

下面我們來初始化一下我們的parseHTML 的狀態機:(把上面的parser.js 的基礎上進行修改)

文件:parser.js

/**
* 解析器
* @filename parser.js
* @author 三钻
* @version v1.0.0
*/

const EOF = Symbol('EOF'); // EOF: end of file

function data(char) {}

/**
* HTTP 解析
* @param {string} html 文本
*/
module.exports.parseHTML = function (html) {
let state = data;
for (let char of html) {
state = state(char);
}
state = state(EOF);
};

+ 上面的代碼中用了一個小技巧,因為HTML 最後是有一個文件終結的+ 所有最後需要給他一個結束字符(重點是這裡用一個沒有特別意義的字符)+ 我們這裡使用了Symbol 創建了一個EOF 字符,代表End of file (文件結束)

解析標籤

HTML 有三種標籤

開始標籤結束標籤自封閉標籤

思路:

主要的標籤有:開始標籤,結束標籤和自封閉標籤在這一步我們暫時忽略屬性

文件:parser.js

/**
* 解析器
* @filename parser.js
* @author 三钻
* @version v1.0.0
*/

const EOF = Symbol('EOF'); // EOF: end of file

/**
* HTML 数据开始阅读状态
* --------------------------------
* 1. 如果找到 `<` 就是标签开始状态 * 2. 如果找到 `EOF` 就是HTML文本结束 * 3. 其他字符就继续寻找 * @param {*} char * * @return {function} */ function data(char) { if (char === '` 就报错 * 3. 如果是结束符合,也是报错 * @param {*} char */ function endTagOpen(char) { if (char.match(/^[a-zA-Z]$/)) { return tagName(char); } else if (char === '>') {
// 报错 —— 没有结束标签
} else if (char === EOF) {
// 报错 —— 结束标签不合法
}
}

/**
* 标签名状态
* --------------------------------
* 1. 如果 `t`(Tab符)、`n`(空格符)、`f`(禁止符)或者是空格,这里就是属性的开始
* 2. 如果找到 `/` 就是自关闭标签
* 3. 如果是字母字符那还是标签名
* 4. 如果是 `>` 就是开始标签结束
* 5. 其他就是继续寻找标签名
* @param {*} char
*/
function tagName(char) {
if (c.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '/') {
return selfClosingStartTag;
} else if (c.match(/^[a-zA-Z]$/)) {
return tagName;
} else if (char === '>') {
return data;
} else {
return tagName;
}
}

/**
* 标签属性状态
* --------------------------------
* 1. 如果遇到 `/` 就是自封闭标签状态
* 2. 如果遇到字母就是属性名
* 3. 如果遇到 `>` 就是标签结束
* 4. 如果遇到 `=` 下来就是属性值
* 5. 其他情况继续进入属性抓取
* @param {*} char
*/
function beforeAttributeName(char) {
if (char === '/') {
return selfClosingStartTag;
} else if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '>') {
return data;
} else if (char === '=') {
return beforeAttributeName;
} else {
return beforeAttributeName;
}
}

/**
* 自封闭标签状态
* --------------------------------
* 1. 如果遇到 `>` 就是自封闭标签结束
* 2. 如果遇到 `EOF` 即使报错
* 3. 其他字符也是报错
* @param {*} char
*/
function selfClosingStartTag(char) {
if (char === '>') {
return data;
} else if (char === 'EOF') {
} else {
}
}

/**
* HTTP 解析
* @param {string} html 文本
*/
module.exports.parseHTML = function (html) {
let state = data;
for (let char of html) {
state = state(char);
}
state = state(EOF);
};

創建元素

在狀態機中,除了狀態遷移,我們還會加入業務邏輯我們在標籤結束狀態提交標籤token

業務邏輯:

首先我們需要建立一個currentToken 來暫存當前的Token(這裡我們是用於存放開始和結束標籤token 的)然後建立一個emit() 方法來接收最後創建完畢的Token(這里後面會用逐個Token 來創建DOM樹)HTML 數據開始狀態—— data

+ 如果找到的是EOF,那就直接emit 一個type: ‘EOF’ 的Token

+ 如果是文本內容的話,直接emit {type: ‘text’, content: char} 的token

標籤開始狀態—— tagOpen

+ 如果匹配中的是字母,那就是開始標籤

+ 直接記錄開始標籤Token 對象{type: ‘startTag, tagName: ”}

+ 在tagName() 狀態中我們會把整個完整的標籤名拼接好

標籤結束狀態—— endTagOpen

+ 如果匹配到字符,那就是結束標籤名

+ 直接記錄結束標籤Token 對象{type: ‘endTag’, tagName: ”}

+ 雷同,後面會在tagName() 狀態中我們會把整個完整的標籤名拼接好

標籤名狀態—— tagName

+ 這裡就是最核心的業務區了

+ 在第三種情況下,匹配到字母時,那就是需要拼接標籤名的時候

+ 這裡我們直接給currentTag 追加字母即可

+ 當我們匹配到> 字符時,就是這個標籤結束的時候,這個時候我們已經擁有一個完整的標籤Token了

+ 所以這裡我們直接把currentToken emit 出去

標籤屬性狀態—— beforeAttributeName

+ 在匹配到> 字符的時候,這裡就是標籤結束的時候,所以可以emit currentToken 的時候

自封閉標籤狀態—— selfClosingStartTag

+ 這裡追加了一個邏輯

+ 在匹配到> 字符時,就是自閉標籤結束的時候

+ 這裡我們直接給currentToken 追加一個isSelfClosing = true 的狀態

+ 然後直接可以把currentToken emit 出去了

文件:parser.js

/**
* 解析器
* @filename parser.js
* @author 三钻
* @version v1.0.0
*/

let currentToken = null;

/**
* 输出 HTML token
* @param {*} token
*/
function emit(token) {
console.log(token);
}

const EOF = Symbol('EOF'); // EOF: end of file

/**
* HTML 数据开始阅读状态
* --------------------------------
* 1. 如果找到 `<` 就是标签开始状态 * 2. 如果找到 `EOF` 就是HTML文本结束 * 3. 其他字符就继续寻找 * @param {*} char * * @return {function} */ function data(char) { if (char === '` 就报错 * 3. 如果是结束符合,也是报错 * @param {*} char */ function endTagOpen(char) { if (char.match(/^[a-zA-Z]$/)) { currentToken = { type: 'endTag', tagName: '', }; return tagName(char); } else if (char === '>') {
// 报错 —— 没有结束标签
} else if (char === EOF) {
// 报错 —— 结束标签不合法
}
}

/**
* 标签名状态
* --------------------------------
* 1. 如果 `t`(Tab符)、`n`(空格符)、`f`(禁止符)或者是空格,这里就是属性的开始
* 2. 如果找到 `/` 就是自关闭标签
* 3. 如果是字母字符那还是标签名
* 4. 如果是 `>` 就是开始标签结束
* 5. 其他就是继续寻找标签名
* @param {*} char
*/
function tagName(char) {
if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '/') {
return selfClosingStartTag;
} else if (char.match(/^[a-zA-Z]$/)) {
currentToken.tagName += char;
return tagName;
} else if (char === '>') {
emit(currentToken);
return data;
} else {
return tagName;
}
}

/**
* 标签属性状态
* --------------------------------
* 1. 如果遇到 `/` 就是自封闭标签状态
* 2. 如果遇到字母就是属性名
* 3. 如果遇到 `>` 就是标签结束
* 4. 如果遇到 `=` 下来就是属性值
* 5. 其他情况继续进入属性抓取
* @param {*} char
*/
function beforeAttributeName(char) {
if (char === '/') {
return selfClosingStartTag;
} else if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '>') {
emit(currentToken);
return data;
} else if (char === '=') {
return beforeAttributeName;
} else {
return beforeAttributeName;
}
}

/**
* 自封闭标签状态
* --------------------------------
* 1. 如果遇到 `>` 就是自封闭标签结束
* 2. 如果遇到 `EOF` 即使报错
* 3. 其他字符也是报错
* @param {*} char
*/
function selfClosingStartTag(char) {
if (char === '>') {
currentToken.isSelfClosing = true;
emit(currentToken);
return data;
} else if (char === 'EOF') {
} else {
}
}

/**
* HTTP 解析
* @param {string} html 文本
*/
module.exports.parseHTML = function (html) {
let state = data;
for (let char of html) {
state = state(char);
}
state = state(EOF);
};

處理屬性

屬性值分為單引號、雙引號、無引號三種寫法,因此需要較多狀態處理處理屬性的方式跟標籤類似屬性結束時,我們把屬性加到標籤Token 上

業務邏輯:

首先我們需要定義一個currentAttribute 來存放當前找到的屬性然後在裡面疊加屬性的名字和屬性值,都完成後再放入currrentToken 之中標籤屬性名開始狀態—— beforeAttributeName

+ 這裡如果遇到空格,換行,回車等字符就可以再次進入標籤屬性名開始狀態,繼續等待屬性的字符

+ 如果我們遇到/或者>就是標籤直接結束了,我們就可以進入屬性結束狀態

+ 如果遇到= 或者EOF 這裡就有HTML 語法錯誤,正常來說就會返回parse error

+ 其他情況的話,就是剛剛開始屬性名,這裡就可以創建新的currentAttribute 對象{name: ”, value: ”},然後返回屬性名狀態

屬性名狀態—— attributeName

+ 如果我們遇到空格、換行、回車、/、> 或者是EOF等字符時,就可以判定這個屬性已經結束了,可以直接遷移到afterAttributeName 狀態

+ 如果我們遇到一個= 字符,證明我們的屬性名讀取完畢,下來就是屬性值了

+ 如果我們遇到u0000 那就是解析錯誤,直接拋出Parse error

+ 最後所有其他的都是當前屬性名的字符,直接疊加到currentAttribute 的name 值中,然後繼續進入屬性名狀態繼續讀取屬性名字符

屬性值開始狀態—— beforeAttributeValue

+ 如果我們遇到空格、換行、回車、/、> 或者是EOF等字符時,我們繼續往後尋找屬性值,所以繼續返回beforeAttributeValue 狀態

+ 如果遇到” 就是雙引號屬性值,進入doubleQuotedAttributeValue

+ 如果遇到’ 就是單引號屬性值,進入singleQuotedAttributeValue

+ 其他情況就是遇到沒有引號的屬性值,使用reconsume 的技巧進入unquotedAttributeValue(char)

雙引號屬性值狀態– doubleQuotedAttributeValue

+ 這裡我們死等” 字符,到達這個字符證明這個屬性的名和值都讀取完畢,可以直接把這兩個值放入當前Token 了

+ 如果遇到u0000 或者EOF 就是HTML 語法錯誤,直接拋出Parse error

+ 其他情況就是繼續讀取屬性值,並且疊加到currentAttribute 的value 中,然後繼續進入doubleQuotedAttributeValue

單引號屬性值狀態—— singleQuotedAttributeValue

+ 與雙引號雷同,這裡我們死等’ 字符,到達這個字符證明這個屬性的名和值都讀取完畢,可以直接把這兩個值放入當前Token 了

+ 如果遇到u0000 或者EOF 就是HTML 語法錯誤,直接拋出Parse error

+ 其他情況就是繼續讀取屬性值,並且疊加到currentAttribute 的value 中,然後繼續進入singleQuotedAttributeValue

引號結束狀態—— afterQuotedAttributeValue

+ 如果我們遇到空格、換行、回車等字符時,證明還有可能有屬性值,所以我們遷移到beforeAttributeName 狀態

+ 這個時候遇到一個/ 字符,因為之前我們讀的是屬性,屬性都是在開始標籤中的,在開始標籤遇到/ ,那肯定是自封閉標籤了。 所以這裡直接遷移到selfClosingStartTag 狀態

+ 如果遇到> 字符,證明標籤要結束了,直接把當前組裝好的屬性名和值加入currentToken, 然後直接emit 出去

+ 如果遇到EOF 那就是HTML 語法錯誤,拋出Parse error

+ 其他情況按照瀏覽器規範,這裡屬於屬性之間缺少空格的解析錯誤(Parse error: missing-whitespace-between-attributes)

無引號屬性值狀態—— unquotedAttributeValue

+ 如果我們遇到空格、換行、回車等字符時,證明屬性值結束,這個時候我們就可以直接把當前屬性加入currentToken,然後還有可能有其他屬性,所以進入beforeAttributeName 狀態

+ 如果遇到/ 證明標籤是一個自封閉標籤,先把當前屬性加入currentToken 然後進入selfClosingStartTag 狀態

+ 如果遇到> 證明標​​籤正常結束了,先把當前屬性加入currentToken 然後直接emit token

+ 遇到其他不合法字符都直接拋出Parse error

+ 其他情況就是還在讀取屬性值的字符,所以疊加當前字符到屬性值中,然後繼續回到unquotedAttributeValue

屬性名結束狀態—— afterAttributeName

+ 如果我們遇到空格、換行、回車等字符時,證明還沒有找到結束字符,繼續尋找,所以重新進入afterAttributeName

+ 如果遇到/ 證明這個標籤是自封閉標籤,直接遷移到selfClosingStartTag 狀態

+ 如果遇到= 字符證明下一個字符開始就是屬性值了,遷移到beforeAttributeValue 狀態

+ 如果遇到> 字符,證明標籤正常結束了,先把當前屬性加入currentToken 然後直接emit token

+ 如果遇到EOF 證明HTML 文本異常結束了,直接拋出Parse error

+ 其他情況下,屬於屬性名又開始了,所以把上一個屬性加入currentToken 然後繼續記錄下一個屬性

文件名:parser.js

/**
* 解析器
* @filename parser.js
* @author 三钻
* @version v1.0.0
*/

let currentToken = null;
let currentAttribute = null;

/**
* 输出 HTML token
* @param {*} token
*/
function emit(token) {
console.log(token);
}

const EOF = Symbol('EOF'); // EOF: end of file

/**
* HTML 数据开始阅读状态
* --------------------------------
* 1. 如果找到 `<` 就是标签开始状态 * 2. 如果找到 `EOF` 就是HTML文本结束 * 3. 其他字符就继续寻找 * @param {*} char * * @return {function} */ function data(char) { if (char === '` 就报错 * 3. 如果是结束符合,也是报错 * @param {*} char */ function endTagOpen(char) { if (char.match(/^[a-zA-Z]$/)) { currentToken = { type: 'endTag', tagName: '', }; return tagName(char); } else if (char === '>') {
// 报错 —— 没有结束标签
} else if (char === EOF) {
// 报错 —— 结束标签不合法
}
}

/**
* 标签名状态
* --------------------------------
* 1. 如果 `t`(Tab符)、`n`(空格符)、`f`(禁止符)或者是空格,这里就是属性的开始
* 2. 如果找到 `/` 就是自关闭标签
* 3. 如果是字母字符那还是标签名
* 4. 如果是 `>` 就是开始标签结束
* 5. 其他就是继续寻找标签名
* @param {*} char
*/
function tagName(char) {
if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '/') {
return selfClosingStartTag;
} else if (char.match(/^[a-zA-Z]$/)) {
currentToken.tagName += char;
return tagName;
} else if (char === '>') {
emit(currentToken);
return data;
} else {
return tagName;
}
}

/**
* 标签属性名开始状态
* --------------------------------
* 1. 如果遇到 `/` 就是自封闭标签状态
* 2. 如果遇到字母就是属性名
* 3. 如果遇到 `>` 就是标签结束
* 4. 如果遇到 `=` 下来就是属性值
* 5. 其他情况继续进入属性抓取
* @param {*} char
*/
function beforeAttributeName(char) {
if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '/' || char === '>') {
return afterAttributeName(char);
} else if (char === '=' || char === EOF) {
throw new Error('Parse error');
} else {
currentAttribute = {
name: '',
value: '',
};
return attributeName(char);
}
}

/**
* 属性名状态
* @param {*} char
*/
function attributeName(char) {
if (char.match(/^[tnf ]$/) || char === '/' || char === '>' || char === EOF) {
return afterAttributeName(char);
} else if (char === '=') {
return beforeAttributeValue;
} else if (char === 'u0000') {
throw new Error('Parse error');
} else {
currentAttribute.name += char;
return attributeName;
}
}

/**
* 属性值开始状态
* @param {*} char
*/
function beforeAttributeValue(char) {
if (char.match(/^[tnf ]$/) || char === '/' || char === '>' || char === EOF) {
return beforeAttributeValue;
} else if (char === '"') {
return doubleQuotedAttributeValue;
} else if (char === "'") {
return singleQuotedAttributeValue;
} else if (char === '>') {
// return data;
} else {
return unquotedAttributeValue(char);
}
}

/**
* 双引号属性值状态
* @param {*} char
*/
function doubleQuotedAttributeValue(char) {
if (char === '"') {
currentToken[currentAttribute.name] = currentAttribute.value;
return afterQuotedAttributeValue;
} else if (char === 'u0000') {
throw new Error('Parse error');
} else if (char === EOF) {
throw new Error('Parse error');
} else {
currentAttribute.value += char;
return doubleQuotedAttributeValue;
}
}

/**
* 单引号属性值状态
* @param {*} char
*/
function singleQuotedAttributeValue(char) {
if (char === "'") {
currentToken[currentAttribute.name] = currentAttribute.value;
return afterQuotedAttributeValue;
} else if (char === 'u0000') {
throw new Error('Parse error');
} else if (char === EOF) {
throw new Error('Parse error');
} else {
currentAttribute.value += char;
return singleQuotedAttributeValue;
}
}

/**
* 引号结束状态
* @param {*} char
*/
function afterQuotedAttributeValue(char) {
if (char.match(/^[tnf ]$/)) {
return beforeAttributeName;
} else if (char === '/') {
return selfClosingStartTag;
} else if (char === '>') {
currentToken[currentAttribute.name] = currentAttribute.value;
emit(currentToken);
return data;
} else if (char === EOF) {
throw new Error('Parse error: eof-in-tag');
} else {
throw new Error('Parse error: missing-whitespace-between-attributes');
}
}

/**
* 无引号属性值状态
* @param {*} char
*/
function unquotedAttributeValue(char) {
if (char.match(/^[tnf ]$/)) {
currentToken[currentAttribute.name] = currentAttribute.value;
return beforeAttributeName;
} else if (char === '/') {
currentToken[currentAttribute.name] = currentAttribute.value;
return selfClosingStartTag;
} else if (char === '>') {
currentToken[currentAttribute.name] = currentAttribute.value;
emit(currentToken);
return data;
} else if (char === 'u0000') {
throw new Error('Parse error');
} else if (char === '"' || char === "'" || char === '') {
currentToken[currentAttribute.name] = currentAttribute.value;
emit(currentToken);
return data;
} else if (char === EOF) {
throw new Error('Parse error');
} else {
currentToken[currentAttribute.name] = currentAttribute.value;
currentAttribute = {
name: '',
value: '',
};
return attributeName(char);
}
}

/**
* 自封闭标签状态
* --------------------------------
* 1. 如果遇到 `>` 就是自封闭标签结束
* 2. 如果遇到 `EOF` 即使报错
* 3. 其他字符也是报错
* @param {*} char
*/
function selfClosingStartTag(char) {
if (char === '>') {
currentToken.isSelfClosing = true;
emit(currentToken);
return data;
} else if (char === 'EOF') {
} else {
}
}

/**
* HTTP 解析
* @param {string} html 文本
*/
module.exports.parseHTML = function (html) {
let state = data;
for (let char of html) {
state = state(char);
}
state = state(EOF);
};

用token 構建DOM 樹

這裡我們開始語法分析,這個與復雜的JavaScript 的語法相比就非常簡單,所以我們只需要用棧基於可以完成分析。 但是如果我們要做一個完整的瀏覽器,只用棧肯定是不行的,因為瀏覽器是有容錯性的,如果我們沒有編寫結束標籤的話,瀏覽器是會去為我們補錯機制的。

那麼我做的這個簡單的瀏覽器就不需要對使用者做的那麼友好,而只對實現者做的更友好即可。 所以我們在實現的過程中就不做那麼多特殊情況的處理了。 簡單用一個棧實現瀏覽器的HTML 語法解析,並且構建一個DOM 樹。

從標籤構建DOM 樹的基本技巧是使用棧遇到開始標籤時創建元素併入棧,遇到結束標籤時出棧自封閉節點可視為入棧後立刻出棧任何元素的父元素是它入棧前的棧頂

文件:parser.js 中的emit() 函數部分

// 默认给予根节点 document
let stack = [{ type: 'document', children: [] }];

/**
* 输出 HTML token
* @param {*} token
*/
function emit(token) {
if (token.type === 'text') return;

// 记录上一个元素 - 栈顶
let top = stack[stack.length - 1];

// 如果是开始标签
if (token.type == 'startTag') {
let element = {
type: 'element',
children: [],
attributes: [],
};

element.tagName = token.tagName;

for (let prop in token) {
if (prop !== 'type' && prop != 'tagName') {
element.attributes.push({
name: prop,
value: token[prop],
});
}
}

// 对偶操作
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('Parse error: Tag start end not matched');
} else {
stack.pop();
}

currentTextNode = null;
}
}

將文本節點加到DOM 樹

這裡是HTML 解析的最後一步,把文本節點合併後加入DOM 樹里面。

文本節點與自封閉標籤處理類似多個文本節點需要合併

文件:parser.js 中的emit() 函數部分

let currentToken = null;
let currentAttribute = null;
let currentTextNode = null;

// 默认给予根节点 document
let stack = [{ type: 'document', children: [] }];

/**
* 输出 HTML token
* @param {*} token
*/
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 prop in token) {
if (prop !== 'type' && prop != 'tagName') {
element.attributes.push({
name: prop,
value: token[prop],
});
}
}

// 对偶操作
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('Parse error: Tag start end not matched');
} 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;
}
}

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 2

CSS 計算

完成HTML 解析並且獲得了我們的DOM 樹之後,我們可以通過CSS 計算來生成帶CSS 的DOM 樹。 CSS Computing 表示的就是我們CSS 規則裡面所包含的那些CSS 屬性,應用到匹配這些選擇器的元素上。

開始這個代碼編寫之前,我們先來看看z在整個瀏覽器工作流程中,我們完成了哪些流程,到達了哪裡。

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 4

上面的圖,我們看到藍色部分就是已經完成的:

上一篇文章我們完成了HTTP 請求然後通過獲得的報文,解析出所有HTTP 信息,裡面就包括了HTML 內容然後通過HTTP 內容解析,我們構建了我們的DOM 樹接下來就是CSS 計算(CSS Computing)

目前的DOM 樹只有我們的HTML 語言裡面描述的那些語義信息,我們像完成渲染,我們需要CSS 信息。 那有的同學就會說我們把所有的樣式寫到style 裡面可不可以呢? 如果我們這樣寫呢,我們就不需要經歷這個CSS 計算的過程了。 但是雖然我們只是做一個虛擬的瀏覽器,但是還是希望呈現一個比較完成的瀏覽器流程,所以我們還是會讓DOM 樹參與CSS 計算的過程。

所以這裡我們就讓DOM 樹掛上CSS 信息,然後在渲染的過程中能使用。

在編寫這個代碼之前,我們需要準備一個環境。 如果我們需要做CSS 計算,我們就需要對CSS 的語法與詞法進行分析。 然後這個過程如果是手動來實現的話,是需要較多的編譯原理基礎知識的,但是這些編譯基礎知識的深度對我們知識想了解瀏覽器工作原理並不是重點。 所以這裡我們就偷個懶,直接用npm 上的一個css現成包即可。

其實這個css 包,就是一個CSS parser,可以幫助我們完成CSS 代碼轉譯成AST 抽象語法樹。 我們所要做的就是根據這棵抽象語法樹抽出各種CSS 規則,並且把他們運用到我們的HTML 元素上。

那麼我們第一步就是先拿到CSS 的規則,所以叫做“收集CSS 規則”

收集CSS 規則

遇到style 標籤時,我們把CSS 規則保存起來

文件:parser.js 中的emit() 函數+ 我們在tagName === ‘endTag’ 的判斷中加入了判斷當前標籤是否style 標籤+ 如果是,我們就可以獲取style 標籤裡面所有的內容進行CSS 分析+這裡非常簡單我們加入一個addCSSRule(top.children[0].content)的函數即可+ 而,top 就是當前元素,children[0] 就是text 元素,而.content 就是所有的CSS 規則文本+ 這裡我們需要注意一個點,我們忽略了在實際情況中還有link 標籤引入CSS 文件的情況。 但是這個過程涉及到多層異步請求和HTML 解析的過程,為了簡化我們的代碼的複雜度,這裡就不做這個實現了。 當然實際的瀏覽器是會比我們做的虛擬瀏覽器複雜的多。

/**
* 输出 HTML token
* @param {*} token
*/
function emit(token) {
// 记录上一个元素 - 栈顶
let top = stack[stack.length - 1];

// 如果是开始标签
if (token.type == 'startTag') {
// ............. 省略了这部分代码 .....................
} else if (token.type == 'endTag') {
// 校验开始标签是否被结束
// 不是:直接抛出错误,是:直接出栈
if (top.tagName !== token.tagName) {
throw new Error('Parse error: Tag start end not matched');
} else {
// 遇到 style 标签时,执行添加 CSS 规则的操作
if (top.tagName === 'style') {
addCSSRule(top.children[0].content);
}
stack.pop();
}

currentTextNode = null;
} else if (token.type === 'text') {
// ............. 省略了这部分代码 .....................
}
}

這裡我們調用CSS Parser 來分析CSS 規則

文件:parser.js 中加入addCSSRule() 函數+ 首先我們需要通過node 引入css 包+ 然後調用css.parse(text) 獲得AST 抽象語法樹+ 最後通過使用… 的特性展開了ast.stylesheet.rules中的所有對象,並且加入到rules 裡面

const css = require('css');

let rules = [];
/**
* 把 CSS 规则暂存到一个数字里
* @param {*} text
*/
function addCSSRule(text) {
var ast = css.parse(text);
console.log(JSON.stringify(ast, null, ' '));
rules.push(...ast.stylesheet.rules);
}

這裡我們必須要仔細研究此庫分析CSS 規則的格式

最終AST 輸出的結果:

type 類型是stylesheet 樣式表然後在stylesheet 中有rules 的CSS 規則數組rules 數組中就有一個declarations 數組,這裡面就是我們CSS 樣式的信息了拿第一個delarations 來說明,他的屬性為width, 屬性值為100px,這些就是我們需要的CSS 規則了

{
type: "stylesheet",
stylesheet: {
source: undefined,
rules: [
{
type: "rule",
selectors: [
"body div #myId",
],
declarations: [
{
type: "declaration",
property: "width",
value: "100px",
position: {
start: {
line: 3,
column: 9,
},
end: {
line: 3,
column: 21,
},
source: undefined,
},
},
{
type: "declaration",
property: "background-color",
value: "#ff5000",
position: {
start: {
line: 4,
column: 9,
},
end: {
line: 4,
column: 34,
},
source: undefined,
},
},
],
position: {
start: {
line: 2,
column: 7,
},
end: {
line: 5,
column: 8,
},
source: undefined,
},
},
],
parsingErrors: [
],
},
}

這裡還有一個問題需要我們注意的,像body div #myId 這種帶有空格的標籤選擇器,是不會逐個給我們單獨分析出來的,所以這種我們是需要在後面自己逐個分解分析。 除非是, 逗號分隔的選擇器才會被拆解成多個delarations。

添加調用

上一步我們收集好了CSS 規則,這一步我們就是要找一個合適的時機把這些規則應用上。 應用的時機肯定是越早越好,CSS 設計裡面有一個潛規則,就是CSS 設計會盡量保證所有的選擇器都能夠在startTag 進入的時候就能被判斷。

當然,我們後面又加了一些高級的選擇器之後,這個規則有了一定的鬆動,但是大部分的規則仍然是去遵循這個規則的,當我們DOM 樹構建到元素的startTag 的步驟,就已經可以判斷出來它能匹配那些CSS 規則了

當我們創建一個元素後,立即計算CSS我們假設:理論上,當我們分析一個元素時,所有的CSS 規則已經被收集完畢在真實瀏覽器中,可能遇到寫在body 的style 標籤,需要重新CSS計算的情況,這裡我們忽略

文件:parser.js 的emit() 函數加入computeCSS() 函數調用

/**
* 输出 HTML token
* @param {*} token
*/
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 prop in token) {
if (prop !== 'type' && prop != 'tagName') {
element.attributes.push({
name: prop,
value: token[prop],
});
}
}

// 元素构建好之后直接开始 CSS 计算
computeCSS(element);

// 对偶操作
top.children.push(element);
element.parent = top;
// 自封闭标签之外,其他都入栈
if (!token.isSelfClosing) stack.push(element);

currentTextNode = null;
} else if (token.type == 'endTag') {
// ............. 省略了这部分代码 .....................
} else if (token.type === 'text') {
// ............. 省略了这部分代码 .....................
}
}

文件:parser.js 中加入computeCSS() 函數

/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
console.log(rules);
console.log('compute CSS for Element', element);
}

獲取父元素序列

為什麼需要獲取父元素序列呢? 因為我們今天的選擇器大多數都是跟元素的父元素相關的。

在computeCSS 函數中,我們必須知道元素的所有父級元素才能判斷元素與規則是否匹配我們從上一步驟的stack,可以獲取本元素的父元素因為我們首先獲取的是“當前元素”,所以我們獲得和計算父元素匹配的順序是從內向外

文件:parser.js 中的computeCSS() 函數+ 因為棧裡面的元素是會不斷的變化的,所以後期元素會在棧中發生變化,就會可能被污染。 所以這裡我們用了一個slice來複製這個元素。 + 然後我們用了reverse() 把元素的順序倒過來,為什麼我們需要顛倒元素的順序呢? 是因為我們的標籤匹配是會從當前元素開始逐級的往外匹配(也就是一級一級往父級元素去匹配的)

/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
var elements = stack.slice().reverse();
}

選擇器與元素的匹配

首先我們來了解一下選擇器的機構,其實選擇器其實是有一個層級結構的:

最外層叫選擇器列表,這個我們的CSS parser 已經幫我們做了拆分選擇器列表裡面的,叫做複雜選擇器,這個是由空格分隔了我們的複合選擇器複雜選擇器是根據親代關係,去選擇元素的複合選擇器,是針對一個元素的本身的屬性和特徵的判斷而復合原則性選擇器,它又是由緊連著的一對選擇器而構成的在我們的模擬瀏覽器中,我們可以假設一個複雜選擇器中只包含簡單選擇器我們就把這種情況當成而外有精力的同學自行去實現了哈

思路:

選擇器也要從當前元素向外排列複雜選擇器拆成對單個元素的選擇器,用循環匹配父級元素隊列

/**
* 匹配函数下一节会重点实现
* @param {*} element
* @param {*} selector
*/
function match(element, selector) {}

/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
var elements = stack.slice().reverse();

if (!elements.computedStyle) element.computedStyle = {};
// 这里循环 CSS 规则,让规则与元素匹配
// 1. 如果当前选择器匹配不中当前元素直接 continue
// 2. 当前元素匹配中了,就一直往外寻找父级元素找到能匹配上选择器的元素
// 3. 最后检验匹配中的元素是否等于选择器的总数,是就是全部匹配了,不是就是不匹配
for (let rule of rules) {
let selectorParts = rule.selectors[0].split(' ').reverse();

if (!match(element, selectorParts[0])) continue;

let matched = false;

let j = 1;
for (let i = 0; i = selectorParts.length) matched = true;

if (matched) console.log('Element', element, 'matched rule', rule);
}
}

計算選擇器與元素

上一節我們沒有完成match 匹配函數的實現,那這一部分我們來一起實現元素與選擇器的匹配邏輯。

根據選擇器的類型和元素屬性,計算是否與當前元素匹配這裡僅僅實現了三種基本選擇器,實際的瀏覽器中要處理複合選擇器同學們可以自己嘗試一下實現複合選擇器,實現支持空格的Class 選擇器

/**
* 匹配元素和选择器
* @param {Object} element 当前元素
* @param {String} selector CSS 选择器
*/
function match(element, selector) {
if (!selector || !element.attributes) return false;

if (selector.charAt(0) === '#') {
let attr = element.attributes.filter(attr => attr.name === 'id')[0];
if (attr && attr.value === selector.replace('#', '')) return true;
} else if (selector.charAt(0) === '.') {
let attr = element.attributes.filter(attr => attr.name === 'class')[0];
if (attr && attr.value === selector.replace('.', '')) return true;
} else {
if (element.tagName === selector) return true;
}

return false;
}

生成computed 屬性

這一部分我們生成computed 屬性,這裡我們只需要把delarations 裡面聲明的屬性給他加到我們的元素的computed 上就可以了。

一旦選擇器匹配中了,就把選擇器中的屬性應用到元素上然後形成computedStyle

/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
var elements = stack.slice().reverse();

if (!elements.computedStyle) element.computedStyle = {};
// 这里循环 CSS 规则,让规则与元素匹配
// 1. 如果当前选择器匹配不中当前元素直接 continue
// 2. 当前元素匹配中了,就一直往外寻找父级元素找到能匹配上选择器的元素
// 3. 最后检验匹配中的元素是否等于选择器的总数,是就是全部匹配了,不是就是不匹配
for (let rule of rules) {
let selectorParts = rule.selectors[0].split(' ').reverse();

if (!match(element, selectorParts[0])) continue;

let matched = false;

let j = 1;
for (let i = 0; i = selectorParts.length) matched = true;

if (matched) {
let computedStyle = element.computedStyle;
for (let declaration of rule.declarations) {
if (!computedStyle[declaration.property]) computedStyle[declaration.property] = {};
computedStyle[declaration.property].value = declaration.value;
}
console.log(computedStyle);
}
}
}

看完代碼的同學,或者自己去實現這個代碼時候的同學,應該會發現這個代碼中有一個問題。 如果我們回去看看我們的HTML 代碼中的style 樣式表,我們發現HTML 中的img 標籤會被兩個CSS 選擇器匹配中,分別是body div #myId 和body div img。 這樣就會導致前面匹配中後加入computedStyle 的屬性值會被後面匹配中的屬性值所覆蓋。 但是根據CSS 中的權重規則,ID選擇器是高於標籤選擇器的。 這個問題我們下一部分會和同學們一起解決掉哦。

Specificity 的計算邏輯

上一節的代碼中,我們只是把匹配中的選擇器中的屬性直接覆蓋上一個,但是其實在CSS 裡面是有一個specification 的規定。 specification 翻譯成中文,很多時候都會被翻譯成優先級,當然在理論上是對的,但是在英文中呢,優先級是priority,所以specificity 是專指程度。

放在CSS 中理解就是,ID 選擇器中的專指度是會比CLASS 選擇器的高,所以CSS 中的ID 的屬性會覆蓋CLASS 的屬性。

好我們先來理解一下specification 是什麼?

首先specifity 會有四個元素按照CSS 中優先級的順序來說就是inline style > id > class > tag所以把這個生成為specificity 就是 [0, 0, 0, 0]數組裡面每一個數字都是代表在樣式表中出現的次數

下面我們用一些例子來分析一下,我們應該如何用specificity 來分辨優先級的:

A組選擇器A 選擇器:div div #idA 的specification :[0, 1, 0, 2]+ id 出現了一次,所以第二位數字是1+ div tag 出現了兩次,所以第四位數是2B組選擇器B 選擇器:div #my #idB 的specification:[0, 2, 0, 1]+ id 出現了兩次,所以第二位數字是2+ div tag 出現了一次,所以第四位數是 1

好,那麼我們怎麼去比較上面的兩種選擇器,那個更大呢?

我們需要從左到右開始比對;遇到同位置的數值​​一樣的,就可以直接跳過;直到我們找到一對數值是有不一樣的,這個時候就看是哪個選擇器中的數值更大,那個選擇器的優先級就更高;只要有一對比對出大小後,後面的就不需要再比對了。

用上面A 和B 兩種選擇器來做對比的話,第一對兩個都是0,所以可以直接跳過。

然後第二位數值對,A選擇器是1,B選擇器是2,很明顯B 要比A 大,所以B 選擇器中的屬性就要覆蓋A 的。

說到這裡同學們應該都明白CSS 中specificity 的規則和對比原理了,下來我們一起來看看如何實現這個代碼邏輯。

CSS 規則根據specificity 和後來優先規則覆蓋specificity 是個四元組,越左邊權重越高一個CSS 規則的specificity 根據包含的簡單選擇器相加而成

文件:parser.js 中添加一個specificity 函數,來計算一個選擇器的specificity

/**
* 计算选择器的 specificity
* @param {*} selector
*/
function specificity(selector) {
let p = [0, 0, 0, 0];
let selectorParts = selector.split(' ');
for (let part of selectorParts) {
if (part.charAt(0) === '#') {
p[1] += 1;
} else if (part.charAt(0) === '.') {
p[2] += 1;
} else {
p[3] += 1;
}
}
return p;
}

文件:parser.js 添加一個compare 函數,來對比兩個選擇器的specificity

/**
* 对比两个选择器的 specificity
* @param {*} sp1
* @param {*} sp2
*/
function compare(sp1, sp2) {
for (let i = 0; i <= 3; i++) { if (i === 3) return sp1[3] - sp2[3]; if (sp1[i] - sp2[i]) return sp1[i] - sp2[i]; } }

文件:parser.js 的computeCSS 中修改匹配中元素後的屬性賦值邏輯

/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
var elements = stack.slice().reverse();

if (!elements.computedStyle) element.computedStyle = {};
// 这里循环 CSS 规则,让规则与元素匹配
// 1. 如果当前选择器匹配不中当前元素直接 continue
// 2. 当前元素匹配中了,就一直往外寻找父级元素找到能匹配上选择器的元素
// 3. 最后检验匹配中的元素是否等于选择器的总数,是就是全部匹配了,不是就是不匹配
for (let rule of rules) {
let selectorParts = rule.selectors[0].split(' ').reverse();

if (!match(element, selectorParts[0])) continue;

let matched = false;

let j = 1;
for (let i = 0; i = selectorParts.length) matched = true;

if (matched) {
let sp = specificity(rule.selectors[0]);
let computedStyle = element.computedStyle;
for (let declaration of rule.declarations) {
if (!computedStyle[declaration.property]) computedStyle[declaration.property] = {};

if (!computedStyle[declaration.property].specificity) {
computedStyle[declaration.property].value = declaration.value;
computedStyle[declaration.property].specificity = sp;
} else if (compare(computedStyle[declaration.property].specificity, sp) < 0) { computedStyle[declaration.property].value = declaration.value; computedStyle[declaration.property].specificity = sp; } } } } }

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 2

最後

我們這裡就完成了瀏覽器工作原理中的HTML 解析和CSS 計算。

下一篇文章我們來一起完成排版和渲染兩個瀏覽器過程。 敬請期待!

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 2

實戰中學習瀏覽器工作原理— HTML 解析與CSS 計算 7