从零实现一个Sass预处理器

32次阅读

共计 13458 个字符,预计需要花费 34 分钟才能阅读完成。

一、Sass 预处理器简介

Sass 是一款强化 CSS 的辅助工具,它在 CSS 语法的基础上增加了 变量 (variables)、 嵌套 (nested rules)、 混合 (mixins)、 导入 (inline imports) 等高级功能,这些拓展令 CSS 更加强大与优雅。使用 Sass 以及 Sass 的样式库(如 Compass)有助于更好地组织管理样式文件,以及更高效地开发项目。

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

经过 sass 编译后,生成的代码结果如下:

// sass 编译后的输出的 css 代码
nav {
  height: 100px;
  border: 1px solid red; }

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

接下来我们将实现一个简单的 Sass 预处理,其基本功能包括:

  • 能够解析变量
  • 能够使用嵌套

二、编译器简介

Sass 预处理器本质是一个编译器 ,Sass 的源文件是.scss 文件,里面的内容包含了 Sass 自己的语法,是无法直接执行的,必须经过编译转换为.css 文件后才能执行,其编译过程就是:

读取 sass 源码,然后对 sass 源码进行 词法分析,生成一个一个的 token;

然后对这些 token 进行 语法分析 ,生成 抽象语法树 (Abstract Syntax Tree,AST),解析成抽象语法树后,就 可以很方便的拿到我们需要的数据并进行相应的处理

然后遍历抽象语法树,对抽象语法树进行转换,转换成我们需要的代码输出结构,方便输出最终代码 ,比如, 因为 Sass 源码采用了嵌套 ,所以我们需要将选择器 变回链式结构

虽然对抽象语法树进行了相应的转换,但是 转换后的结果仍然是对象的形式 ,所以我们还需要进行代码的生成, 将对象形式转换为字符串形式输出

三、实现 Sass 预处理器

① 词法分析
词法分析就是要 找出源码中包含的 token,这个 token 也是一个对象,其中包含 所属的类型 type对应的值 value(词在源码中对应的字符串内容)当前 token 在源码中的缩进值 indent。其中 type 类型有 变量定义 变量引用 选择器 属性

{
    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");
}

正文完
 0