從零實現一個Sass預處理器

1、Sass預處理器簡介

Sass 是一款強化 CSS 的輔助工具,它在 CSS 語法的基礎上增長了變量 (variables)、嵌套 (nested rules)、混合 (mixins)、導入 (inline imports) 等高級功能,這些拓展令 CSS 更增強大與優雅。使用 Sass 以及 Sass 的樣式庫(如 Compass)有助於更好地組織管理樣式文件,以及更高效地開發項目。css

// index.scss sass源碼
$redColor: red;
$yellowBg: yellow;
nav {
    height: 100px;
    border: 1px solid $redColor;
}
#content {
    height: 300px;
    p {
        margin: 10px;
        .selected {
            backgournd: $yellowBg;
        }
    }
}

通過sass編譯後,生成的代碼結果以下:node

// sass編譯後的輸出的css代碼
nav {
  height: 100px;
  border: 1px solid red; }

#content {
  height: 300px; }
  #content p {
    margin: 10px; }
    #content p .selected {
      backgournd: yellow; }

接下來咱們將實現一個簡單的Sass預處理,其基本功能包括:數組

  • 可以解析變量
  • 可以使用嵌套

2、編譯器簡介

Sass預處理器本質是一個編譯器,Sass的源文件是.scss文件,裏面的內容包含了Sass本身的語法,是沒法直接執行的,必須通過編譯轉換爲.css文件後才能執行,其編譯過程就是:sass

讀取sass源碼,而後對sass源碼進行詞法分析,生成一個一個的token;工具

而後對這些token進行語法分析,生成抽象語法樹(Abstract Syntax Tree,AST),解析成抽象語法樹後,就能夠很方便的拿到咱們須要的數據並進行相應的處理測試

而後遍歷抽象語法樹,對抽象語法樹進行轉換,轉換成咱們須要的代碼輸出結構,方便輸出最終代碼,好比,由於Sass源碼採用了嵌套,因此咱們須要將選擇器變回鏈式結構spa

雖然對抽象語法樹進行了相應的轉換,可是轉換後的結果仍然是對象的形式,因此咱們還須要進行代碼的生成,將對象形式轉換爲字符串形式輸出code

3、實現Sass預處理器

① 詞法分析
詞法分析就是要找出源碼中包含的token,這個token也是一個對象,其中包含所屬的類型type對應的值value(詞在源碼中對應的字符串內容)當前token在源碼中的縮進值indent。其中type類型有變量定義變量引用選擇器屬性orm

{
    type: "variableDef" | "variableRef" | "selector" | "property" | "value", // 當前詞所屬類型
    value: string, // Sass源碼中對應的字符串內容
    indent: number // 當前詞在Sass源碼中的縮進值
}
  • 對Sass源碼字符串進行以換行符進行分割,分割成數組,每一行的內容做爲數組中的一個元素
const sassSourceCode = `

`; // Sass的源碼
// 對Sass源碼以換行符進行分割
const lines = sassSourceCode.trim().split(/\n/);
  • 拿到每一行的內容後,須要對每一行的內容進行遍歷,拿到每一行內容前面的空格數,即縮進,接着對每一行的內容以冒號進行分割,分割成數組,將每一行中的詞(word)做爲數組的一個元素
// 遍歷每一行中的內容,將生成的token放到tokens數組中,最初爲[]
lines.reduce((tokens, line) => {
    const spaces = line.match(/^\s+/) || [""]; // 匹配每行開頭的空格部分
    const indent = spaces[0].length; // 拿到每行的縮進空格數量
    const input = line.trim(); // 去除首尾空格
    let words = input.split(/:/); // 用冒號進行分割,拿到每一行中的全部詞
}, []);
  • 拿到每一行中包含的詞後,咱們就能夠對每個詞進行處理了,經過查看上面的Sass源碼,能夠看到,每一行以冒號分割後,若是是選擇器,如#content {,那麼分割後的words數組中只有一個元素,咱們能夠以此找到選擇器,如:
