2021年 は Fullstack Next.js 元年なので、有望な Next.js 系フレームワークを全部試した

38 min read読了の目安(約34900字 1

この記事は、Next.js Advent Calendar 2020 の6日目。

突然だが、2021年 は Fullstack Next.js 元年になる。

その理由として自分は以下のものがあると思っている。

  • ベストプラクティスとしての TypeScript のデファクト化
  • Next.js の Dynamic Routes による動的パス、 getStaticProps/getServerSideProps による使い勝手の向上
  • Vercel によるISRの発明
  • prisma の成熟
  • Vercel / Serverless / Cloudflare Workers / Cloudrun 等による Node.js サーバーの運用コスト減

参考:

vue の nuxt だけではなく、 rollup の sapper, deno の aleph などの next.js follower, そして blitz や frourio のような next のラッパーライブラリも出現してきた。

2020 はこれらの試行錯誤の年だったと思っていて、来年以降はこれらがプロダクションで実用されていくようになるだろう。

とはいえ、Node やWebpackのベストプラクティスが収束しつつあるが、フルスタックフレームワークとしてのベストプラクティスが集約しきったわけではない。今回の記事では、現時点で有望そうなフレームワークを片っ端から手元で動かして、そのコードを比較していくこととする。

  • next: 本家
  • nuxt: vue 版 next
  • blitz: next+prisma で rails like fullstack
  • frourio: aspida+fastify+任意のUIフレームワーク
  • bison: ライブラリ剥き出しな控えめな blitz
  • redwood: 自前 router のフレームワーク
  • aleph: deno 版 next
  • sapper: svelte 版 next
  • sveltekit: 次期版 svelte 本体に組み込まれる sapper の後継
  • flareact: Cloudflare Workers に特化した Edge Side next
  • layr: RPC実装

Next.js の発明とは何か

  • File Based (Dynamic) Routing: ファイルを置いた場所で自動的にルーティングが決定される。また、ファイル名そのものに動的パラメータを含める。
  • Component Based SSR / Hydration: ルーティングに対応した Component が自動的にSSRされ、Hydration される。また画面遷移の差分更新のCSRも最適される。
  • Server / SSG Hybrid: 静的サイトと動的サイトを必要に応じて切り替えられる

規約を持ち込むことで生産性やパフォーマンスを最適化する方向性が Next 系の本質だと個人的に考えている。

フレームワークを試す方向性

  • index ページを作る
  • API Routes の機能があれば使う
  • そのフレームワーク特有の機能を試す
  • typescript 化する

それでは、やっていこう。


next.js

まずは、 next.js の基本的な機能を紹介する。

Next.js by Vercel - The React Framework

  • src/pages/**.tsx で export default したコンポーネントを SSR する
  • src/pages/api/**.ts で export default した関数を Lambda Function として呼び出す
$ mkdir next-app
$ cd next-app
$ yarn add react react-dom next
$ yarn add @types/react-dom @types/node typescript -D

# / に対応するコンポーネントを作成
$ code src/pages/index.tsx
export default function Index() {
  return <h1>Hello Next</h1>;
}
# /api/hello に対応する API Endpoint
$ code src/pages/api/hello.ts
export default function handler(_req, res) {
  res.send("ok");
}

開発用サーバーを起動

$ yarn next # 起動

# ブラウザで開く
$ open http://localhost:3000

# API
$ curl http://localhost:3000/api/hello
ok

プロダクションビルド

$ yarn next build

これが next 型アプリケーションの基本構成


Nuxt

vue 版 next.js

$ mkdir nuxt-app
$ cd nuxt-app
$ yarn init -y
$ yarn add nuxt @nuxt/typescript-runtime
$ yarn add @nuxt/types @nuxt/typescript-build -D
$ code tsconfig.json

tsconfig を追加

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "ESNext",
    "moduleResolution": "Node",
    "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"],
    "esModuleInterop": true,
    "allowJs": true,
    "sourceMap": true,
    "strict": true,
    "noEmit": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./*"],
      "@/*": ["./*"]
    },
    "types": ["@types/node", "@nuxt/types"]
  },
  "exclude": ["node_modules"]
}

エントリポイントを追加

$ code pages/index.vue
<template>
  <h1>Hello Nuxt</h1>
</template>
<script lang="ts">
export default {};
</script>

サーバー API を追加

$ code nuxt.config.js
export default {
  buildModules: ["@nuxt/typescript-build"],
  serverMiddleware: [{ path: "/api", handler: "~/api/index.ts" }],
};
$ code api/index.ts
import express from "express";
module.exports = express().get(
  "/hello",
  (_req: express.Request, res: express.Response) => res.send("ok")
);

今の状態

$ tree . -I node_modules
.
├── api
│   └── index.ts
├── nuxt.config.js
├── package.json
├── pages
│   └── index.vue
├── tsconfig.json
├── types
│   └── nuxt.d.ts
└── yarn.lock

サーバー起動

$ yarn nuxt-ts

next と同じように、 http://localhost:3000/ でページの表示、 http://localhost:3000/api/hello で ok が帰ってくる

ビルド

$ yarn nuxt-ts build

.nuxt 以下に出力される


Blitz

next.js + prisma で Rails ライクな規約ベースの開発フローを目指すフレームワーク。

Blitz.js - The Fullstack React Framework | Blitz.js ⚡️

詳細はこちらを参照。 https://zenn.dev/mizchi/articles/cbe81299e145491676tf8

$ npx blitz new blitz-app
$ cd blitz-app
$ blitz db migrate
$ blitz generate all project name:string
$ yarn blitz db migrate --init
$ blitz db migrate
# browser
$ open http://localhost:3000/projects

普通の next.js アプリケーションとは違い、 users や projects の下が一つのアプリケーションになっているが、これらはビルド時に一つの next.js アプリケーションとしてマージされる。詳しくは .blitz の下をみればわかる。

また、 blitz では、 */{queries,mutations}/* の下のファイルは、ある程度の規約に従えば、クライアントからでも呼べるようにコンパイルされる。詳細はこちら。

blitz-js がどうやってサーバー上の関数のクライアントでの呼び出しを実現しているのか、調査した

ビルドする。

$ blitz build

.blitz の下に、 next のディレクトリ構成に従ったディレクトリが作成される。
つまり多段にビルドされている。


Frourio

frouriojs/frourio: Fast and type-safe full stack framework, for TypeScript

フルスタックフレームワーク、というかフルスタックに適応するようなサーバーサイド生成ツール。 TypeScript+Aspida を軸に、いくつかのライブラリを選んでプロジェクトを生成する。

aspida/aspida: TypeScript friendly HTTP client wrapper for the browser and node.js.

選べるライブラリ。

  • next/nuxt
  • prisma/typeorm
  • fetch/axios

今回は next/prisma(sqlite)/fetch で試した。

.
├── README.md
├── aspida.config.js
├── components
│   └── UserBanner.tsx
├── next-env.d.ts
├── package.json
├── pages
│   ├── _app.tsx
│   └── index.tsx
├── public
│   ├── favicon.png
│   └── vercel.svg
├── server
│   ├── $server.ts
│   ├── api
│   │   ├── $api.ts
│   │   ├── $relay.ts
│   │   ├── controller.ts
│   │   ├── index.ts
│   │   ├── tasks
│   │   │   ├── $relay.ts
│   │   │   ├── _taskId@number
│   │   │   │   ├── $relay.ts
│   │   │   │   ├── controller.ts
│   │   │   │   └── index.ts
│   │   │   ├── controller.ts
│   │   │   └── index.ts
│   │   ├── token
│   │   │   ├── $relay.ts
│   │   │   ├── controller.ts
│   │   │   └── index.ts
│   │   └── user
│   │       ├── $relay.ts
│   │       ├── controller.ts
│   │       ├── hooks.ts
│   │       └── index.ts
│   ├── index.js
│   ├── index.ts
│   ├── package.json
│   ├── prisma
│   │   ├── dev.db
│   │   ├── migrations
│   │   │   ├── 20201001130532
│   │   │   │   ├── README.md
│   │   │   │   ├── schema.prisma
│   │   │   │   └── steps.json
│   │   │   └── migrate.lock
│   │   └── schema.prisma
│   ├── public
│   │   └── icons
│   │       └── dammy.svg
│   ├── service
│   │   ├── envValues.ts
│   │   ├── tasks.ts
│   │   └── user.ts
│   ├── tsconfig.json
│   ├── types
│   │   └── index.ts
│   ├── validators
│   │   └── index.ts
│   ├── webpack.config.js
│   └── yarn.lock
├── styles
│   ├── Home.module.css
│   ├── UserBanner.module.css
│   └── globals.css
├── tsconfig.json
├── utils
│   └── apiClient.ts
└── yarn.lock

19 directories, 51 files

クライアント側から見ていく。

pages/index.tsx

import Head from 'next/head'
import { useCallback, useState, FormEvent, ChangeEvent } from 'react'
import useAspidaSWR from '@aspida/swr'
import styles from '~/styles/Home.module.css'
import { apiClient } from '~/utils/apiClient'
import { Task } from '$prisma/client'
import UserBanner from '~/components/UserBanner'

const Home = () => {
  const { data: tasks, error, mutate: setTasks } = useAspidaSWR(apiClient.tasks)
  const [label, setLabel] = useState('')
  const inputLavel = useCallback(
    (e: ChangeEvent<HTMLInputElement>) => setLabel(e.target.value),
    []
  )

  const createTask = useCallback(
    async (e: FormEvent) => {
      e.preventDefault()
      if (!label) return

      await apiClient.tasks.post({ body: { label } })
      setLabel('')
      setTasks(await apiClient.tasks.$get())
    },
    [label]
  )

  const toggleDone = useCallback(async (task: Task) => {
    await apiClient.tasks._taskId(task.id).patch({ body: { done: !task.done } })
    setTasks(await apiClient.tasks.$get())
  }, [])

  const deleteTask = useCallback(async (task: Task) => {
    await apiClient.tasks._taskId(task.id).delete()
    setTasks(await apiClient.tasks.$get())
  }, [])

  if (error) return <div>failed to load</div>
  if (!tasks) return <div>loading...</div>

  return (
    <div className={styles.container}>
      <Head>
        <title>frourio-todo-app</title>
        <link rel="icon" href="/favicon.png" />
      </Head>

      <main className={styles.main}>
        <UserBanner />

        <h1 className={styles.title}>
          Welcome to <a href="https://nextjs.org">Next.js!</a>
        </h1>

        <p className={styles.description}>frourio-todo-app</p>

        <div>
          <form style={{ textAlign: 'center' }} onSubmit={createTask}>
            <input value={label} type="text" onChange={inputLavel} />
            <input type="submit" value="ADD" />
          </form>
          <ul className={styles.tasks}>
            {tasks.map((task) => (
              <li key={task.id}>
                <label>
                  <input
                    type="checkbox"
                    checked={task.done}
                    onChange={() => toggleDone(task)}
                  />
                  <span>{task.label}</span>
                </label>
                <input
                  type="button"
                  value="DELETE"
                  style={{ float: 'right' }}
                  onClick={() => deleteTask(task)}
                />
              </li>
            ))}
          </ul>
        </div>
      </main>

      <footer className={styles.footer}>
        <a
          href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          target="_blank"
          rel="noopener noreferrer"
        >
          Powered by{' '}
          <img src="/vercel.svg" alt="Vercel Logo" className={styles.logo} />
        </a>
      </footer>
    </div>
  )
}

export default Home

@aspida/swr の useAspidaSWR を使ってデータを取得している。

aspida/packages/aspida-swr at master · aspida/aspida

どうやって型を得てるんだろう?と思って調べたらこんな感じで、aspidaの自動生成された型定義を引いてる。

util/apiClient.ts

import aspida from '@aspida/fetch'
import api from '~/server/api/$api'

export const apiClient = api(aspida())

サーバーサイドを見てみよう。

  • fastify
    • fastify-jwt
  • prisma
  • dotenv

みたいな構成になっている。

npm scripts をみてみる。

    "dev": "npm run migrate:up && run-p dev:*",
    "dev:server": "webpack --watch --mode=development",
    "dev:frourio": "frourio --watch",
    "dev:prisma": "prisma generate --watch",
    "build": "npm run build:frourio && webpack --mode=production",
    "build:frourio": "npm run migrate:up && prisma generate && frourio",
    "migrate": "npm run migrate:save && npm run migrate:up",
    "migrate:save": "prisma migrate save --create-db --experimental",
    "migrate:up": "prisma migrate up --create-db --experimental",
    "migrate:down": "prisma migrate down --experimental",
    "start": "cross-env NODE_ENV=production node index.js"

どうやら frourio-app というボイラープレート生成とは別に、 frourio というコマンドが aspida 周りの型生成などをしてるっぽい。

クライアントから呼んだ tasks の実装を見てみよう。

server/tasks/controller.ts

import { defineController } from './$relay'
import { getTasks, createTask } from '$/service/tasks'

export default defineController(() => ({
  get: async () => ({ status: 200, body: await getTasks() }),
  post: async ({ body }) => ({
    status: 201,
    body: await createTask(body.label)
  })
}))

server/api/tasks/index.ts

import { Task } from '$prisma/client'

export type Methods = {
  get: {
    query?: {
      limit?: number
    }

    resBody: Task[]
  }
  post: {
    reqBody: Pick<Task, 'label'>
    resBody: Task
  }
}

server/service/tasks.ts の getTasks をみる。

import { PrismaClient } from '@prisma/client'
import { Task, TaskUpdateInput } from '$prisma/client'

const prisma = new PrismaClient()

export const getTasks = async (limit?: number) =>
  (await prisma.task.findMany()).slice(0, limit)

export const createTask = (label: Task['label']) =>
  prisma.task.create({ data: { label } })

export const updateTask = (id: Task['id'], partialTask: TaskUpdateInput) =>
  prisma.task.update({ where: { id }, data: partialTask })

export const deleteTask = (id: Task['id']) =>
  prisma.task.delete({ where: { id } })

おそらく内部的に fastify 用の JSONSchema も生成してるので、これによって fastify の JSONSchema を書くと高速化するという特性を十分に活かしてる。なるほど賢い。

api 層と service 層の切り分けも合理的。たしかにこれならモジュラーにどんなリポジトリ層の実装にも挟み込める。

もっと詳しい解説をしようかと思ったが、これ以上は frourio の記事になってしまうので、別途記事を書くことにする。

ファイルシステムの規約で振る舞いが決まることといい、制約によってパフォーマンス最適化するところで、 next.js の API サーバー版という印象を受けた。


Redwood

RedwoodJS - Bringing Full-stack to the Jamstack

prisma と Graphql を使う。 next.js インスパイア系だが ルーターと SSR が独自で、next は使ってない。TypeScript も使ってないので、色々と思想が違いそう。

$ yarn create redwood-app redwood-app

こんな感じのディレクトリ構成

├── LICENSE
├── README.md
├── api
│   ├── db
│   │   ├── schema.prisma
│   │   └── seeds.js
│   ├── jest.config.js
│   ├── jsconfig.json
│   ├── package.json
│   └── src
│       ├── functions
│       │   └── graphql.js
│       ├── graphql
│       ├── lib
│       │   └── db.js
│       └── services
├── babel.config.js
├── graphql.config.js
├── package.json
├── prettier.config.js
├── redwood.toml
├── web
│   ├── jest.config.js
│   ├── jsconfig.json
│   ├── package.json
│   ├── public
│   │   ├── README.md
│   │   ├── favicon.png
│   │   └── robots.txt
│   └── src
│       ├── Routes.js
│       ├── components
│       ├── index.css
│       ├── index.html
│       ├── index.js
│       ├── layouts
│       └── pages
│           ├── FatalErrorPage
│           │   └── FatalErrorPage.js
│           └── NotFoundPage
│               └── NotFoundPage.js
└── yarn.lock

15 directories, 27 files

クラサバは web と api で2つ区別する。

Router が独自のもの。

web/src/routes/Routes.js

// In this file, all Page components from 'src/pages` are auto-imported. Nested
// directories are supported, and should be uppercase. Each subdirectory will be
// prepended onto the component name.
//
// Examples:
//
// 'src/pages/HomePage/HomePage.js'         -> HomePage
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage

import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
  return (
    <Router>
      <Route notfound page={NotFoundPage} />
    </Router>
  )
}

export default Routes

react-router っぽさがある。


Bison

blitz と同じく、 next ラッパー系だが、blitz が next を隠蔽するのに対して、 bison は使ってるライブラリをむき出しにするのが特長。

echobind/bisonapp: A Full Stack JAMstack in-a-box brought to you by Echobind

Getting Started

$ yarn create bison-app bison-app
$ tree . -I node_modules
.
├── README.md
├── _templates
│   ├── cell
│   │   └── new
│   │       └── new.ejs
│   ├── component
│   │   └── new
│   │       └── new.ejs
│   ├── graphql
│   │   └── new
│   │       ├── graphql.ejs
│   │       └── injectImport.ejs
│   ├── page
│   │   └── new
│   │       └── new.ejs
│   └── test
│       ├── component
│       │   └── component.ejs
│       ├── factory
│       │   └── factory.ejs
│       └── request
│           └── request.ejs
├── api.graphql
├── chakra
│   └── index.ts
├── codegen.yml
├── components
│   ├── AllProviders.tsx
│   ├── CenteredBoxForm.tsx
│   ├── ErrorText.tsx
│   ├── FullPageSpinner.tsx
│   ├── LoginForm.tsx
│   ├── Logo.tsx
│   ├── Nav.tsx
│   └── SignupForm.tsx
├── constants.ts
├── context
│   └── auth.tsx
├── cypress
│   ├── plugins
│   │   └── index.ts
│   ├── support
│   │   ├── commands.ts
│   │   └── index.ts
│   └── tsconfig.json
├── cypress.json
├── graphql
│   ├── context.ts
│   ├── modules
│   │   ├── index.ts
│   │   ├── profile.ts
│   │   ├── scalars.ts
│   │   └── user.ts
│   └── schema.ts
├── jest.config.js
├── layouts
│   ├── LoggedIn.tsx
│   └── LoggedOut.tsx
├── lib
│   ├── apolloClient.ts
│   ├── cookies.ts
│   └── prisma.ts
├── next-env.d.ts
├── package.json
├── pages
│   ├── _app.tsx
│   ├── api
│   │   └── graphql.ts
│   ├── index.tsx
│   ├── login.tsx
│   └── signup.tsx
├── prettier.config.js
├── prisma
│   ├── migrations
│   │   ├── 20200726094736-initial-migration
│   │   │   ├── README.md
│   │   │   ├── schema.prisma
│   │   │   └── steps.json
│   │   ├── 20200726203519-profile-timestamps
│   │   │   ├── README.md
│   │   │   ├── schema.prisma
│   │   │   └── steps.json
│   │   └── migrate.lock
│   ├── schema.prisma
│   └── seeds.js
├── public
│   ├── favicon.ico
│   └── vercel.svg
├── scripts
│   ├── buildProd.ts
│   ├── dropDatabase.ts
│   └── yarnWithEnv.ts
├── services
│   ├── auth.ts
│   └── permissions.ts
├── tests
│   ├── e2e
│   │   ├── login.test.js
│   │   ├── logout.test.js
│   │   └── tsconfig.json
│   ├── factories
│   │   ├── index.ts
│   │   └── user.ts
│   ├── helpers.ts
│   ├── jest.setup.js
│   ├── jest.teardown.js
│   ├── matchMedia.mock.js
│   ├── requests
│   │   └── user
│   │       ├── createUser.test.ts
│   │       ├── login.test.ts
│   │       ├── me.test.ts
│   │       ├── signup.test.ts
│   │       └── users.test.ts
│   ├── unit
│   │   ├── components
│   │   │   ├── CenteredBoxForm.test.tsx
│   │   │   ├── ErrorText.test.tsx
│   │   │   ├── LoginForm.test.tsx
│   │   │   ├── Logo.test.tsx
│   │   │   ├── Nav.test.tsx
│   │   │   └── SignupForm.test.tsx
│   │   └── utils
│   └── utils.tsx
├── tsconfig.cjs.json
├── tsconfig.json
├── types.ts
└── utils
    └── setErrors.ts

41 directories, 88 files

GraphqlNexus は DSL で型定義しつつ、実装も定義するやつ。こんな感じになる。

import { objectType } from '@nexus/schema';

// Profile Type
export const Profile = objectType({
  name: 'Profile',
  description: 'A User Profile',
  definition(t) {
    t.model.id();
    t.model.firstName();
    t.model.lastName();
    t.model.createdAt();
    t.model.updatedAt();
    t.model.user();
    t.string('fullName', {
      nullable: true,
      description: 'The first and last name of a user',
      resolve({ firstName, lastName }) {
        return [firstName, lastName].filter((n) => Boolean(n)).join(' ');
      },
    });
  },
});

その prisma binding
graphql-nexus/nexus-plugin-prisma: A plugin for Nexus that integrates Prisma

blitz generate 相当のコマンドを持たない代わりに、 hygen という scaffolding ツールを使っている。

hygen で生成 - 対話形式の Component 雛形

chakra-ui は最近よく使われるのを見れるようになった、tailwind インスパイア系のコンポーネントフレームワーク。 blitz のレシピの一つにもある。マークアップエンジニアと連携しないなら便利。

ビルド。

$ yarn build

普通の next と同じようにビルドできる


Aleph

aleph は deno 製の next.js インスパイア系フレームワーク

Get Started - Aleph.js

$ aleph init aleph-app
$ cd aleph-app
$ aleph dev # localhost:8080

pages/index.tsx

import { Import, useDeno } from 'https://deno.land/x/aleph/mod.ts'
import React, { useState } from 'https://esm.sh/react'
import Logo from '../components/logo.tsx'

export default function Home() {
    const [count, setCount] = useState(0)
    const version = useDeno(() => {
        return Deno.version
    })

    return (
        <div className="page">
            <Import from="../style/index.less" />
            <p className="logo"><Logo /></p>
            <h1>Welcome to use <strong>Aleph.js</strong>!</h1>
            <p className="links">
                <a href="https://alephjs.org" target="_blank">Website</a>
                <span>&middot;</span>
                <a href="https://alephjs.org/docs/get-started" target="_blank">Get Started</a>
                <span>&middot;</span>
                <a href="https://alephjs.org/docs" target="_blank">Docs</a>
                <span>&middot;</span>
                <a href="https://github.com/alephjs/aleph.js" target="_blank">Github</a>
            </p>
            <p className="counter">
                <span>Counter:</span>
                <strong>{count}</strong>
                <button onClick={() => setCount(n => n - 1)}>-</button>
                <button onClick={() => setCount(n => n + 1)}>+</button>
            </p>
            <p className="copyinfo">Built by Aleph.js in Deno v{version.deno}</p>
        </div>
    )
}

https://alephjs.org/docs/advanced-features/use-deno-hook

useDeno はサーバーサイドでだけ実行される。クライアントでは hydration 時に実行結果が注入される。

const post = useDeno(async () => {
  return await (await fetch(`https://.../post/${params.id}`)).json()
}, true)

第2引数に true をつけるとクライアントでも実行される。

構成

.
├── app.tsx
├── components
│   └── logo.tsx
├── import_map.json
├── pages
│   └── index.tsx
├── public
│   ├── favicon.ico
│   └── logo.svg
└── style
    └── index.less

新しい routing を追加

$ code pages/foo.tsx
import React from "https://esm.sh/react";

export default function Foo() {
  return <div className="page">foo</div>;
}

API Route を追加

$ code api/foo.tsx
import type { APIRequest } from "https://deno.land/x/aleph/types.ts";

export default function handler(req: APIRequest) {
  req.status(200).send("ok");
}

request オブジェクトに対して send するの、変な感じがする…

デプロイ方法を見てたら面白そうなものを見つけた。

Vercel の他に、 Fleek というホスティングをサポートしてる。IPFS ベースのホスティングサイトらしい。ドキュメント見る感じ、任意なコンテナをアップロードできる感じ。

  • Build Command: deno run -A https://deno.land/x/aleph@v0.2.26/cli.ts build
  • Docker Image Name: hayd/deno
  • Output Directory: dist (outputDir, you can override it in aleph.config.js)
  • Environment Variables: NO_COLOR (recommended)
  • APIs(Functions): not supported currently

ただ、どちらも現状は aleph の api routes は動いてないっぽい。


Sapper

svelte ベースの next インスパイア系フレームワーク。 svelte は rollup などの開発者, Rich Harris の開発した、 Vue 風でプリコンパイル時に最適化を行う、ランタイムが限界まで薄くなったフレームワーク。

Svelte • Cybernetically enhanced web apps

Sapper • The next small thing in web development

├── README.md
├── __sapper__
│   └── dev
│       ├── build.json
│       ├── client
│       │   ├── 465898c830bb9d2c.jpg
│       │   ├── [slug]-5bc8f95f.css
│       │   ├── [slug].2922cc3a.js
│       │   ├── about.2cf04b36.js
│       │   ├── client-e118e612.css
│       │   ├── client.0d1fad1f.js
│       │   ├── index-39716d32.css
│       │   ├── index-7ed37c94.css
│       │   ├── index.4ca45b6e.js
│       │   ├── index.cd2c953b.js
│       │   ├── inject_styles.5607aec6.js
│       │   ├── sapper-dev-client.1e7a4a5e.js
│       │   └── shimport@2.0.4.js
│       ├── server
│       │   └── server.js
│       └── service-worker.js
├── package.json
├── rollup.config.js
├── scripts
│   └── setupTypeScript.js
├── src
│   ├── ambient.d.ts
│   ├── client.js
│   ├── components
│   │   └── Nav.svelte
│   ├── routes
│   │   ├── _error.svelte
│   │   ├── _layout.svelte
│   │   ├── about.svelte
│   │   ├── blog
│   │   │   ├── [slug].json.js
│   │   │   ├── [slug].svelte
│   │   │   ├── _posts.js
│   │   │   ├── index.json.js
│   │   │   └── index.svelte
│   │   └── index.svelte
│   ├── server.js
│   ├── service-worker.js
│   └── template.html
├── static
│   ├── favicon.png
│   ├── global.css
│   ├── logo-192.png
│   ├── logo-512.png
│   └── manifest.json
└── yarn.lock

10 directories, 41 files

src/routes/index.svelte をみる。

<script>
	import successkid from 'images/successkid.jpg';
</script>

<style>
	h1, figure, p {
		text-align: center;
		margin: 0 auto;
	}

	h1 {
		font-size: 2.8em;
		text-transform: uppercase;
		font-weight: 700;
		margin: 0 0 0.5em 0;
	}

	figure {
		margin: 0 0 1em 0;
	}

	img {
		width: 100%;
		max-width: 400px;
		margin: 0 0 1em 0;
	}

	p {
		margin: 1em auto;
	}

	@media (min-width: 480px) {
		h1 {
			font-size: 4em;
		}
	}
</style>

<svelte:head>
	<title>Sapper project template</title>
</svelte:head>

<h1>Great success!</h1>

<figure>
	<img alt="Success Kid" src="{successkid}">
	<figcaption>Have fun with Sapper!</figcaption>
</figure>

<p><strong>Try editing this file (src/routes/index.svelte) to test live reloading.</strong></p>

<svelte:head> みたいなカスタムタグで注入が出来たりする。

src/server.jssrc/client.js でエントリポイントがいじれる。

src/server.js

import sirv from 'sirv';
import polka from 'polka';
import compression from 'compression';
import * as sapper from '@sapper/server';

const { PORT, NODE_ENV } = process.env;
const dev = NODE_ENV === 'development';

polka() // You can also use Express
	.use(
		compression({ threshold: 0 }),
		sirv('static', { dev }),
		sapper.middleware()
	)
	.listen(PORT, err => {
		if (err) console.log('error', err);
	});

src/client.js

import * as sapper from '@sapper/app';

sapper.start({
	target: document.querySelector('#sapper')
});

sapper を触ってて明らかに next.js より便利〜と思ったのが、 src/routes/items/[id([0-9]+)].svelte のような Dynamic Routing ができる。

Docs • Sapper

next で URL のパターンで分岐したい時にこれが欲しくなるときがあった。


SvelteKit(svelte@next)

で、実は ↑ の sapper は開発が止まることが決まってて、sveltekit というフレームワークで svelte 本体と統合されるらしい。

What's the deal with SvelteKit?

$ npm init svelte@next sveltekit-app
$ cd sveltekit-app
$ yarn
$ yarn dev --open

で、面白いところとして、現時点(2020/12/5)で動いてない。この記事をはじめてみたときは動いてた気がする。
@snowpack/plugin-svelte がないので足してみるも、次のようなエラーが表示される。

Failed to init component
<Root>
Error: options.hydrate only works if the component was compiled with the `hydratable: true` option

SSR と Hydration 周りが噛み合ってなさそう。というか sapper は確か hydration に問題を抱えていたので、 svelte 本体として SSR ごと作り直してるっぽい気配がする。

動かないのはいいとして、とりあえず構成だけ掴んでおく。

.
├── README.md
├── package-lock.json
├── package.json
├── snowpack.config.js
├── src
│   ├── app.html
│   ├── components
│   │   └── Counter.svelte
│   ├── globals.d.ts
│   └── routes
│       └── index.svelte
├── static
│   ├── favicon.ico
│   └── robots.txt
├── svelte.config.js
├── tsconfig.json
└── yarn.lock

4 directories, 13 files

rollup の作者なのに、 snowpack を使っているのが面白い。たしかにどちらも native esm ベースの設計なので、相性はよさそう。

Snowpack - The faster frontend build tool

routes/index.svelte をみてみたが、特筆すべき点はない。ただの svelte component。

他に気になる点といえば、 現状のsvelte にはない svelte.config.js というのが新設されている。

const sveltePreprocess = require('svelte-preprocess');
module.exports = {
    // Consult https://github.com/sveltejs/svelte-preprocess
    // for more information about preprocessors
    preprocess: sveltePreprocess(),
	// By default, `npm run build` will create a standard Node app.
	// You can create optimized builds for different platforms by
	// specifying a different adapter
	adapter: '@sveltejs/adapter-node'
};

@sveltejs/adapter-node が気になる。これは将来的に deno や他の処理系(ブラウザ含む)でも動くことがありえるんだろうか。

まだまだ開発途上という感じで、この辺は開発が進むにつれて変わっていきそう。


Remix

今までのものとはちょっと経路が違うが、react-router の Michael Jackson と Ryan Florence がはじめた、フルスタックフレームワークの開発環境。未リリース。有料のサポーターライセンスがある。

Features | Remix

///////////////////////////////////////////////////////////////////
// Server side loaders fetch data from anywhere
const db = require("../db");
module.exports = async ({ params }) => {
  let user = await db.query(`users/${params.userId}`);
  return fetch(`https://api.github.com/users/${user.githubLogin}`);
};

///////////////////////////////////////////////////////////////////
// Data gets passed to your route component
export default function UserGithubProfile({ data }) {
  return (
    <div>
      <h1>{data.name}</h1>
      <Avatar src={data.avatar_url} />
    </div>
  );
}

サンプルコードはこれだけなので、有料登録してない自分からはまだ実体が伺えないのだが、blitzのようなフルスタックフレームワークだと予想できる。

個人的には react-router には煮え湯を飲まされた記憶しかないので近寄りがたいのだが、誰か試してほしい。


Flareact

flareact/flareact: Edge-rendered React framework built for Cloudflare Workers

Next.js インスパイアだが、 Cloudflare Workers の Edge Worker で動くように設計されたもの。

Cloudflare Workers の CLI ツールである wrangler をインストールしてセットアップする。

$ npm i @cloudflare/wrangler -g
$ wrangler generate flareact-app https://github.com/flareact/flareact-template
$ cd flareact-app
$ yarn install
$ yarn add flareact@alpha typescript
$ yarn add -D @types/react
$ yarn add @cloudflare/wrangler -D # ドキュメントにないが必要だった。パス次第?

参考: https://flareact.com/docs/typescript

動かすには cloudflare workers のアカウントが必要なので、 https://workers.cloudflare.com/ でユーザー登録して、 account_id を取得する。雑に動かす分には、制限はあるが無料。

取得した account_id を wrangler.toml にセットする

$ yarn dev # http://127.0.0.1:8787/

動的ルートと edge 実行のテストのために、 pages/xxx/[yyy]/[zzz].tsx を追加してみる。

export async function getEdgeProps({ params }) {
  return {
    props: {
      params,
    },
    revalidate: 60
  };
}
export default function Index(props) {
  return (
    <h1>
      xxx/yyy/zzz
      <pre>{JSON.stringify(props)}</pre>
    </h1>
  );
}

getEdgeProps() が next.js の getServerSideProps() 相当で、Edge Worker で実行される部分。next.js の ISR のように 60 秒後に再検証できる。

pages/api/hello.ts API ルートも追加する.

export default async (event) => {
  return new Response("ok");
};

cloudflare workers は ServiceWorker の API を模してるので、こんな感じになる。

今の構成。

pages/api/*.ts

.
├── README.md
├── dist
│   └── worker.js
├── index.js
├── out
├── package.json
├── pages
│   ├── api
│   │   └── hello.ts
│   ├── index.ts
│   └── xxx
│       └── [yyy]
│           └── [zzz].tsx
├── public
├── tsconfig.json
├── worker
│   └── script.js
├── wrangler.toml
└── yarn.lock

8 directories, 11 files

flareact の組み込みの webpack で、 dist/worker.jsout/_flareact/* の静的アセットを吐き出す。

他に Edge で動くものといえば、 next.js を AWS Lambda@Edge で動くようにする serverless-next がある。

serverless-nextjs/serverless-next.js

個人的には AWS Lambda@Edge より、 cloudflare workers を推したい。その理由として次のベンチマーク記事がある。

Serverless Performance: Cloudflare Workers, Lambda and Lambda@Edge

前触ったときよりだいぶ機能が増えていてびっくりした。良さそうなので、フィットする場所があったら採用していきたい。flareact+prisma なんて一応可能なんじゃないか?


layr

layrjs/layr: Dramatically simplify full‑stack development

RPC でクラサバで一つのモデルを操作してるように見せかけるライブラリ。ある種の isomorphism。

時間がないので手元では動かさなかったが、このリポジトリが面白い。

layrjs/crud-example-app-ts-webpack: A simple example showing how to build a full-stack CRUD app with Layr and TypeScript

backend/src/components/movie.ts

import {Component, expose, validators} from '@layr/component';
import {Storable, primaryIdentifier, attribute} from '@layr/storable';

const {notEmpty} = validators;

@expose({
  find: {call: true},
  prototype: {
    load: {call: true},
    save: {call: true},
    delete: {call: true}
  }
})
export class Movie extends Storable(Component) {
  @expose({get: true, set: true}) @primaryIdentifier() id!: string;

  @expose({get: true, set: true}) @attribute('string', {validators: [notEmpty()]}) title = '';

  @expose({get: true, set: true}) @attribute('number?') year?: number;

  @expose({get: true, set: true}) @attribute('string') country = '';
}

frontend/src/components/movie-list.tsx

import {Component, attribute, consume} from '@layr/component';
import {Routable, route} from '@layr/routable';
import React from 'react';
import {view, useAsyncMemo} from '@layr/react-integration';

import type {Movie} from './movie';
import type {Common} from './common';

export class MovieList extends Routable(Component) {
  ['constructor']!: typeof MovieList;

  @consume() static Movie: ReturnType<typeof Movie>;
  @consume() static Common: typeof Common;

  @attribute('Movie[]?') items?: InstanceType<ReturnType<typeof Movie>>[];

  @view() static Layout({children}: {children: React.ReactNode}) {
    return (
      <div>
        <h2>Movies</h2>
        {children}
      </div>
    );
  }

  @route('/movies', {aliases: ['/']}) @view() static Main() {
    const [movieList, isLoading, loadingError, retryLoading] = useAsyncMemo(async () => {
      const movieList = new this();

      movieList.items = await this.Movie.find(
        {},
        {title: true, year: true},
        {sort: {year: 'desc', title: 'asc'}}
      );

      return movieList;
    }, []);

    if (isLoading) {
      return <this.Common.LoadingMessage />;
    }

    if (loadingError || movieList === undefined) {
      return (
        <this.Common.ErrorMessage
          message="Sorry, something went wrong while loading the movies."
          onRetry={retryLoading}
        />
      );
    }

    return (
      <this.Layout>
        <movieList.Main />
      </this.Layout>
    );
  }

  @view() Main() {
    const {Movie} = this.constructor;

    return (
      <>
        <ul>
          {this.items!.map((movie) => (
            <movie.ListItem key={movie.id} />
          ))}
        </ul>
        <p>
          <button onClick={() => Movie.Creator.navigate()}>New</button>
        </p>
      </>
    );
  }
}

継承ベースのAPIは個人的に好みではないのだが、この RPC化の発想が面白い。


総評

現時点では frourio が一番使いやすそうに見える。UI層と切り離されているので、場所を選ばず投入できると思えた。

次点で blitz だが、良い規約を作れたら frourio より使いやすく進化する余地が残っている。現状はラップしてる部分が単に見通しを悪くしてるように見える。そのため bison のようなカウンターも出ているのだろう。とりあえず zod や react-final-form は

bison を使う気はあんまりしないのだが、 hygen のコード生成は blitz generate のようなルールベースの設計を個別に導入できる余地がありそうで、何にせよ hygen は手持ちの弾の一つとしていきたい。

ORM は prisma 一強。現時点でまだ prisma migrate が experimental だが、それでも兎にも角にも prisma の採用が多い。

React の UI Framework は Chakra の採用が増えているように感じる。Chakra は Tailwind インスパイアな UI フレームワークなので、 Tailwind が近年すごく伸びてて、かつ React 界 Tailwind として採用が増えているのだろう。

flareact は場所を選べば使える。 React で Edge でしかできない体験を実現したいなら一択。AWS 環境なら serverless-next もアリかも。

graphql 周りが枯れてきているが、 blitz や layr、 aspida のような RPC 化でURLを隠蔽するものが多くなってきている。個人的にも言語をまたぐならともかく、TS と組み合わせるには GraphQL はオーバーキルだと感じていて、フルスタックならこの路線の方が嬉しい。

sveltekit や aleph は… 後発な分、必須な機能の実装も足りてないなーという印象。rollup や deno での選択肢として頑張ってほしい。

この記事に贈られたバッジ