徒然かえる日記

気になった技術を徒然と。

Next.js + ReactHookForm (RHF) + Zod + MUI でバリデーションの効く UI を作る

フレームワークおまかせバリバリのバリデーションが効く UI を作ります! (ただし、テキスト入力だけ…その他は気が向いたら…)
きれいな設計としては以下参考文献がおすすめ。この文書を作る際にかなり参照させていただきました!良テキスト非常に感謝です。

ラクス斉藤様著:React Hook Form v7 + MUI v5 + zod v3を使ったコンポーネント実装例

概要

  • RHF と MUI、Zod の組み合わせでの UI 作成を Next.js で実施しようとした際のポイントをまとめた
  • とりあえず手っ取り早く動かしたい際に、最低限理解しとくポイントも記載

利用 OSS のバージョン

  • Next.js : 13.4.12
  • React : 18.2.0
  • React Hook Form : 7.45.3
  • MUI : 5.14.3
  • Zod : 3.21.4

インストール

ひとまず Next.js を利用するのでインストールします。プロジェクト名や設定はお好みで!

$ npx create-next-app@latest

MUI をインストール

$ npm i @mui/material @emotion/react @emotion/styled

RHF をインストール

$ npm i react-hook-form

Zod、RHF のための Resolvers をインストール

$ npm i zod @hookform/resolvers

CustomTemplate を利用しないならこれだけで Next.js 上で MUI を利用することができます。*1

バリデーション実現!

MUI の TextField 2つに RHF + Zod でバリデーションを効かせてみます。
以下、Next.js で利用する際のポイント。

  • MUI を使うページでは先頭に”use client”;をつける。MUI は現状 Client Component でしか動作しないので必須。*2
  • TextField に必ず id つける。styled の問題らしいが深追いしてない。。。

また、MUI + RHF + Zod の連携について、少し理解すべきことが多かったので以下にポイントをまとめます。

  • zod.infer で型作って useForm にわたすと、defaultValues などで型推論効いて便利
  • defaultValues で各値を漏れなく初期化するの超重要。 詳細はJunsei Nagao様著:React Hook Form と Zod を使うときの注意点
  • Controlled なコンポーネントは RHF の Controller でラップする必要がある。TextField は Controlled、UnControlled どちらでも行けるようだが*3、RHF と組にするとなぜか Controlled 扱いでないと動かなかったため、ラップが必要 *4
  • Controller と TextField の紐づけのため inputReffield.ref を渡す
  • Controller の render メソッドで渡される fieldState にバリデーション結果が入ってくる。それを MUI の helperTexterror に渡せば良しなにしてくれる
"use client";
import { TextField } from "@mui/material";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";

const schema = z.object({
  min3: z.string().min(3, "3文字以上で入力してください!"),
  num: z.string().regex(/^[0-9]+$/, "数字以外が入力されています!"),
});

type FormType = z.infer<typeof schema>;

export default function Home() {
  const { control } = useForm<FormType>({
    mode: "onChange",
    resolver: zodResolver(schema),
    defaultValues: { min3: "abc", num: "" },
  });
  return (
    <>
      <main>
        <Controller
          name={"min3"}
          control={control}
          render={({ field, fieldState }) => (
            <TextField
              inputRef={field.ref}
              id="alpha"
              size="small"
              variant="outlined"
              {...field}
              helperText={fieldState.error?.message ?? ""}
              error={!!fieldState.error}
            ></TextField>
          )}
        ></Controller>

        <Controller
          name={"num"}
          control={control}
          render={({ field, fieldState }) => (
            <TextField
              inputRef={field.ref}
              id="text2"
              size="small"
              variant="outlined"
              {...field}
              helperText={fieldState.error?.message ?? ""}
              error={!!fieldState.error}
            ></TextField>
          )}
        ></Controller>
      </main>
    </>
  );
}

実行結果:

実行結果

コンポーネント

このままでは格好悪いので( MUI のコンポーネント使うごとにラップは面倒くさい)、最低限まとめます。ページトップに挙げた参考文献のように分けるときれいなのですが、とりあえず MUI の素のコンポーネントっぽく使えることを最低限として、簡素でシンプルにしてみました。

