前言
上一部分我们完成了从 HTTP 发送 Request,到接收到 Response,并且把 Response 中的文本都解析出来。
这一部分我们主要讲解如何做 HTML 解析 和 CSS 计算这两个部分。
根据我们上部分列出的一个完整的浏览器架构的话,蓝色背景的部分就是我们目前已经完成的流程。
思路:
加入 HTML Parser
Response
对象Response
中的 body 内容文件: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 内容
};
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);
};
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 === '<') {
// 标签开始
return tagOpen;
} else if (char === EOF) {
// 文本结束
return;
} else {
return data;
}
}
/**
* 标签开始状态
* ----------------------------------
* 1. 如果找到 `/` 证明是自关闭标签
* 2. 如果是字母就是标签名
* 3. 其他字符就直接继续寻找
* @param {*} char
*/
function tagOpen(char) {
if (char === '/') {
// 自关闭标签
return endTagOpen;
} else if (char.match(/^[a-zA-Z]$/)) {
// 标签名
return tagName(char);
} else {
return;
}
}
/**
* 标签结束状态
* --------------------------------
* 1. 如果是字母就是标签名
* 2. 如果直接是 `>` 就报错
* 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(/^[\t\n\f ]$/)) {
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(/^[\t\n\f ]$/)) {
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);
};
业务逻辑:
currentToken
来暂存当前的 Token(这里我们是用于存放开始和结束标签 token 的)emit()
方法来接收最后创建完毕的 Token(这里后面会用逐个 Token 来创建 DOM 树)EOF
,那就直接 emit 一个 type: ‘EOF’ 的 Token{type: 'text', content: char}
的 token{type: 'startTag, tagName: ''}
tagName()
状态中我们会把整个完整的标签名拼接好{type: 'endTag', tagName: ''}
tagName()
状态中我们会把整个完整的标签名拼接好currentTag
追加字母即可>
字符时,就是这个标签结束的时候,这个时候我们已经拥有一个完整的标签 Token了currentToken
emit 出去>
字符的时候,这里就是标签结束的时候,所以可以 emit currentToken
的时候>
字符时,就是自闭标签结束的时候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 === '<') {
// 标签开始
return tagOpen;
} else if (char === EOF) {
// 文本结束
emit({
type: 'EOF',
});
return;
} else {
// 文本
emit({
type: 'text',
content: char,
});
return data;
}
}
/**
* 标签开始状态
* ----------------------------------
* 1. 如果找到 `/` 证明是自关闭标签
* 2. 如果是字母就是标签名
* 3. 其他字符就直接继续寻找
* @param {*} char
*/
function tagOpen(char) {
if (char === '/') {
// 自关闭标签
return endTagOpen;
} else if (char.match(/^[a-zA-Z]$/)) {
// 标签名
currentToken = {
type: 'startTag',
tagName: '',
};
return tagName(char);
} else {
return;
}
}
/**
* 标签结束状态
* --------------------------------
* 1. 如果是字母就是标签名
* 2. 如果直接是 `>` 就报错
* 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(/^[\t\n\f ]$/)) {
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(/^[\t\n\f ]$/)) {
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);
};
业务逻辑:
currentAttribute
来存放当前找到的属性currrentToken
之中/
或者>
就是标签直接结束了,我们就可以进入属性结束状态=
或者 EOF
这里就有 HTML 语法错误,正常来说就会返回 parse error
currentAttribute
对象 {name: '', value: ''}
,然后返回属性名状态/
、>
或者是 EOF
等字符时,就可以判定这个属性已经结束了,可以直接迁移到 afterAttributeName
状态=
字符,证明我们的属性名读取完毕,下来就是属性值了\u0000
那就是解析错误,直接抛出 Parse error
currentAttribute
的 name
值中,然后继续进入属性名状态继续读取属性名字符/
、>
或者是 EOF
等字符时,我们继续往后寻找属性值,所以继续返回 beforeAttributeValue
状态"
就是双引号属性值,进入 doubleQuotedAttributeValue
'
就是单引号属性值,进入 singleQuotedAttributeValue
reconsume
的技巧进入 unquotedAttributeValue(char)
"
字符,到达这个字符证明这个属性的名和值都读取完毕,可以直接把这两个值放入当前 Token 了\u0000
或者 EOF
就是 HTML 语法错误,直接抛出 Parse error
currentAttribute
的 value
中,然后继续进入 doubleQuotedAttributeValue'
字符,到达这个字符证明这个属性的名和值都读取完毕,可以直接把这两个值放入当前 Token 了\u0000
或者 EOF
就是 HTML 语法错误,直接抛出 Parse error
currentAttribute
的 value
中,然后继续进入 singleQuotedAttributeValuebeforeAttributeName
状态/
字符,因为之前我们读的是属性,属性都是在开始标签中的,在开始标签遇到 /
,那肯定是自封闭标签了。所以这里直接迁移到 selfClosingStartTag
状态>
字符,证明标签要结束了,直接把当前组装好的属性名和值加入 currentToken
, 然后直接 emit 出去EOF
那就是 HTML 语法错误,抛出 Parse error
beforeAttributeName
状态/
证明标签是一个自封闭标签,先把当前属性加入 currentToken 然后进入 selfClosingStartTag
状态>
证明标签正常结束了,先把当前属性加入 currentToken 然后直接 emit tokenParse error
unquotedAttributeValue
afterAttributeName
/
证明这个标签是自封闭标签,直接迁移到 selfClosingStartTag
状态=
字符证明下一个字符开始就是属性值了,迁移到 beforeAttributeValue
状态>
字符,证明标签正常结束了,先把当前属性加入 currentToken 然后直接 emit tokenEOF
证明HTML 文本异常结束了,直接抛出 Parse error
文件名: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 === '<') {
// 标签开始
return tagOpen;
} else if (char === EOF) {
// 文本结束
emit({
type: 'EOF',
});
return;
} else {
// 文本
emit({
type: 'text',
content: char,
});
return data;
}
}
/**
* 标签开始状态
* ----------------------------------
* 1. 如果找到 `/` 证明是自关闭标签
* 2. 如果是字母就是标签名
* 3. 其他字符就直接继续寻找
* @param {*} char
*/
function tagOpen(char) {
if (char === '/') {
// 自关闭标签
return endTagOpen;
} else if (char.match(/^[a-zA-Z]$/)) {
// 标签名
currentToken = {
type: 'startTag',
tagName: '',
};
return tagName(char);
} else {
return;
}
}
/**
* 标签结束状态
* --------------------------------
* 1. 如果是字母就是标签名
* 2. 如果直接是 `>` 就报错
* 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(/^[\t\n\f ]$/)) {
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(/^[\t\n\f ]$/)) {
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(/^[\t\n\f ]$/) || 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(/^[\t\n\f ]$/) || 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(/^[\t\n\f ]$/)) {
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(/^[\t\n\f ]$/)) {
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 === '<' || char === '=' || char === '`') {
throw new Error('Parse error');
} else if (char === EOF) {
throw new Error('Parse error');
} else {
currentAttribute.value += char;
return unquotedAttributeValue;
}
}
/**
* 属性名结束状态
* @param {*} char
*/
function afterAttributeName(char) {
if (char.match(/^[\t\n\f ]$/)) {
return afterAttributeName;
} else if (char === '/') {
return selfClosingStartTag;
} else if (char === '=') {
return beforeAttributeValue;
} else if (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);
};
这里我们开始语法分析,这个与复杂的 JavaScript 的语法相比就非常简单,所以我们只需要用栈基于可以完成分析。但是如果我们要做一个完整的浏览器,只用栈肯定是不行的,因为浏览器是有容错性的,如果我们没有编写结束标签的话,浏览器是会去为我们补错机制的。
那么我做的这个简单的浏览器就不需要对使用者做的那么友好,而只对实现者做的更友好即可。所以我们在实现的过程中就不做那么多特殊情况的处理了。简单用一个栈实现浏览器的 HTML 语法解析,并且构建 一个 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;
}
}
这里是 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 解析并且获得了我们的 DOM 树之后,我们可以通过 CSS 计算来生成带 CSS 的 DOM 树。CSS Computing 表示的就是我们 CSS 规则里面所包含的那些 CSS 属性,应用到匹配这些选择器的元素上。
开始这个代码编写之前,我们先来看看在整个浏览器工作流程中,我们完成了哪些流程,到达了哪里。
上面的图,我们看到 蓝色
部分就是已经完成的:
目前的 DOM 树只有我们的 HTML 语言里面描述的那些语义信息,我们像完成渲染,我们需要 CSS 信息。那有的同学就会说我们把所有的样式写到 style 里面可不可以呢?如果我们这样写呢,我们就不需要经历这个 CSS 计算的过程了。但是虽然我们只是做一个虚拟的浏览器,但是还是希望呈现一个比较完成的浏览器流程,所以我们还是会让 DOM 树参与 CSS 计算的过程。
所以这里我们就让 DOM 树挂上 CSS 信息,然后在渲染的过程中能使用。
在编写这个代码之前,我们需要准备一个环境。如果我们需要做 CSS 计算,我们就需要对 CSS 的语法与词法进行分析。然后这个过程如果是手动来实现的话,是需要较多的编译原理基础知识的,但是这些编译基础知识的深度对我们知识想了解浏览器工作原理并不是重点。所以这里我们就偷个懒,直接用 npm 上的一个css
现成包即可。
其实这个 css
包,就是一个 CSS parser,可以帮助我们完成 CSS 代码转译成 AST 抽象语法树。我们所要做的就是根据这棵抽象语法树抽出各种 CSS 规则,并且把他们运用到我们的 HTML 元素上。
那么我们第一步就是先拿到 CSS 的规则,所以叫做 “收集 CSS 规则”
文件:parser.js 中的 emit() 函数
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') {
// ............. 省略了这部分代码 .....................
}
}
文件:parser.js 中加入 addCSSRule() 函数
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);
}
最终 AST 输出的结果:
type
类型是 stylesheet
样式表stylesheet
中有 rules
的 CSS 规则数组rules
数组中就有一个 declarations
数组,这里面就是我们 CSS 样式的信息了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 规则了
文件: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);
}
为什么需要获取父元素序列呢?因为我们今天的选择器大多数都是跟元素的父元素相关的。
文件:parser.js 中的 computeCSS() 函数
slice
来复制这个元素。reverse()
把元素的顺序倒过来,为什么我们需要颠倒元素的顺序呢?是因为我们的标签匹配是会从当前元素开始逐级的往外匹配(也就是一级一级往父级元素去匹配的)/**
* 对元素进行 CSS 计算
* @param {*} element
*/
function computeCSS(element) {
var elements = stack.slice().reverse();
}
首先我们来了解一下选择器的机构,其实选择器其实是有一个层级结构的:
思路:
/**
* 匹配函数下一节会重点实现
* @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 < elements.length; i++) {
if (match(elements[i], selectorParts[j])) j++;
}
if (j >= selectorParts.length) matched = true;
if (matched) console.log('Element', element, 'matched rule', rule);
}
}
上一节我们没有完成 match
匹配函数的实现,那这一部分我们来一起实现元素与选择器的匹配逻辑。
/**
* 匹配元素和选择器
* @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 属性,这里我们只需要把 delarations
里面声明的属性给他加到我们的元素的 computed
上就可以了。
/**
* 对元素进行 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 < elements.length; i++) {
if (match(elements[i], selectorParts[j])) j++;
}
if (j >= 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选择器是高于标签选择器的。这个问题我们下一部分会和同学们一起解决掉哦。
上一节的代码中,我们只是把匹配中的选择器中的属性直接覆盖上一个,但是其实在 CSS 里面是有一个 specification
的规定。specification
翻译成中文,很多时候都会被翻译成 优先级
,当然在理论上是对的,但是在英文中呢,优先级是 priority
,所以 specificity
是 专指程度
。
放在 CSS 中理解就是,ID 选择器中的专指度是会比 CLASS 选择器的高,所以 CSS 中的 ID 的属性会覆盖 CLASS 的属性。
好我们先来理解一下 specification
是什么?
specifity
会有四个元素specificity
就是 [0, 0, 0, 0]
下面我们用一些例子来分析一下,我们应该如何用 specificity
来分辨优先级的:
A组选择器 A 选择器:
div div #id
A 的specification
:[0, 1, 0, 2]
1
2
B组选择器
B 选择器:div #my #id
B 的 specification
:[0, 2, 0, 1]
2
1
好,那么我们怎么去比较上面的两种选择器,那个更大呢?
用上面 A 和 B 两种选择器来做对比的话,第一对两个都是 0
,所以可以直接跳过。
然后第二位数值对,A选择器是 1
,B选择器是 2
,很明显 B 要比 A 大,所以 B 选择器中的属性就要覆盖 A 的。
说到这里同学们应该都明白 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 < elements.length; i++) {
if (match(elements[i], selectorParts[j])) j++;
}
if (j >= 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 计算。
下一篇文章我们来一起完成排版和渲染两个浏览器过程。敬请期待!