🌲

PostCSSで2つのCSSが同じであるか検証してみた

2021/01/01に公開

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 に食わせてみる

example01.mjs
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": {}
}

見やすくするために、rawssource といった位置情報などを取り除くと以下の通りです:

{
  "type": "root",
  "nodes": [
    {
      "type": "rule",
      "selector": ".foo",
      "nodes": [
        {
          "type": "decl",
          "prop": "letter-spacing",
          "value": "0.05em"
        }
      ]
    }
  ]
}

rawssource を出力しないようにする

postcss.Node.prototype.toJSON を書き換えることで、上記の JSON のような rawssource といった位置情報などを取り除いた JSON を出力することができます:

mutateToJsonOfNode.mjs
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 が同じであるという結果を得ることを目標としてみます:

css1
.foo, div[data-foo="hello"] {
  letter-spacing: 0.05em;
  font-family: "Foo Gothic";
}
css2
.foo,
div[data-foo=hello] {
  letter-spacing: .05em;
  font-family:    'Foo Gothic'
}

JSON AST を出力してみる

以下のようなコードで css1, css2 それぞれの JSON AST (source などを取り除いたもの) を出力してみます:

example02.mjs
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 で実行すると以下のように出力されます:

css1 の JSON AST
{
  "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\""
        }
      ]
    }
  ]
}
css2 の JSON AST
{
  "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 を正規化する

今回は以下の方針で正規化します:

example03.mjs
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() の方が簡単に比較対象となるオブジェクトを得ることができます。

JSON の差分の表示には jsondiffpatch が便利

jsondiffpatch は CLI で2つの .json ファイルを比較するのに便利なツールです。
JSONの差分を見たいならjsondiffpatchが便利 - なっく日報

2つの JSON AST に差分がある場合、以下のようにすれば jsondiffpatch でわかりやすく表示することができます:

example04.mjs
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つのものが同じか判定する関数」は同値関係となっていることが多いです。

一般に f: A \to B と、 B の同値関係 \sim_B があるとき、A の二項関係 \sim_Aa_1 \sim_A a_2 \iff f(a_1) \sim_B f(a_2) のように定義すれば A の同値関係になります。

A を CSS の集合、B を JSON の集合、f を「PostCSS を用いて CSS を正規化・JSON AST に変換」という関数、\sim_B を「deep イコールであるか」[2]のように当てはめると、今回の比較に用いた方法は CSS の集合における同値関係として捉えることができます。

今回用いた方法による「同じ」という尺度は推移律によって「css1 と css2 が同じ」 かつ「css2 と css3 が同じ」 ならば「css1 と css3 が同じ」となります。

ECMAScript における同じとは...

こちらの資料が参考になります:
同じ、とは - N.Shimizu (We Are JavaScripters! @10th 登壇資料)

まとめ

同じ、は難しいのう

脚注
  1. 正確には AST JSON から rawssource などを取り除いた JSON ↩︎

  2. deep イコール系ライブラリが提供する関数が、JSON の集合上で同値関係となっていると信じることにします ↩︎

Discussion