大家好,我是微微笑的蜗牛,。
上篇文章中,我们讲述了 html 的解析,并实现了一个小小的 html 解析器。没看过的同学可以戳下面链接先回过头去看看。
- 听说你想写个渲染引擎 - html 解析
今天,主要讲解 css 的解析,同样会实现一个简单的 css 解析器,输出样式表。
css 规则
css 的规则有些复杂,除了基本的通用选择器、元素选择器、类选择器、ID 选择器外,还有分组选择器,组合选择器等。
- 通用选择器,
*
为通配符,表示匹配任意元素。
* {
width: 100px;
}
- 元素选择器,定义标签的样式。
// 任何 div 元素都匹配该样式
div {
width: 100px;
}
- ID 选择器,以
#
开头,元素中使用id
属性指定。
// id 为 test 的元素都可匹配
#test {
text-align: center;
}
// 设置 id
另外,它还可跟元素进行组合,表示双重匹配。
// 表示当为 h1 标签且 id = test 才进行匹配
h1#test {
text-align: center;
color: #ffffff;
}
- 类选择器,以
.
开头,元素中使用class
属性指定。
.test {
height: 200px;
}
// 匹配
同样,它也可以跟元素进行组合,双重匹配。这样一来,只有当元素相同,且元素的 class 属性包含规则中指定的全部 class 时,才会匹配。
div.test.test1 {
height: 200px;
}
// 匹配
// 不匹配
- 分组选择器,指定一组选择器,以
,
隔开。节点满足任意一个选择器即可匹配样式。
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)
可用如上代码进行测试,看看输出结果。
完整代码可点此查看。
总结
这一讲,我们主要介绍了如何进行单个选择器、单个属性、单条规则的解析,以及如何将它们组合起来,完成整体解析,最终生成样式表。
这几部分的解析,思考方式上有个共同点。从整体到局部,再从局部回到整体。
先将整体解析任务拆分为单个目标,这样问题就变小了。专注完成单个目标的解析,再循环调用单个解析,从而实现整体目标。
下一篇将介绍样式树的生成。敬请期待~
参考资料
- css 规则:https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Selectors
- github:https://github.com/silan-liu/tiny-web-render-engine-swift