iframe から、丸型の共有ボタンと「いいね」へ。Promari SNS Share を公開しました。
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの設計を、用語のポップアップと身近なたとえでたどります。表示・保存・計測・配信の内容を省略せず、四層の責任と設計判断を詳しく解説。全5ページ、連載予定は16章。
ボタンを増やすたびに、全部直したくない
WordPressの外へも持ち出せる部品にする
それでは、ここからはボタンの中をもう少し詳しく見ていきましょう。今回のプラグインは <promari-sns-share> というWeb Componentです。自分で付けた名前のHTML要素を、ブラウザへ登録して使います。せっかく作るのなら、WordPressだけでなく普通のHTMLにも置いて使いたい。そう思って、この形を選びました。丸型の表示は variant="circle" で指定します。内部はShadow DOMで描くため、テーマ側のボタン用CSSと不用意にぶつかりにくくなっています。
ここまでは、部品がページの中でどう振る舞うかを見てきました。次は、部品の中のコードをどう整理したかのお話です。その前に、一つだけ考え方をご紹介させてください。プログラムを役割ごとの層に分け、層どうしが頼り合う向きを先に決めておく設計を、レイヤードアーキテクチャと呼びます。今回のブラウザ側のソースも、この考え方で四つの層に分けています。
なぜ分けるのかは、分けない場合を考えると見えてきます。小さな共有ボタンでも、中ではいくつもの仕事が動いています。どの共有先を出し、どんなURLを作るかを決める判断。コピーを頼み、完了を待って、結果を案内する手順。クリップボードなど、ブラウザの機能を呼び出す処理。そして、ボタンや件数を描く表示です。これらが一つの処理に混ざっていると、ボタンの色を変えたいだけなのに、URLの組み立てやコピーの成功条件まで読み直すことになります。触るつもりのなかった場所が壊れていないか、毎回心配しなければなりません。
層に分けると、この状況が三つの点で変わります。一つ目は、変更の影響が届く範囲を絞れることです。変える理由ごとに置き場所が分かれているので、見た目を変えるなら表示の層、共有URLの形式が変わったら判断の層、というように、直し始める場所を要求から決められます。二つ目は、確かめやすくなることです。判断の層がブラウザを知らなければ、共有URLの組み立てはブラウザなしのテストで確かめられます。ブラウザとの接続を差し替えられるようにしておけば、わざと失敗する代役を渡して、コピーに失敗したときの案内も試せます。三つ目は、外側の道具を入れ替えやすくなることです。ブラウザAPIの呼び方や表示の方法が変わっても、共有の決まりそのものは書き直さずに済みます。
ただし、フォルダーを分けて処理を移すだけでは、この三つの効果は得られません。効き目を左右するのは、層どうしが頼り合う向きです。今回は、判断の層は他の層を参照しません。手順の層は判断の層だけを使います。ブラウザとの接続の層は、判断の層が持つ約束(インターフェース)を実装し、表示の層は手順の層を通して処理を頼みます。内側の決まりが外側の都合を知らないので、外側を変えても内側へ影響が返ってきません。この向きをなぜ選んだのかは、図を見たあとで改めてご紹介します。
では、なぜ四つなのでしょうか。層の数は決まっているものではなく、その機能にどんな種類の仕事があるかで決めます。今回の共有部品には、先ほどの判断・手順・ブラウザとの接続・表示という四種類の仕事がありました。そこで、層構造でよく使われる名前に当てはめて、判断をdomain、手順をapplication、ブラウザとの接続をinfrastructure、表示をpresentationという四つの層にしています。
一方で、層を分けることには負担もあります。受け渡しのためのコードが増え、一つの処理を追うのに複数のファイルを開くことになります。小さく単純な機能なら、もっと少ない区分で十分なこともあります。今回は、共有の判断、非同期の操作手順、ブラウザへの接続、表示を、それぞれ別に変更し、確かめたかったので、この負担を引き受けて四つに分けました。
それでは、四つの層がそれぞれ何を受け持ち、今回のコードではどの処理が当てはまるのかを見ていきましょう。変更したい内容ごとに最初に調べる場所は、図3.1-2のあとで表にまとめています。
domainは、その機能が扱う意味や判断のルールを置く層です。画面の描き方や外部の道具の使い方から切り離して、『何を選ぶか』『どういう結果にするか』を決めます。今回なら、共有先の選択、共有URLの組み立て、操作がコピーかリンク移動かといった判断が当てはまります。DDDの考え方に沿って、共有内容や共有先を表す値オブジェクト、判断をまとめたドメインサービス、そして外側に求める約束であるリポジトリとゲートウェイのインターフェースを、この層に置いています。
applicationは、一つの操作を完了させるための手順を組み立てる層です。domainの判断を使い、必要な処理を依頼し、その結果に応じて次へ進めます。今回なら、表示に必要なデータをそろえることや、『コピーを依頼する→完了を待つ→結果を返す』という手順が当てはまります。実際にどのブラウザAPIを呼ぶかは、domainの約束を実装した外側へ任せます。
infrastructureは、ブラウザや通信など、外部の具体的な仕組みと接続する層です。domainが求める操作を、実際の環境で動く処理へつなぎます。今回なら、生成済みの定義から共有先を返すリポジトリ、クリップボードへの書き込み、端末の共有機能の呼び出しが当てはまります。どれも、domainが持つインターフェースの実装です。
presentationは、利用者へ情報を表示し、操作を受け付ける層です。手順や判断の結果を画面へ反映し、利用者の入力を処理につなぎます。今回なら、HTML属性を設定値として読み取ること、丸いボタンや件数の描画、クリックの受付、操作の結果を受けて出す『コピーしました』という案内が当てはまります。
共有先は destinations/ に1つ1ファイルのデータ(TOML)で宣言し、設定の生成スクリプトがそれを読み込んでWeb Component用の定義へ書き出します。データは送り先と渡す項目を宣言するだけで、共有URLを組み立てる処理はブラウザ側が担います。共有先を増やす際はデータファイルと設定を更新し、生成物とテストの整合を確認します。
ここから図3.1-2・3.1.1-1・3.1.1-2・3.1.1-4では、TypeScriptの四層構造、レイヤード+DDDの依存方向とデータファイルとブラウザ側の役割分担を見ていきます。共有URLはブラウザ側で組み立て、共有先のデータファイルが「どこへ、どの項目を送るか」を宣言します。四つの名前に加えて「どちらのコードが、どちらを知っているか」を見ます。担当の箱、依存の矢印、実行の順番を分けて追ってみましょう。
実装の根拠:共有先・操作の判断
この分担を、飲食店の仕事に置き換えてみます。domainは「何を注文でき、どう組み合わせるか」という決まり、applicationは注文から提供までの手順、infrastructureは調理機器や配達手段、presentationはメニューと受け渡し口です。設備を替えたとき、料理の組み合わせの決まりまで毎回直すのは避けたいですよね。コードでも、何が変わったときに直す部分なのかを基準にします。
たとえば共有先のURL形式が変わったら、そのサービスのURL生成と期待値を見直します。コピーに使うブラウザ機能が変わったら、外側の接続実装を調べます。丸型ボタンの余白を変えるなら描画とスタイルを調べます。いいねの保存先を変えるなら、サイト側の接続が同じ確定状態を返せるか確認します。「どこから直し始めるか」が要求から分かることが、分ける利点です。
| 変更したいこと | 最初に調べる場所 | 保ちたい約束 |
|---|---|---|
| 共有先やURLの選び方 | domainとサービス定義 | 同じ入力に対する判断とURLの意味 |
| コピー成功・失敗後の手順 | application | 結果を待って正しい案内へ進む順序 |
| ブラウザ機能の呼び方 | infrastructure | 必要な能力と失敗の返し方 |
| 色・件数・操作中の表示 | presentation | 公開属性・メソッド・イベントの意味 |
| いいねの保存先 | サイト側の接続と保存API | 確定したlikedと件数を部品へ返すこと |
- 表示
- プラグインが共有ボタン、追加メニュー、いいね、件数、操作結果を描画します。
- 保存
- サイト側がいいねの要求を受け取り、サーバーで確定した状態を返します。
- 計測
- 共有イベントをサイト側で受け取り、同意設定に従ってアクセス解析へ渡します。
Web Componentsは、独自のHTML要素をブラウザへ登録して再利用するための仕組みです。Custom Elementsが要素の名前とライフサイクルを扱い、Shadow DOMが内部のDOMとスタイルの範囲を分けます。Reactなど特定のUIフレームワークをホスト側へ要求せず、同じ要素を静的HTMLにもWordPressにも配置できることが採用理由でした。 まず、依存という言葉の意味を確認しましょう。設計ポイント:フレームワークに依存しない部品
Shadow DOMはセキュリティの隔離環境ではありません。同じページのJavaScriptからのアクセスを完全に遮断する仕組みでもなく、CSSカスタムプロパティなど継承する値もあります。テーマとの偶発的なスタイル衝突を減らす境界として使います。また、独自要素の登録前にメソッドを呼べば利用できないため、連携コードではcustomElements.whenDefinedで登録の完了を待ちます。読み込み順を偶然に任せないためです。
Custom Elementで気になるのは、HTMLとJavaScriptの準備が同時ではないところです。ホスト側がsetLikeState()を呼ぶ時点で、まだ要素がアップグレードされていない可能性があります。そこでcustomElements.whenDefined()を待ちます。ただし、登録完了は保存APIの初期値取得まで保証しません。要素を呼べる状態と、操作を許可できる状態は別。この二段階を一緒にすると、回線が遅いときだけ再現する不具合になりやすいんです。
待つ対象を二つに分けると、何の準備が終わったのかを整理できます。部品の登録を待つのは、メソッドを呼べるようにするため。保存APIへ問い合わせるのは、件数と選択状態を知るためです。図3.1-3は、この確認の順番を示しています。初期取得に失敗したときは、件数を0にせず未取得の「—」のままにして、エラー案内を出します。0を出すのは、サーバーが0件と返したときだけです。この区別は図3.5.1-1・4.1-3で扱います。
ライフサイクルは、部品が生まれ、画面につながり、外されるまでの節目です。画面につながるたびにイベントを登録し、外すときに後始末をしなければ、同じ操作へ何度も反応する原因になります。SPAのようにページ全体を読み直さず画面を切り替える環境では、特に見落としたくないところです。一度表示できたかだけでなく、「外して、もう一度置いたらどうなるか」まで試します。
Shadow DOMで内部のCSSを閉じるなら、外へ何を公開するかもセットで考えます。CSSカスタムプロパティや::partは調整用の入口になりますが、公開すれば利用側が依存するインターフェースにもなります。「見た目だけだから自由に変えていい」とは言いにくくなるわけです。内部のDOM構造をそのままAPIにするより、色や表示バリエーションなど、意図の分かる単位で渡したい。今回の部品も::partは公開しておらず、外から変えられるのは、variantで選ぶ表示の種類や、標準の型で使うaccentなどの属性に絞っています。丸型の配色は部品の中で固定です。また、要素へ--accentを指定すると色が変わってしまう経路も閉じ、属性だけを色の入口にしました。隔離の強さだけでなく、後から変えられる範囲を残すための設計です。
今回のサイトでは、共有欄の見出しは記事側、操作部品はプラグイン側という分担で扱えます。どちらが読者へ何を伝えるかを決めておけば、部品の内部構造を無理にのぞき込んで装飾する必要が減ります。再利用しやすい部品とは、外から触ってよい場所が分かる部品でもあるんですね。別の場所で使いたくなったときにも、この分かりやすさが助けになります。
その変更は、なぜ隣の機能まで壊すのか
ここで、先ほど図3.1-2で並べた四つの層へ話を戻します。domain、application、infrastructure、presentationときれいに名前が並ぶと、それだけで整理できた気になってしまいますよね。ただ、フォルダーを分けても、処理どうしのつながりまで自動で変わるわけではありません。私が見たいのは「URL生成のテストにブラウザが必要か」「保存先を変えると描画コードまで変わるか」です。図の上できれいに分かれているかだけでなく、実際に差し替えたらどこまで直すことになるのか。ここまで見ると、分けた意味が分かってきます。
各層の役割は、冒頭で見たとおりです。ここで確かめたいのは、その役割どおりに変更が閉じるかどうかです。たとえばClipboard APIの呼び方を変えるとき、共有URLの判断まで直すことにならないか。共有URLの判断を直すとき、DOM操作まで変える必要がないか。ブラウザを起動せずに確認できるルールはどれか。ファイルを4つのフォルダーへ置けば完成ではありません。こうした変更と検証の単位で、境界を確かめます。
実際のコピー操作を一往復でたどると、境界がはっきりします。presentationが押されたボタンを特定し、applicationへ渡します。domainの判断でコピーを選び、applicationがdomainのコピーのインターフェースを呼び、そのインターフェースを実装したinfrastructureがブラウザへ書き込みます。完了したらapplicationが結果を返し、presentationが成功案内を出します。拒否されたら手動コピーの案内へ進みます。画面に「コピーしました」と出るのは、入口を押した直後ではなく、書き込みが成功した後です。
この実行順と、ソースコードの依存の向きは分けて考えます。applicationは、domainが定める「コピーできる道具」のインターフェースを使い、外側のinfrastructureがそのインターフェースを実装します。実行するときに外側を呼ぶからといって、内側がnavigator.clipboardの具体的な使い方まで知る必要はありません。お店の注文票に料理名は書いても、どのコンロで作るかまでは書かないのと同じです。
実装の根拠:操作の手順とimport
もう少し中へ進んでみましょう。大事な判断をブラウザの具体的なAPIへ直接つなぐと、そのAPIを変えたいときに判断のコードまで直すことになります。必要な能力を先にインターフェースとして決め、その外側へ実装をつなげば、テストでは代わりの実装を渡せます。これが依存性逆転の考え方です。ただ、何でも抽象化すると、読むためにあちこちのファイルを行き来することにもなります。私も、小さな処理まで一律に分けるのではなく、変更や検証に意味のある境界なのかを考えるようにしています。
実装の根拠:ブラウザ機能への接続
レイヤーの話を、共有URLを作る場面へ戻してみましょう。「タイトルに含まれる&をどう扱うか」は、画面の色を決める仕事ではありません。「コピーする」ことを選んだとき、Clipboard APIをどう呼ぶかも、SNSごとのURLルールとは別です。変更の理由が違うものを離しておくと、ブラウザが使えないテスト環境でも、URLの判断だけを確かめられます。
Ports and Adaptersを使うなら、メソッドの形だけでなく、失敗の返し方までインターフェースで揃えたいところです。コピー処理なら、利用不可・権限拒否・成功を利用側へどう伝えるか。テスト用の実装だけいつも成功し、本番のアダプターだけ黙って失敗するなら、差し替え可能とは言えません。ポートは単にブラウザAPIを一対一で包むためのものではなく、中心の処理が必要とする振る舞いを固定する場所、と捉えています。
Clipboard APIの代わりにテストダブルを差し込めば、渡したURLと呼び出し回数、拒否時の分岐をブラウザなしで確かめられます。ただし、テストダブルが実装側の思い込みをそのまま再現していたら、両方まとめて間違います。そこでインターフェースの検証と、実ブラウザでの権限・ユーザー操作の確認を分けます。どちらかを増やせばもう片方が不要、とはならないところが面白いですね。速いテストには速いテストの担当があります。
フォルダーの名前より、依存の向きを見てみてください。ドメインのコードが画面のquerySelectorやWordPressの関数を直接呼んでいれば、置き場所がdomainでも環境へ強く結び付いています。逆に、すべてを別ファイルへ分ければよいわけでもありません。変更時に一緒に考えたいものは近くへ置き、環境に振り回されたくない判断を内側へ残す。私はその程度の具体的な問いから始めたいと思っています。
applicationは画面の通知を呼ばず、操作の結果(copiedなど)を返します。案内を描くのは、その結果を受け取ったpresentationです。具体的な道具を選んで渡すのは、四層の外に置いた組み立て役(DIコンテナ)です。presentationとinfrastructureは互いの具体実装をimportせず、infrastructureはdomainが持つインターフェースを実装することでつながります。フォルダーの分け方に加えて、内側が知る型と、外側の道具を選ぶ場所にも注目してみてください。
また、SNS固有のURL形式をどこに置くかは、扱う業務の範囲で変わります。今回は小さな共有部品の規則としてdomainに置いていますが、別の業務アプリでSNSが単なる外部連携先なら、その形式変換を接続側へ寄せる設計もあります。フォルダー名から唯一の正解を決めず、守りたい判断と変更されやすい詳細を見分けます。ロゴ・色・ラベルはapplicationの表示用インターフェースで扱います。共有先のリポジトリはdomainの値オブジェクトへ規則に必要な項目だけを渡し、表示用の情報はapplicationのShareButtonCatalogが後から結合します。domainは画面設定や表示メタデータを保持しません。
依存の向きを内側へ揃える考え方はClean Architectureの原著、外部の道具を差し替えて内側を検証する考え方はPorts and Adaptersの原著を参照しています。両者を、この実装が完全に達成したという意味で使っているわけではありません。
組み立て役は、四層を起動時につなぐ場所です。現在は、DIコンテナのInversifyJSを使うcompositionが、domainのインターフェースへブラウザ側の実装を結び付けています。入口のindex.tsはコンテナを作って独自要素を登録するだけで、四層のクラスはコンテナを知らず、これまでどおりコンストラクタで依存を受け取ります。業務判断を追加する第五層ではありません。domainはdomainのみ、applicationはapplicationとdomain、infrastructureは自身とdomain、presentationは自身とapplicationへの参照を許可しています。リポジトリとゲートウェイのインターフェースをdomainが持つので、infrastructureはapplicationを参照しません。この規則を型importや再exportも含めて検査します。さらにdomainとapplicationだけをDOM・Node.jsの型なしでコンパイルし、ElementやWindowを混ぜると失敗することも確かめています。名前を見て安心するより、戻してはいけない依存をテストで止めるほうが、次の改修でも境界を守れますね。
仕組みの定義は、HTML仕様のCustom Elements、whenDefined()、React公式の解説、Ports and Adaptersの原著を参照できます。
SOLIDは、小さな共有ボタンにも必要か
SOLIDは5つの設計原則をまとめた呼び名です。この部品に当てはめたときの意味で、1つずつ書いておきます。
- 単一責任(SRP):「クラスを小さくする」ことだけではなく、変更する理由を揃えること。
- 開放閉鎖(OCP):新しい振る舞いを足すとき、既存の判断をどれだけ変更せずに済ませられるか。
- リスコフの置換(LSP):インターフェースを満たす実装を差し替えても、利用側の前提が崩れないこと。
- インターフェース分離(ISP):利用しない能力まで依存させないこと。
- 依存性逆転(DIP):具体的な道具ではなく、必要なインターフェースへ依存させること。
では、その「インターフェース」をコードで見てみましょう。共有先は、1つ1ファイルのデータで、名前、ラベル、送り先、送る項目、アイコン、ブランド色、操作の種類の7項目を宣言します。必要な共有先だけは、投稿画面へ渡す下書きのひな形も書けます。この7つがそろっていれば、設定の生成スクリプトとWeb Componentは、X(旧Twitter)・LINE・Facebookといった共有先のどれか一つだけを特別扱いせず、同じ手順で処理できます。ブラウザ側でURLの規則として受け取るのは、このうち名前・操作の種類・送り先・送る項目の4つと、任意の下書きのひな形(draft)で、ShareDestinationSpecという型で表します。ラベル・アイコン・色は表示用として別に扱います。以下は公開実装と同じシグネチャです。import文も載せていますので、どの型へ依存しているかも合わせて確認できます。
この型で固定したいのは、利用する側が共有先ごとの事情へ踏み込まなくても必要な情報を取得できることです。どの共有先のデータから作られたかは知らなくてよい。一方で、戻り値の型だけ揃っても、URLを開く操作なのか、コピーなのかが分からなければ実行できません。そこで操作の種類も型へ含めています。共通化するほど、差分を表す情報は明示する。差分を消すことと、扱える形にすることは違います。
この分離が役立つのは、変更が入ったときです。Xの共有URLの決まりが変わったとして、LINEの定義やコピー処理まで修正するなら、関係のない仕事が絡み合っています。逆に、サービスごとの定義を直して、共通のインターフェースのテストで確認できれば、変更の影響を追いやすくなります。原則の略語を覚えるより、変更の前後で触るファイルと、確かめる振る舞いを比べるほうが実感できます。
SRPは「一つのクラスには一つの変更理由を」、OCPは「拡張を加えるとき、安定した判断をなるべく触らずに」。ここでいう一つの理由は、行数の少なさではありません。名前、色、URLが同じ共有サービスの仕様として一緒に変わるなら、まとめて理解できる良さがあります。何でも細分化して、意味が十個のファイルへ散らばると、かえって読みにくくなります。
下のコードでは、endpoint・paramsとactionの組み合わせに注目してください。送り先を持つサービスだけを前提にすると、endpointが空文字のCopyやNativeを足したところで利用側の分岐が破綻します。型として呼べること、戻り値がインターフェースの定めどおりであること、その操作に意味があること。今回の生成スクリプトは、送り先があるのに送る項目が無い定義や、送り先が無いのに項目がある定義を止め、テストで抽出結果を確かめています。Composeについては、送り先がhttps://で始まる投稿画面であることと、送る項目を書いていないことも確かめます。ただし「開く操作なのに送り先が空」という操作と送り先の組み合わせまでは、まだ検査していません。インターフェースを作って終わりにせず、確かめている範囲と残っている穴を分けておきます。
この先のコードに出るparamsは、どのクエリパラメーターへ、URLやタイトルなどページ側のどの項目を渡すかの対応表です。値の組み立てはデータ側では行わず、ブラウザ側が対応表に従って符号化とURL構築を担います。ブラウザ側ではShareRequestという依頼票に共有する内容をまとめます。また、ここでいうactionはWordPressのactionフックではなく、共有操作の種類を表す項目です。同じ綴りでも、どこで定義された名前かを確かめると読み違いを防げます。
import type { ShareAction } from './ShareAction.ts';
export type ShareRequestField = 'url' | 'title' | 'text' | 'via' | 'site' | 'hashtagsCsv' | 'draft';
export type DraftFormat = 'text' | 'html';
export interface DraftSpec {
readonly template: string;
readonly format: DraftFormat;
}
export interface ShareDestinationSpec {
readonly key: string;
readonly action: ShareAction;
readonly endpoint: string;
readonly params: Readonly<Record<string, ShareRequestField>>;
readonly draft?: DraftSpec;
}「ファイルを足せば終わり」にしない拡張のインターフェース
ここまで読むと、「では、データファイルを一つ足せば完成?」と思われるかもしれません。実は、もう少し作業が残っています。設定、生成物、テストも更新します。このあたりは、手で整える必要があるんですね。生成スクリプトはdestinationsディレクトリのデータファイルを自動で拾います。私が避けたいのは、追加のたびに既存サービスの定義や共有部品の判断まで直すことです。定義と設定を変更するのと、安定している分岐を何か所も変更するのでは、確認する範囲が違います。OCPを説明するなら、この差まで含めたいですね。
また、CopyやNativeはSNSのWebページへ飛ぶ操作ではありません。Composeも送り先のURLを持ちますが、記事の情報をURLで送らず、コピーを始め、その完了を待たずに投稿画面を開く処理を呼びます。共通のインターフェースへ載せる場合、利用側がすべての実装を「開くURLを持つもの」と思い込んでいないかを確かめます。ShareActionによって実行方法を分け、空のURLや利用できないAPIも含めて振る舞いを検証します。原則の名前を満たすより、実装を入れ替えたとき利用者に何が起こるかを確認することが重要です。
LSPをこの実装で考えると、問題は「同じ型のオブジェクトを渡せるか」だけではありません。利用側がすべてを外部URLとして開くなら、URLを持たないCopyはその前提を満たせません。空文字を返してごまかすと、エラーが利用側へ押し出されます。Open・Copy・Native・Composeを操作のインターフェースとして扱い、実行方法を選ぶところへ差分を集める。置換したときに守るべき事後条件も、操作ごとに確認します。
ISPとDIPも、実際に依存をたどると話が早いです。URLを組み立てる処理に保存の能力まで要求していないか。アプリケーションが必要な操作を、ブラウザAPIの具体名で直接固定していないか。抽象化を増やすこと自体が目的ではありません。テストで代替できる、変更の波及を止められる、といった利点がある境界に絞ります。小さな処理のたびにファイルを行き来する構造は、私も避けたいところです。
5つの原則を別々の採点項目にするより、一つの変更で試すと理解しやすくなります。「新しい共有先を足す」とき、関係する定義だけを変更できるか、古い利用側が同じインターフェースで動くか、不要な保存機能を実装させられないか、ブラウザ抜きでURLを検証できるかを見ます。略語は、その問いへ戻るための見出しとして使います。
生成スクリプトがデータファイルを自動探索するため、何がどの順番で読み込まれるかは設定と生成物を見て確かめます。自動で見つかることと、意図した設定になっていることは別です。設定に手作業があるからOCP違反、と即断する必要はありません。重要なのは、共有先を追加したとき既存サービスの判断まで毎回書き換える構造になっていないかです。
新しい共有先を追加できたところで、前からあるボタンも一緒に見てみましょう。新しいボタンが出るだけでは、前からあるボタンのURLや並びが壊れていないとは言えません。未知のキーを渡した場合も含めて、どこで拒否されるかを決めておくと、設定ミスが画面まで流れ込みにくくなります。
タイトルに「&」が入っただけで壊れる理由
まずは、何も対策をしないとどうなるかを見ておきましょう。タイトルに「&」や「#」が入ったまま共有URLへつなぐと、図3.3-1のように、値の途中でURLが区切られてしまいます。
URLへつなぐ前に、文字の意味を守る
ここで少し、実験にお付き合いください。記事タイトルを TypeScript & PHP #1 に変えてみます。タイトルに記号を入れただけですが、これでURLの作り方の違いが見えてくるんです。
そのまま共有URLの末尾へつなげると、&は次のパラメーター、#はフラグメントとして解釈され、届けたかったタイトルが途中で別の意味になります。日本語や絵文字でも、URLの組み立てを曖昧にできません。データファイルのx.tomlは送り先と渡す項目を決めます。ブラウザ側では、PHPのrawurlencodeと同じ規則でキーと値を符号化し、URLを組み立てます。データファイルは定義だけを持ち、値が空文字のパラメーターは、ブラウザ側が省いて送りません。「このタイトルでも壊れない?」と入力を一つ変えるだけで、共通処理を切り出す理由が見えてきます。
この処理はURLの構文を守るためのものです。任意のURLを安全な共有先に変える機能ではなく、HTML出力時のエスケープとも別です。入力の許可、URLの組み立て、HTML属性への出力という各境界で、異なる問題に対応します。データファイルのx.tomlが決めるのは送り先と渡す項目だけで、URLの組み立てと符号化は、その定義を受け取ったブラウザ側が行います。受け取る側の型のコードは、次の3.3.2で見ていきます。
URLの組み立てで揃えておきたいのは、関数へ渡す値が未エンコードなのか、すでにエンコード済みなのかです。タイトルの&を%26へ変換する処理が二か所にあると、次は%2526になってしまいます。見た目では気付きにくいんですよね。入力は生の値、キーと値の符号化は共通のビルダーが一度だけ担当する。この役割分担を固定すると、サービスごとの定義へ余計な加工が散らばりません。
よくあるつまずきは、完成したURL全体をまとめて符号化することです。https:// や ? まで値の一部として変えてしまうと、URLとしての骨組みが崩れます。逆に、すでに符号化した値へもう一度同じ処理をすると、% がさらに符号化され、意図しない文字列になることもあります。どの時点では生の値で、どの時点からURLの一部なのかを、処理の境界で揃えます。
HTML属性へ出す段階では、今度は引用符や&がHTMLの構文として解釈されないようにします。URLの組み立てとHTMLのエスケープは順番も役割も違います。テストの期待値を見るときも、ブラウザが実際に開くURLと、HTMLソースに書かれた表現を混同しないようにしましょう。画面に & が見えたから、必ずURLが壊れているとは限りません。
試す値は、日本語だけでなく、空文字、半角スペース、&、#、絵文字、すでにクエリを持つ記事URLも用意します。普段のタイトル一つで成功した処理が、区切りを含むタイトルでも動くか。小さな入力の工夫が、共有先ごとの巨大な画面テストより早く不具合を見つけてくれることがあります。
変更できない・選べない形を、型で作る
「このオブジェクトの一部分だけ、後から変えられる?」。そうできたら便利そうですよね。ただ、後から読むときには、どこで何が変えられたのかも追わなくてはいけません。ブラウザ側のShareDestinationは、作った直後にObject.freezeで凍結し、送り先と対応表は#で始まる非公開の欄に持たせています。外から一部分だけ書き換えたり、覗いて別の値を差し込んだりはできません。共有先を増やすなら、このクラスを継承して一部分を置き換えるのではなく、データファイルを1つ足します。これはすべての継承が悪いという意味ではなく、共有先ごとの違いをデータへ集め、振る舞いを追いやすくする選択です。次のコードはShareDestinationの抜粋です。readonlyの欄、#の非公開の欄、最後のObject.freezeに注目してください。URLを組み立てるshareUrlなどのメソッドは省略しています。
export class ShareDestination {
readonly key: string;
readonly action: ShareAction;
readonly #endpoint: string;
readonly #params: Readonly<Record<string, ShareRequestField>>;
readonly #draft: DraftSpec | undefined;
constructor({ key, action, endpoint, params, draft }: ShareDestinationSpec) {
this.key = key;
this.action = action;
this.#endpoint = endpoint;
this.#params = params;
this.#draft = draft;
Object.freeze(this);
}
}readonlyのもう一つの例が、URL・タイトル・本文・ハッシュタグなどを持つ値オブジェクトのShareRequestです。readonlyの欄にすると、初期化後の再代入をコンパイルの時点で止められます。共有先ごとにURLを変えるwithUrlは、元の要求を変更せず新しい値を返します。ただしTypeScriptのreadonlyは型の上の約束で、実行時には止めません。そこでShareRequestもObject.freezeを併用しています。さらにfreezeは浅く、参照先の配列までは凍結しないため、ハッシュタグの配列は別に凍結しています。何を保持するかと合わせて考えます。
ShareActionはOpen・Copy・Native・Composeの4つだけを持つ、文字列の定数と型の組です。Composeは、記事を紹介する下書きのコピーと、書く場所(QiitaやZennなど)の投稿画面を開く処理を、クリックのその場で順に始める操作です。TypeScriptのenum構文ではなく、as constを付けた定数から、取り得る値の型を作っています。任意の文字列を渡すより、取り得る選択肢を限定できます。ただし型はコンパイル時の約束で、外から届いた文字列が正しいとは限りません。データファイルに書いた操作の種類は、生成スクリプトがopen・copy・native・composeのどれかであることを確かめ、それ以外は生成の時点で止めます。「綴りを間違えたら必ずどこかで止まる」と言えるのは、型と生成時の検査とテストが、それぞれ異なる時点で誤りを見つけるからです。定義は次のとおりです。各値が、HTMLのdata属性やイベントでやり取りする文字列(’open’・’copy’・’native’・’compose’)になります。
export const ShareAction = { Open: 'open', Copy: 'copy', Native: 'native', Compose: 'compose' } as const;
export type ShareAction = (typeof ShareAction)[keyof typeof ShareAction];考えてみる:readonlyなら、受け取った値は正しい?
書き換えられないことと、正しいことは別です。形式の違うURLでも、検証せずに不変の値へ入れれば、そのまま保持されます。外から受け取る時点で確かめ、内部では不変の値として扱う。型と検証が受け持つ仕事を分けると、「型を付けたから安心」の先へ進めます。
値オブジェクトという名前は大げさですが、入口はシンプルです。「この要求を表す値のまとまり」を作り、どの項目が一緒に動くのかを明確にします。URLとタイトルを毎回ばらばらの順番で渡すより、ShareRequestとして受け渡せば、その処理が何を扱っているか読み取りやすくなります。項目名を一文字間違える問題も、型や生成時の検査へ寄せられます。
ここでreadonlyが効く場面を考えてみましょう。同じ要求からX向け、LINE向けと順番にURLを作る途中で、最初のサービスが要求のURLを書き換えたら、次のサービスは書き換え後の値を受け取ってしまいます。元の値を保ち、変更版は別の値として返せば、実行順序に引きずられにくくなります。変更しない約束は、処理の前後を覚えておく負担を減らすためにも使えます。
選択肢を限定する型も、単に入力を短くする機能ではありません。「この操作は四種類のどれか」とコードへ書くと、五つ目の未知の操作が来たときに、どこで止めるかを考えられます。四つ目のComposeを足したときは、操作ごとの分岐にneverによる網羅の確認を置き、扱いを書き忘れた操作があればコンパイルの時点で止まるようにしました。外部からの文字列をそのまま信用せず、変換できなかった場合を入口で扱う。内部へ入った後は限定された選択肢として処理する。この順番が、後で紹介するunionや実行時検証にもつながります。
凍結・readonly・選択肢の限定を全部付ければよい、という話ではありません。差し替えてほしい場所まで凍結すれば、必要な拡張まで止めます。変化する状態を無理に一つの不変値へ押し込めれば、扱いづらくなる場合もあります。どの自由を残し、どの変更を禁止したいか。その意図を言葉にしてから、言語の機能を選びたいですね。
型の機能を試すなら、正常な例を一つ書いた後、わざと違う種類の値を渡してみてください。開発中の型検査で止まるのか、実行したときに例外になるのか、それとも何も起きずに不正な値が残るのか。発見できる時点の違いが見えてきます。型・静的解析・入力検証・テストは競争相手ではありません。同じ間違いを見ているようで、守っている場所が違います。どの道具が、いつ気付かせてくれるかを知っておくと、安心できる範囲も正確になります。
TypeScriptのreadonlyの範囲はTypeScript公式のオブジェクト型の解説、Object.freezeが浅い凍結であることはMDNのObject.freezeで確認できます。
型があるのに、なぜ実行時にも確かめるのか
型で形を固めたところで、次は実行時の話へ進みましょう。型でインターフェースを揃えても、ブラウザへ届くJSONまで型が保証してくれるわけではありません。コードを書くときに確かめられることと、実際にデータが届いてから確かめること。ここには違いがあるんですね。受け取った値へas LikeStateと書いた瞬間に、検証を済ませた気になってしまうのが、いちばん厄介です。asは「この値をLikeState型とみなす」とコンパイラへ伝える型アサーションで、LikeStateは、いいね済みかどうかと件数を表す型として、この先のコード例で定義します。
型を付けておけば、書いている段階で渡し間違いに気付きやすくなります。共有先のキーや操作を文字列のunionで表すと、扱い忘れた場合も見つけやすくなりますよ。ただし、型の情報はビルド後のJavaScriptでは基本的に消えます。HTML属性やJSON、CustomEventのdetailが予定どおりの形かどうかは、受け取る場所で別に確かめなければなりません。
たとえばAPIがcountを返すと決めても、障害時にHTMLのエラーページやnullが届く可能性があります。数値として妥当か、負になっていないか、likedが真偽値かを確かめてから画面の状態にします。型アサーションで「正しい型だ」と宣言しても入力は検証されません。内部では扱いやすい型に揃え、境界では失敗を明示する。この二段構えにしておくと、型で決めた約束を、実際に届いたデータにも守らせることができます。
型アサーションは変換でも検証でもありません。response.json()の結果をそのまま既知の型へ押し込めると、実際にはエラーページや別形式のJSONが来ていても、型検査の上では正常な状態に見えます。境界ではunknownとして受け、オブジェクトか、必須項目があるか、値域が正しいかを確認してから内部の型へ渡す。この順番なら、UIのあちこちへ防御的なチェックを散らさずに済みます。次のコードで、型アサーションと境界での確認を並べてみます。
// いいねの状態を、画面が受け取りたい形で表す(説明用の型)
type LikeState = { liked: boolean; count: number };
// 型アサーション:型検査は通るが、届いた中身は何も確かめていない
const unchecked = (await response.json()) as LikeState;
// 境界での確認:unknownで受け、形と値域を確かめてからLikeStateにする
function toLikeState(value: unknown): LikeState | null {
if (typeof value !== 'object' || value === null || !('liked' in value) || !('count' in value)) return null;
const { liked, count } = value;
if (typeof liked !== 'boolean') return null;
if (typeof count !== 'number' || !Number.isSafeInteger(count) || count < 0) return null;
return { liked, count };
}
const checked = toLikeState(await response.json());たとえば { count: "12", liked: false } は、人間には12件と読めますが、countは数値ではなく文字列です。暗黙に足し算すると、1を足したつもりが文字列の連結になることもあります。仕様として文字列を許すなら明示的に変換し、数値だけを受けるなら拒否する。どちらを選んだかが分かることが大切です。as LikeState と書いても、この変換や検査が勝手に追加されるわけではありません。
unknownは「まだ何か分からない値」として受け取るための型です。何でも操作できるanyで境界を通してしまうより、型や項目の存在を確かめてから扱う流れを作れます。unionは複数の候補をまとめる表現で、たとえば成功と失敗を別の形に分けられます。成功時だけ件数を持つ形なら、失敗した応答からうっかり件数を読む誤りにも気付きやすくなります。
また、count がnumberであることだけでは、件数として妥当とは限りません。たとえば「いいねがマイナス1件」と表示されたら、何が起きたのかと思いますよね。小数や無限大を件数として受け取ってよいかも、考える必要があります。画面へ届くデータでは「型」と「業務上の意味」の両方を見ます。型が得意なところへ任せ、意味の検査は小さな関数へまとめる。こうすると、テストも「この変な値を受け取ったらどうする?」という形で書きやすくなります。





































このページ(2ページ目)の感想・質問・設計へのコメント