0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ジョブカンAdvent Calendar 2024

Day 7

Deno Freshでひっかかったポイントの中身を見てみる

Last updated at Posted at 2024-12-06

ジョブカン事業部のアドベントカレンダー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の値は直接更新しない方がいい」というのを見たことがあったので2structuredCloneを使ってコピーしてみるとちゃんと再描画が行われるようになりました。
実際には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として定義されています。

packages/core/src/index.ts
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-domnext/linkを使ったときみたいな遷移になります。
画面が一瞬真っ白になることがないので目に優しくなります。

Freshの場合はこれらとは違ってクライアントで完結しておらず、リンクをクリックした際に当該ページのHTMLを取得し、<Partial>タグ内のDOMのみを置換することによって実現します。
毎回通信をするのでデメリットもありますが、各ページへの初回アクセス時にやりとりするデータが減るといったメリットもあります。

使い方は簡単で、f-client-nav属性をつけた要素の中に、

routes/_layout.tsx
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>
  );
}
routes/a.tsx
import { Partial } from "$fresh/runtime.ts";
export default function A() {
  return (
    <Partial name="main">
      <span>This is A</span>
    </Partial>
  );
}
routes/b.tsx
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な要素の外に置いてしまうと、リンクをクリックしたときに普通に遷移してしまいます。
つまり、

routes/_layout.tsx
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にそれっぽい定数が定義されており、

src/constants.ts
export const CLIENT_NAV_ATTR = "f-client-nav";

この定数名で検索をかけるとsrc/runtime/entrypoints/main.tsにそれっぽい処理が書いてあるのが見つかります。

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を返す関数のようです。

これを呼んでいるのが同じファイルのこの部分です。

src/runtime/entrypoints/main.ts
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で、reactreact-dom@react/typesがこれを向くようにしてやります。

deno.json
{
  "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用のライブラリを利用するときに、

deno.json
{
  "imports": {
    "library": "https://esm.sh/library?external=react,react-dom,@types/react
  }
}

と書くことで、ライブラリがimport "react"などとしている箇所でもReactではなくpreact/compatを通してPreactが使われるようになります。

例えば今回作っているアプリでは@reach/comboboxを使ってみていますが、externalの有無でesm.shが返すコードを見比べてみます。

https://esm.sh/@reach/combobox@0.18.0
/* 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";
https://esm.sh/@reach/combobox@0.18.0?external=react
/* 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では一緒に働くメンバーを募集しています。
リンクから採用ページを覗いてみてください。

  1. 最近(特にv2になってから)はNodeっぽくなってきていて不穏ですが……

  2. あったので横着だという自覚はありました

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?