本篇讲述如何解析 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 ↵
上述的解析结果,可以看成是虚拟机运行所需要的汇编语句了,现在所要做的就是去编制一台机器去解释执行它:
这里有个问题,那就是,这台机器上还需要一个内存,以及一个寻址机关:
- 内存是一个 html 文件,我们的机器将会在上面运行刚刚得到的 CSS 汇编,最终会修改它
- 我所说的寻址机关是一个函数:根据选择器来找到 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 ↵
- css-parse.js 的作用是解析 CSS 源码,得到 CSS 汇编
- cssEval.js 是执行第一步汇编的机器, 得到结果
… 总之 … 词法解析很有趣 …