浏览器工作原理——动手实现一个toy-browser(二):生成DOM树和计算CSS

接上一篇博客浏览器工作原理——动手实现一个toy-browser(一),本篇主要介绍浏览器工作流程的HTML解析和CSS计算环节。

1 浏览器工作原理——解析HTML生成DOM树

1.1 HTML parser模块的文件拆分

  1. 设计HTML parser模块的使用接口
  2. 为便于文件管理,将parser模块拆分到其它文件中

1.2 用FSM实现HTML的分析

  1. HTML标准中已经规定了HTML分析的各种状态,toy-browser只需要实现其中最基础的部分
  2. 用FSM分析HTML的框架如下
module.exports.parseHTML = function parseHTML(html) {
  let state = data
  for(let c of html) {
    state = state(c)
  }
  state = state(EOF) // 添加文件终结符,强制状态机结束
  return stack.pop()
}

1.3 解析标签

  1. 总共有3种标签:开始标签、结束标签和自封闭标签
  2. 解析HTML时的状态迁移画出图来比较直观(状态迁移图如下)
    在这里插入图片描述
    对应代码(1.1~1.5相关代码)如下
const EOF = Symbol('EOF')

function data(c) {
  if(c == "<") {
    return tagOpen
  }else if(c == EOF) {
    return 
  }else{
    currentToken = {
      type: "text",
      content: ""
    }
    return textNode(c)
  }
}

function textNode(c) {
  if(c != "<"){
    currentToken.content += c
    return textNode
  }else{
    emit(currentToken)
    return tagOpen
  }
}

function tagOpen(c) {
  if(c == '/') {
    return endTagOpen
  }else if(c.match(/^[a-zA-Z]$/)){
    currentToken = {
      type: "startTag",
      tagName: ""
    }
    return tagName(c)
  }else if(c == '!'){
    return doctypeCommentTag
  }else{
    return // 非法html
  }
}

function doctypeCommentTag(c) {
  if(c == '>') {
    return data
  }else{
    // 忽略<!DOCTYPE xxx>和<!-- xxx -->节点
    return doctypeCommentTag
  }
}

function endTagOpen(c) {
  if(c.match(/^[a-zA-Z]$/)) {
    currentToken = {
      type: "endTag",
      tagName: ""
    }
    return tagName(c)
  }else{
    return // 非法html
  }
}

function tagName(c) {
  if(c.match(/[\t\n\f ]/)) { // \f表示换页符
    return beforeAttributeName
  }else if(c.match(/^[a-zA-Z]$/)) {
    currentToken.tagName += c
    return tagName
  }else if(c == '/') {
    return selfClosingTag
  }else if(c == '>'){
    emit(currentToken)
    return data
  }else{
    return // 非法html
  }
}

function beforeAttributeName(c) {
  if(c.match(/[\t\n\f ]/)) {
    return beforeAttributeName
  }else if(c == '/' || c == '>' || c == EOF) {
    return afterAttributeName(c)
  }else if(c == '='){
    // 非法
  }else{
    // 获取属性
    currentAttribute = {
      name: '',
      value: ''
    }
    return attributeName(c)
  }
}

function afterAttributeName(c) {
  if(c == '/'){
    return selfClosingTag
  }else if(c == '>') {
    emit(currentToken)
    return data
  }else if(c == '='){
    return beforeAttributeValue
  }else if(c.match(/[\t\n\f ]/)){
    return afterAttributeName
  }else{ // EOF
    return 
  }
}

function attributeName(c) {
  if(c == '=') {
    return beforeAttributeValue
  }else if(c.match(/[\t\n\f ]/) || c == '/' || c == '>' || c == EOF) {
    return afterAttributeName(c)
  }else if(c == '\u0000' || c == '\"' || c == '\'' || c == '<') {
    // 非法,\u0000为二进制的0,在C语言中为字符串结束标志
  }else {
    currentAttribute.name += c
    return attributeName
  }
}