コンポーネント

import TextField, { TextFieldProps } from "@mui/material/TextField";
import React from "react";
import { Control, Controller, FieldValue, FieldValues } from "react-hook-form";

type TextFieldElementProps<T extends FieldValues> = TextFieldProps & {
  name: FieldValue<T>;
  control: Control<T>;
};

const TextFieldElement = <T extends FieldValues>({
  name,
  control,
  ...textFieldProps
}: TextFieldElementProps<T>) => {
  return (
    <Controller
      name={name}
      control={control}
      render={({ field, fieldState }) => (
        <TextField
          inputRef={field.ref}
          {...field}
          {...textFieldProps}
          error={!!fieldState.error?.message}
          helperText={fieldState.error?.message ?? ""}
        ></TextField>
      )}
    ></Controller>
  );
};

export default TextFieldElement;

使う側:

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import TextFieldElement from "@/elements/TextFieldElement";

const schema = z.object({
  min3: z.string().min(3, "3文字以上で入力してください!"),
  num: z.string().regex(/^[0-9]+$/, "数字以外が入力されています!"),
});

type FormType = z.infer<typeof schema>;

export default function Home() {
  const { control } = useForm<FormType>({
    mode: "onChange",
    resolver: zodResolver(schema),
    defaultValues: { min3: "abc", num: "" },
  });
  return (
    <>
      <main>
        <TextFieldElement
          name={"min3"}
          control={control}
          id="min3-text"
          size="small"
          variant="outlined"
        ></TextFieldElement>

        <TextFieldElement
          name={"num"}
          control={control}
          id="num-text"
          size="small"
          variant="outlined"
        ></TextFieldElement>
      </main>
    </>
  );
}

割りと素の MUI コンポーネントみたいに使えるようになったのではないでしょうか!
ただ、Controller や TextField などで利用されるプロパティを使う側から渡してしまうと( onChange とか )、意図したようには動作しないので注意が必要です。この辺は RHF を普通に使う場合も同じかもしれませんが。

ではでは。

ハンダ後にスタビライザーの静音化をして微妙な結果を得た

標題の件、DZ60キーボードのキーキャップを新しいものに取り替えるついでに、前々から気になっていたスタビライザーのカチャカチャ音を低減することを試みたので記録に残す。

要約

ハンダ付したスイッチを外したく無いがため独自の静音化を試みた。そして失敗した! やはり静穏化は組み立て前にするのが絶対いい。 究極的な解決策はスタビライザー使わない。

スタビライザー静音化とは

キーボードの長めのキーにはキーの押下が均一となるよう通常棒状の金属パーツ、スタビライザーが付与されている。このパーツがないと、キーの端を押さえた際に片側だけ沈み込んでうまくスイッチが押し込めない事になるので重要なパーツであるのだが、意図してか部品同士の遊びが多く、プラスチックや金属が接触してカチャカチャと不快な音を立てる。感触的にもキーを押し込む前にカチャっとひっかかるためよくない。
そこで、なんとかしてノイズを低減、できるなら押下時の感触も向上したいというのが作業の目的となる。

一般的なスタビライザー
一般的なMXスタビライザー

通常この手の作業はキーボード組み立て時に行うのだが、なにぶん私が素人であったことや組み立て時に参考にした記事に記載がなかったことからできていなかった。
では、分解すればいいじゃんと思うかもしれないのだが、スタビライザーはPCBとキーを固定するためのプレートにサンドイッチされ、そこをハンダ付けされたスイッチがカスガイのごとくガッチリホールドしているため、ハンダをとって、スイッチを取らなければ取り外しや分解ができない。
ハンダが苦手な私には時間がかかる上、ともすればPCB破損のリスクもあり、その上面倒でやりたくはないのだった。海外にも同じ境遇の同志がいるようで"mod stabilizer without desoldering"でググると沢山悲鳴が聞こえてくる。が、いまいちこれといった解決策がないので自分で考えて試してみた。ところ撃沈した。悲しみ。。。

