PostCSSで2つのCSSが同じであるか検証してみた
PostCSS を用いると CSS を AST(抽象構文木) と呼ばれる構造の JavaScript オブジェクトに変換でき、文字列としての CSS よりも加工等の取り扱いがしやすくなったりします。
Sass のコンパイラーのバージョン更新や、SCSS のリファクタリングなどによって SCSS -> CSS の変換結果に差分が生じないか・差分が生じたとして問題ないレベルの差分であるかを検証をするといったケースを想定し、2つの CSS から生成される AST を比較することを試してみました。
動作確認環境
- macOS Catalina
- Node.js v14.15.3
- Top-Level Await をしたいので v14.8.0 以降を推奨
- npm 6.14.9
-
postcss
8.2.2 -
stylelint
13.8.0 -
postcss-minify-selectors
: 4.0.2 -
jsondiffpatch
: 0.4.1
本記事における JavaScript のサンプルコードはモジュールであり、拡張子に .mjs
を用います
CSS を PostCSS に食わせてみる
import postcss from 'postcss'
const css = `
.foo {
letter-spacing: 0.05em;
}`
const { root } = await postcss().process(css)
console.log(root)
Node.js で実行すると以下のように出力されます:
console.log(root)
を console.log(JSON.stringify(root))
にすると以下の JSON (PostCSS のドキュメントでは JSON AST と呼んでいるもの) が出力されます:
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".foo",
"nodes": [
{
"type": "decl",
"prop": "letter-spacing",
"value": "0.05em",
"raws": { "before": "\n ", "between": ": " },
"source": { "inputId": 0, "start": { "offset": 10, "line": 3, "column": 3 }, "end": { "offset": 32, "line": 3, "column": 25 } }
}
],
"raws": { "before": "\n", "between": " ", "semicolon": true, "after": "\n" },
"source": {
"inputId": 0,
"start": { "offset": 1, "line": 2, "column": 1 },
"end": { "offset": 34, "line": 4, "column": 1 }
},
"lastEach": 1,
"indexes": {}
}
],
"raws": { "semicolon": false, "after": "" },
"source": {
"inputId": 0,
"start": { "offset": 0, "line": 1, "column": 1 }
},
"lastEach": 1,
"indexes": {}
}
見やすくするために、raws
や source
といった位置情報などを取り除くと以下の通りです:
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".foo",
"nodes": [
{
"type": "decl",
"prop": "letter-spacing",
"value": "0.05em"
}
]
}
]
}
raws
や source
を出力しないようにする
postcss.Node.prototype.toJSON
を書き換えることで、上記の JSON のような raws
や source
といった位置情報などを取り除いた JSON を出力することができます:
import postcss from 'postcss'
const NodePrototypeToJSON = postcss.Node.prototype.toJSON
postcss.Node.prototype.toJSON = function () {
const obj = NodePrototypeToJSON.apply(this, arguments)
delete obj.inputs
delete obj.source
delete obj.raws
delete obj.lastEach
delete obj.indexes
return obj
}
2つの CSS を比較してみる
例として、以下の2つの CSS が同じであるという結果を得ることを目標としてみます:
.foo, div[data-foo="hello"] {
letter-spacing: 0.05em;
font-family: "Foo Gothic";
}
.foo,
div[data-foo=hello] {
letter-spacing: .05em;
font-family: 'Foo Gothic'
}
JSON AST を出力してみる
以下のようなコードで css1, css2 それぞれの JSON AST (source
などを取り除いたもの) を出力してみます:
import postcss from 'postcss'
import './mutateToJsonOfNode.mjs'
const css1 = `
.foo, div[data-foo="hello"] {
letter-spacing: 0.05em;
font-family: "Foo Gothic";
}`
const css2 = `
.foo,
div[data-foo=hello] {
letter-spacing: .05em;
font-family: 'Foo Gothic'
}`
const { root: root1 } = await postcss().process(css1)
console.log(JSON.stringify(root1))
const { root: root2 } = await postcss().process(css2)
console.log(JSON.stringify(root2))
Node.js で実行すると以下のように出力されます:
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".foo, div[data-foo=\"hello\"]",
"nodes": [
{
"type": "decl",
"prop": "letter-spacing",
"value": "0.05em"
},
{
"type": "decl",
"prop": "font-family",
"value": "\"Foo Gothic\""
}
]
}
]
}
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".foo,\ndiv[data-foo=hello]",
"nodes": [
{
"type": "decl",
"prop": "letter-spacing",
"value": ".05em"
},
{
"type": "decl",
"prop": "font-family",
"value": "'Foo Gothic'"
}
]
}
]
}
root.nodes[0].selector
, root.nodes[0].nodes[0].value
, root.nodes[0].nodes[1].value
といった部分に差異がありますが、それ以外の値や構造は同じように見えます。
PostCSS プラグインや stylelint で CSS を正規化する
今回は以下の方針で正規化します:
-
".foo, div[data-foo=\"hello\"]"
/".foo,\ndiv[data-foo=hello]"
-
postcss-minify-selectors
でセレクターを minify して正規化する
-
-
"0.05em"
/".05em"
-
stylelint のルール
number-leading-zero
で"0.05em"
側に寄せて正規化する
-
stylelint のルール
-
"\"Foo Gothic\""
/"'Foo Gothic'"
-
stylelint のルール
string-quotes
で"\"Foo Gothic\""
側に寄せて正規化する
-
stylelint のルール
import postcss from 'postcss'
import stylelint from 'stylelint'
import postcssMinifySelectors from 'postcss-minify-selectors'
import './mutateToJsonOfNode.mjs'
const css1 = `
.foo, div[data-foo="hello"] {
letter-spacing: 0.05em;
font-family: "Foo Gothic";
}`
const css2 = `
.foo,
div[data-foo=hello] {
letter-spacing: .05em;
font-family: 'Foo Gothic'
}`
const plugins = [
stylelint({
config: {
rules: {
'number-leading-zero': 'always',
'string-quotes': 'double',
},
},
fix: true,
}),
postcssMinifySelectors(),
]
const { root: root1 } = await postcss(plugins).process(css1)
console.log(JSON.stringify(root1))
const { root: root2 } = await postcss(plugins).process(css2)
console.log(JSON.stringify(root2))
Node.js で実行すると、結果は css1, css2 ともに以下のものとなりました:
{
"type": "root",
"nodes": [
{
"type": "rule",
"selector": ".foo,div[data-foo=hello]",
"nodes": [
{
"type": "decl",
"prop": "letter-spacing",
"value": "0.05em"
},
{
"type": "decl",
"prop": "font-family",
"value": "\"Foo Gothic\""
}
]
}
]
}
deep イコール系のライブラリ(deep-equal など)を用いることで2つの変換結果が同じであることを検証できますが、その際は JSON.parse(JSON.stringify(root))
よりも root.toJSON()
の方が簡単に比較対象となるオブジェクトを得ることができます。
jsondiffpatch
が便利
JSON の差分の表示には jsondiffpatch
は CLI で2つの .json
ファイルを比較するのに便利なツールです。
JSONの差分を見たいならjsondiffpatchが便利 - なっく日報
2つの JSON AST に差分がある場合、以下のようにすれば jsondiffpatch
でわかりやすく表示することができます:
import postcss from 'postcss'
import './mutateToJsonOfNode.mjs'
import * as jsondiffpatch from 'jsondiffpatch'
const css1 = `
.foo, div[data-foo="hello"] {
letter-spacing: 0.05em;
font-family: "Foo Gothic";
}`
const css2 = `
.foo,
div[data-foo=hello] {
letter-spacing: .05em;
font-family: 'Foo Gothic'
}`
const { root: root1 } = await postcss().process(css1)
const { root: root2 } = await postcss().process(css2)
jsondiffpatch.console.log(jsondiffpatch.diff(root1.toJSON(), root2.toJSON()))
そもそも同じ、とは
例として挙げた css1 と css2 を文字列として(===
で)比較しても false
となりますが、今回は「PostCSS を用いてブラウザによる解釈が変わらなさそうな範囲で CSS を正規化・JSON AST[1] に変換」して「deep イコールであるか」を確認する方法で css1 と css2 を比較しました。
同値関係として捉えると...
数学には同値関係という概念があり、「2つのものが同じか判定する関数」は同値関係となっていることが多いです。
一般に
今回用いた方法による「同じ」という尺度は推移律によって「css1 と css2 が同じ」 かつ「css2 と css3 が同じ」 ならば「css1 と css3 が同じ」となります。
ECMAScript における同じとは...
こちらの資料が参考になります:
同じ、とは - N.Shimizu (We Are JavaScripters! @10th 登壇資料)
まとめ
同じ、は難しいのう
Discussion