在前两篇中,我们已经完成:
本篇在此基础上实现浏览器渲染流程中的下一步:为 DOM 上的每个元素计算 CSS(Computing CSS),并支持复合选择器与优先级判断。历史代码大家可以去第二篇中获取
渲染流程回顾
1.取得HTML文本
2.解析HTML,构建DOM
3.计算CSS
4.布局(Layout)与绘制(Painting)
本篇的目标是实现步骤3:当元素生成时进行样式计算
1. 收集 CSS 规则
当解析到 </style> 结束标签时,获取其文本内容并解析为规则,存入全局 rules 中:
我们的目的是为了了解渲染的流程 而不是一个个的去实现这些css的解析 所以这里我们直接使用第三方的库来解析css
const css = require('css')
let rules = []
function addCSSRules (text) {
const ast = css.parse(text)
rules.push(...ast.stylesheet.rules)
}
在 emit 的结束标签分支中挂载收集逻辑:
case 'endTag':
if (top.tagName !== token.tagName) {
throw new Error(`Tag start end does'n match`)
} else {
if (top.tagName === 'style') {
addCSSRules(top.children[0].content)
}
stack.pop()
}
currentTextNode = null
break
要点:
- 在 computeCSS 过程中,我们必须知道元素的所有父元素
- 我们在元素“生成时”计算样式,确保规则已准备好(style 通常位于 head)
- 真实浏览器中若在 body 动态插入 style,可能需要触发重新计算,这里不展开。
2. 选择器匹配(支持复合选择器与多类名)
对单个简单/复合选择器进行匹配,当前实现支持:标签、ID、类,且能处理 div#id.class1.class2 这类复合形式:
function match (element, selector) {
if (!element.attributes || !selector) {
return false
}
// 复合选择器拆分:#id、.class、tag
const regPattern = /(#[a-zA-Z]+[_a-zA-Z0-9-]*?)|(\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*)|([a-z]+)/g
const matched = selector.match(regPattern)
let matchTime = 0
for (const p of matched) {
if (p.charAt(0) === '#') {
const attr = element.attributes.filter(attr => attr.name === 'id')[0]
if (attr && attr.value === p.replace('#', '')) matchTime++
} else if (p.charAt(0) === '.') {
const attr = element.attributes.filter(attr => attr.name === 'class')[0]
if (attr) {
const classes = attr.value.split(' ')
for (let className of classes) {
if (className === p.replace('.', '')) matchTime++
}
}
} else {
if (element.tagName === p) matchTime++
}
}
return matchTime === matched.length
}
匹配策略的约束:
- 类名允许以
-开头,后接下划线与字母,之后可含_-与数字字母 - id 以字母开头,足以满足示例
- 标签名为小写字母
- 复合选择器“命中次数与选择器长度相等”才算匹配
3. 选择器优先级(specificity)与比较
使用四元组 [inline, id, class, tag] 表示优先级,从高位到低位比较:
// 我们没有实现inline style的解析 因为这不是我们要关注的重点 所以这里p[0]的位置没有任何处理
function specificity (selector) {
const p = [0, 0, 0, 0]
const regPattern = /(#[a-zA-Z]+[_a-zA-Z0-9-]*?)|(\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*)|([a-z]+)/g
const selectorParts = selector.split(' ').map(item => item.match(regPattern))
for (const parts of selectorParts) {
for (const part of parts) {
if (part.charAt(0) === '#') p[1] += 1
else if (part.charAt(0) === '.') p[2] += 1
else p[3] += 1
}
}
return p
}
function compare (sp1, sp2) {
for (let i = 0; i < 4; i++) {
if (sp1[i] > sp2[i]) return sp1[i] - sp2[i]
}
return 0
}
同优先级后出现的声明覆盖先出现的声明(由比较逻辑与赋值顺序体现)。
4.计算并写入computedStyle(从右到左匹配)
核心是对每条规则判断是否匹配当前元素及其祖先链,匹配成功后把声明写入元素的computedStyle,并随属性保存specificity以做级联覆盖:
function computeCSS (element) {
const elements = stack.slice().reverse() // 自内向外的祖先链
if (!element.computedStyle) element.computedStyle = {}
for (const rule of rules) {
const selectorParts = rule.selectors[0].split(' ').reverse()
if (!match(element, selectorParts[0])) continue
let matched = false
let j = 1
for (let i = 0, len = elements.length; i < len; i++) {
if (match(elements[i], selectorParts[j])) j++
}
matched = j >= selectorParts.length
if (matched) {
const sp = specificity(rule.selectors[0])
const computedStyle = element.computedStyle
for (const 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
}
}
}
}
}
选择器也要从当前元素向外排列;复杂选择器拆分成针对单个元素的选择器,循环匹配父元素队列;一旦匹配就形成computedStyle
5. 何时触发计算:在元素生成时
在 emit 的开始标签分支里,构造元素、收集其属性后立刻调用 computeCSS:
case 'startTag':
let element = {
type: 'element',
children: [],
attributes: [],
tagName: token.tagName
}
computeCSS(element)
top.children.push(element)
if (!token.isSelfClosing) stack.push(element)
currentTextNode = null
break
这样可以保证每个元素生成时都进行了css计算
6. 经过修改后 我们的最终的domParser中的代码如下
const css = require('css') // 引入css解析
const EOF = Symbol('EOF') // End of file 是一个文件结束的标志
const stack = [{ type: 'document', children: [] }]// 这里我们栈中首先加入了一个根元素 是因为一个正确的栈在我们对DOM匹配结束之后会空掉 我们用一个根元素来接收我们生成的DOMTree
let currentToken = null // 当前的Token
let currentAttribute = null // 当前属性
let currentTextNode = null // 当前的文本节点
// css规则收集
let rules = []
function addCSSRules (text) {
const ast = css.parse(text)
rules.push(...ast.stylesheet.rules)
}
function match (element, selector) {
if (!element.attributes || !selector) {
return false
}
// 复合选择器拆分:#id、.class、tag
const regPattern = /(#[a-zA-Z]+[_a-zA-Z0-9-]*?)|(\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*)|([a-z]+)/g
const matched = selector.match(regPattern)
let matchTime = 0
// 循环匹配选择器
for (const p of matched) {
if (p.charAt(0) === '#') {
const attr = element.attributes.filter(attr => attr.name === 'id')[0]
if (attr && attr.value === p.replace('#', '')) matchTime++
} else if (p.charAt(0) === '.') {
const attr = element.attributes.filter(attr => attr.name === 'class')[0]
if (attr) {
const classes = attr.value.split(' ')
for (let className of classes) {
if (className === p.replace('.', '')) matchTime++
}
}
} else {
if (element.tagName === p) matchTime++
}
}
return matchTime === matched.length
}
// 我们没有实现inline style的解析 因为这不是我们要关注的重点 所以这里p[0]的位置没有任何处理
function specificity (selector) {
const p = [0, 0, 0, 0]
const regPattern = /(#[a-zA-Z]+[_a-zA-Z0-9-]*?)|(\.-?[_a-zA-Z]+[_a-zA-Z0-9-]*)|([a-z]+)/g
const selectorParts = selector.split(' ').map(item => item.match(regPattern))
for (const parts of selectorParts) {
for (const part of parts) {
if (part.charAt(0) === '#') p[1] += 1
else if (part.charAt(0) === '.') p[2] += 1
else p[3] += 1
}
}
return p
}
// 权重对比 以四元组形式依次对比
function compare (sp1, sp2) {
for (let i = 0; i < 4; i++) {
if (sp1[i] > sp2[i]) return sp1[i] - sp2[i]
}
return 0
}
function computeCSS (element) {
const elements = stack.slice().reverse() // 自内向外的祖先链
if (!element.computedStyle) element.computedStyle = {}
for (const rule of rules) {
// 选择器拆分 从当前元素向外排列
const selectorParts = rule.selectors[0].split(' ').reverse()
if (!match(element, selectorParts[0])) continue
let matched = false
let j = 1
// 循环匹配选择器
for (let i = 0, len = elements.length; i < len; i++) {
if (match(elements[i], selectorParts[j])) j++
}
// 当匹配的数量大于等于选择器数量时 说明当前规则是匹配成功的
matched = j >= selectorParts.length
// 如果匹配成功 要把对应rule加到element中
if (matched) {
const sp = specificity(rule.selectors[0]) // 计算选择器的权重
const computedStyle = element.computedStyle
for (const 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
}
}
}
}
}
// emit方法用来向栈中推送当前解析的token
function emit (token) {
let top = stack[stack.length - 1]// 取出栈中的最后一个 如果当前token是开始标签 这个top元素就是当前元素的父元素 如果token是结束标签 拿这个元素去和top比对是否匹配即可
switch (token.type) {
// 这里的逻辑是如果是一个开始标签 我们要初始化一个元素 元素的标签名就是当前token的标签名
case 'startTag':
let element = {
type: 'element',
children: [],
attributes: [],
tagName: token.tagName
}
// 遍历token的属性 映射到我们生成的元素中去
for (const p in token) {
if (p !== 'type' && p !== 'tagName') {
element.attributes.push({
name: p,
value: token[p]
})
}
}
computeCSS(element)
top.children.push(element)
// 如果不是一个自闭合标签 将这个元素推入栈中 自闭合标签视为自己和自己匹配成功相当于直接出栈了
if (!token.isSelfClosing) {
stack.push(element)
}
// 初始化当前的文本节点
currentTextNode = null
break
case 'endTag':
// 如果和栈中最后一个元素不匹配 说明标签没有正确的书写
if (top.tagName !== token.tagName) {
throw new Error(`Tag start end does'n match`)
} else {
// 如果是style标签 收集css逻辑
if (top.tagName === 'style') {
addCSSRules(top.children[0].content)
}
// 如果匹配到 将最后一个元素出栈
stack.pop()
}
currentTextNode = null
break
case 'text':
// 如果当前文本节点为null 就初始化一个文本节点 文本节点就直接推入父元素的children中
if (currentTextNode === null) {
currentTextNode = {
type: 'Text',
content: ''
}
top.children.push(currentTextNode)
}
currentTextNode.content += token.content
break
}
}
// 这个相当于我们的入口 中间开始的每个状态机中的业务逻辑和状态流转都可以在whatwg中的文档找到 大家可以自行对照文档理解
function data (c) {
if (c === '<') {
return tagOpen
} else if (c === EOF) {
emit({
type: 'EOF'
})
return
} else {
emit({
type: 'text',
content: c
})
return data
}
}
function tagOpen (c) {
if (c === '/') {
return endTagOpen
} else if (c.match(/^[a-zA-Z]$/)) {
currentToken = {
type: 'startTag',
tagName: ''
}
return tagName(c)
} else {
return
}
}
function endTagOpen (c) {
if (c.match(/^[a-zA-Z]$/)) {
currentToken = {
type: 'endTag',
tagName: ''
}
return tagName(c)
} else if (c === '>') {
} else if (c === EOF) {
} else {
return data
}
}
function tagName (c) {
if (c.match(/^[\t\n\f ]$/)) {
return beforeAttributeName
} else if (c === '/') {
return selfClosingStartTag
} else if (c.match(/^[a-zA-Z]$/)) {
currentToken.tagName += c
return tagName
} else if (c === '>') {
emit(currentToken)
return data
} else {
return tagName
}
}
function beforeAttributeName (c) {
if (c.match(/^[\t\n\f ]$/)) {
return beforeAttributeName
} else if (c === '=') {
} else if (c === '>' || c === '/' || c === EOF) {
return afterAttributeName(c)
} else {
currentAttribute = {
name: '',
value: ''
}
return attributeName(c)
}
}
function afterAttributeName (c) {
if (c.match(/^[\t\n\f ]$/)) {
return beforeAttributeName
} else if (c === '>') {
return endTagOpen
} else if (c === EOF) {
} else if (c === '/') {
return selfClosingStartTag
} else {
return beforeAttributeName
}
}
function attributeName (c) {
if (c.match(/^[\n\t\f ]$/) || c === '/' || c === '>' || c === EOF) {
return afterAttributeName(c)
} else if (c === '=') {
return beforeAttributeValue
} else if (c === '\u0000') {
} else if (c === '"' || c === "'" || c === '<') {
} else {
currentAttribute.name += c
return attributeName
}
}
function beforeAttributeValue (c) {
if (c.match(/^[\n\t\f ]$/) || c === '/' || c === '>' || c === EOF) {
return afterAttributeValue(c)
} else if (c === '"') {
return doubleQuotedAttributeValue
} else if (c === "'") {
return singleQuotedAttributeValue
} else if (c === '>') {
} else {
return unquotedAttributeValue(c)
}
}
function doubleQuotedAttributeValue (c) {
if (c === '"') {
currentToken[currentAttribute.name] = currentAttribute.value
return afterQuotedAttributeValue
} else if (c === '\u0000') {
} else if (c === EOF) {
} else {
currentAttribute.value += c
return doubleQuotedAttributeValue
}
}
function singleQuotedAttributeValue (c) {
if (c === "'") {
currentToken[currentAttribute.name] = currentAttribute.value
return afterQuotedAttributeValue
} else if (c === '\u0000') {
} else if (c === EOF) {
} else {
currentAttribute.value += c
return singleQuotedAttributeValue
}
}
function afterQuotedAttributeValue (c) {
if (c.match(/^[\n\t\f ]$/)) {
currentToken[currentAttribute.name] = currentAttribute.value
return beforeAttributeName
} else if (c === '/') {
currentToken[currentAttribute.name] = currentAttribute.value
return selfClosingStartTag
} else if (c === '>') {
currentToken[currentAttribute.name] = currentAttribute.value
emit(currentToken)
return data
} else if (c === EOF) {
} else {
currentAttribute.value += c
return doubleQuotedAttributeValue
}
}
function unquotedAttributeValue (c) {
if (c.match(/^[\n\t\f ]$/)) {
currentToken[currentAttribute.name] = currentAttribute.value
emit(currentToken)
return beforeAttributeName
} else if (c === '/') {
currentToken[currentAttribute.name] = currentAttribute.value
return selfClosingStartTag
} else if (c === '>') {
currentToken[currentAttribute.name] = currentAttribute.value
emit(currentToken)
return data
} else if (c === '"' || c === "'" || c === '<' || c === '=' || c === '`') {
} else if (c === '\u0000') {
} else if (c === EOF) {
} else {
currentAttribute.value += c
return unquotedAttributeValue
}
}
function selfClosingStartTag (c) {
if (c === '>') {
currentToken.isSelfClosing = true
emit(currentToken)
return data
} else if (c === EOF) {
return beforeAttributeName
} else {
}
}
module.exports.parseHTML = function parseHTML (html) {
// 初始状态
let state = data
for (let c of html) {
// 把接收到的每个字符依次传递给状态机做处理
state = state(c)
}
// 处理完之后要加入一个文档结束的标识符
state = state(EOF)
return stack
}
此时我们重新执行client.js 可以看到我们解析的DOM树中已经包含了computedStyle
[
{
"type": "document",
"children": [
{
"type": "element",
"children": [
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [
{
"type": "Text",
"content": " body div #myid{ width:100px; background-color: #ff5000; }
body div img{ width:30px; background-color: #ff1111; } div #myid{ width:40px; backg
round-color: #ff5000;} html body div img.img1{ width:40px; background-color: #ff5000;} html body
div .img2{ width:400px; background-color: blue;} body div img.img2.img3#myid{ width:50px; b
ackground-color: #ff5000;} "
}
],
"attributes": [],
"tagName": "style",
"computedStyle": {}
},
{
"type": "Text",
"content": " "
}
],
"attributes": [],
"tagName": "head",
"computedStyle": {}
},
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [],
"attributes": [
{
"name": "id",
"value": "myid"
},
{
"name": "class",
"value": "img2 img3"
},
{
"name": "isSelfClosing",
"value": true
}
],
"tagName": "img",
"computedStyle": {
"width": {
"value": "50px",
"specificity": [
0,
1,
2,
3
]
},
"background-color": {
"value": "#ff5000",
"specificity": [
0,
1,
2,
3
]
}
}
},
{
"type": "Text",
"content": " "
},
{
"type": "element",
"children": [],
"attributes": [
{
"name": "class",
"value": "img1 img2"
},
{
"name": "isSelfClosing",
"value": true
}
],
"tagName": "img",
"computedStyle": {
"width": {
"value": "40px",
"specificity": [
0,
0,
1,
4
]
},
"background-color": {
"value": "#ff5000",
"specificity": [
0,
0,
1,
4
]
}
}
},
{
"type": "Text",
"content": " "
}
],
"attributes": [],
"tagName": "div",
"computedStyle": {}
},
{
"type": "Text",
"content": " "
}
],
"attributes": [],
"tagName": "body",
"computedStyle": {}
},
{
"type": "Text",
"content": " "
}
],
"attributes": [
{
"name": "maaa",
"value": "a"
}
],
"tagName": "html",
"computedStyle": {}
}
]
}
]
client.js 在拿到响应体后将 HTML 交给 parser.parseHTML,此时就会:
- 解析 DOM;
- 收集
<style>; - 元素生成时计算
computedStyle; - 最终你可以在元素节点的
computedStyle中看到被优先级覆盖后的样式值(如width)。
提示:img#myid.img2.img3 会同时命中多条规则,最终 width 以 specificity 更高且(在同等 specificity 下)靠后的声明为准。
7. 小结与后续
css的解析与计算其实是一个很复杂的流程 我们并没有实现一个完善的css计算流程 文章的目的只是带大家了解这个庞然大物的一些基础原理 让大家可以对浏览器中css的computing有一个基础的认知
本篇我们完成了:
- 收集 CSS 规则;
- 复合选择器匹配与多类名处理;
- specificity 计算与比较;
- 从右到左匹配祖先链,写入
computedStyle; - 在元素生成时进行 CSS 计算。
在下一篇中,我们将进行最后一步,也就是布局(Layout)与绘制(Painting),把 computedStyle 转换为几何盒模型并渲染。