ノイズの発生源

おそらくノイズの発生源は以下三つが存在する。結局、スタビライザーの稼働部分と固定部分全てだ。
今回は1と2を想定してそれぞれ対策した。3は今回対策後に気づいた最も重要な原因。そして、今回は対策できてない。。。

  1. 基板とスタビライザーの土台部分のガタつき
  2. スタビライザーの土台部分と稼働部分のガタつき
  3. スタビライザーの稼働部分と金属パーツのガタつき

ノイズの発生源
ノイズの発生源(①のツメでPCBに固定されている)

基板側の対策

スタビライザーとPCBにできる隙間を塞いでノイズ対策をする。ネジで固定するスクリューインタイプのスタビライザーを使うのがベストだが、そうでないスタビライザーには隙間を絆創膏や、専用のフィルムで塞ぐ対策が知られている。

スクリューインタイプのスタビライザー
GMK Screw-in Stabilizers 104 kitshop.yushakobo.jp

専用のフィルム
KBDfans Stabilizers foam sticker (20Pcs)shop.yushakobo.jp

今回はPCBとスタビライザーを分解することができないので、基板下から抑え込むことにした。使用する道具はこちら。
エポキシパテ!模型とかで使われる硬化材だ。粘土をこねこねして成形後、およそ1日でプラスチックのようにもしくはそれ以上に硬くなる。

www.cemedine.co.jp

で、これをおもむろにスタビライザーのツメの部分に押し込んでペタペタする。スクリューインタイプのスタビライザーを再現と言われるとそんな感じがするが、それとは違い恐らくもうスタビライザーの取り外しは不可能だ!

エポキシパテによる施工例
エポキシパテでスタビライザーの根本を固定した

で、硬化後、PCBとスタビライザーは完全に一体となった!
土台部分のガタつきはなくなり目的を達成出来た!こころなしかキーを押した時の不快な感触が減ったような。。。プラシーボレベルで。。。
しかしこの時点でも、やはりカチャカチャいうのに変わりない。次なる対策に向かう。

スタビライザー側の対策

スタビライザーの稼働部分に大きな隙間があるので、稼働部分にテフロンテープを貼り、隙間を埋めつつスムーズにするという対策がある。*1 しかしスタビライザーが分解できてこその技。今回は使えないので、何とか隙間を埋められないか考えた。

対策したい隙間
ここの隙間

そこで編み出したのがクリアファイル差し込み。
クリアファイルを稼働部分と土台部分との接触面と同じサイズに切り、片側に接着剤をつけて差し込んで固定する。

  接着剤をつけずに手元のスタビライザーでやったところ、かなり良好な動き具合。若干遊びはあるものの、クリアファイルの柔らかさにより部品同士が接触してもカチャカチャしない!
ということで、早速接着。

クリアファイルの施工例
クリアファイルなので見えないかもしれないが接着されている。。。

で、接着後にキーをはめて試したところ、キーが戻らない!スタビライザーが接着剤で固まったとかではなく、スタビライザーの左右が窮屈な感じ。推測だが、土台部分、稼働部分、キーの接続部分が全て平行ならスムーズに動いたのだろうが、それぞれ曲がっている上、遊びがないため動きを阻害することになっているようだ。。。

こちらの対策は完全失敗。PCB側の対策をしていなければ、こちらの方が効き目があったかもしれないが。。。
泣く泣く切り刻んだクリアファイルの残骸をスタビライザーから剥がして、稼働部分に少し残った接着剤を綺麗に清掃した*2。細く切ったクリアファイルくんを差し込んでゴシゴシ。切り刻まれた上役にも立たず、その上後始末をさせられるクリアファイルがなんだか不憫に思えてきた。。。

本当に必要だった対策

PCB側だけスタビライザーを固定してなおもカチャカチャ言ってるキーボードを見ながら気づいた。カチャカチャ言ってるのは、スタビライザーの稼働部分と金属の接触部分だよな。。。なんか周辺ばっかり対策してたわ。。。
こちらは正直ルブを塗るくらいしか対策が思いつかない、が恐らくそれが最も効果が高いはず!*3ということで今回の対策を終わった。カチャカチャしないほどにルブを塗るのはかなりルブを使う事になりそう。今回はルブもないし先送りに。。。 *4

