聽說你想寫個渲染引擎 - css 解析

大家好,我是微微笑的蝸牛,??。

上篇文章中,我們講述了 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]
}

整體數據結構如下圖所示:

image

關于選擇器優先級,通過一個三元組來區分。

// 用于選擇器排序,優先級從高到低分別是 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)

可用如上代碼進行測試,看看輸出結果。

完整代碼可點此查看

總結

這一講,我們主要介紹了如何進行單個選擇器、單個屬性、單條規則的解析,以及如何將它們組合起來,完成整體解析,最終生成樣式表。

這幾部分的解析,思考方式上有個共同點。從整體到局部,再從局部回到整體。

先將整體解析任務拆分為單個目標,這樣問題就變小了。專注完成單個目標的解析,再循環調用單個解析,從而實現整體目標。

下一篇將介紹樣式樹的生成。敬請期待~

參考資料

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,461評論 6 532
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,538評論 3 417
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,423評論 0 375
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,991評論 1 312
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,761評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,207評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,268評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,419評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,959評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,782評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,983評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,528評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,222評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,653評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,901評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,678評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,978評論 2 374

推薦閱讀更多精彩內容