Elmのa要素の謎に迫る
シングルページアプリケーション(SPA)では通常、リンク(a
要素)をクリックして他のページに移動するときに、あえて本来のブラウザのページ遷移の振る舞いをキャンセルします。そして、その代わりにページの内容とURLを書き換えて、ページを移動したように見せかけます。こうすることで、ネットワークのアクセスやHTMLやCSS等のリソースの再読み込みをすることなく、瞬時にページを切り替えて見せるわけですよね。
そのため、例えばNext.jsのようなフレームワークでは、a
要素をLink
コンポーネントで包むことでこのような振る舞いを実現しているようです。
<h1 className="title">
Read{' '}
<Link href="/posts/first-post">
<a>this page!</a>
</Link>
</h1>
しかし、Elmでは特にそのような特別なコンポーネントを明示的に噛ませることなく、ただのa
要素を置くだけでシングルページアプリケーション的な挙動になります。Elmではこのような振る舞いをどのように実装しているのか、その実装の詳細を追ってみます。
DevToolsでのぞいてみる
まずはElmでSPAの最小限のコードを用意してみます。ElmでSPA
を作るときは、Browser.application
を使います。
module Main exposing (..)
import Browser exposing (application)
import Html exposing (a, text)
import Html.Attributes exposing (href)
main : Program () () ()
main =
application
{ init = \_ _ _ -> ( (), Cmd.none )
, view = \_ -> { title = "", body = [ a [ href "/next" ] [ text "Hello" ] ] }
, update = \_ _ -> ( (), Cmd.none )
, subscriptions = \_ -> Sub.none
, onUrlRequest = \_ -> ()
, onUrlChange = \_ -> ()
}
さて、このページをChromeのDevToolsでのぞいてみます。
a
要素にはclick
、window
にpopState
にイベントリスナが仕掛けられていますね。ちなみに、Browser.application
を使わない場合はこのイベントリスナは仕掛けられません。
生成されたコードをのぞいてみる
ここでDevTools上のリンクをたどると仕掛けられているイベントリスナがどこにあるのか調べることができますが、残念なことに仕掛けられているイベントリスナはF2
です。F2
は関数がカリー化されているElmの都合で使われている関数ですが、このせいでリンクがクリックされたときどのコードが実行されるのかわかりづらいですね。仕方ないので気合でコードを追っていくと、次の_Browser_application
の中のreturn F2(function(domNode, event)
以下のコードが実行されていることがわかりました。
function _Browser_application(impl)
{
var onUrlChange = impl.onUrlChange;
var onUrlRequest = impl.onUrlRequest;
var key = function() { key.a(onUrlChange(_Browser_getUrl())); };
return _Browser_document({
setup: function(sendToApp)
{
key.a = sendToApp;
_Browser_window.addEventListener('popstate', key);
_Browser_window.navigator.userAgent.indexOf('Trident') < 0 || _Browser_window.addEventListener('hashchange', key);
return F2(function(domNode, event)
{
if (!event.ctrlKey && !event.metaKey && !event.shiftKey && event.button < 1 && !domNode.target && !domNode.hasAttribute('download'))
{
event.preventDefault();
var href = domNode.href;
var curr = _Browser_getUrl();
var next = $elm$url$Url$fromString(href).a;
sendToApp(onUrlRequest(
(next
&& curr.protocol === next.protocol
&& curr.host === next.host
&& curr.port_.a === next.port_.a
)
? $elm$browser$Browser$Internal(next)
: $elm$browser$Browser$External(href)
));
}
});
},
init: function(flags)
{
return A3(impl.init, flags, _Browser_getUrl(), key);
},
view: impl.view,
update: impl.update,
subscriptions: impl.subscriptions
});
}
event.preventDefault();
でブラウザ本来の挙動をキャンセルしていることがわかります。それから、ホストやポートがまったく同じなら$elm$browser$Browser$Internal(next)
、そうでなければ$elm$browser$Browser$External(href)
を呼び出してメッセージを作成し、それからsendToApp
を呼び出してonUrlRequest
メッセージを送信しています。
setup
で定義されたこのa
要素専用のイベントリスナは、_VirtualDom_divertHrefToApp
というグローバルな変数に代入されます。そして、function _VirtualDom_render(vNode, eventNode)
の中で、_VirtualDom_divertHrefToApp
にイベントハンドラが存在するときだけa
要素にそのイベントリスナを仕掛けます。
if (_VirtualDom_divertHrefToApp && vNode.c == 'a')
{
domNode.addEventListener('click', _VirtualDom_divertHrefToApp(domNode));
}
このようにして、Browser.application
で初期化されたときだけ、仮想DOMでa
要素をレンダリングするときに特別な処理を挟み込んで、シングルページアプリケーション的な振る舞いを実現しています。
さいごに
React/Nextのような構成だと仮想DOMとアプリケーションフレームワークは分離されていますが、Elmだと仮想DOMと状態管理フレームワークが一体なので、フレームワークの都合による例外的な処理を仮想DOMの実装に差し込めます。これによって、すべてのa
要素が自動的にシングルページアプリケーションとしての振る舞いになってくれるので、開発者はリンクのシングルページアプリケーション的な振る舞いを意識しなくて済むわけです。
なぜこんなことを調べてみたのかというと、先日あるサービスをテストしていて、あるページへのリンクをうっかり張り忘れてしまいまして。しかも、そのページはなにかの都合で直接URLを指定してもうまく開かないように作ってしまっていました。そこでDevToolsを使って適当にa
要素を差し込めばそのページにSPA的な遷移ができるかなーと思ってやってみたのですが、そうやって差し込まれたa
要素ではSPA的なページ遷移はできませんでした。それでそういえばElmのa
要素ってどうなっていたんだっけ、と思って調べてみた次第です。なんかもっとバブリングの根本のほうでpreventDefault
してるのかと思ったら、ぜんぜん違いました。
Discussion