function beforeAttributeValue(c) {
  if(c.match(/[\t\n\f ]/)) {
    return beforeAttributeValue
  }else if(c == '\"') {
    return doubleQuotedAttributeValue
  }else if(c == '\'') {
    return singleQuotedAttributeValue
  }else if(c == '/' || c == '>' || c == EOF) {
    // 非法
  }else{
    return unquotedAttributeValue
  }
}

function doubleQuotedAttributeValue(c) {
  if(c == '\"') {// 解析完一个属性
    currentToken[currentAttribute.name] = currentAttribute.value
    return afterQuotedAttributeValue
  }else if(c == '\u0000' || c == EOF) {
    // 非法
  }else{
    currentAttribute.value += c
    return doubleQuotedAttributeValue
  }
}

function singleQuotedAttributeValue(c) {
  if(c == '\'') {// 解析完一个属性
    currentToken[currentAttribute.name] = currentAttribute.value
    return afterQuotedAttributeValue
  }else if(c == '\u0000' || c == EOF) {
    // 非法
  }else{
    currentAttribute.value += c
    return singleQuotedAttributeValue
  }  
}

function afterQuotedAttributeValue(c) {
  if(c.match(/[\t\n\f ]/)) {
    return beforeAttributeName
  }else if(c == '/') {
    return selfClosingTag
  }else if(c == '>') {
    // 下面的currentAttribute.name可能是在跳转到beforeAttributeName后转移过来的,不能省略
    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(/[\t\n\f ]/)) {
    return beforeAttributeName
  }else if(c == '/') {
    return selfClosingTag
  }else if(c == '>') {
    // 下面的currentAttribute.name可能是在跳转到beforeAttributeName后转移过来的,不能省略
    currentToken[currentAttribute.name] = currentAttribute.value 
    emit(currentToken)
    return data
  }else if(c == '\u0000' || c == '\"' || c == '\'' || c == '<' || c == EOF || c == '=') {
    // 非法
  }else{
    currentAttribute.vlaue += c
    return unquotedAttributeValue 
  }
}
function selfClosingTag(c) {
  if(c == '>') {
    currentToken.isSelfClosing = true
    emit(currentToken)
    return data
  }else{
    return // 非法html
  }
}

1.4 创建元素

  1. 在状态转移的过程中要加入业务逻辑
  2. 在标签结束状态提交创建的标签token,通过上图中的emit实现

1.5 处理属性

  1. 属性分为单引号、双引号和无引号,需要较多状态处理
  2. 处理属性的方式跟标签类似
  3. 属性结束时把属性加到标签Token上

1.6 用Token构建DOM树

  1. 根据标签构建DOM树的基本思想是使用栈(HTML的标签层层嵌套,符合栈先入后出的操作特点)
  2. 遇到开始标签时创建元素并入栈,遇到结束标签时出栈
  3. 自封闭标签可视作入栈后立即出栈,将
    将其直接添加到当前栈顶元素的children列表中
  4. 任何元素的父元素是它入栈前的栈顶

1.7 将文本节点加到DOM树

  1. 文本字符需要合并为文本节点
  2. 文本节点与自封闭标签处理类似

1.6和1.7相关代码如下

// 生成DOM节点构建DOM树
function emit(token) {
  // console.log(token)
  let top = stack[stack.length-1]
  if(token.type == "startTag") {
    // 为开始标签创建节点
    let element = {
      type: "element",
      tagName: token.tagName,
      attributes: [],
      children: [],
      parent: top
    }
    for(let key in token) {
      if(key !== "type" && key !== "tagName" && key !== "isSelfClosing") {
        element.attributes.push({name: key, value: token[key]}) // 原写法{key, token[key]}错误
      }
    }
    computeCSS(element) // 在遇到startTag时就开始计算CSS
    if(!token.isSelfClosing) {
      stack.push(element) // 开始标签入栈
    }
    top.children.push(element)
  }else if(token.type == "endTag") {
    // 将结束标签出栈
    if(token.tagName !== top.tagName) {
      console.log(token.tagName + " doesn't match top element of stack!")
      return 
    }else{
      // 解析CSS文本获取CSS规则
      if(token.tagName === "style") {
        addCSSRules(top.children[0].content)
      }
      stack.pop()
    }
  }else if(token.type == "text") {
      currentTextNode = {
        type: "text",
        content: token.content,
        parent: top
      }
    top.children.push(currentTextNode)
  }
}

