听说你想写个渲染引擎 - css 解析

862 阅读9分钟

大家好,我是微微笑的蜗牛,🐌。

上篇文章中,我们讲述了 html 的解析,并实现了一个小小的 html 解析器。没看过的同学可以戳下面链接先回过头去看看。

今天,主要讲解 css 的解析,同样会实现一个简单的 css 解析器,输出样式表。

css 规则

css 的规则有些复杂,除了基本的通用选择器、元素选择器、类选择器、ID 选择器外,还有分组选择器,组合选择器等。

  • 通用选择器,* 为通配符,表示匹配任意元素。
* {
	width: 100px;
}
  • 元素选择器,定义标签的样式。
// 任何 div 元素都匹配该样式
div {
	width: 100px;
}
  • ID 选择器,以 # 开头,元素中使用 id 属性指定。
// id 为 test 的元素都可匹配
#test {
	text-align: center;
}

// 设置 id
<span id="test"></span>
<h1 id="test"></h1>

另外,它还可跟元素进行组合,表示双重匹配。

// 表示当为 h1 标签且 id = test 才进行匹配
h1#test {
	text-align: center;
	color: #ffffff;
}

<h1 id="test"></h1>
  • 类选择器,以 . 开头,元素中使用 class 属性指定。
.test {
	height: 200px;
}

// 匹配
<div class="test"></div>
<p class="test"></p>

同样,它也可以跟元素进行组合,双重匹配。这样一来,只有当元素相同,且元素的 class 属性包含规则中指定的全部 class 时,才会匹配。

div.test.test1 {
	height: 200px;
}

// 匹配
<div class="test test1"></div>
<div class="test test1 test2"></div>

// 不匹配
<div class="test test2"></div>
  • 分组选择器,指定一组选择器,以 , 隔开。节点满足任意一个选择器即可匹配样式。
div.test, #main {
	height: 200px;
}
  • 组合选择器,有多种组合方式,这里就不展开说了。

实现目标

为了简单起见,我们只实现上面提到的几种选择器:通用选择器、元素选择器、类选择器、ID 选择器外、分组选择器。

除此之外,选择器还存在优先级。优先级如下:

ID 选择器 > 类选择器 > 元素选择器

对于属性值来说,可以有多种表示方式,比如:

  • 关键字,即满足一定规则的纯字符串,如:text-align: center;
  • 长度,有数值+单位的方式,如 height: 200px;,而单位又可有多种,em/px 等;还有百分比形式,如height: 90%;
  • 色值,可使用十六进制 color: #ffffff;,也可使用颜色字符串表示 color: white;
  • ...

这里,只支持最基础的形式。

  • 关键字。
  • 长度为数值类型,且单位固定为 px
  • 色值,固定为十六进制,支持 rgba/rgb

数据结构定义

样式表,由 css 规则列表组成,也是 css 解析的最终产物。

那么该如何定义数据结构,来表示 css 规则呢?

根据上面的 css 写法,我们可以知道:

css 规则 = 选择器列表 + 属性值列表

其中,选择器又有元素选择器、类选择器、ID 选择器三种形式。简单来说,可包含 tag、class、id,且 class 可有多个。

那么,对于选择器的结构来说,可定义如下:

struct SimpleSelector {
    // 标签名
    var tagName: String?
    
    // id
    var id: String?
    
    // class
    var classes: [String]
}

// 可作为扩展,比如可添加组合选择器,现只支持简单选择器
enum CSSSelector {
    case Simple(SimpleSelector)
}

属性结构,比较好定义。属性名+属性值。

struct Declaration {
    let name: String
    let value: Value
}

上面说到,属性值分为三种类型:

  • 关键字
  • 色值
  • 数值长度,单位只支持 px

因此,属性值结构定义如下:

enum Value {
		// 关键字
    case Keyword(String)
    
    // rgba
    case Color(UInt8, UInt8, UInt8, UInt8)
    
    // 长度
    case Length(Float, Unit)
}

// 单位
enum Unit {
    case Px
}

有了如上结构,便可定义出 css 规则的结构。

// css 规则结构定义
struct Rule {
    // 选择器
    let selectors: [CSSSelector]
    
    // 声明的属性
    let declarations: [Declaration]
}

同样,样式表的结构也可定义出来了。

// 样式表,最终产物
struct StyleSheet {
    let rules: [Rule]
}

整体数据结构如下图所示:

关于选择器优先级,通过一个三元组来区分。

