WordPress / Performance

WordPressからjQueryを外したら、9日前から壊れていた機能が出てきました

50代クラウドエンジニアのterralienです。最近このサイトで「昔はJavaScriptが必要だったUIが、今はCSSだけで書ける」というのを並べて遊んでいたんですが、遊んでいるうちに欲が出ました。じゃあ実際に運用している媒体から、JavaScriptを引き剥がしたらどうなるのか。ちょうど手元に、自分で回しているWordPress媒体(以下MT)があります。実験台としては申し分ないですよね。自分のサイトですし。

結果から言うと、外部リクエストは137本から82本まで減りました。減ったのはいいんですが、途中で2回サイトを全面停止させ、しかも9日前から壊れていた機能を掘り当てました。順番にいきます。

作業台の上のミニチュアジオラマ。黄色いヘルメットをかぶった小さな作業員たちが、積みすぎた手押し台車から木箱を一つずつ降ろしている
やったことはこれだけです。積みすぎた荷物を、一箱ずつ降ろして数えました。

まず測る。「たぶん重い」で外さない

方眼紙の上を真上から撮った静物。真鍮の天秤、ノギス、機械式のカウンター、番号を振った小さな紙のタグの束が並んでいる
「たぶん重い」は当たっていましたが、重かったのは思っていた場所ではありませんでした。

いきなりjQueryを消しにいく前に、そのページが何本のリクエストを投げているのかを数えました。ブラウザの performance.getEntriesByType('resource') をホスト別に集計するだけです。これが出発点になりました。

出どころ本数中身
自サイト44画像30 / CSS 13 / JS 1
相互送客のブログパーツ26枠ごとに外部JS+画像
ランキングサイトのパーツ20他ブログのサムネ17枚ほか
広告10収益源。触らない
ソーシャルボタン・動画サムネほか17—
合計137うち外部が93本(68%)

ここで最初の発見がありました。jQueryは確かにレンダーブロッキングで、gzipで31KBあります。外す価値はある。でもその隣で、外部のブログパーツが46本飛んでいました。桁が違うんですよね。「重いのはJSだろう」という思い込みで始めていたら、たぶん本命を素通りしていました。

移植したのは「実際に使われている機能」だけ

使っているテーマ(Cocoon)の javascript.js は、全体が (function($){…})(jQuery) で囲まれています。つまりこのファイルが生きている限りjQueryは外せません。中身を素のJavaScriptに書き直すことにしました。

ただ、全部を移植する必要はないはずです。そこで、公開済み記事3,324本を走査して「その機能が実際に呼ばれる要素が本文に存在するか」を数えました。

機能実体判断
画像ライトボックス(baguetteBox)436件移植する
TOPへ戻る / メニュー追従 / コピーボタン全ページ移植する
FAQアコーディオン0件落とす
Google検索ボタン・目次ジャンプ・カルーセル0件落とす
アーカイブ/カテゴリのセレクト遷移あり落とす(WordPress本体が同じ処理を出していて二重バインドだった)

FAQアコーディオンは危なかったです。CSSの定義が43箇所あったので「使われている」と数えかけました。使われているのはスタイルの定義であって、記事本文に該当要素は1つもありませんでした。定義の数と実体の数は別なんですよね。

ついでに1つバグを踏みました。コピーボタンの元コードがこうなっていたんです。

navigator.clipboard.writeText($(selector).attr('data-clipboard-text'))

jQueryの $(selector) は集合を返し、.attr() はその先頭の値を読みます。記事ページにはコピーボタンが3つあるので、どれを押しても1個目の文字列がコピーされていました。移植のついでに、押された要素自身から読むように直しました。手で書き直すと、こういうのが出てきますね。

jQueryは wp_dequeue_script() では外れませんでした

普通はこう書きます。依存関係を見てくれる行儀のいいやり方です。

wp_dequeue_script( 'jquery' );
wp_dequeue_script( 'jquery-core' );

これで親テーマのJSと初期化インラインは消えました。ところがjQuery本体だけが残りました。テーマがjQueryをCDNから直接読ませる設定になっていると、登録情報を書き換える方向のアプローチが届かないんですね。出力されるタグそのものを落としました。

add_filter( 'script_loader_tag', function( $tag, $handle ) {
	if ( is_admin() ) { return $tag; }
	if ( 'jquery' === $handle || 'jquery-core' === $handle ) { return ''; }
	return $tag;
}, 10, 2 );

この順番は逆にしないほうがいいです。

  • wp_dequeue_script() は依存を尊重するので、jQueryを必要とするプラグインが1つでもあればjQueryは残ります。黙って壊れないのが利点
  • script_loader_tag で消すのは問答無用です。jQueryを使うインラインが1つも残っていないことを確認してから使うもの