2 浏览器工作原理——CSS计算

2.1 收集CSS规则

  1. 遇到style标签,把css规则保存起来(暂不考虑link标签中的样式表)
  2. 为简化实现难度,使用node的css库中的CSS Parser来解析CSS规则
  3. 注意css库分析CSS规则的格式

2.2 添加CSS计算调用

  1. 当创建一个元素后,立即计算CSS(CSS设计的一条潜规则是CSS的所有选择器会尽量保证在startTag进入时判断是否匹配)
  2. 理论上,当分析一个元素时,所有CSS规则已经收集完毕(为简化实现,html标签的内联样式暂不考虑)
  3. 在真实浏览器中,可能遇到写在body中的style标签,需重新计算CSS,暂不考虑

2.3 获取父元素

  1. 在computeCSS函数中,必须知道元素的所有父元素才能判断元素与规则是否匹配
  2. 在解析HTML生成DOM树步骤中的stack,可以获取当前元素的所有父元素
  3. 因为首先获取和处理的是“当前元素”,所以获得和计算父元素匹配的顺序是从内向外(div div #myid)

2.4 选择器与元素匹配

  1. 选择器也要从当前元素向外排列
  2. 复杂选择器拆成单个元素的选择器,用循环匹配父元素队列

2.5 计算选择器与元素匹配

  1. 根据选择器的类型和元素属性,计算当前元素是否与规则匹配
  2. 视频教程中仅实现了三种简单选择器(元素选择器、类选择器和id选择器),实际还有更复杂的复合选择器、复杂选择器等
  3. 作业:跟上课程进度,实现复合选择器和支持空格的class选择器(使用正则)

2.6 生成computed属性

  1. 对于元素匹配到的规则,为元素生成相应的计算属性

2.7 specificity的计算逻辑

specificity被译作优先级或特定度,用来确定多条CSS规则作用于同一元素时的覆盖顺序

  1. CSS规则是根据specificity和后来优先规则覆盖
  2. specificity用一个四元组(inline, id, class, tag)表示,越左边权重越高
  3. 一个CSS规则的specificity根据包含的简单选择器相加而成
    例如:
    div div #myId的specificity: (0, 1, 0, 2)小于.cls #myId的specificity: (0, 1, 1, 0)

2.1~2.7相关代码

const css = require("css")
let currentToken = null
let currentAttribute = null
let currentTextNode = null
let stack = [{type: "document", children: []}] // 利用数组模拟栈(只在数组的一端操作)

let rules = []
function addCSSRules(text) {
  var ast = css.parse(text)
  rules.push(...ast.stylesheet.rules)
}

// selector:复合选择器 #myImg 或 p.text#name
function match(element, selector) {
  if(!selector || !element.attributes) {// 无attributes的文本节点不处理
    return false
  }
  let regMatches = selector.match(/([a-zA-Z]+)|(.[a-zA-Z]+)|(#[a-zA-Z]+)/g)
  for(let subSelector of regMatches) {
    let attr = ''
    if(subSelector.charAt(0) == "#") {// id选择器
      attr = element.attributes.filter(attr => attr.name === "id")[0]
      if(!attr || attr.value !== subSelector.substring(1)) {
        return false
      }
    }else if(subSelector.charAt(0) == ".") {// class选择器
      attr = element.attributes.filter(attr => attr.name === "class")[0]
      if(!attr || !attr.value.split(/[\t ]+/).includes(subSelector.substring(1))) {
        return false
      }
    }else if(subSelector !== element.tagName) {// 元素选择器
      return false
    }
  }
  return true
  // let attr = ''
  // if(selector.charAt(0) == "#") {// id选择器
  //   attr = element.attributes.filter(attr => attr.name === "id")[0]
  //   if(attr && attr.value === selector.substring(1)) {
  //     return true
  //   }
  // }
  // if(selector.charAt(0) == ".") {// class选择器
  //   attr = element.attributes.filter(attr => attr.name === "class")[0]
  //   if(attr && attr.value.split(/[\t ]+/).includes(selector.substring(1))) {
  //     return true
  //   }
  // }
  // if(selector == element.tagName) {// 元素选择器
  //   return true
  // }
  // return false
}

/*
计算css规则的specificity
*/
function computeSpecificity(selector) {
  let spec = [0, 0, 0, 0]
  let compSelectors = selector.split(" ") 
  for(let compSelector of compSelectors) {//compSelector:复合选择器 #myImg 或 p.text#name
    let regMatches = compSelector.match(/([a-zA-Z]+)|(.[a-zA-Z]+)|(#[a-zA-Z]+)/g)
    for(let subSelector of regMatches) {
      if(subSelector.charAt(0) == "#") {
        spec[1]++
      }else if(subSelector.charAt(0) == ".") {
        spec[2]++
      }else if(subSelector.match(/^[a-zA-Z]+/)) {
        spec[3]++
      }
    }
  }
  return spec
}


function compareSpecificity(spec1, spec2) {
  if(spec1[0] != spec2[0]) {
    return spec1[0] - spec2[0]
  }else if(spec1[1] != spec2[1]) {
    return spec1[1] - spec2[1]
  }else if(spec1[2] != spec2[2]) {
    return spec1[2] - spec2[2]
  }
  return spec1[3]-spec2[3]
}

function computeCSS(element) {
  // 深拷贝一份当前栈(包含当前元素的所有父元素)并由内向外排列
  var elements = stack.slice().reverse() 
  console.log("compute css for element", element)

  let matched = false
  for(let rule of rules){
    //CSS规则也由内向外,简单起见不考虑选择器列表和复杂选择器
    let selectorPaths = rule.selectors[0].split(" ").reverse() 
    if(!match(element, selectorPaths[0])) {
      continue
    }
    // 由内向外循环遍历父元素队列,判断当前元素是否能匹配当前规则
    let j=1
    for(let i=0; i<elements.length; ++i) {
      if(match(elements[i], selectorPaths[j])) {
        ++j
      }
    }
    if(j >= selectorPaths.length) {
      matched = true
    }

    if(matched) {
      console.log("Element", element, "matched rule", rule)
      if(!element.computedStyle) {
        element.computedStyle = {}
      }
      let sp = computeSpecificity(rule.selectors[0])
      // if(!element.computedStyle.specificity) {
      //   element.computedStyle.specificity = sp
      //   for(let declaration of rule.declarations) {
      //     if(!element.computedStyle[declaration.property]) {
      //       element.computedStyle[declaration.property] = {}
      //     }
      //     element.computedStyle[declaration.property].value = declaration.value
      //   }
      // }else{
        for(let declaration of rule.declarations) {
          if(!element.computedStyle[declaration.property]) {
            element.computedStyle[declaration.property] = {}
          }
          if(!element.computedStyle[declaration.property].specificity) {
            element.computedStyle[declaration.property].value = declaration.value
            element.computedStyle[declaration.property].specificity = sp
          }
          // 针对同一元素同一css属性的多条规则,取优先级更高或相同但后出现的规则
          if(compareSpecificity(element.computedStyle[declaration.property].specificity, sp) < 0){
            element.computedStyle[declaration.property].value = declaration.value
            element.computedStyle[declaration.property].specificity = sp
          }
        }

      // }
    }
  }
}

3 本节代码github地址

如需查看完整代码,请移步github

PS:如果你对本文有任何疑问,可以留言评论或私信,我看到了会尽量回复。如果对你有所帮助,欢迎点赞转发_

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值