LSP――Copyに「開くURL」を要求すると何が壊れるか
作成: / 公開: / 内容更新:
執筆:tamito0201 / 掲載・運営:プロマリ
丸い共有ボタンの裏側を掘る連載の第6回。SOLID の3つ目の原則 LSP を、長方形と正方形の例と、共有ボタンの実物で確かめます。コピーのボタンに「開く URL」を求める書き換えを実際に作ると、型検査は通り、テストが5件落ちました。事前条件・事後条件・不変条件の3つの約束を、図15点で読み解きます。
共有ボタンの「開く URL」は、誰にとっても開く先か
ここからは、共有ボタンの実物に戻ります。前のページでは、長方形と正方形の例で、LSPが見ている「約束」を確かめました。このページでは、共有先の部品に、どんな約束があるかを見ていきます。
その前に、連載で使ってきた呼び名を短く思い出しておきます。このプラグインでは、共有先ごとに名札カードという小さな設定ファイルが1枚ずつあります。X なら destinations/x.toml、コピーなら destinations/copy.toml です。名札カードには、共有先の名前(key)、押したときの動き(action)、開く共有画面の住所(endpoint)などが書いてあります。そして、どの SNS でも同じ手順で動く共有欄の土台のコードを、この連載では共通処理と呼んでいます。
コピーの名札カードは、アイコンの行を除くと次の7行です。1行目はコメント、2〜6行目が項目、最後の [params] が「SNS に渡す値の対応表」です。
# URL のコピー(共有画面なし)。
key = 'copy'
label = 'URLをコピー'
brand_color = '#5F6368'
action = 'copy'
endpoint = ''
[params]X の名札カードと比べると、違いは2つです。action が open(開く)ではなく copy(コピー)であること。そして、endpoint が空で、[params] にも何も書かれていないことです。コピーには開く共有画面が無いので、住所も、渡す値の対応表もありません。
名札カードを読んで、共有画面の URL を組み立てる部品が ShareDestination です。その中の shareUrl というメソッドを見てみます。
/**
* Return the share dialog URL, or the page URL for copy and native sharing.
* Queries are encoded with RFC 3986 and empty values are omitted.
*/
shareUrl(request: ShareRequest): string {
if (!this.#endpoint) return request.url;
const pairs = Object.entries(this.#params)
.map(([name, field]) => [name, FIELD[field](request)] as const)
.filter(([, value]) => value !== '')
.map(([name, value]) => `${UriEncoder.encode(name)}=${UriEncoder.encode(value)}`);
return this.#endpoint + (pairs.length ? `?${pairs.join('&')}` : '');
}最初のコメントは「共有画面の URL を返す。コピーと端末の共有では、ページの URL を返す」という意味です。本文の1行目、if (!this.#endpoint) return request.url; がそれを受け持っています。endpoint が空なら、共有画面の URL を組み立てずに、いま読んでいる記事の URL(request.url)をそのまま返します。2行目からあとは、endpoint がある共有先のための手順で、[params] の対応表どおりに値を並べ、endpoint の後ろにつなげます。
つまり、shareUrl が返すものは、共有先によって意味が違います。X では「開けば投稿画面になる URL」です。コピーと端末の共有では「記事そのものの URL」です。メソッドの名前も、返す値の型(string)も同じなのに、返ってくる URL の役目が違うのです。もし、使う側が「shareUrl は、開けば共有できる URL だ」と思い込んでいたら、コピーのボタンで記事そのものを開いてしまいます。
action を見て、実行方法を選ぶ
このプラグインは、この取り違えを防ぐために、URL だけを見て動きを決めることをしていません。押されたときに何をするかは、名札カードの action を見て決めています。その判断を受け持つのが ShareActionPolicy で、実行するのが HandleShareClickUseCase です。
/** Choose the click action; the application layer executes it through gateways. */
static decide({ action, popup }: { action: ShareAction; popup: unknown }): ShareActionDecision {
return action === ShareAction.Copy ? 'copy' : action === ShareAction.Native ? 'native' : popup ? 'popup' : 'follow';
}
async #perform({ button, preventDefault }: ShareClickCommand): Promise<ShareClickResult> {
const decision = ShareActionPolicy.decide(button);
switch (decision) {
case 'copy': {
preventDefault();
try {
await this.#gateways.clipboard.write(button.url);
return 'copied';
} catch {
this.#gateways.clipboard.fallback?.(button.url);
return 'copy-fallback';
}
}
case 'native': {
preventDefault();
return this.#gateways.nativeShare.share({ title: button.title, url: button.url }).then(() => 'shared' as const, () => 'share-dismissed' as const);
}
case 'popup': {
if (button.popup && this.#gateways.popup.open(button.href, button.popup)) { preventDefault(); return 'popup'; }
return 'follow';
}
case 'follow':
return 'follow'; // Let the browser follow the link.前半の decide は、action を見て、4つの実行方法のどれにするかを決めます。action が copy なら ‘copy’、native なら ‘native’、どちらでもなければ(つまり open なら)、小窓の設定(popup)があれば ‘popup’、無ければ ‘follow’ です。follow は「ブラウザにリンクをそのままたどらせる」という意味です。
後半の #perform は、決まった実行方法ごとに、別々の手順を取ります。’copy’ なら、まず preventDefault() でリンクをたどるブラウザの動きを止め、クリップボードに記事の URL(button.url)を書き込みます。書き込みが終わったら ‘copied’ を、失敗したら代わりの方法(fallback)を出して ‘copy-fallback’ を返します。’native’ なら、端末の共有メニューを開きます。’popup’ なら、共有画面の URL(button.href)を小窓で開きます。ここで大事なのは、共有画面の URL(href)を使うのは ‘popup’ のときだけで、コピーと端末の共有では記事の URL(url)を使っていることです。
共有 URL だけを取り出して開かず、action との組み合わせで実行方法を選ぶ。これが、コピーや端末の共有を、開く共有先と同じ部品の並びに置いても壊れない理由です。LSP の目で言えば、このプラグインは「shareUrl を開けば共有できる」という約束を、そもそも立てていません。立てていない約束には、誰も頼らずに済みます。
同じ open でも、条件が違う
実行方法の中にも、事前条件があります。小窓で開くのは、URL が mailto: で始まらないときだけです。メールの共有先(email.toml)も action は open ですが、開く先がメールソフトなので、小窓を開いても何も残らないからです。この判断は ShareActionPolicy.canOpenInPopup が受け持ち、共有欄を組み立てるときに、メールの共有先には小窓の設定を付けません。
端末の共有にも事前条件があります。ブラウザが共有の仕組み(Web Share API の navigator.share)を持っていることです。こちらは、共有欄を組み立てるときに DestinationSelectionPolicy が確かめ、仕組みを持たないブラウザでは、端末の共有のボタンをそもそも並べません。押す前に条件を満たせないボタンは、画面に出さない。事前条件を、押したあとではなく、並べる時点で守っているのです。







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