// 用于选择器排序,优先级从高到低分别是 id, class, tag
typealias Specifity = (Int, Int, Int)

排序是根据「否存在 id」、「class 个数」、「是否存在 tag」来做逻辑。

extension CSSSelector {
    public func specificity() -> Specifity {
     
        if case CSSSelector.Simple(let simple) = self {
            // 存在 id
            let a = simple.id == nil ? 0 : 1
            
            // class 个数
            let b = simple.classes.count
            
            // 存在 tag
            let c = simple.tagName == nil ? 0 : 1
            
            return Specifity(a, b, c)
        }
        
        return Specifity(0, 0, 0)
    }
}

选择器解析

由于我们支持分组选择器,它是一组选择器,以 , 分隔。比如:

div.test.test2, #main {
}

这里只需重点关注单个选择器的解析,因为分组选择器解析只是循环调用单个选择器的解析方式。

单个选择器解析

不同选择器的区分,有些比较明显的规则:

  • * 是通配符
  • . 开头的是 class
  • # 开头的是 id

另外,不在规则之内的,我们将做如下处理:

  • 其余情况,如果字符满足一定规则,认为是元素
  • 剩下的,认为无效

下面,我们来一一分析。

  • 对于通配符 * 来说,不需要进行数据填充,选择器中的 id,tag,classes 全部为空就好。因为这样就能匹配任意元素。

  • 对于 . 开头的字符,属于 class。那么将 class 名称解析出来即可。

class 名称需满足一定条件,即数组、字母、下划线、横杠的组合,比如 test-2_a。我们将其称之为有效字符串。注:下面很多地方都会用到这个判定规则。

// 有效标识,数字、字母、_-
func valideIdentifierChar(c: Character) -> Bool {
    if c.isNumber || c.isLetter || c == "-" || c == "_" {
        return true
    }
    
    return false
}

// 解析标识符
mutating func parseIdentifier() -> String {
    // 字母数字-_
    return self.sourceHelper.consumeWhile(test: validIdentifierChar)
}
  • 对于 # 开头的字符,属于 id 选择器。同样使用有效字符串判定规则,将 id 名称解析出来。

  • 其他情况,如果字符串是有效字符串,认为是元素。

  • 再剩下的,属于无效字符,退出解析过程。

整个解析过程如下:

// 解析选择器
// tag#id.class1.class2
mutating func parseSimpleSelector() -> SimpleSelector {
    var selector = SimpleSelector(tagName: nil, id: nil, classes: [])
    
    outerLoop: while !self.sourceHelper.eof() {
        switch self.sourceHelper.nextCharacter() {
        // id
        case "#":
            _ = self.sourceHelper.consumeCharacter()
            selector.id = self.parseIdentifier()
            break
            
        // class
        case ".":
            _ = self.sourceHelper.consumeCharacter()
            let cls = parseIdentifier()
            selector.classes.append(cls)
            break
            
        // 通配符,selector 中无需数据,可任意匹配
        case "*":
            _ = self.sourceHelper.consumeCharacter()
            break
            
        // tag
        case let c where valideIdentifierChar(c: c):
            selector.tagName = parseIdentifier()
            break
            
        case _:
            break outerLoop
        }
    }
    
    return selector
}

分组选择器解析

