CSS tree shaking で不要な定義をふるい落として縮小化

WordPressもブロックエディターが使えるようになり、便利になってきていますが、それに伴いCSSのサイズがとても増大してきています

WordPress コア側でも使用しているブロックのCSSのみを出力する機能がありますが、ブロック単位なのでざっくりとしたCSSの削減しか行っていません。

もっと高機能で汎用的な不要なCSSを取り除く CSS Tree Shaking 機能のPHPプログラムを開発したので紹介します

なんで不要コードを取り除くのを ツリーシェイキング っていうのかわかりませんが、思わず木を揺らしてオリーブを収穫するのをイメージしてしまいました。全く関係ないですが youtube を貼ってみました… 😋

CSS Tree Shaking プログラム

このライブラリは、CSSを縮小化する主要な2つの機能があり、コードも200行程度のコンパクトなプログラムです

  1. simple_minify : CSS 内のコメント、改行、空白等を削除するだけの縮小化
  2. extended_minify : CSS 内の未使用 id, class, tag に関する定義を取り除く縮小化

Ver3.0 公開にあたり CSS nesting に対応するため、ほぼ作り直しました

CSS Nesting – Chrome Developers
One of our favorite CSS preprocessor features is now built into the language: nesting style rules.
CSS Nesting - Chrome Developers

CSS nesting は、2023年3月現在まだ使えませんが、まもなく各ブラウザで対応していくと思いますので、そうなれば一気に普及するのではないかと思っています

かなり古めのノートPCの Docker を使った PHP8.2 のテスト環境において、サンプルページで WordPress のコアブロックと自作テーマのCSSを結合して、CSS Tree shaking を行ってみました
結果は 約8000行のCSSを1/4 程度に 100ms 前後で縮小化できています🚀
なかなかのパフォーマンスと自画自賛してますが、WordPressが全面的に CSS nesting に対応してくれればさらなるパフォーマンスの向上、うまく行けば 30ms 前後も狙えると期待しちゃってます 😍

下記コードで全て… Wow!! 👍 

<?php
/**
 * CSS Simple Tree Shaking
 * 
 * Description: CSS tree shaking minify library
 * Version: 3.0.0
 * Author: enomoto@celtislab
 * Author URI: https://celtislab.net/
 * License: GPLv2 
 */
namespace celtislabv3_0;

class CSS_tree_shaking {
    
    private static $is_parse;
    private static $tag;
    private static $id;
    private static $class;
    private static $style;
    private static $type;
    private static $attrlist;
    private static $jsaddlist;

	function __construct() {}