おまけ

PCBをケースから取り外す際に、ケースに破損を発見!
ケースとPCBを固定するための台座でナットを固定している部分が全て壊れている。。。状況から見るに経年劣化か、はたまた私が気づかずにアルコールで拭いたからかによるクラック様の破損だった。スタビライザーの固定でも使ったエポキシパテで修復&補強!

破損修理
PCB固定のためのナットがついている部分の破損を修理

元々はこんなんでした。。。
60% プラスチックケースshop.yushakobo.jp

エポキシパテのおかげでがっちり固まった。クリアなケースにパテ跡が何だかみすぼらしい。。。
しかしながら今回買ったキーキャップをセットすれば見えません!小さいことは気にしない!

修理の後
いろいろ修理した後、外が良ければ全て良し!

以上。
何ヶ月後になるかは分からないが、今度はラズパイでキーボードの原型を作る記事を書こうと思います。

*1: 実施されてる方 【自作キーボード】スタビライザーがうるさいので静かにする | ONION BLOG

*2: 瞬間接着剤だったら終わっていた。そうでなくて良かった。。。

*3: すごく丁寧に解説されてる方 スタビライザーのルブの話 - 自作キーボード温泉街の歩き方

*4: ワセリン使う例が海外では多い。オリーブオイル使う記事も見かけたが流石にそれは抵抗があるな。。。

DenoランドでReactのTodoリスト(2022 May)

きっかけ

1 年前の v1.0.0 リリース時に見かけた Deno、当時は興味を持ちながらもお試しを先送りしていましたが、1 年経った今、目にする機会も多くなり気になったのでお試ししてみました。

お試し結果

  • インポートの管理(import_map) やタスクランナー(Tasks) など、実開発で必須だが足りなかった部品が標準として登場、使い勝手が向上しているよう感じました。
  • ESM の標準サポートで node.js で利用できるメジャーなモジュールはほぼ使える模様。しかし依存関係が多いものはそこが起点となって使えなくなるか、動かすのに相当苦労するケースがあります。
  • 利用する中での苦労はまだまだ多いようです。使う中でサンプルが無く Github の issue や、コードを読んでなんとか使うようなケースが多々あリます。
  • 標準プラットフォームに全部入りという利点は大きいと感じました。現時点でもツール系で力を発揮する可能性があるかと思います。

コード置き場

https://github.com/zackaeru/todoAtDeno

フロントだけで動く版:https://github.com/zackaeru/todoAtDeno/tree/feature/frontOnly

DenoランドでReactのTodoリスト作る

