はじめに
vinxi はフルスタックアプリケーションやメタフレームワークの構築が可能なパッケージです。 開発サーバーとバンドラーに Vite を、本番サーバーには Nitro を使用しています。 SolidStart や TanstackStart で採用されています。
今回は vinxi を使ってメタフレームワークを作ってみます。 進めるにあたり、公式のサンプルや以下の記事を参考にさせていただきました。
Bullding a React Metaframework with Vinxi
セットアップ
package.json を作成し、
npm init -y
以下の内容を追加します。
{ "name": "try-vinxi", "version": "1.0.0", "description": "", "type": "module", "scripts": { "dev": "vinxi dev", "build": "vinxi build", "preview": "vinxi preview" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "@vinxi/react": "^0.2.5", "@vitejs/plugin-react": "^4.3.4", "react": "^18.3.1", "react-dom": "^18.3.1", "vinxi": "^0.5.0" }, "devDependencies": { "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "typescript": "^5.7.2" } }
tsconfig.json を作成します。
{ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", "jsx": "react-jsx", "esModuleInterop": true, "strict": true } }
依存関係をインストールします。
npm install
SPA
まずはシンプルな SPA モードを作成します。
index.ts を作成
ルートに index.ts
を作成します。空の HTML を返すだけのハンドラーです。
import { eventHandler } from 'vinxi/http'; export default eventHandler(() => { return new Response( `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vinxi</title> </head> <body> <div id="root"></div> <script src="./src/entry-client.tsx" type="module"></script> </body> </html>`, { status: 200, headers: { 'Content-Type': 'text/html', }, } ); });
entry-client.tsx を作成
src
ディレクトリを作成し、その中に entry-client.tsx
を作成します。
import { createRoot } from 'react-dom/client'; import Counter from './counter'; createRoot(document.getElementById('root')!).render(<Counter />);
counter.tsx
を作成します。
import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return ( <div> <h1>Counter</h1> <p>{count}</p> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }
app.config.ts を作成
ルートに app.config.ts
を作成し、createApp
内に vinxi の設定を記述します。
@vitejs/plugin-react
を追加します。vinxi は Vite を使用しているため、Vite のプラグインをそのまま使用できます。
npm install @vitejs/plugin-react
spa
ルーターを追加します。handler には先ほど作成した index.ts
を指定します。
import { createApp } from 'vinxi'; import pluginReact from '@vitejs/plugin-react'; export default createApp({ routers: [ { name: 'spa', type: 'spa', handler: './index.ts', target: 'browser', plugins: () => [pluginReact()], }, ], });
開発サーバーを起動します。
npm run dev
カウンターが表示されることを確認します 🎉
SSR
次に SSR モードを作成します。
app.config.ts を編集
まずは app.config.ts
を編集します。spa
ルーターを削除し、client
と ssr
ルーターを追加します。
import { createApp } from 'vinxi'; import pluginReact from '@vitejs/plugin-react'; export default createApp({ routers: [ { name: 'client', type: 'client', handler: './src/entry-client.tsx', target: 'browser', plugins: () => [pluginReact()], base: '/_build', }, { name: 'ssr', type: 'http', handler: './src/entry-server.tsx', target: 'server', plugins: () => [pluginReact()], }, ], });
各ファイルを作成していきます。
app.tsx を作成
src
ディレクトリに app.tsx
を作成します。
createAssets
は各アセットを注入するコンポーネントを作成します。遅延コンポーネントになっているため、Suspense
でラップします。
import { getManifest } from 'vinxi/manifest'; import { createAssets } from '@vinxi/react'; import { Suspense } from 'react'; import Counter from './counter'; const Assets = createAssets( getManifest('client').handler, getManifest('client') ); export default function App() { return ( <html> <head> <Suspense> <Assets /> </Suspense> </head> <body> <div id="root"> <Counter /> </div> </body> </html> ); }
entry-client.tsx を編集
createRoot
を hydrateRoot
に変更します。
クライアントのランタイムを読み込むために vinxi/client
をインポートします。
import { hydrateRoot } from 'react-dom/client'; import App from './app'; import 'vinxi/client'; hydrateRoot(document, <App />);
entry-server.tsx を作成
src
ディレクトリに entry-server.tsx
を作成します。
import { getManifest } from 'vinxi/manifest'; import { eventHandler } from 'vinxi/http'; import { renderToPipeableStream } from 'react-dom/server'; import App from './app'; export default eventHandler({ handler: async (event) => { const clientManifest = getManifest('client'); const stream = await new Promise(async (resolve) => { const stream = renderToPipeableStream(<App />, { onShellReady() { resolve(stream); }, bootstrapModules: [ clientManifest.inputs[clientManifest.handler].output.path, ], bootstrapScriptContent: `window.manifest = ${JSON.stringify( await clientManifest.json() )}`, }); }); event.node.res.setHeader('Content-Type', 'text/html'); return stream; }, });
開発サーバーを起動します。
npm run dev
SSR された HTML が返ってきました 🎉
File system routing
vinxi はファイルシステムルーティングを作成するための機能を提供しています。
FileSystemRouter を作成
vinxi の BaseFileSystemRouter
を継承した FileSystemRouter
を作成します。今回は app.config.ts
の中に書いていきます。
toPath
メソッドは引数にファイルのパスを受け取り、ルートのパスを返します。cleanPath
はディレクトリ名と拡張子を取り除く vinxi のユーティリティ関数です。
toRoute
メソッドは引数にファイルのパスを受け取り、ルートオブジェクトを返します。このルートオブジェクトは vinxi/routes
モジュールによってアプリケーションに提供されます。
class FileSystemRouter extends BaseFileSystemRouter { toPath(src: string) { const routePath = cleanPath(src, this.config) .slice(1) .replace(/index$/, ''); return routePath?.length > 0 ? `/${routePath}` : '/'; } toRoute(filePath: string) { return { path: this.toPath(filePath), $component: { src: filePath, pick: ['default'], }, }; } }
各ルーターの routes
プロパティに FileSystemRouter
を追加します。最終的に app.config.ts
は以下のようになります。
import { createApp } from 'vinxi'; import pluginReact from '@vitejs/plugin-react'; import { BaseFileSystemRouter, cleanPath } from 'vinxi/fs-router'; import path from 'node:path'; class FileSystemRouter extends BaseFileSystemRouter { toPath(src: string) { const routePath = cleanPath(src, this.config) .slice(1) .replace(/index$/, ''); return routePath?.length > 0 ? `/${routePath}` : '/'; } toRoute(filePath: string) { return { path: this.toPath(filePath), $component: { src: filePath, pick: ['default'], }, }; } } export default createApp({ routers: [ { name: 'client', type: 'client', handler: './src/entry-client.tsx', target: 'browser', plugins: () => [pluginReact()], base: '/_build', routes: (router, app) => { return new FileSystemRouter( { dir: path.join(__dirname, 'src/routes'), extensions: ['tsx', 'ts'], }, router, app ); }, }, { name: 'ssr', type: 'http', handler: './src/entry-server.tsx', target: 'server', plugins: () => [pluginReact()], routes: (router, app) => { return new FileSystemRouter( { dir: path.join(__dirname, 'src/routes'), extensions: ['tsx', 'ts'], }, router, app ); }, }, ], });
各ルートファイルを作成
先ほど指定した src/routes
ディレクトリに、いくつかルートファイルを作成します。
src/routes/index.tsx
import Counter from '../counter'; export default function Index() { return ( <div> <h1>Index</h1> <Counter /> </div> ); }
src/routes/about.tsx
export default function About() { return ( <div> <h1>About</h1> <p>This is the about page.</p> </div> ); }
src/routes/docs/guide.tsx
export default function Guide() { return ( <div> <h1>Guide</h1> <p>This is the guide page.</p> </div> ); }
React Router をインストール
今回はルーターライブラリに React Router v6 を使用します。
npm install react-router-dom@6
app.tsx を編集
import { getManifest } from 'vinxi/manifest'; import { createAssets, lazyRoute } from '@vinxi/react'; import { Suspense } from 'react'; import fileRoutes from 'vinxi/routes'; import { Route, Routes } from 'react-router-dom'; const clientManifest = getManifest('client'); const ssrManifest = getManifest('ssr'); const routes = fileRoutes.map((route) => ({ ...route, component: lazyRoute(route.$component, clientManifest, ssrManifest), })); const Assets = createAssets(clientManifest.handler, clientManifest); export default function App() { return ( <html> <head> <Suspense> <Assets /> </Suspense> </head> <body> <div id="root"> <Suspense> <Routes> {routes.map((route) => ( <Route key={route.path} path={route.path} element={<route.component />} /> ))} </Routes> </Suspense> </div> </body> </html> ); }
entry-server.tsx を編集
App コンポーネントを StaticRouter
でラップし、event.path
を location に渡します。
<StaticRouter location={event.path}> <App /> </StaticRouter>
entry-client.tsx を編集
App コンポーネントを BrowserRouter
でラップします。
hydrateRoot( document, <BrowserRouter> <App /> </BrowserRouter> );
index.tsx にリンクを追加
export default function Index() { return ( <div> <h1>Index</h1> <Counter /> <Link to="/about">About</Link> <Link to="/docs/guide">Docs</Link> </div> ); }
開発サーバーを起動します。
npm run dev
リンクから各ページに遷移できることを確認します 🎉
おわりに
今回は vinxi を使って SSR とファイルシステムルーティングを備えたメタフレームワークを作成しました。 他にもミドルウェアや Server functions など、vinxi には様々な機能があります。 自分だけのメタフレームワークを作ってみるのも楽しそうですね。