分组选择器的解析,循环调用上述过程,注意退出条件。当遇到 { 时,表示属性列表的开始,即可退出了。

另外,当得到选择器列表后,还要按照选择器优先级从高到低进行排序,为下一阶段生成样式树做准备。

// 对 selector 进行排序,优先级从高到低
selectors.sort { (s1, s2) -> Bool in
    s1.specificity() > s2.specificity()
}

属性解析

属性的规则定义比较明了。它以 : 分隔属性名和属性值,以 ; 结尾。

属性名:属性值;

margin-top: 10px;

照旧,先看单条属性的解析。

  • 解析出属性名,仍参照上面有效字符的规则。
  • 确保存在 : 分隔符。
  • 解析属性值。
  • 确保以 ; 结束。

属性值解析

由于属性值包含三种情况,稍微有点复杂。

1. 色值解析

色值以 # 开头,这点很好区分。接下来是 rgba 的值,8 位十六进制字符。

不过,我们平常不会把 alpha 全都写上。因此需兼容只有 6 位的情况,此时 alpha 默认为 1。

思路很直观,只需逐次取出两位字符,转换为十进制数即可。

  • 取出两位字符,转换为十进制。
mutating func parseHexPair() -> UInt8 {
        // 取出 2 位字符
        let s = self.sourceHelper.consumeNCharacter(count: 2)
        
        // 转化为整数
        let value = UInt8(s, radix: 16) ?? 0
        
        return value
    }
  • 逐个取出 rgb。如果存在 alpha,那么进行解析。
// 解析色值,只支持十六进制,以 # 开头, #897722
    mutating func parseColor() -> Value {
        assert(self.sourceHelper.consumeCharacter() == "#")
        
        let r = parseHexPair()
        let g = parseHexPair()
        let b = parseHexPair()

        var a: UInt8 = 255
        
        // 如果有 alpha
        if self.sourceHelper.nextCharacter() != ";" {
            a = parseHexPair()
        }
        
        return Value.Color(r, g, b, a)
    }
    
    

2. 长度数值解析

width: 10px;

此时,属性值 = 浮点数值 + 单位。

  • 首先,解析出浮点数值。这里简单处理,「数字」和「点号」的组合,并没有严格判断有效性。
// 解析浮点数
mutating func parseFloat() -> Float {
    let s = self.sourceHelper.consumeWhile { (c) -> Bool in
        c.isNumber || c == "."
    }
    
    let floatValue = (s as NSString).floatValue
    return floatValue
}
  • 然后,解析单位。单位只支持 px。
// 解析单位
mutating func parseUnit() -> Unit {
    let unit = parseIdentifier()
    if unit == "px" {
        return Unit.Px
    }
    
    assert(false, "Unexpected unit")
}

3. 关键字,也就是普通字符串

关键字还是依据有效字符的规则,将其提取出来即可。

属性列表解析

当解析出单条属性后,属性列表就很简单了。同样的套路,循环。

  • 确保字符以 { 开头。
  • 当遇到 },则说明属性声明完毕。

过程如下所示:

// 解析声明的属性列表
/**
 {
    margin-top: 10px;
    margin-bottom: 10px
 }
 */
mutating func parseDeclarations() -> [Declaration] {
    var declarations: [Declaration] = []
    
    // 以 { 开头
    assert(self.sourceHelper.consumeCharacter() == "{")
    
    while true {
        self.sourceHelper.consumeWhitespace()
        
        // 如果遇到 },说明规则声明结束
        if self.sourceHelper.nextCharacter() == "}" {
            _ = self.sourceHelper.consumeCharacter()
            break
        }
        
        // 解析单条属性
        let declaration = parseDeclaration()
        declarations.append(declaration)
    }
    
    return declarations
}

规则解析

由于单条规则由选择器列表+属性列表组成,上面已经完成了选择器和属性的解析。那么要想得到规则,只需将两者进行组合即可。

mutating func parseRule() -> Rule {
		// 解析选择器
    let selectors = parseSelectors()

		// 解析属性
    let declaration = parseDeclarations()
    
    return Rule(selectors: selectors, declarations: declaration)
}

解析整个规则列表,也就是循环调用单条规则的解析。

// 解析 css 规则
mutating func parseRules() -> [Rule] {
    var rules:[Rule] = []
    
    // 循环解析规则
    while true {
        self.sourceHelper.consumeWhitespace()
        
        if self.sourceHelper.eof() {
            break
        }
        
				// 解析单条规则
        let rule = parseRule()
        rules.append(rule)
    }
    
    return rules
}

生成样式表

样式表是由规则列表组成,将上一步中解析出来的规则列表套进样式表中就可以了。

// 对外提供的解析方法,返回样式表
mutating public func parse(source: String) -> StyleSheet {
    self.sourceHelper.updateInput(input: source)
    
    let rules: [Rule] = parseRules()
    
    return StyleSheet(rules: rules)
}

测试代码

let css = """
     .test {
        padding: 0px;
        margin: 10px;
        position: absolute;
     }

    p {
        font-size: 10px;
        color: #ff908912;
    }
"""

// css 解析
var cssParser = CSSParser()
let styleSheet = cssParser.parse(source: css)
print(styleSheet)

可用如上代码进行测试,看看输出结果。

完整代码可查看:github.com/silan-liu/t…

总结

这一讲,我们主要介绍了如何进行单个选择器、单个属性、单条规则的解析,以及如何将它们组合起来,完成整体解析,最终生成样式表。

这几部分的解析,思考方式上有个共同点。从整体到局部,再从局部回到整体。

先将整体解析任务拆分为单个目标,这样问题就变小了。专注完成单个目标的解析,再循环调用单个解析,从而实现整体目标。

下一篇将介绍样式树的生成。敬请期待~

参考资料