Deno の概要

  • Rustで作られ、V8で動くJavaScript TypeScriptの実行環境
  • TypeScript をプラットフォームレベルでサポート
  • ES Module を標準サポート、Common JS は使えない (互換レイヤによって一部は使える模様 *1
  • Lint や Formatter を標準ツールとして包含している。開発系ツールは基本標準プラットフォームとして提供する方針
  • トレードマークの恐竜がかわいい

環境構築

Deno のインストール

brew install deno

VSCode 拡張のインストール
公式の拡張をインストールする。 インストール後要再起動。コマンドパレットからDeno: Initialize Workspace Configurationで勝手に VSCode 用の設定を作ってくれる。以下、作成されたのち若干の修正を加えた.vscode/settings.json

{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true,
  "[typescript]": {
    "editor.defaultFormatter": "denoland.vscode-deno"
  }
}

公式の解説から shell のオートコンプリート設定を行う。

そして早速、Hello, World を表示してみる。
適当に 以下内容のファイル(ここではfoo.tsと名付けた)を用意。内容的には JS でしかないけど。

console.info(“hello, world”);

denoに食わせると実行してくれた!追加で何か入れる必要全くなし。これが TypeScript ネイティブの力だよ。

deno run foo.ts

なお、runの後ろはファイル名以外に URL 指定でもいけるらしい。なんだかすごい。

フロントエンド作り

後から思い立って、フロントエンド-バックエンドと分けたのでまずはフロントエンドのUIから作る。

プロジェクト構成(フロントエンド)

早速フロント用のプロジェクトを作ってみる。以下構成にした。

./
├── .vscode
│   └── settings.json
└── front
    ├── deno.json
    ├── import_map.json
    ├── dist
    │   ├── sample.js
    │   ├── style.css
    │   └── index.html
    └── src
        └── study
            ├── foo.ts #先程のハロワもここに仲良くつっこんである。。。特に深い意味はない。
            └── sampleApp.tsx

プロジェクト設定(フロントエンド)

deno.jsonを見様見真似で作ってみる。
公式のマニュアルを参照。

{
  "compilerOptions": {
    "lib": ["deno.window"],
    "jsx": "react",
    "jsxFactory": "React.createElement",
    "jsxFragmentFactory": "React.Fragment"
  },
  "lint": {
    "files": {
      "include": ["src/**/*"],
      "exclude": []
    }
  },
  "fmt": {
    "files": {
      "include": ["src/**/*"],
      "exclude": []
    },
    "options": {
      "useTabs": false,
      "lineWidth": 100,
      "indentWidth": 4,
      "singleQuote": true,
      "proseWrap": "always"
    }
  }
}

いつの間にかタスクランナーが登場しているのでタスクを定義してみる。 (.vscode/setting.json"deno.unstable": trueを設定しておく必要あり) 構文は npm script と似ている。deno.jsontasksに以下定義

"tasks": {
  "run": "deno run ./src/study/foo.ts"
}

deno task runで先程の Hello, World が呼べた。 deno taskが登場するまでは、Velociraptorが使われていたようだ。yaml でタスクを記述するので、yaml 好きだとこれが良いかも。

次に、React のライブラリをインポートする。
今回は大人しく本家 React を使うことにした。 Next.js の Deno 版でAleph.jsを見つけたのだが、v1.0.0 リリースが大詰めらしく、公式にて「今は使うな」と警告されていたため。

Deno は ES Module を利用できるので import で ES Module の URL を指定すればそれを取り込むこむことができる。以下のような CDN の配布サイトから URL を取得できる。

今回はドキュメントが豊富な skypack を利用することにした。*2
URL 末端に?dstを付与することで TypeScript 向けの型情報が利用できる。なお、CDN ごとにこの辺の URL 仕様は異なる模様。

空のsampleApp.tsxを作成して、import 文に URL を指定。

import React from "https://cdn.skypack.dev/react@17.0.2?dts",
import ReactDom from “https://cdn.skypack.dev/react-dom@17.0.2?dts",

記載しただけでは、「モジュールが存在しない」ような注意が。そこでターミナルから以下deno cacheすると import の内容をダウンロードできる。npm i的な。するとエディタ側で無事認識される。

deno cache ./src/study/sampleApp.tsx

モジュールの依存関係を見るにはdeno info

deno info ./src/study/sampleApp.tsx

見られるのはいいんだけど、依存モジュールのバージョン固定したり無理くりバージョンあげるにはどうすれば良いんだろう。。。今後要調査 *3

ライブラリのバージョンアップごとに import 文を書き換えるのも面倒なので、import_map を利用する。 import_map.jsonを追加。以下を記載する。

  "imports": {
    "react": "https://cdn.skypack.dev/react@17.0.2?dts",
    "react-dom": "https://cdn.skypack.dev/react-dom@17.0.2?dts",
    "./": "./"
  }
}

sampleApp.tsxの import 文を変更。

import React from "react";
import ReactDOM from "react-dom";

先程のdeno cacheで問題なく解決されることを確認。

React での UI 作り

deno.jsonの lib に記載しているdeno.windowはサーバ向けのライブラリしか持っていないため、以下内容に変更。

"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"],

import 文 しかなかったsampleApp.tsxHello World のコードを追加。

import React from "react";
import ReactDOM from "react-dom";

addEventListener("DOMContentLoaded", () => {
  main();
});