実際、確認は機械でやりました。撤去後のHTMLに jQuery または $( を使うインラインスクリプトが0件、window.jQuery が undefined、それでいて baguetteBox はオブジェクトとして生きている。ここまで見て初めて「外れた」と言えます。

途中で2回、サイトを全面停止させました

ここからが本題みたいなものです。作業と並行して、別の作業でウィジェットの中身をREST API経由で書き換えていました。その書き込みで、サイトが2回落ちました。トップも記事も、REST APIもRSSも、全部500です。

原因はテーマ独自の「ウィジェット表示条件」フィールドでした。ここが型で2種類に分かれているんです。

木製の型はめ玩具のマクロ写真。丸い木の栓が合わない穴に無理やり押し込まれ、栓の角が欠けて木がささくれている
やっていたことは、要するにこれです。入らない形を押し込んで、受け側が割れました。
期待する型フィールド渡される先
配列カテゴリ / ページ / 投稿者 / カスタム投稿タイプin_array()
カンマ区切りの文字列投稿ID / 固定ページID / タグIDtrim()

WordPressのREST APIは instance.raw をJSONとフォームエンコードで往復させるので、PHPの型が保存されません。書き込みは成功します。エラーも返りません。壊れるのは次の描画のときです。

1回目は、配列であるべきものが '[]' という文字列になって in_array() が落ちました。そして2回目は、その対策として入れた「空でも配列で送る」を文字列フィールドにも当ててしまい、今度は trim() が落ちました。対策そのものが次の事故を作ったわけです。我ながら見事な往復ビンタでした。

ウィジェット1個で全滅する理由

この判定は wp_widgets_init、つまり init の段階で走ります。テーマの描画より前です。だからフロントもREST APIもRSSも同時に死にます。ウィジェット1個の型が違うだけでサイト全体が止まる作りになっているんですね。

直す層を変えました:書き込み側ではなく読み取り側

2回目のあと、DBを直して終わりにするのはやめました。まだ10個ぶん書き込む予定が残っていたので、直しても13回目で落ちます。

なので、読み取り側で型を吸収することにしました。option_widget_* フィルタで、テーマが設定を読む瞬間に型を揃えます。これならDBがどう壊れていても描画は通ります。

// 配列であるべきものは配列へ、文字列であるべきものは文字列へ。
// 配列→文字列に戻すときはカンマで繋ぎ直す(表示条件を勝手に消さない)
add_action( 'after_setup_theme', function () {
	foreach ( $bases as $base ) {
		add_filter( "option_widget_{$base}", 'normalize_display_conditions' );
	}
}, 0 );

ここでもう1回転びました。最初これを plugins_loaded に登録したんですが、テーマの functions.php が読まれるのは plugins_loaded が発火した後なんですよね。つまり永久に呼ばれません。after_setup_theme に移して通りました。

ただし、この対策には代償があります。

落ちなくなったぶん、型崩れに気付かなくなります。配列フィールドが文字列で来ると空配列に正規化される=表示条件が「全ページ表示」に変わる。500にならないので誰も気付きません。落ちない仕組みを入れたら、代わりに書き込み後の確認を義務にする、という交換です。

そして、9日前から壊れていたものが出てきました

一通り終わったあと、サイドバーの追従が効かないという指摘をもらいました。「JSを外したせいだろう」と思いますよね。私もそう思いました。

違いました。CSSでした。しかも9日前からです。

壁一面の付箋の接写。何枚かが剥がれて宙を落ちていく途中で、上のほうの一枚だけがまだしっかり貼り付いている
貼り付いているつもりのものが、9日間ずっと落ち続けていました。誰も気付いていません。
html, body, #container {
  overflow-x: hidden !important;  /* モバイルの横揺れ防止 */
}

CSSの仕様上、overflow-x が hidden で overflow-y が visible のとき、overflow-y の使用値は auto に変わります。computed値も hidden auto でした。これで body がスクロールコンテナになり、その中の position: sticky はビューポートではなくそのコンテナを基準にします。コンテナ自身はスクロールしないので、追従が一度も発火しません。

ブラウザ上でそのルールだけ差し替えて測りました。

指定スクロール6000pxでの位置追従横はみ出し
overflow-x: hiddentop −2329px(流れていく)✗なし
overflow-x: cliptop 0px(張り付く)✓なし

clip はスクロールコンテナを作らないので、はみ出しは止めたまま sticky が生きます。1単語です。

html, body, #container {
  overflow-x: hidden !important;  /* 非対応ブラウザ向けフォールバック */
}
@supports (overflow-x: clip) {
  html, body, #container {
    overflow-x: clip !important;
  }
}

これ、JSの作業をしていなければ見つかっていません。「JSを外したせいでは」と疑われたおかげで初めて追従を測ったので、9日間ずっと死んでいたことが分かりました。誰も気付いていなかったんですよね。私も含めて。

そして3回目は、CSSで落としました

