Hono & SolidJS で API 付き SPA
概要
SolidJS に入門した & 以下の記事を読んだので、SolidJS で同じようなことをするためにあれこれ調べながら進めた際のメモ。
元ネタの記事と異なる点は以下:
- React ではなく SolidJS を使う
- スタート地点となる Hono のテンプレートは
nodejs
を使う[1]
現時点でできていないことは以下:
- Tailwind CSS などの CSS フレームワークの導入
本記事執筆時点のコミットは以下:
2025-03-20 追記
Vite の server.proxy
オプションを利用した方が色々好ましいと感じたので色々と書き換えた。
たとえば以前の設定のまま Panda CSS を入れる方法を思い付けなかった。
相変わらず Node.js で動かす想定だが、Static Asset を利用すれば Cloudflare Workers にデプロイもできると思う。
プロジェクトセットアップ
create-hono
コマンドでセットアップする(テンプレートは nodejs
を選ぶ)。
パッケージマネージャーは pnpm を利用している。
pnpm create hono@latest solidjs-hono-weatherapp
Vite の導入
nodejs
テンプレートでは tsx を使うようになっているので、tsx の代わりに Vite を使う。
honojs/vite-plugins にある各プラグインの README に従って諸々インストールする。
また、必要ないパッケージはアンインストールしておく。
pnpm uninstall tsx
pnpm add -D vite @hono/vite-build @hono/vite-dev-server @hono/node-server
@hono/vite-dev-server の README に従って vite.config.ts
と src/index.ts
を編集する。
import { defineConfig } from "vite";
import devServer from "@hono/vite-dev-server";
import nodeAdapter from "@hono/vite-dev-server/node";
export default defineConfig({
plugins: [
devServer({
entry: "src/index.ts",
adapter: nodeAdapter,
})
]
});
@@ -1,4 +1,3 @@
-import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
@@ -7,9 +6,4 @@ app.get('/', (c) => {
return c.text('Hello Hono!')
})
-serve({
- fetch: app.fetch,
- port: 3000
-}, (info) => {
- console.log(`Server is running on http://localhost:${info.port}`)
-})
+export default app;
pnpm vite
で開発サーバーが起動することを確認できたら OK。
続いて @hono/vite-build のための設定を追加する。
import { defineConfig } from "vite";
+import build from "@hono/vite-build/node";
import devServer from "@hono/vite-dev-server";
import nodeAdapter from "@hono/vite-dev-server/node";
export default defineConfig({
plugins: [
+ build({
+ entry: "src/index.ts",
+ }),
devServer({
entry: "src/index.ts",
adapter: nodeAdapter,
必要に応じて .gitignore
を編集しておく。
echo "dist/" >> .gitignore
試しにビルドして localhost:3000
にアクセスしてみる。「Hello Hono!」が表示されれば OK。
pnpm vite build && node dist/index.js
最後に npm script を編集しておく。
@@ -2,7 +2,9 @@
"name": "solidjs-hono-weatherapp",
"type": "module",
"scripts": {
- "dev": "tsx watch src/index.ts"
+ "dev": "vite",
+ "build": "vite build",
+ "start": "node dist/index.js"
},
"dependencies": {
"hono": "^4.7.4"
SolidJS のインストール
SolidJS 関連の依存を入れる。
pnpm add -D typescript vite-plugin-solid
pnpm add solid-js
TypeScript
tsconfig.json
を SolidJS のドキュメントや create-vite
の solid-ts テンプレートを参考に書き換える[2]。
修正後の tsconfig.json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
solid-ts テンプレートの設定から types
/ include
/ exclude
を少し変更している。
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts", "src/index.ts"]
}
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"],
"exclude": ["src/index.ts"]
}
vite.config.ts
SolidJS の vite プラグインにオプション { ssr: true }
を渡すのがポイント。
それ以外は @hono/vite-build や @hono/vite-dev-server の README に書かれている通りにする。
今回は ./dist/build
にビルドしたクライアント JS ファイルを吐き出すようにした。
import { defineConfig } from "vite";
import build from "@hono/vite-build/node";
import devServer from "@hono/vite-dev-server";
import nodeAdapter from "@hono/vite-dev-server/node";
import solid from "vite-plugin-solid";
export default defineConfig(({ mode }) => {
if (mode === "client") {
return {
esbuild: {
jsxImportSource: "solid-js",
},
build: {
rollupOptions: {
input: "src/client.tsx",
output: {
entryFileNames: "build/client.js",
chunkFileNames: "build/[name]-[hash].js",
assetFileNames: "build/[name]-[hash].[ext]",
},
},
// emptyOutDir: false,
copyPublicDir: false,
},
plugins: [solid()]
}
}
return {
plugins: [
solid({ ssr: true }),
build({
entry: "src/index.ts",
}),
devServer({
entry: "src/index.ts",
adapter: nodeAdapter,
})
]
};
});
クライアント側のエントリーファイル作成
動きを見るために適当にカウンターのコンポーネントを書く。
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";
const root = document.getElementById("root")!;
render(() => <App />, root);
import { createSignal } from "solid-js";
const App = () => {
const [count, setCount] = createSignal(0);
return (
<>
<h1>Hono and SolidJS</h1>
<div>
<button type="button" onClick={() => setCount(count() + 1)}>
You clicked me {count()} times
</button>
</div>
</>
)
};
export default App;
サーバー側のエントリーファイル
サーバー側のエントリーファイルに SolidJS の renderToString を利用するとエラーが出るため、Hono の Context.html を利用する。
renderToString を利用する場合
src/index.ts
は src/index.tsx
にリネームし、vite.config.ts
内のファイル名も変えておく。
src/index.tsx
の内容は以下の通り:
import { Hono } from 'hono'
import { serveStatic } from '@hono/node-server/serve-static';
import { renderToString } from "solid-js/web";
const app = new Hono()
app.use("/build/*", serveStatic({ root: "./dist/" }));
app.get("*", (c) => {
const html = renderToString(() => (
<html>
<head>
<title>Hono & SolidJS</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{import.meta.env.PROD ? (
<script type="module" src="/build/client.js"></script>
) : (
<script type="module" src="/src/client.tsx"></script>
)}
</head>
<body>
<div id="root"></div>
</body>
</html>
));
return c.html(html);
})
export default app;
Vite の ssr.external オプションに solid-js
を追加する:
@@ -27,13 +27,16 @@ export default defineConfig(({ mode }) => {
}
return {
+ ssr: {
+ external: ["solid-js"]
+ },
plugins: [
solid({ ssr: true }),
また src/index.tsx
で型チェックエラーが生じるため tsconfig.node.json
に compilerOptions.jsx
と compilerOptions.jsxImportSource
を設定しておく。
ビルドしたクライアント JS を配信するために serveStatic を利用する[3]。
import { Hono } from 'hono'
+import { serveStatic } from '@hono/node-server/serve-static';
const app = new Hono()
-app.get('/', (c) => {
- return c.text('Hello Hono!')
+app.use("/build/*", serveStatic({ root: "./dist/" }));
+
+app.get("*", (c) => {
+ const html = `
+ <html>
+ <head>
+ <title>Hono & SolidJS</title>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ ${import.meta.env.PROD ? (
+ '<script type="module" src="/build/client.js"></script>'
+ ) : (
+ '<script type="module" src="/src/client.tsx"></script>'
+ )}
+ </head>
+ <body>
+ <div id="root"></div>
+ </body>
+ </html>
+ `;
+
+ return c.html(html);
})
export default app;
動作確認
ビルド用の npm script を直して pnpm run build && pnpm run start
する。
http://localhost:3000
にアクセスしてカウンターが動いたら OK。
"type": "module",
"scripts": {
"dev": "vite",
- "build": "vite build",
+ "build": "vite build --mode client && vite build",
"start": "node dist/index.js"
},
"dependencies": {
その他
クライアントルーティング
Solid Router でクライアントルーティングができる。
pnpm add @solidjs/router
例
動作確認のため、先ほど作った src/App.tsx
を src/routes/Home.tsx
と src/routes/Counter.tsx
に分割する。
/* @refresh reload */
import { render } from "solid-js/web";
-import App from "./App";
+import { Route, Router } from "@solidjs/router";
+import { lazy } from "solid-js";
+
+const Home = lazy(() => import("./routes/Home"));
+const Counter = lazy(() => import("./routes/Counter"));
+
+const App = () => (
+ <Router>
+ <Route path="/" component={Home} />
+ <Route path="/counter" component={Counter} />
+ </Router>
+)
const root = document.getElementById("root")!;
render(() => <App />, root);
import { A } from "@solidjs/router";
const Home = () => {
return (
<>
<h1>Hono and SolidJS</h1>
<div>
<ul>
<li><A href="/">Home</A></li>
<li><A href="/counter">Counter</A></li>
</ul>
</div>
</>
)
}
export default Home;
import { createSignal } from "solid-js";
const Counter = () => {
const [count, setCount] = createSignal(0);
return (
<>
<h1>Hono and SolidJS: Counter</h1>
<div>
<button type="button" onClick={() => setCount(count() + 1)}>
You clicked me {count()} times
</button>
</div>
</>
)
};
export default Counter;
API
Hono で API を作って SolidJS の createResource や createAsync でデータ取得するだけ。
例: ClockButton
元ネタにさせてもらった記事にあるコードを SolidJS に移植してみる。
src/index.ts
で現在時刻を返す実装は一緒。
@@ -3,6 +3,10 @@
const app = new Hono()
+app.get("/api/clock", (c) => {
+ return c.json({ time: new Date().toLocaleTimeString() });
+})
+
app.use("/build/*", serveStatic({ root: "./dist/" }));
app.get("*", (c) => {
コンポーネントの実装は SolidJS の Suspense と createResource を利用する。
import { createResource, Suspense } from "solid-js";
const Clock = () => {
const [data, { refetch }] = createResource<string>(async () => {
const response = await fetch("/api/clock");
const data = await response.json();
const headers = Array.from(response.headers)
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
return JSON.stringify({
url: response.url,
status: response.status,
headers,
body: data,
}, null, 2);
})
return (
<>
<h1>Hono and SolidJS: Clock</h1>
<div>
<button type="button" onClick={() => refetch()}>
Get Server Time
</button>
<Suspense fallback={<p>Loading...</p>}>
<pre>{data()}</pre>
</Suspense>
</div>
</>
)
};
export default Clock;
src/client.tsx
と src/routes/Home.tsx
を修正して動作確認できれば OK。
@@ -5,11 +5,13 @@
const Home = lazy(() => import("./routes/Home"));
const Counter = lazy(() => import("./routes/Counter"));
+const Clock = lazy(() => import("./routes/Clock"));
const App = () => (
<Router>
<Route path="/" component={Home} />
<Route path="/counter" component={Counter} />
+ <Route path="/clock" component={Clock} />
</Router>
)
@@ -8,6 +8,7 @@
<ul>
<li><A href="/">Home</A></li>
<li><A href="/counter">Counter</A></li>
+ <li><A href="/clock">Clock</A></li>
</ul>
</div>
</>
Discussion