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 / タグID | trim() |
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日前からです。
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: hidden | top −2329px(流れていく) | ✗ | なし |
overflow-x: clip | top 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要素でした。数えていなかったんです。
| 要素 | id | class |
|---|---|---|
| a | — | menu-button-in |
| a | — | go-to-top-common … menu-button-in |
| label | share-menu-open | menu-open menu-button-in |
| label | share-menu-close | display-none |
| a | — | menu-button-in |
| label | sidebar-menu-open | menu-open menu-button-in |
| label | sidebar-menu-close | display-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をそのまま叩くのが正解でした。
結果
| 指標 | 作業前 | 作業後 |
|---|---|---|
| トップページの総リクエスト | 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語間違えたときの効き方も広いということでした。これだからやめられません。
参考
- CSS Overflow Module Level 3 (W3C) —
overflow-xとoverflow-yの片方だけがvisibleのとき使用値が変わる規定 - overflow - CSS (MDN) —
clipとhiddenの違い(スクロールコンテナを作るかどうか) - position - CSS (MDN) —
stickyがどの祖先を基準にするか - script_loader_tag (WordPress Developer Resources)
- wp_dequeue_script() (WordPress Developer Resources)