const main = () => {
  ReactDOM.render(<App />, document.querySelector("#root"));
};

const App = () => {
  return (
    <>
      <h1>Hello, World!</h1>
    </>
  );
};

記述中に Deno の VSCode 拡張がdocumentをうまく解決しないことを発見。「documentが存在しません」みたいな警告が出る。

調べたところ、.vscode/setting.jsonに以下の設定で、deno.jsonの場所を指定するとdeno.jsonに記述したlibの内容を拡張が認識してくれるようだ。

  "deno.config": "./front/deno.json"

ただ、.vscodeはプロジェクトごと、deno.jsonはモジュールごとに定義だけどモノレポ構成だとどうすれば良いんだろ。複数指定するのか? と思ったらまだ未解決だった。。。 *4

当面の解決策としては、各プロジェクトで.vscodeを作って、プロジェクトごとに VSCode を開くという感じ。。。 *5

寄り道したけど、React の UI をバンドルする。

deno bundle ./src/study/sampleApp.tsx ./dist/sample.js

うまく出力されたようなので、表示するためのindex.htmlを作成しfront/dist/以下に配置。

<html>
  <head> </head>
  <script src="./sample.js"></script>
  <body>
    <div id="root"></div>
  </body>
</html>

front/dist/index.htmlを表示すると Hello, World の文字列が!

