2018-01-14
CSS
Parser
CSS 的解析
本篇讲述如何解析 CSS 文本,我这里的解析是指词法意义上的解析,不是浏览器上如何如何渲染 (虽然有一定联系,词法解析是渲染解析的基础)。

# 为什么要做词法解析

在做邮件发送的时候发现,接收方的显示界面不解析 HTML 文本里的 Link, Style 这些标签,因而做不到外联样式,只能把样式写进行内。
00<h1 style="color: #BBB">标题 01</h1>
01<h1 style="color: #BBB">标题 02</h1>
02<h1 style="color: #BBB">标题 03</h1>
03<h1 style="color: #BBB">标题 04</h1>
一看,那么大块重复的,看来可以编程了,解决这个问题,其实只需要让机器理解如下 CSS:
00h1 {
01    color: #BBB; 
02}
并做出切实可行的行动:即找到 h1 然后吧 color: #BBB; 放进 style 属性里。
最后输出结果,即我要做的,就是虚拟一台简单的 CSS 渲染机。

# 结构抽象

CSS 的结构其实很简单:
00[Selector] {
01    Prop: Val; 
02}
一个选择器,后面跟着一个花括号,里面是对应的属性和值。
这样的一个选择器 + 花括号的结构构成了 CSS 的最基本执行单元,暂且称为 CSS 块吧。

# 解析 CSS 块

CSS 里的花括号不是嵌套的,只需要取出最表层的就行了,要做词法解析,首先得标出每块 CSS 的各个部分的位置 (选择器的位置,花括号的位置)。
00// css-parse.js 上半部分
01function parseBucket(css){
02    let chars = css.split(''); 
03    let i = 0, deep = 0, res = [], start = -1, end = -1; 
04    
05    for (; i < chars.length; i++){
06        let char = chars[i]; 
07    
08        if (char === '{'){
09            if (deep === 0){
10                deep = deep + 1; 
11                // 记录左花括号位置 
12                start = i; 
13            } else {
14                console.log('Parse Error'); 
15            }
16        } else if (char === '}'){
17            if (deep === 1){
18                deep = deep - 1; 
19                
20                res.push({
21                    // s 是上一个右括号的结束位置
22                    s: end, 
23                    // 左花括号开始位置 
24                    l: start, 
25                    // 当前右花括号的位置 
26                    r: i
27                }); 
28
29                // 记录这次右括号位置,供下次上面那个 push 使用
30                end = i; 
31            } else {
32                console.log('Parse Error'); 
33            }
34        }
35    }
36
37    return res; 
38}
s 表示上一个 CSS 块的结束, l 表示左括号的开始, r 表示右括号的结束。
故此, s ~ l 这边,就叫做选择器;
l ~ r 那边,就叫做 CSS 属性集定义
从 s 到 r 为一个 CSS 块区间。
执行结果
执行结果
(注: -1 代表行首)
当然,因为 s 代表上一个 css 块的结束,因此中间可能还有些毛刺,上面给出的函数,其实只是 css-parse.js 的一部分,下半部分如下,他在上一步的基础上,进一步处理得到一个看起来比较舒服的结构:
00// css-parse.js 下半部分
01module.exports = css => {
02    css = css.replace(/\/\*(.*?)\*\//g, '');
03    let res = parseBucket(css); 
04    
05    let css_ast = res.map(({s, l, r}) => {
06        return {
07            el: css.substring(s, l).replace(/(\t|\n|\r|\})/g, '').trim(), 
08            val: css.substring(l + 1, r).replace(/\t/g, '').trim()
09        } 
10    }); 
11
12    return css_ast; 
13}
一个可行的结构
一个可行的结构

# 结果是 AST

上述的解析结果,可以看成是虚拟机运行所需要的汇编语句了,现在所要做的就是去编制一台机器去解释执行它:
这里有个问题,那就是,这台机器上还需要一个内存,以及一个寻址机关:
  1. 内存是一个 html 文件,我们的机器将会在上面运行刚刚得到的 CSS 汇编,最终会修改它
  2. 我所说的寻址机关是一个函数:根据选择器来找到 html 对应的标签
第一点是易行的,第二点不那么好办,我采用了第三方的库来做(html 的词法解析更复杂… 嗯)
这个库叫做 cheerio, 他是仿 jQuery 的,可以这样用:
00const cheerio = require('cheerio')
01
02let html = `<h1> Hello </h1>`;
03let $ = cheerio.load(html); 
04
05
06console.log($('h1'))
07// => 
08// 得到跟 jQuery 那样的一种结构
完整的实现如下:
00// cssEval.js
01const cheerio = require('cheerio')
02    , parse = require('./css-parse')
03
04module.exports = css => {
05    // 这是上一步得到的 CSS 汇编 
06    let ast = parse(css); 
07
08    // 返回这样的一个函数:它需要 html 完成最后的运算 
09    return html => {
10        // 加载 
11        let $ = cheerio.load(html); 
12
13        // 遍历执行 
14        ast.forEach(({el, val}) => {
15            // 选择 
16            $(el).each((idx, elem) => {
17                // 目标元素 
18                let $elem = $(elem); 
19
20                // 取得这个元素目前的 style 属性,如果没有,取 ''
21                let pre = $elem.attr('style') || ''; 
22
23                // 目前的 style 接上汇编里面的
24                $elem.attr('style', pre + val);
25            });
26        });
27
28        // 还原回 html 
29        // (注意:cheerio 会自动为你补上 body 这些标签,我们只需要 body 里面的内容)
30        return $('body').html(); 
31    }
32}

# TL;DR

  1. css-parse.js 的作用是解析 CSS 源码,得到 CSS 汇编
  2. cssEval.js 是执行第一步汇编的机器, 得到结果
… 总之 … 词法解析很有趣 …




回到顶部