先日、DenoのWebフレームワークFresh 1.0が安定版をリリース。
可愛い..//
- Just-in-time rendering on the edge.
- Island based client hydration for maximum interactivity.
- Zero runtime overhead: no JS is shipped to the client by default.
- No build step.
- No configuration necessary.
- TypeScript support out of the box.
和訳すると、
- エッジでのJust-in-timeレンダリング
- ビルドの工程が不要
- デフォルトではクライアントにJavaScriptを用いないため、ランタイムオーバーヘッドがゼロ
- 高いインタラクティブ性を実現するためアイランドベースのクライアントハイドレーションを採用
- コンフィグレーション不要
- TypeScriptをサポート
しているらしい。
超絶砕いて言うと、HTTPリクエスト時に即時レンダリングされるため、ビルドという概念はなく、基本コンテンツはサーバーサイドでレンダリングされるため、クライアントにjsを送信せず(そのおかげで画面表示パフォーマンスは良い)、インタラクティブ性を持たせる場合は一部コンテンツにjsのEvent listenerを登録するIslands Architectureを採用しており、もちろんts対応。
意味がわかるようなわからないような・・・とりあえず実際に手を動かして遊んでみる。
成果物
環境構築
Freshを使用するにはDeno v1.22.3 以降のVersionが必要となるため、まずはInstallする。
# install
$ curl -fsSL https://deno.land/install.sh | sh
# Version確認
$ deno --version
deno 1.23.4 (release, x86_64-apple-darwin)
v8 10.4.132.8
typescript 4.7.4
DenoのInstallが完了したらFreshのProjectを作成。
$ deno run -A --no-check https://raw.githubusercontent.com/lucacasonato/fresh/main/init.ts fresh
Folder Structure
$ tree
.fresh/
├── README.md
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands
│ └── Counter.tsx
├── main.ts
├── routes
│ ├── [name].tsx
│ ├── api
│ │ └── joke.ts
│ └── index.tsx
├── static
│ ├── favicon.ico
│ └── logo.svg
└── utils
└── twind.ts
deno.json
所謂Denoの設定ファイル。
npm / yarnで言うpackage.jsonとほぼ同じような内容。
dev.ts / main.ts
開発、本番環境のエントリーポイント。
fresh.gen.ts
routes と islands の情報を含むマニフェストファイル。
※ 正直よくわからない・・Islands Architectureにおける動的な部分を定義したファイルかな?
routes と islands 実装時に自動で更新されるため、修正は不要。
import_map.json
Denoのパッケージ管理
※ deps.tsでやるものと思っていたら、Deno v1.8からimport mapsがサポートされた様子。
islands/
Islands ArchitectureでのIslandsコンポーネントを実装する階層。
/** @jsx h */
import { h } from "preact";
import { useState } from "preact/hooks";
import { IS_BROWSER } from "$fresh/runtime.ts";
import { tw } from "@twind";
interface CounterProps {
start: number;
}
export default function Counter(props: CounterProps) {
const [count, setCount] = useState(props.start);
const btn = tw`px-2 py-1 border(gray-100 1) hover:bg-gray-200`;
return (
<div class={tw`flex gap-2 w-full`}>
<p class={tw`flex-grow-1 font-bold text-xl`}>{count}</p>
<button
class={btn}
onClick={() => setCount(count - 1)}
disabled={!IS_BROWSER}
>
-1
</button>
<button
class={btn}
onClick={() => setCount(count + 1)}
disabled={!IS_BROWSER}
>
+1
</button>
</div>
);
}
デフォルトでは、クリックするとカウントアップ/ダウンされる処理が実装されている。
routes/
Nuxtのpagesのようなファイルベースのルーティング。
デフォルトでは
├── routes
│ ├── [name].tsx
│ ├── api
│ │ └── joke.ts
│ └── index.tsx
のように3種定義されており、
それぞれ
- http://localhost:8000/ : routes/index.tsx
- http://localhost:8000/{param} : routes/[name].tsx
- http://localhost:8000/api/joke : routes/api/joke.tsx
に対応している。
static/
静的ファイル。
utils/
ユーティリティ。
環境構築時に脳死でEnter押していたら
Do you want to use 'twind' (https://twind.dev/) for styling? [y/N] y
としていたため、Tailwind CSSがデフォルトで入っている。嬉しい。
Coding
いつもながら、タイピングゲームでも実装してみようかと。
タイピング用のお題取得
とある偉人の名言を適当にとってくるAPIを実行。
import { HandlerContext } from "$fresh/server.ts";
interface Advice {
slip: {
id: number,
advice: string
}
}
export const handler = async (_req: Request, _ctx: HandlerContext): Promise<Response> => {
const res = await fetch("https://api.adviceslip.com/advice");
if (res.status === 404) {
throw Error;
}
const advice: Advice = await res.json();
return Response.json(advice.slip);
};
TOPを軽くいじる
/** @jsx h */
import { h } from "preact";
import { tw } from "@twind";
import Counter from "../islands/Game.tsx";
export default function Home() {
return (
<div class={tw`p-4 mx-auto max-w-screen-md text-center`}>
<img
src="/logo.png"
class={tw`rounded-full h-64 flex items-center m-0 m-auto`}
alt="logo"
/>
<p class={tw`my-6`}>
Let's Typing.
</p>
<Game />
</div>
);
}
特に触れることはなし。
※Tailwind CSSのStyleがなかなか適応されなくて、なんでかなーと思ったら{tw ***}
の形式で実装できていなかったのは秘密。
island/Game.tsx
もう少しマシな書き方はありそうだが、
/** @jsx h */
import { h } from "preact";
import { useState } from "preact/hooks";
import { get } from "apis/advice.ts";
import { tw } from "@twind";
export default function Game() {
const [active, setActive] = useState(false);
const [question, setQuestion] = useState('-');
const [num, setNum] = useState(0);
const [timer, setTimer] = useState(0);
const methods = {
clickStart: async () => {
setActive(true);
const res = await get();
setQuestion(res.advice);
methods.startTimer();
},
inputForm: (e: InputEvent) => {
const value = (e.target as HTMLInputElement).value;
if (value !== question) return;
methods.stopTimer();
setActive(false);
},
startTimer: () => {
const start = Date.now(), accum = 0;
setTimer(setInterval(() => { setNum(accum + (Date.now() - start) * 0.001) }, 10));
},
stopTimer: () => {
clearInterval(timer);
},
}
return (
<div>
<div class={tw`mb-10`}>
<button
class={tw`inline-flex items-center justify-center w-full px-6 py-3 mb-2 text-lg text-white
bg-green-500 rounded-md hover:bg-green-400 sm:w-auto sm:mb-0 disabled:bg-gray-300`}
disabled={ active }
onClick={ methods.clickStart }
>
Start.
</button>
</div>
<div class={tw`mb-10`}>
<strong class={tw`text-3xl font-thin leading-none text-neutral-600 lg:text-4xl`}>
{ num.toFixed(2) }
</strong>
</div>
<div class={tw`mb-10`}>
<strong class={tw`text-3xl font-thin leading-none text-neutral-600 lg:text-4xl`}>
{ question }
</strong>
</div>
<div class={tw`mb-10`}>
<input
type="text"
class={tw`relative outline-none rounded py-3 px-3 w-full bg-white shadow text-sm
text-gray-700 placeholder-gray-400 focus:outline-none focus:shadow-outline`}
placeholder="Let's Typing."
disabled={ !active }
onInput={ methods.inputForm }
/>
</div>
</div>
);
}
Event listenerがピュアなjsと同じ・・・(Vue / Reactのように専用の記法があるのかな・・)
状態管理にはPreact useState
フックを使用。
import { h } from "preact";
import { useState } from "preact/hooks";
const [active, setActive] = useState(false);
例
const [flg, setFlg] = useState(false);
return (
<div>
<button
disabled={ flg }
onClick={() => setFlg(true)}
>
</div>
)
これはNG
let flg = false;
return (
<div>
<button
disabled={ flg }
onClick={() => flg = true}
>
</div>
)
utils/apis/advice.ts
api クライアント.
import axiod from "https://deno.land/x/axiod@0.26.1/mod.ts";
const ENDPOINT = "/api/advice";
interface Advice {
id: number,
advice: string
}
export async function get(): Promise<Advice> {
const res = await axiod.get(ENDPOINT);
return res.data;
}
しっかり作り込むのであれば、http client
を実装して、mock切り替えなど実装した方が良いがそこまでリッチにする意味はないので割愛。
import_map.jsonにもしっかり追加。
{
"imports": {
+ "apis/": "./utils/apis/",
"$fresh/": "https://raw.githubusercontent.com/lucacasonato/fresh/main/",
"preact": "https://esm.sh/preact@10.8.2",
"preact/": "https://esm.sh/preact@10.8.2/",
"preact-render-to-string": "https://esm.sh/preact-render-to-string@5.2.0?external=preact",
"@twind": "./utils/twind.ts",
"twind": "https://esm.sh/twind@0.16.17",
"twind/": "https://esm.sh/twind@0.16.17/"
}
}
Deploy
Deno Deployを使用。
こんな感じで設定して
ぽちぽちすれば
はい完了。
まとめ
ちょっと触った感じ、現時点では微妙・・・
環境構築やドキュメント量も少なく理解しやすいが、インタラクティブ性が低い。(まぁそういうアーキテクチャだし。)
Jamstackっぽいので、ブログサイトとかには適しているかも。
とわ言え、まだ1.0がリリースされたばかり、今後に期待ですな。