🌳

Elmのa要素の謎に迫る

2021/04/17に公開

シングルページアプリケーション(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要素にはclickwindowpopStateにイベントリスナが仕掛けられていますね。ちなみに、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