ここまで書いておいて何なんですが、この記事を書いている最中に3回目をやりました。今度はCSSです。しかもスマホのトップページが全面真っ黒という、一番わかりやすいやつです。

客席から見た劇場の舞台。黒い緞帳が誤って下まで降りきっていて、下端の隙間から明るい舞台照明が細く漏れている
中身は生きています。上に蓋がしてあっただけでした。

ついでにモバイルのタップ領域も広げていたんですね。上部の固定メニューのボタンが、枠は63x50pxあるのに反応するのは35.6x39.2pxしかなかったので、こう書きました。

.mobile-menu-buttons > li > a,
.mobile-menu-buttons > li > label {
  display: flex !important;
  width: 100%; height: 100%;
}

コメントには「6ボタンすべて」と書いていました。実際に li の直下にあったのは7要素でした。数えていなかったんです。

要素idclass
a—menu-button-in
a—go-to-top-common … menu-button-in
labelshare-menu-openmenu-open menu-button-in
labelshare-menu-closedisplay-none
a—menu-button-in
labelsidebar-menu-openmenu-open menu-button-in
labelsidebar-menu-closedisplay-none

この -close の2枚は、テーマが display: none で隠している全画面の黒い幕です。position: fixed で画面いっぱい、背景が黒、z-index: 99。メニューを開いたときだけ #…-input:checked ~ #…-close で出てくる、よくある作りですね。

私の !important が、その display: none を打ち消しました。閉じたままの黒い幕が2枚、常時全画面で乗った状態です。中身は生きているんですが、上に蓋がしてあるので真っ黒でした。

直すのは1語です。実ボタン5つは全部 menu-button-in を持っていて、黒幕2枚は持っていません。

.mobile-menu-buttons > li > a.menu-button-in,
.mobile-menu-buttons > li > label.menu-button-in {

除外側ではなく、許可側で書いています。

:not([id$="-menu-close"]) でも同じ結果になりますが、それだとテーマが将来ほかの黒幕を足したとき、また巻き込みます。「当てていいものの条件」で書いておけば、知らない要素が増えても勝手には拾いません。

なお、直したあと私はもう一度「まだ黒い」と誤診しました。キャッシュです。ページのHTMLがキャッシュされていて古いCSSのURLを指したままだったので、CSSは直っているのに画面は黒いまま。しかも ?nocache= を付けて取りにいくと、ページが実際に読んでいるものより古い内容が返ってきました(4,577バイト対9,275バイト)。キャッシュバスターを付けたほうが古い、という状況があるんですね。ページ内の <link> が持っているURLをそのまま叩くのが正解でした。

結果

朝の光が差す板張りの床に並んだ2つのスーツケース。片方は巨大でベルトで無理やり留められ、もう片方は小さく整然と閉じている
入っている物はほぼ同じです。違うのは、要らない物を降ろしたかどうかだけでした。
指標作業前作業後
トップページの総リクエスト137本82本
うち外部93本38本
うち広告以外の外部84本13本
jQuery(gzip)31KB+レンダーブロッキング0
サイドバー追従死んでいた(9日間)動く
コピーボタン3つとも1個目をコピー直った
失われた機能—なし

面白かったのは、「JSを外す」という作業が、JSと無関係な劣化を2つ掘り出したことです。9日前から死んでいたサイドバー追従と、ずっと間違った文字列をコピーしていたボタン。どちらも、既存の動作を1つずつ確かめないと出てきませんでした。速くするために測っていたら、壊れていたものが見つかったという順番ですね。

で、JSはもう要らないのか

半分そうで、半分違う、というのが正直なところです。線引きははっきりしていて、「見え方の制御」はCSS側にほぼ移った。「状態とデータの管理」は今もJSの仕事。今回jQueryを丸ごと外せたのは、この媒体でやっていたことが全部前者だったからです。

かつてJSが必要だったもの今のCSS
サイドバー追従position: sticky
アコーディオン・FAQ<details>
モーダル・ツールチップ<dialog> / Popover API
親の状態で子を変える:has()
画面幅ではなく要素幅で切り替えContainer Queries
ページ遷移アニメーションView Transitions

つまり「JSが要らなくなった」のではなく、「JSでやる必要のなかったものが、ようやくCSSに返ってきた」のほうが近いです。ログイン状態やサーバとのやり取りは、当然これからもJSの領分です。

…と、賢そうな整理をした後でなんですが。「CSSで足りる」と主張する記事を書いておいて、最後にサイトを落としたのはCSSでした。しかもセレクタが1クラスぶん広かっただけです。JavaScriptを31KB外すのに、その何倍もの時間を型とセレクタに溶かしました。

道具が軽いことと、書いた人間が間違えないことは、まったく別なんですよね。CSSはたしかにJSより短く書けるんですが、短く書けるということは、1語間違えたときの効き方も広いということでした。これだからやめられません。

参考