博客
Demo

在前两篇中,我们已经完成:

本篇在此基础上实现浏览器渲染流程中的下一步:为 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 转换为几何盒模型并渲染。

Skelanimals Blog © 2026 Made By Skelanimals
冀公网安备 13098102000240号  冀ICP备17020251号-1