ジョブカン事業部のアドベントカレンダー7日目です。
普段からごりごりアプリを書いている訳ではありませんが、TypeScript・Denoエコシステムの充実した機能と、コードを統一できるという点を鑑みて、サーバーもクライアントもDenoで書くのが割と幸せなんじゃないかという説が私のなかで浮上しています1。
そんなDenoは公式からFreshというフレームワークが出ています。フロントエンドではPreactが使われています。
導入などは他の記事に譲りますが、最近趣味で作り始めたアプリをこれで書いていて、ちょっと時間を使ったポイントがあったので、対処方法を書いておいて、ついでにコードを眺めて何が起きているのかを見てみました。
オブジェクトのSignalが更新されない
これはPreactの話にはなりますが、SignalというStateの上位互換みたいなのがいます。
Contextに近いですが、それ単体を受け渡すだけでは更新時の再描画の対象にならない変数です。
function GrandParent() {
const [state, setState] = useState(0);
const signal = useSignal(0);
return (
<Parent state={state} setState={setState} signal={signal} />
);
}
function Parent({ state, setState, signal }) {
return (
<Child state={state} setState={setState} signal={signal} />
);
}
function Child({ state, setState, signal }) {
return (
<>
<input type="button" onClick={() => setState(state + 1)} value="Increment state" />
<span>State: {state}</span>
<input type="button" onClick={() => { signal.value = signal.value + 1}} value="Increment signal" />
<span>Signal.value: {signal.value}</span>
</>
);
}
"Increment state"のボタンを押すとstate
が変更され、このとき<Parent>
も再描画されますが、"Increment signal"のボタンを押してsignal
が更新されたときは、state
の時とは違って<Parent>
は再描画の対象になりません。
これによってページ全体の描画のコストを抑えることができます。
signalはコード例の通り内部の値をsignal.value
で取得できます。
また、signal.value = data
のように、.value
への代入を行うことによって内部の値を更新することができます。StateでいうとsetState()
に相当します。
さて、オブジェクト等ではこの取得した値のプロパティ等を変更することもできますが、変更したあとの値をそのままsignal.value
に代入しても再描画が行われませんでした(内部の値は更新されていました)。
著者がReactを学んだときに、「stateの値は直接更新しない方がいい」というのを見たことがあったので2、structuredClone
を使ってコピーしてみるとちゃんと再描画が行われるようになりました。
実際にはArray.from()
やObject.assign()
などのシャローコピーでも問題ありません。
const obj = signal.value;
obj.foo = 'bar';
signal.value = obj;
const obj = Object.assign({}, signal.value)
obj.foo = 'bar';
signal.value = obj;
直接再代入だとダメでシャローコピーすれば良い理由を見てみます。
signalのソースコードは https://github.com/preactjs/signals で公開されています。
packages
ディレクトリにいくつかのパッケージがあり、preact
にはuseSignal
などが定義されています。いずれにせよ中ではcore
の機能を使っています。
signals
├── packages
│ ├── core
│ ├── preact
│ ├── react
│ └── react-transform
└── ...
signal.value
に代入を行うときの処理はcore/src/index.ts
内で、value
プロパティのsetterとして定義されています。
Object.defineProperty(Signal.prototype, "value", {
get(this: Signal) { /* 省略 */ },
set(this: Signal, value) {
if (value !== this._value) {
if (batchIteration > 100) {
throw new Error("Cycle detected");
}
this._value = value;
this._version++;
globalVersion++;
/**@__INLINE__*/ startBatch();
try {
for (
let node = this._targets;
node !== undefined;
node = node._nextTarget
) {
node._target._notify();
}
} finally {
endBatch();
}
}
},
});
今回の理由を探るだけであれば細かく読む必要はなく、set
の最初の1行だけで十分です。
_value
はsignalが内部で持っている状態、value
は代入された値ですが、この値が異なるときのみ中の処理(再描画など)を行うようになっています。
同じオブジェクトを再代入してもここがtrue
になってしまい、中の処理に入らないので再描画が発生しないのでした。
Partialsを使っているのにリンクをクリックするとリロードが入る
Partialsはv1.5で追加された、クライアントルーティングを実現する機能です。
要するにreact-router-dom
やnext/link
を使ったときみたいな遷移になります。
画面が一瞬真っ白になることがないので目に優しくなります。
Freshの場合はこれらとは違ってクライアントで完結しておらず、リンクをクリックした際に当該ページのHTMLを取得し、<Partial>
タグ内のDOMのみを置換することによって実現します。
毎回通信をするのでデメリットもありますが、各ページへの初回アクセス時にやりとりするデータが減るといったメリットもあります。
使い方は簡単で、f-client-nav
属性をつけた要素の中に、
export default function Layout({ Component }) {
return (
<div f-client-nav>
<nav>
<a href="/a">Link to A</a>
<a href="/b">Link to B</b>
</nav>
<Component />
</div>
);
}
import { Partial } from "$fresh/runtime.ts";
export default function A() {
return (
<Partial name="main">
<span>This is A</span>
</Partial>
);
}
import { Partial } from "$fresh/runtime.ts";
export default function B() {
return (
<Partial name="main">
<span>This is B</span>
</Partial>
);
}
こうすると<a>
タグをクリックしたとき、当該ページにまるごと遷移するのではなく、遷移先のページのDOMを取得して、name
が一致する<Partial>
タグの中身だけが書き換えられます。
1つのページに複数の異なるname
を持つ<Partial>
タグを含むこともできます。
このとき、<a>
タグをf-client-nav
な要素の外に置いてしまうと、リンクをクリックしたときに普通に遷移してしまいます。
つまり、
export default function Layout({ Component }) {
return (
<>
<nav>
<a href="/a">Link to A</a>
<a href="/b">Link to B</b>
</nav>
<div f-client-nav>
<Component />
</div>
</>
);
}
のように書くとうまく働きません。
各<a>
タグがf-client-nav
な要素の子孫要素にするとスムーズに遷移するようになります。
特にファイルを分けている場合はちゃんと中に来るように注意しなければなりません。
こうなる理由を見てみます。
Freshのソースコード (https://github.com/denoland/fresh) 内でf-client-nav
と検索をかけるとsrc/constants.ts
にそれっぽい定数が定義されており、
export const CLIENT_NAV_ATTR = "f-client-nav";
この定数名で検索をかけるとsrc/runtime/entrypoints/main.ts
にそれっぽい処理が書いてあるのが見つかります。
function checkClientNavEnabled(el: HTMLElement | null) {
if (el === null) {
return document.querySelector(`[${CLIENT_NAV_ATTR}="true"]`) !== null;
}
const setting = el.closest(`[${CLIENT_NAV_ATTR}]`);
if (setting === null) return false;
return setting.getAttribute(CLIENT_NAV_ATTR) === "true";
}
雰囲気で読んでみると、指定された要素の最も近い親でf-client-nav
が設定されている要素を取得し、それが見つからないか、false
が指定されている場合にfalse
を返す関数のようです。
これを呼んでいるのが同じファイルのこの部分です。
document.addEventListener("click", async (e) => {
let el = e.target;
if (el && (el instanceof HTMLElement || el instanceof SVGElement)) {
const originalEl = el;
// Check if we clicked inside an anchor link
if (el.nodeName !== "A") {
el = el.closest("a");
}
if (el === null) {
el = originalEl.closest("button");
}
if (
// Check that we're still dealing with an anchor tag
el && el instanceof HTMLAnchorElement &&
// Check if it's an internal link
el.href && (!el.target || el.target === "_self") &&
el.origin === location.origin &&
// Check if it was a left click and not a right click
e.button === 0 &&
// Check that the user doesn't press a key combo to open the
// link in a new tab or something
!(e.ctrlKey || e.metaKey || e.altKey || e.shiftKey || e.button) &&
// Check that the event isn't aborted already
!e.defaultPrevented
) {
const partial = el.getAttribute(PARTIAL_ATTR);
// Check if the user opted out of client side navigation or if
// we're doing a fragment navigation.
if (
el.getAttribute("href")?.startsWith("#") ||
!checkClientNavEnabled(el)
) {
return;
}
// deno-lint-ignore no-explicit-any
const indicator = (el as any)._freshIndicator;
if (indicator !== undefined) {
indicator.value = true;
}
e.preventDefault();
const nextUrl = new URL(el.href);
try {
maybeUpdateHistory(nextUrl);
const partialUrl = new URL(
partial ? partial : nextUrl.href,
location.href,
);
await fetchPartials(partialUrl);
updateLinks(nextUrl);
scrollTo({ left: 0, top: 0, behavior: "instant" });
} finally {
if (indicator !== undefined) {
indicator.value = false;
}
}
} else if (
el && el instanceof HTMLButtonElement &&
(el.type !== "submit" || el.form === null)
) {
const partial = el.getAttribute(PARTIAL_ATTR);
// Check if the user opted out of client side navigation.
if (partial === null || !checkClientNavEnabled(el)) {
return;
}
const partialUrl = new URL(
partial,
location.href,
);
await fetchPartials(partialUrl);
}
}
});
document.addEventListener()
でドキュメント全体にclick
イベントのコールバックを設定していました(びっくり)。
ごちゃごちゃしていますが、基本的には<a>
タグと<button>
タグのクリックにあわせて上で書いたような処理をしています。
そして、
// Check if the user opted out of client side navigation or if
// we're doing a fragment navigation.
の下にあるif文でcheckClientNavEnabled
が使われており、false
であればreturn
、つまり特別な処理を何もしないのだとわかります。
したがって<a>
タグがf-client-nav
の子要素でなければならなかった訳です。
一方で、<Partial>
タグはf-client-nav
の子要素でなくても問題なさそうなことがわかります。
esm.sh経由でReactのライブラリを使いたい
Preactは基本的にReactと同じように使うことができますが、内部のAPIが微妙に異なっており、ライブラリを使おうとするとエラーが出ました。
これを回避するために、Preactはpreact/compat
という互換性確保のためのモジュールを提供してくれています。
import mapで、react
とreact-dom
と@react/types
がこれを向くようにしてやります。
{
"imports": {
"react": "https://esm.sh/preact/compat",
"react-dom": "https://esm.sh/preact/compat",
"@react/types": "https://esm.sh/preact/compat"
}
}
ただ、これだけだと不十分で、esm.shはパッケージが他のパッケージを参照している場合、自動でそちらもimportするようになっています。
したがって利用するライブラリがreact
をインポートしていると、Reactを読み込んでしまいます。
これにはesm.shのURLに、external
クエリでパッケージ名を指定してやると起きなくなります。
つまり、React用のライブラリを利用するときに、
{
"imports": {
"library": "https://esm.sh/library?external=react,react-dom,@types/react
}
}
と書くことで、ライブラリがimport "react"
などとしている箇所でもReactではなくpreact/compat
を通してPreactが使われるようになります。
例えば今回作っているアプリでは@reach/combobox
を使ってみていますが、external
の有無でesm.shが返すコードを見比べてみます。
/* esm.sh - @reach/combobox@0.18.0 */
import "/stable/react@17.0.2/es2022/react.mjs";
import "/v135/@reach/utils@0.18.0/es2022/utils.mjs";
import "/v135/@reach/descendants@0.18.0/es2022/descendants.mjs";
import "/v135/@reach/auto-id@0.18.0/es2022/auto-id.mjs";
import "/v135/@reach/popover@0.18.0/es2022/popover.mjs";
export * from "/v135/@reach/combobox@0.18.0/es2022/combobox.mjs";
/* esm.sh - @reach/combobox@0.18.0 */
import "/v135/@reach/utils@0.18.0/X-ZS9yZWFjdA/es2022/utils.mjs";
import "/v135/@reach/descendants@0.18.0/X-ZS9yZWFjdA/es2022/descendants.mjs";
import "/v135/@reach/auto-id@0.18.0/X-ZS9yZWFjdA/es2022/auto-id.mjs";
import "/v135/@reach/popover@0.18.0/X-ZS9yZWFjdA/es2022/popover.mjs";
export * from "/v135/@reach/combobox@0.18.0/X-ZS9yZWFjdA/es2022/combobox.mjs";
2行目が消えています。
この状態であればutils.mjs
などがreact
をインポートしても、import mapで登録したpreact/compat
がインポートされます。
おわりに
あまりまとまりのない記事だった気がしますが、ライブラリまわりでうまくいかないときはドキュメントはもちろん、コードも読もう!ということで締めたいと思います。楽しいですし。
ちなみにFreshはv2の開発が進んでいて、JSRにはbeta版として上がっています。
この記事が出る前にリリースされたらちょっと悲しくなりそうでしたが大丈夫でした。
DONUTSでは一緒に働くメンバーを募集しています。
リンクから採用ページを覗いてみてください。