ついでに、先程の bundle コマンドをdeno.jsondeno tasksに登録。

  "tasks": {
    "bundle": "deno bundle ./src/study/sampleApp.tsx ./dist/sample.js",
...

これで以下コマンドからバンドルできるようになった。

deno tasks bundle

後は淡々とコードを書くのみ。sampleApp.tsxにひたすら追加。

import React, { useState } from "react";
import ReactDOM from "react-dom";

addEventListener("DOMContentLoaded", () => {
  main();
});

const main = () => {
  ReactDOM.render(<App />, document.querySelector("#root"));
};

type Todo = { id: number; value: string };

type TodoArray = Todo[];

const App = () => {
  const [todo, setTodo] = useState<TodoArray>([]);
  const [id_counter, setIdCounter] = useState<number>(0);
  const deleteHandler = (deleted: Todo) => {
    setTodo(todo.filter((todo) => todo.id !== deleted.id));
  };
  return (
    <>
      <h1>シンプルなTodoリスト</h1>
      <TodoInputForm
        todos={todo}
        setTodoState={setTodo}
        currentMaxId={id_counter}
        setIdCounterState={setIdCounter}
      ></TodoInputForm>
      <TodoList todos={todo} deleteHandler={deleteHandler}></TodoList>
    </>
  );
};

type TodoItemProps = {
  deleteHandler: (todo: Todo) => void;
  todo: Todo;
};

const TodoElement: React.VFC<TodoItemProps> = (props) => {
  return (
    <li>
      <div>{props.todo.value}</div>
      <Button
        handler={() => {
          props.deleteHandler(props.todo);
        }}
      >
        del
      </Button>
    </li>
  );
};

type ButtonProps = {
  handler: () => void;
  children: React.ReactNode;
};

const Button: React.VFC<ButtonProps> = (props) => {
  return <button onClick={props.handler}>{props.children}</button>;
};

type TextBoxProps = {
  onChange: React.Dispatch<React.SetStateAction<string>>;
};

const TextBox: React.VFC<TextBoxProps> = (props) => {
  return (
    <input
      type="text"
      onChange={(evt) => {
        props.onChange(evt.target.value);
      }}
    ></input>
  );
};

type AddTodoProps = {
  todos: TodoArray;
  currentMaxId: number;
  setIdCounterState: React.Dispatch<React.SetStateAction<number>>;
  setTodoState: React.Dispatch<React.SetStateAction<TodoArray>>;
};

const TodoInputForm = (props: AddTodoProps) => {
  const [textInput, setTextInput] = useState("");
  const addTodoHandler = async () => {
    const newId = ++props.currentMaxId;
    props.setIdCounterState(newId);
    props.setTodoState([...props.todos, { id: newId, value: textInput }]);
  };
  return (
    <div>
      <TextBox onChange={setTextInput}></TextBox>
      <Button handler={addTodoHandler}>Add Todo!</Button>
    </div>
  );
};

type TodoListProps = {
  deleteHandler: (todo: Todo) => void;
  todos: TodoArray;
};

const TodoList: React.VFC<TodoListProps> = (props) => {
  return (
    <ul>
      {props.todos.map((todo) => {
        return (
          <TodoElement
            todo={todo}
            deleteHandler={props.deleteHandler}
          ></TodoElement>
        );
      })}
    </ul>
  );
};

以上でバンドルしてindex.htmlを開くと無事 Todo リストが開き動作したのだが、最後の最後で問題発生。Material UIかReact Bootstrapで見栄えを良くしようと考えてインポート、ソースを書いてバンドルしたところ、エラー発生で動かなくなる。
依存ライブラリが増えると Deno さんで不具合出る可能性が上がるのかも。動かすのに時間かかりそうなので今回は以下自前 CSS で最低限の見た目を整えた。そして Todo リストが完成。

ul {
  width: 30em;
}

li {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: center;
  border-radius: 5px;
  border-color: gray;
  border-width: 1px;
  border-style: solid;
  padding: 3px;
  margin-top: 3px;
}

li div {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

button {
  margin: 3px;
  padding: 3px;
}
  • index.html
<head>
  <link href="./style.css" rel="stylesheet" />
</head>

至って普通なTodoリスト
なんて事のないTodoリスト

バックエンドの作成

プロジェクト構成(バックエンド)

せっかくなのでバックエンドも作成してみる。

バックエンドの構成は以下の通り。フロントの横に生やしただけ。*.tsをそのまま実行できるため、distディレクトリが存在しない。少しスッキリ。

./
├─── front
│    └── 略
└─── back
    ├── .vscode
    │   └── setting.json #deno拡張にlibを認識させるため
    ├── deno.json
    ├── import_map.json
    └── src
        ├── server.ts
        └── todo.ts

プロジェクト設定(バックエンド)

今回は Deno ネイティブな HTTP サーバOAKを使ってみる。 import_map.json を作成し、oak を追加。 server.ts を作成し、import から oak を参照するようにする。

{
  "imports": {
    "oak": "https://deno.land/x/oak@v10.5.1/mod.ts",
    "./": "./"
  }
}
  • server.ts
import { Application } from "oak";

フロントの部分で記載した、deno.jsonlibが解決されない問題が発生するため、back/.vscode/setting.jsonを作成してバックエンド単体で VSCode を開く。

{
  "deno.enable": true,
  "deno.lint": true,
  "deno.unstable": true,
  "[typescript]": {
    "editor.defaultFormatter": "denoland.vscode-deno"
  }
}

バックエンド作り

後はコードを書くのみ。以下コード掲載します。 file:///にて React アプリを開くことに固執したため少し複雑になってしった。。。

  • back/src/server.ts
import { Application, Router, RouterMiddleware, Status } from "oak";
import { TodoRepo } from "./todo.ts";

const todoRepo = new TodoRepo();

const app = new Application();
const router = new Router();

const PORT = 8000;
const HOSTNAME = "localhost";
const TODO_PATH: string = "/todo";

//file から localhost 通すために CORS 有効にしました(テスト用劇ゆる設定注意)
const experimentalCorsResponse: RouterMiddleware<string> = async (
  ctx,
  next
) => {
  ctx.response.headers.set("Access-Control-Allow-Origin", "_");
  ctx.response.headers.set("Access-Control-Allow-Methods", "_");
  ctx.response.headers.set("Access-Control-Allow-Headers", "*");
  ctx.response.status = Status.NoContent;
  await next();
};

router.options(TODO * PATH, experimentalCorsResponse);
router.get(TODO_PATH, async (ctx, next) => {
  ctx.response.headers.set("Access-Control-Allow-Origin", "*");
  ctx.response.body = JSON.stringify(todoRepo.list());
  ctx.response.type = "json";
  ctx.response.status = Status.OK;
  await next();
});
router.post(TODO * PATH, async (ctx, next) => {
  const body = await ctx.request.body();
  if (body.type == "json") {
    const todoValue = (await body.value).value;
    if (todoValue) {
      ctx.response.headers.set("Access-Control-Allow-Origin", "*");
      ctx.response.body = todoRepo.add(todoValue);
      ctx.response.type = "json";
      ctx.response.status = Status.OK;
      await next();
    } else {
      ctx.response.headers.set("Access-Control-Allow-Origin", "*");
      ctx.response.body = "todo value is empty";
      ctx.response.type = "text/plain";
      ctx.response.status = Status.BadRequest;
      await next();
    }
  }
});

router.options(`${TODO_PATH}/:id`, experimentalCorsResponse);
router.delete(`${TODO_PATH}/:id`, async (ctx, next) => {
  todoRepo.delete(Number.parseInt(ctx.params.id));
  ctx.response.headers.set("Access-Control-Allow-Origin", "*");
  ctx.response.status = Status.NoContent;
  await next();
});

app.use(router.routes());
app.use(router.allowedMethods());

await app.listen({ hostname: HOSTNAME, port: PORT });
  • back/src/todo.ts
export type Todo = {
  id: number;
  value: string;
};

export class TodoRepo {
  private static id_counter = 0;
  private todoList: Todo[] = [];

  add = (value: string) => {
    const todo = { id: TodoRepo.id_counter++, value: value };
    this.todoList.push(todo);
    console.info(`add ${todo.id}`);
    return todo;
  };

  delete = (id: number) => {
    this.todoList = this.todoList.filter((todo) => todo.id !== id);
    console.info(`delete ${id}`);
  };

  list = () => {
    console.info(`list ${this.todoList.flatMap((todo) => `${todo.id}`)}`);
    return [...this.todoList];
  };
}

実行はdeno.jsonに以下タスクを追加して実行。Deno はネットアクセスやファイルアクセスにパーミッションを指定してやる必要があるので注意(以下--allow-netの部分)。

"tasks": {
  "run": "deno run --allow-net ./src/server.ts"
}

シンプルな Todo リストができました。フロント単体版と見栄えは全く違いがないが、リロードしても値を持ち続けてるところだけ動きが変わってる。。。

少し OAK を使ってみての感想。

  • 明確な send 系メソッドがなく少し不安。ただ、これはこれで利点はあるのかも。誰かが send したばかりに以降のハンドラが呼ばれなくなるなどは無くなりそう。*6
  • 例外用のハンドラは無く、await next();をキャッチして例外処理する。例外処理を先頭に書くことになるので違和感が。慣れの問題か。
  • リクエストやレスポンスについて@types/express-server-static-coreにあるようなジェネリクスでの型付けがない?json を型変換するのが少しだけ面倒かも。(あったら申し訳ない。知ってる方、教えてください。)

最後に

今回は Deno の上に、React で Todo アプリを作ってみました。Deno で動かす苦労よりも久しぶりに使った React を思い出す苦労の方が多かった気が。。。

まだまだ開発環境やライブラリの動作で苦労することは多いように感じました。しかし、確実に普及に向けて進化しているのを感じられましたし、TypeScript ネイティブの使用感や ESM のインポートからは環境構築が格段に楽になりそうという手応えがありました。

次回触る際は、今回あまり扱えなかった標準ライブラリ周りを試してみたいと思います。豊富になっているとの噂も聞いており、Deno 君は環境構築の速さからしばらくツール系を作るのにお役に立ちそうな気がしています。*7

また、そのうち今回の Todo で出来なかったAleph.jsMaterial UIなどなど 3rd のライブラリを色々と取り込んで試せたらと思います。

*1: 公式から

*2: どれか一つしか使えないということはない

*3:多分一年後くらい。。。

*4: github でのやりとり

*5: 解決策の一例@stackoverflow

*6: そんなんで不具合を出すのは私ぐらいしかいないかもなのだが。。。

*7: 開発環境構築楽なので新人研修の材料とかも良いかも