    //Remove CSS data including unused id / class / tag
    private static function tree_shaking($css) {
        $mincss = preg_replace_callback( '`(?<sel>[^{]+?){(?<pv>.*)}(?<after>[^}]*$)`u', function($mstyle) {            
            $before = '';
            $sel    = $mstyle['sel'];                    
            if(false !== ($sep = strrpos($sel, ';'))){
                $before = substr($sel, 0, $sep + 1);
                $sel    = substr($sel, $sep + 1);
            }
            $pv     = $mstyle['pv'];
            $after  = $mstyle['after'];
            
            //Remove unused selectors
            $_sel = (strpos($sel, '(') !== false)? preg_replace_callback( '`((((?>[^()]+)|(?R))*))`', function($rep){ return str_replace(',', "t", $rep[0]); }, $sel) : $sel;
            array_map( function($s) use(&$sel){
                if(empty($s) || strpos($s, '@') !== false){
                    //Skip @ at-rule
                } else {
                    //Skip pseudo-classes that can describe selectors for simplification (determine by $_s and target $s when deleting)
                    //Except for :not, the evaluation does not exclude elements with a single element.
                    $_s = $s;
                    if(strpos($s, '(') !== false){
                        $_s = preg_replace_callback( '`(:not|:where|:is|:has|:nth-child|:nth-last-child)((((?>[^()]+)|(?2))*))`', function($pseudo){
                                return ($pseudo[1] !== ':not' && strpos($pseudo[2], ',') === false)? preg_replace( '`(:not)((((?>[^()]+)|(?2))*))`', '', $pseudo[0]) : '';
                            }, str_replace("t", ',', $s) );
                    }

                    $offset = 0;
                    $maxlen = strlen($_s);
                    while($offset < $maxlen && preg_match( '`(?<id>#)(?<iname>[w-\%]+)|(?<class>.)(?<cname>[w-\%]+)|(?<attr>[)(?<atype>class|id|style|type)(?<mark>^|$|*)?=(?<astr>.+?)]|^(?<tag1>[w-\%]+)|[,s>+~()]|](?<tag2>[w-\%]+)`u', $_s, $item, PREG_OFFSET_CAPTURE, $offset)){
                        if(!empty($item['id'][0])){
                            $name = $item['iname'][0];
                            if(!isset(self::$jsaddlist[$name]) && !isset(self::$id[$name])){
                                $sel = preg_replace( '`(' . preg_quote($s) . ')(,|$)`u', '$2', $sel, 1 );
                                break;
                            }                             
                        } elseif(!empty($item['class'][0])){
                            $name = $item['cname'][0];
                            if(!isset(self::$jsaddlist[$name]) && !isset(self::$class[$name])){
                                $sel = preg_replace( '`(' . preg_quote($s) . ')(,|$)`u', '$2', $sel, 1 );
                                break;
                            }
                        } elseif(!empty($item['tag1'][0]) || !empty($item['tag2'][0])){
                            $name = strtolower( (empty($item['tag1'][0]))? $item['tag2'][0] : $item['tag1'][0] );
                            if(!isset(self::$jsaddlist[$name]) && !isset(self::$tag[$name])){
                                $sel = preg_replace( '`(' . preg_quote($s) . ')(,|$)`u', '$2', $sel, 1 );
                                break;
                            }                            
                        } elseif(!empty($item['attr'][0])){
                            $before = $after  = '';
                            if($item['mark'][0] == '^'){
                                $before = $item['mark'][0];
                            } elseif($item['mark'][0] == '
){
                                $after  = $item['mark'][0];
                            } elseif($item['mark'][0] != '*'){
                                $before = '^';
                                $after  = '
;
                            }
                            if ( strpos(self::$attrlist[ $item['atype'][0] ], $before. trim($item['astr'][0], ' '"') . $after ) === false){
                                $sel = preg_replace( '`(' . preg_quote($s) . ')(,|$)`u', '$2', $sel, 1 );
                                break;
                            }                            
                        }
                        $offset = $item[0][1] + strlen($item[0][0]);
                    }
                }                    
            }, explode(',', $_sel));
            if(!empty($sel)){
                $sel = trim( preg_replace_callback( '`(,|s){2,}`su', function($_s){ return $_s[1]; }, $sel ), ' ,');                  
            }
          
            if(empty($sel) || empty($pv)){
                $result = $before . $after;
            } elseif(strpos($sel, '@') !== false && preg_match("#@(-[w]+-)?(keyframes|counter-style)s(.+)$#", $sel, $aid)) {
                self::$attrlist['atref'][$aid[3]] = $aid[2];                            
                $result = $before . $sel . '{'. $pv .'}' . $after;
            } elseif(strpos($pv, '{') !== false) {
                $nest = preg_replace_callback( '`([^{]*?)({((?>[^{}]+)|(?2))*})`', function($_css){ return self::tree_shaking($_css[0]); }, $pv );                        
                if(empty($nest)){
                    $result = $before . $after;
                } else {
                    $result = $before . $sel . '{'. $nest .'}'  . $after;
                }
            } else {
                $result = $before . $sel . '{'. $pv .'}' . $after;
            }
            return $result;
        }, $css);
        return $mincss;
    }

    //Delete unused variable definitions (implemented after executing tree_shaking)
    //Note that if the CSS file is split, even the variable definitions used may be deleted.
    public static function tree_shaking4var($css) {
        $varlist = (preg_match_all( "/var((--[^s),]+?)[s),]/u", $css, $vmatches))? array_flip( $vmatches[1] ) : array();
        if(!empty(self::$attrlist['style'])){
            $inline  = (preg_match_all( "/var((--[^s),]+?)[s),]/u", self::$attrlist['style'], $vmatches))? array_flip( $vmatches[1] ) : array();
            $varlist = array_merge( $varlist, $inline);
        }
        return preg_replace_callback( '`(--[w-]+?):.*?(url([^)]+?).*?)?([;}])`u', function($match) use($varlist) {
            if(!isset($varlist[ trim($match[1]) ])){
                $match[0] = ($match[3] === '}')? '}' : '';
            }
            return $match[0];
        }, $css);            
    }

    /*=============================================================
     * Simple minification that just removes comments, line breaks, whitespace, etc. in CSS
     */
    public static function simple_minify( $css ) {
        $css = preg_replace('`/*[^*]**+([^/][^*]**+)*/`', '', $css );
        $css = preg_replace('`s{2,}`su', ' ', str_replace(array("r", "n", "t"), ' ', $css));
        return preg_replace_callback('`(s(?<fs>[{}=,)/;>])|(?<rs>[{}:=,(/!;])s)`su', function($_s) { return (!empty($_s['fs']))? $_s['fs'] : $_s['rs']; }, $css);
    }

    /*=============================================================
     * Remove CSS data including unused id / class / tag
     * 
     * $css         : CSS for tree shaking (simple_minified normalized CSS data)
     * $html        : HTML of the target page
     * $jsaddlist   : Register IDs and classes added by JS after DOM loading (excludes tree shaking)
     * $varminify   : Remove unused CSS variable definitions
     * $atrefminify : Remove unused referrer definitions such as @atrule keyframes 
     */
    public static function extended_minify($css, $html, $jsaddlist=array(), $varminify=false, $atrefminify=false) {

        self::$jsaddlist = (!empty($jsaddlist))? array_flip( $jsaddlist ) : array(); 
        
        //Extract tag, id, class from HTML
        if(empty(self::$is_parse)){
            self::$is_parse = true;
            self::$id    = array();
            self::$class = array();
            self::$style = array();
            self::$type  = array();
            self::$tag   = array_flip( array('html','head','body','title','style','meta','link','script','noscript') );        
            
            preg_replace_callback( '`<style(?<catrb>[^>]*?)>|<script(?<satrb>[^>]*?)>|<(?<tag>[w-]+)(?<tatrb>[^>]*?)>`s', function($item){
                $atrb = '';
                if(!empty($item['catrb'])) {
                    $atrb = trim($item['catrb']);
                } elseif(!empty($item['satrb'])) {
                    $atrb = trim($item['satrb']);
                } elseif(!empty($item['tag'])){
                    $atrb = trim($item['tatrb']);
                    self::$tag[strtolower($item['tag'])] = 1;
                }
                if(!empty($atrb)){
                    preg_replace_callback( '`(id|class|style|type)s?=s?(['"])(.+?)2`u', function($_atrb){
                        $sep = ($_atrb[1] === 'style')? ';' : ' ';
                        array_map( function($_s) use($_atrb) { self::${$_atrb[1]}[trim($_s)] = 1; }, explode($sep, trim($_atrb[3], ' ;')));
                        return $_atrb[0];
                    }, $atrb );
                }
                return $item[0];                
            }, $html );
            //for id / css / inline style / type attribute selector matching
            self::$attrlist['id']    = '^' . implode("$^", array_keys(self::$id)) . '
;
            self::$attrlist['class'] = '^' . implode("$^", array_keys(self::$class)) . '
;            
            self::$attrlist['style'] = '^' . implode("$^", array_keys(self::$style)) . '
;            
            self::$attrlist['type']  = '^' . implode("$^", array_keys(self::$type)) . '
;            
        }
        
        if( substr_count($css, '{') === substr_count($css, '}') ){
            $css = preg_replace_callback( '`([^{]*?)({((?>[^{}]+)|(?2))*})`', function($_css){ return self::tree_shaking($_css[0]); }, $css );            
            if($atrefminify && !empty(self::$attrlist['atref'])){
                foreach(self::$attrlist['atref'] as $aid => $type){
                    if(!preg_match("`:$aid(s|;)`", $css)){
                        $css = preg_replace("`@((-[w]+-)?$types$aid)({((?>[^{}]+)|(?3))*})`", '', $css, 1);
                    }
                }                
            }
            if($varminify){
                $css = self::tree_shaking4var( $css );
            }
        }
        return $css;
    }
}

正規表現による処理を多用しているのでちょっと解りづらいかもしれませんが、使うのは簡単です。上記コードを適当なファイル名(とりあえず css-tree-shaking.php )で保存します

次に、テーマの function.php 等でライブラリファイルを配置したパスを指定して読み込みます

require_once get_template_directory() . '/css-tree-shaking.php';

次に 、テンプレート等のCSSファイルを読み込んでいる処理あたりで、縮小化したいCSSコード(styleタグを含まない中身のみ)を simple_minify で不要なコメントや改行、空白などを取り除きます

次がいよいよ tree shaking で simple_minify っ済みの style データとそのページのHTMLデータを以下のように指定して実行すると使われていない id, class, tag のCSSデータを取り除きます

$style = celtislabv3_0CSS_tree_shaking::extended_minify($style, $html);

これで渡した CSSデータがツリーシェイキングされ縮小化したCSSデータに変換されます

JS によりDOMロード後に追加される ID や class 等がある場合は、それを事前に指定して除外されないよいうにしてください。また、使用していないCSS変数定義を取り除くことも出来ます

AMPで使用する場合にはさらに !important を取り除く処理等も必要となります

多くのケースで 10K 以上の不要コードを取り除くことが期待でき、1/4以下に縮小化できる場合もあります。より詳細な実装は、私の開発している Jewelbeetle テーマ及び、YSASKANI Cache プラグインを参照してみてください。
Google Lighthouse のパーフォーマンスにどのぐらい効果があるかをぜひ確かめてみてください (^^)

WordPressテーマ : Jewelbeetle
WordPress ハイブリッドテーマ Jewelbeetl について紹介しています
WordPressテーマ : Jewelbeetle
WordPress Plugin : YASAKANI Cache
WordPress が超高速になるシンプルで使いやすいページキャッシュプラグインです。簡易的なアクセスログ、統計情報、セキュリティ機能もありとっても便利です (^^)
WordPress Plugin : YASAKANI Cache

プログラムは、GPLライセンスの元で活用して頂いて良いのですが、WordPress のプラグインやテーマにカスタマイズして組み込まれる場合は、PHPの名前空間(namespace)をオリジナルの celtislabv3_0 から変更して名前空間が YASAKANI Cache プラグインと衝突しないようにしてください

以上

PHPを使ったシンプルな CSS Tree Shaking の方法について紹介いたしました


まとめ記事紹介

go-to-top