let firstWord = words.shift(); // 取出並刪除每行的第一個詞
const selectorReg = /([0-9a-zA-Z-.#\[\]]+)\s+\{$/; // 選擇器匹配正則
if (words.length === 0) { // 若是取出並刪除第一個詞後,words數組長度變爲爲0,說明該行只有一個詞,那麼這個詞就是選擇器
    const result = firstWord.match(selectorReg); // 有多是 },冒號分割後words的長度也會變成0,因此須要進行正則匹配
    if (result) {
        tokens.push({ // 將選擇器放到tokens中
            type: "selector",
            value: result[1],
            indent
        });
    }
}
  • 接下來就是處理變量定義屬性變量引用這些類型了,若是當前行的第一個詞是以美圓符開頭,那麼這個詞就是變量定義不然就是屬性,由於值和變量引用不多是第一個詞,而是在第一個詞以後。
if (words.length === 0) {

} else { // 變量定義、屬性、變量引用、值
    let type = "";
    if (/^\$/.test(firstWord)) { // 若是每行的第一個詞是以$開頭的,那麼這個詞就是一個變量定義
        type = "variableDef"; // 那麼type就是變量定義,即variableDef
    } else {
        type = "property";
    }
    tokens.push({ // 將變量定義或者屬性放到tokens中
        type,
        value: firstWord,
        indent
    });
}
  • 至此,第一個詞已經處理完畢,接着開始處理以後的詞了,剩下的詞要麼是要麼是變量引用,而且有些詞比較特殊,如 1px solid red,其中包含了3個值,因此須要用空格進行分割成數組分紅3個詞處理,如:
// 繼續取出words中剩餘的詞進行分析,剩下的詞多是值或者是變量引用兩種類型
while (firstWord = words.shift()) { // 取出下一個詞更新firstWord
    firstWord = firstWord.trim(); // 去除詞的首尾空格
    const values = firstWord.split(/\s/); // 有些詞(1px solid red)可能包含多個值,因此須要用空格進行分割, 拿到全部的值
    if (values.length > 1) { // 若是值有多個
        words = values; // 將全部的值做爲words繼續遍歷
        continue;
    }
    firstWord = firstWord.replace(/;/, ""); // 去除值中包含的分號
    tokens.push({ // 將值或者變量引用加入到tokens中
        type: /^\$/.test(firstWord) ? "variableRef" : "value",
        value: firstWord,
        indent: 0
    });
}

通過一層一層遍歷,源碼中的全部詞都被解析成了token而且放到了tokens數組中,完整代碼以下:對象

/*
* 將Sass源碼傳入進行詞法分析生成tokens數組
*/
function tokenize(sassSourceCode) {
    return sassSourceCode.trim().split(/\n/).reduce((tokens, line) => {
        const spaces = line.match(/^\s+/) || [""]; // 匹配空格開頭的行
        const indent = spaces[0].length; // 拿到每行的縮進空格數量
        const input = line.trim(); // 去除首尾空格
        let words = input.split(/:/); // 用冒號進行分割,拿到每一行中的全部詞
        let firstWord = words.shift(); // 取出並刪除每行的第一個詞
        const selectorReg = /([0-9a-zA-Z-.#\[\]]+)\s+\{$/;
        if (words.length === 0) { // 若是取出並刪除第一個詞後,words數組長度變爲爲0,說明該行只有一個詞,那麼這個詞就是選擇器
            const result = firstWord.match(selectorReg);
            if (result) {
                tokens.push({ // 將選擇器放到tokens中
                    type: "selector",
                    value: result[1],
                    indent
                });
            }
        } else { // 變量定義、變量引用、屬性、值
            let type = "";
            if (/^\$/.test(firstWord)) { // 若是每行的第一個詞是以$開頭的,那麼這個詞就是一個變量定義
                type = "variableDef"; // 那麼type就是變量定義,即variableDef
            } else { // 若是每行的第一個次是非美圓符開頭,那麼就是屬性
                type = "property";
            }
            tokens.push({ // 將變量定義或者屬性放到tokens中
                type,
                value: firstWord,
                indent
            });
            // 繼續取出words中剩餘的詞進行分析,剩下的詞多是值或者是變量引用兩種類型
            while (firstWord = words.shift()) {
                firstWord = firstWord.trim(); // 去除詞的首尾空格
                const values = firstWord.split(/\s/); // 有些詞(1px solid red)可能包含多個值,因此須要用空格進行分割, 拿到全部的值
                if (values.length > 1) { // 若是值有多個
                    words = values; // 將全部的值做爲words繼續遍歷
                    continue;
                }
                firstWord = firstWord.replace(/;/, ""); // 去除值中包含的分號
                tokens.push({ // 將值或者變量引用加入到tokens中
                    type: /^\$/.test(firstWord) ? "variableRef" : "value",
                    value: firstWord,
                    indent: 0
                });
            }
        }
        return tokens;
    }, []);
}

用上面的源碼測試一下詞法分析的結果以下:

[ { type: 'variableDef', value: '$redColor', indent: 0 },
  { type: 'value', value: 'red', indent: 0 },
  { type: 'variableDef', value: '$yellowBg', indent: 0 },
  { type: 'value', value: 'yellow', indent: 0 },
  { type: 'selector', value: 'nav', indent: 0 },
  { type: 'property', value: 'height', indent: 4 },
  { type: 'value', value: '100px', indent: 0 },
  { type: 'property', value: 'border', indent: 4 },
  { type: 'value', value: '1px', indent: 0 },
  { type: 'value', value: 'solid', indent: 0 },
  { type: 'variableRef', value: '$redColor', indent: 0 },
  { type: 'selector', value: '#content', indent: 0 },
  { type: 'property', value: 'height', indent: 4 },
  { type: 'value', value: '300px', indent: 0 },
  { type: 'selector', value: 'p', indent: 4 },
  { type: 'property', value: 'margin', indent: 8 },
  { type: 'value', value: '10px', indent: 0 },
  { type: 'selector', value: '.selected', indent: 8 },
  { type: 'property', value: 'backgournd', indent: 12 },
  { type: 'variableRef', value: '$yellowBg', indent: 0 } ]

② 語法分析
語法分析就是對tokens進行遍歷將其解析成一個樹形結構。整個樹有一個根節點,根節點下有children子節點數組,只有選擇器類型才能成爲一個節點,而且每個節點下有一個rules屬性用於存放當前節點的樣式規則,根節點以下:

const ast = { // 定義一個抽象語法樹AST對象,一開始只有根節點
    type: "root", // 根節點
    value: "root",
    children: [],
    rules: [],
    indent: -1
};

每一條規則也是一個對象,結構以下:

// 樣式規則
{ 
    property: "border",
    value: ["1px", "solid", "red"],
    indent: 8
}
  • 解析前,首先初始化一個root根節點,和解析路徑,用於定位樣式所屬的節點,接着準備按順序遍歷每個token,如:
function parse(tokens) {
    const ast = { // 定義一個抽象語法樹AST對象
        type: "root", // 根節點
        value: "root",
        children: [],
        rules: [],
        indent: -1
    };
    const path = [ast]; // 將抽象語法樹對象放到數組中,即當前解析路徑,最後一個元素爲父元素
    let parentNode = ast; // 將當前根節點做爲父節點
    // 遍歷全部的token
    while (token = tokens.shift()) {
    }
    return ast;
}
  • 首先處理變量的定義,若是該token的類型是variableDef,而且它的下一個token的類型是value,那麼就是變量的定義,將變量的名稱和值保存到變量字典中,以便後面變量引用的時候能夠從變量字典中讀取變量的值,如:
const variableDict = {}; // 保存定義的變量字典
while (token = tokens.shift()) {
    if (token.type === "variableDef") { // 若是這個token是變量定義
        if (tokens[0] && tokens[0].type === "value") { // 而且若是其下一個token的類型是值定義,那麼這兩個token就是變量的定義
            const variableValueToken = tokens.shift(); // 取出包含變量值的token
            variableDict[token.value] = variableValueToken.value; // 將變量名和遍歷值放到vDict對象中
        }
        continue;
    }
}
  • 接着處理類型爲selector的token,對於selector選擇器類型,咱們須要建立一個新節點,而後和當前父節點的縮進值進行比較,若是當前建立的新節點的縮進值比當前父節點,說明是當前父節點的子節點,直接將當前建立的新節點push到父節點的children數組中,而且更新當前建立的新節點爲父節點。若是當前建立的新節點的縮進值比當前父節點,說明不是當前父節點的子節點,那麼咱們就須要從當前解析路徑中逐個取出最後一個節點,直到找到當前建立節點的父節點,即找到縮進值比當前建立節點小的那個節點做爲父節點,找到父節點後將當前建立的新節點放到父節點的children數組中,同時將父節點和當前建立的新節點push到解析路徑中,一樣更新當前建立的新節點爲父節點
if (token.type === "selector") { // 若是是選擇器
    const selectorNode = { // 建立一個選擇器節點,而後填充children和rules便可
        type: "selector",
        value: token.value,
        indent: token.indent,
        rules: [],
        children: []
    }
    if (selectorNode.indent > parentNode.indent) { // 當前節點的縮進大於其父節點的縮進,說明當前選擇器節點是父節點的子節點
        path.push(selectorNode); // 將當前選擇器節點加入到path中,路徑變長了,當前選擇器節點做爲父節點
        parentNode.children.push(selectorNode); // 將當前選擇器對象添加到父節點的children數組中
        parentNode = selectorNode; // 當前選擇器節點做爲父節點
    } else { // 縮進比其父節點縮進小,說明是非其子節點,多是出現了同級的節點 
        parentNode = path.pop(); // 移除當前路徑的最後一個節點
        while (token.indent <= parentNode.indent) { // 同級節點
            parentNode = path.pop(); // 拿到其父節點的父節點
        }
        // 找到父節點後,由於父節點已經從path中移除,因此還須要將父節點再次添加到path中
        path.push(parentNode, selectorNode);
        parentNode.children.push(selectorNode); // 找到父節點後,將當前選擇器節點添加到父節點children中
        parentNode = selectorNode; // 當前選擇器節點做爲父節點
    }
}
  • 接着處理類型爲property的token,對於屬性類型,和選擇器類型差很少,咱們須要建立一個rule對象,而後和當前父節點的縮進值進行比較,若是當前屬性token的縮進值比當前父節點的縮進值,說明是當前父節點的樣式,直接將建立的rule對象添加到當前父節點的rules數組便可。若是當前屬性token的縮進值比當前父節點的縮進值小,說明不是當前父節點的樣式,那麼咱們就須要從當前解析路徑中逐個取出最後一個節點,直到找到當前屬性token的父節點,即找到縮進值比當前token縮進值小的那個節點做爲父節點,找到父節點後,直接將建立的rule對象添加到父節點的rules數組中,同時將父節點再次放回到解析路徑中便可
if (token.type === "property") { // 若是是屬性節點
    if (token.indent > parentNode.indent) { // 若是該屬性的縮進大於父節點的縮進,說明是父節點選擇器的樣式
        parentNode.rules.push({ // 將樣式添加到rules數組中 {property: "border", value:[]}
            property: token.value,
            value: [],
            indent: token.indent
        });
    } else { // 非當前父節點選擇器的樣式
        parentNode = path.pop(); // 取出並移除最後一個選擇器節點,拿到當前父節點
        while (token.indent <= parentNode.indent) { // 與當前父節點的縮進比較,若是等於,說明與當前父節點同級,若是小於,則說明比當前父節點更上層
            parentNode = path.pop(); // 比當前父節點層次相等或更高,取出當前父節點的父節點,再次循環判其父節點,直到比父節點的縮進大爲止
        }
        // 拿到了其父節點
        parentNode.rules.push({ // 將該樣式添加到其父選擇器節點中
            property: token.value,
            value: [],
            indent: token.indent
        });
        path.push(parentNode); // 因爲父節點已從path中移除,須要再次將父選擇器添加到path中
    }
    continue;
}
  • 最後就是處理類型爲valuevariableRef的token了,這兩個本質都屬於值,只不過變量引用真實的值須要到變量字典中去取,對於值,咱們不須要像上面一個經過縮進值去判斷父節點,當前這個值確定是屬於當前父節點的,直接將值放到當前父節點的最後一個rule對象的value數組中便可。
if (token.type === "value") { // 若是是值節點
    // 拿到上一個選擇器節點的rules中的最後一個rule的value將值添加進去
    parentNode.rules[parentNode.rules.length - 1].value.push(token.value);
    continue;
}

if (token.type === "variableRef") { // 若是是變量引用,從變量字典中取出值並添加到父節點樣式的value數組中
    parentNode.rules[parentNode.rules.length - 1].value.push(variableDict[token.value]);
    continue;
}

tokens通過一個個遍歷後,就按照上面的規則添加到了由根節點開始的樹結構上,完整代碼以下:

function parse(tokens) {
    const ast = { // 定義一個抽象語法樹AST對象
        type: "root", // 根節點
        value: "root",
        children: [],
        rules: [],
        indent: -1
    };
    const path = [ast]; // 將抽象語法樹對象放到數組中,即當前解析路徑,最後一個元素爲父元素
    let parentNode = ast; // 將當前根節點做爲父節點
    let token;
    const variableDict = {}; // 保存定義的變量字典
    // 遍歷全部的token
    while (token = tokens.shift()) {
        if (token.type === "variableDef") { // 若是這個token是變量定義
            if (tokens[0] && tokens[0].type === "value") { // 而且若是其下一個token的類型是值定義,那麼這兩個token就是變量的定義
                const variableValueToken = tokens.shift(); // 取出包含變量值的token
                variableDict[token.value] = variableValueToken.value; // 將變量名和遍歷值放到vDict對象中
            }
            continue;
        }

        if (token.type === "selector") { // 若是是選擇器
            const selectorNode = { // 建立一個選擇器節點,而後填充children和rules便可
                type: "selector",
                value: token.value,
                indent: token.indent,
                rules: [],
                children: []
            }
            if (selectorNode.indent > parentNode.indent) { // 當前節點的縮進大於其父節點的縮進,說明當前選擇器節點是父節點的子節點
                path.push(selectorNode); // 將當前選擇器節點加入到path中,路徑變長了,當前選擇器節點做爲父節點
                parentNode.children.push(selectorNode); // 將當前選擇器對象添加到父節點的children數組中
                parentNode = selectorNode; // 當前選擇器節點做爲父節點
            } else { // 縮進比其父節點縮進小,說明是非其子節點,多是出現了同級的節點 
                parentNode = path.pop(); // 移除當前路徑的最後一個節點
                while (token.indent <= parentNode.indent) { // 同級節點
                    parentNode = path.pop(); // 拿到其父節點的父節點
                }
                // 找到父節點後,由於父節點已經從path中移除,因此還須要將父節點再次添加到path中
                path.push(parentNode, selectorNode);
                parentNode.children.push(selectorNode); // 找到父節點後,將當前選擇器節點添加到父節點children中
                parentNode = selectorNode; // 當前選擇器節點做爲父節點
            }
        }

        if (token.type === "property") { // 若是是屬性節點
            if (token.indent > parentNode.indent) { // 若是該屬性的縮進大於父節點的縮進,說明是父節點選擇器的樣式
                parentNode.rules.push({ // 將樣式添加到rules數組中 {property: "border", value:[]}
                    property: token.value,
                    value: [],
                    indent: token.indent
                });
            } else { // 非當前父節點選擇器的樣式
                parentNode = path.pop(); // 取出並移除最後一個選擇器節點,拿到當前父節點
                while (token.indent <= parentNode.indent) { // 與當前父節點的縮進比較,若是等於,說明與當前父節點同級,若是小於,則說明比當前父節點更上層
                    parentNode = path.pop(); // 比當前父節點層次相等或更高,取出當前父節點的父節點,再次循環判其父節點,直到比父節點的縮進大爲止
                }
                // 拿到了其父節點
                parentNode.rules.push({ // 將該樣式添加到其父選擇器節點中
                    property: token.value,
                    value: [],
                    indent: token.indent
                });
                path.push(parentNode); // 因爲父節點已從path中移除,須要再次將父選擇器添加到path中
            }
            continue;
        }

        if (token.type === "value") { // 若是是值節點
            // 拿到上一個選擇器節點的rules中的最後一個rule的value將值添加進去
            parentNode.rules[parentNode.rules.length - 1].value.push(token.value);
            continue;
        }

        if (token.type === "variableRef") { // 若是是變量引用,從變量字典中取出值並添加到父節點樣式的value數組中
            parentNode.rules[parentNode.rules.length - 1].value.push(variableDict[token.value]);
            continue;
        }
    }
    return ast;
}

對上一步生成的tokens解析後的結果以下:

{
    "type": "root",
    "value": "root",
    "children": [{
        "type": "selector",
        "value": "nav",
        "indent": 0,
        "rules": [{
            "property": "height",
            "value": ["100px"],
            "indent": 4
        }, {
            "property": "border",
            "value": ["1px", "solid", "red"],
            "indent": 4
        }],
        "children": []
    }, {
        "type": "selector",
        "value": "#content",
        "indent": 0,
        "rules": [{
            "property": "height",
            "value": ["300px"],
            "indent": 4
        }],
        "children": [{
            "type": "selector",
            "value": "p",
            "indent": 4,
            "rules": [{
                "property": "margin",
                "value": ["10px"],
                "indent": 8
            }],
            "children": [{
                "type": "selector",
                "value": ".selected",
                "indent": 8,
                "rules": [{
                    "property": "backgournd",
                    "value": ["yellow"],
                    "indent": 12
                }],
                "children": []
            }]
        }]
    }],
    "rules": [],
    "indent": -1
}

③ 轉換
所謂轉換就是對抽象語法樹進行處理,將樹結構對象轉換成咱們最終須要的數據對象,根據上面Sass編譯後輸出的源碼,能夠發現咱們最終須要生成每一個選擇器下的樣式,而且這個選擇器是呈鏈式結構的,因此咱們須要遍歷抽象語法樹,找到每一個選擇器及其樣式,並記錄當前選擇器的父鏈,從新生成一個對象,以下:

// 根據這個對象咱們就能夠輸出一條樣式 #content p {margin: 10px}
{
    selector: "#content p", // 鏈式結構的選擇器
    rules:[{"property":"margin","value":"10px","indent":8}], // 鏈式選擇器最右邊選擇器的樣式,每條樣式包含屬性名和屬性值,以及該樣式的縮進值
    indent: 4 // 鏈式選擇器最右邊選擇器的縮進值
}

咱們只須要傳入上面生成的抽象語法樹即根節點,而後進行遞歸遍歷其子節點,若是節點的type類型爲selector,咱們就須要進行處理,拿到當前選擇器下的全部樣式組成的rules數組和選擇器鏈一塊兒生成上面結構的對象做爲一條樣式並放到styles數組中便可。

function transform(ast) {
    const styles = []; // 存放要輸出的每一條樣式
    function traverse(node, styles, selectorChain) {
        if (node.type === "selector") { // 若是是選擇器節點
            selectorChain = [...selectorChain, node.value]; // 解析選擇器層級關係,拿到選擇器鏈
            if (node.rules.length > 0) {
                styles.push({
                    selector: selectorChain.join(" "),
                    rules: node.rules.reduce((rules, rule) => { // 遍歷其rules, 拿到當前選擇器下的全部樣式
                        rules.push({ // 拿到該樣式規則的屬性和屬性值並放到數組中
                            property: rule.property,
                            value: rule.value.join(" "),
                            indent: rule.indent
                        });
                        return rules;
                    }, []),
                    indent: node.indent
                });
            }
        }
        // 遍歷根節點的children數組
        for (let i = 0; i < node.children.length; i++) {
            traverse(node.children[i], styles, selectorChain);
        }
    }
    traverse(ast, styles, []);
    return styles;
}

用上面的抽象語法樹轉換後生成的styles數組以下:

[{
    "selector": "nav",
    "rules": [{
        "property": "height",
        "value": "100px",
        "indent": 4
    }, {
        "property": "border",
        "value": "1px solid red",
        "indent": 4
    }],
    "indent": 0
}, {
    "selector": "#content",
    "rules": [{
        "property": "height",
        "value": "300px",
        "indent": 4
    }],
    "indent": 0
}, {
    "selector": "#content p",
    "rules": [{
        "property": "margin",
        "value": "10px",
        "indent": 8
    }],
    "indent": 4
}, {
    "selector": "#content p .selected",
    "rules": [{
        "property": "backgournd",
        "value": "yellow",
        "indent": 12
    }],
    "indent": 8
}]

④ 代碼生成
上面通過轉換後仍然是對象的形式,因此咱們須要遍歷每一條樣式,對其rules數組中的每個rule的屬性和值用冒號拼接起來,而後將rules數組中的全部rule用換行符拼接起來生成樣式規則字符串,而後與選擇器一塊兒拼接成一條字符串形式的樣式便可。

function generate(styles) {
    return styles.map(style => { // 遍歷每一條樣式
        const rules = style.rules.reduce((rules, rule) => { // 將當前樣式的全部rules合併起來
            return rules += `\n${" ".repeat(rule.indent)}${rule.property}:${rule.value};`;
        }, "");
        return `${" ".repeat(style.indent)}${style.selector} {${rules}}`;
    }).join("\n");
}
相關文章
相關標籤/搜索