はじめに
varとかfunctionとかいう文字列を見ると蕁麻疹が出るようになった今日このごろ。
IEはご臨終なさったのだ。
Babelさんありがとうの感謝を込めてフォームが簡単に作れることを示してみたい。
環境構築から
- 最新のnodeを入れたら適当なディレクトリでnpm initしてEnter連打して「yes」。
- npm i parcel
- package.jsonを開いてscriptsの項目を変更
"scripts": {
"start": "parcel ./index.html",
"build": "parcel build ./index.html",
"test": "echo \"Error: no test specified\" && exit 1"
},
- 以下のファイルを作る
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
</head>
<body>
<div id="root"></div>
<script type="text/javascript" src="./src/index.tsx"></script>
</body>
</html>
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import Form from './form'
ReactDOM.render(<Form />, document.getElementById('root'))
import * as React from 'react'
const Form = () => <div><h1>Hello Form</h1></div>
export default Form
- npm run start
これでlocalhost:1234にアクセスしてHello Formが見られればひとまず成功。
Parcelが裏でReactとかTypeScriptのモジュールをダウンロードしてくれます。
噴き出してしまうほど簡単ですね。
VsCodeでコーディング用の設定も作っておきます。
- npm i -D @types/react, @types/react-dom, tslint
- mkdir .vscode
{
"recommendations": [
"eg2.tslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}
{
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.renderWhitespace": "boundary",
"editor.rulers": [
120
],
"files.encoding": "utf8",
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/public/**": true,
"**/.cache/**": true
},
"[javascript]": {
"editor.formatOnSave": true
},
"[typescript]": {
"editor.formatOnSave": true
},
"[typescriptreact]": {
"editor.formatOnSave": true
}
}
VsCodeでこう保存すると
const Form = () => {
return (<div><h1>Hello Form!</h1>
</div>
)
}
こう整形されます。楽ですね。
const Form = () => {
return (
<div>
<h1>Hello Form!</h1>
</div>
);
};
これで現代人のjavascript環境が最低限整ったので作っていきます。
- npm run start
VsCodeでソースを変えて保存するとブラウザのF5を押さなくても変更が反映されていることがわかるかと思います。
これが地味なようで開発には大きな恩恵なのです。
設計方針
cssをどうするか
cssに銀の弾丸はない。
というのもcssの仕様が超複雑なルールによって適用されるようになっているからだ。
なので構造的にcssを組んでいくには何らかの仕組みが必要なわけだが、
統一的な勢力はなく、みんなが好き勝手に"ぼくがかんがえたさいきょうのCSS環境"を作っているからだ。
全部一人でやる場合の考え方としては実装とデザインの融合が望ましいというのがある。
というのもReactによるコンポーネント化によってcssが外部に汚染することがなくなったからだ。
BEMとかPostCSSとは何だったのかという状態。
ならスコープを狭められるのがよい。
このhtmlタグのデザインはどこいったかな~と探し回るようではいけない。
理想は近接かインラインで書くことを目標としましょう。
分業なら逆にファイルが分かれていた方がやりやすいかもね。
ここではReactとも親和性が高いemotionを使ってみるよ。
- npm i emotion, @emotion/core, @emotion/styled
リセッターとしてはress.cssを使う。
ローカルにダウンロードしてきてindex.htmlのheadで読み込んでおこう。
<link rel="stylesheet" type="text/css" href="ress.min.css">
フォームの雛形を作る
import * as React from "react";
import { useState } from "react";
const Form = () => {
const [radio, setRadio] = useState(true);
const [text1, setText1] = useState("");
const [text2, setText2] = useState("");
console.log(
`radio: ${radio ? "Old" : "Modan"}, textBox1: ${text1}, textBox2: ${text2}`
);
return (
<form>
<input type="radio" onChange={() => setRadio(true)} checked={radio} />
<span>Old Style</span>
<input type="radio" onChange={() => setRadio(false)} checked={!radio} />
<span>Modan Style</span>
<input
type="text"
onChange={(e: any) => setText1(e.target.value)}
value={text1}
/>
<input
type="text"
onChange={(e: any) => setText2(e.target.value)}
value={text2}
/>
<button>送信</button>
</form>
);
};
export default Form;
スタイルを廃した要素だけのフォームができました。
2つのテキストボックスとラジオボタンの状態がconsole.logで取れているはず。
React Hooksの力ってすげー。
ボタンコンポーネントの分離
- ボタンとは押した時に関数を実行する要素である。
- 常にボタンが押せるのではなく無効化状態を用意したい。
- ボタンが有効なときはホバーでのインタラクションでユーザーに伝えたい。
ボタンを上記のように定義して、そのエッセンスだけを切り出して実装するとこうなる。
/** @jsx jsx */
import { jsx } from "@emotion/core";
interface Props {
text: string; // 表示されるテキスト
isButtonOn: boolean; // ボタンのオンオフの状態
enable: boolean; // trueならクリックしても関数を実行しない
onClick: any;
}
const ButtonOnStyle = {
button: {
color: "#021a3f",
background: "lightblue"
}
};
const ButtonOffStyle = {
button: {
color: "#052d0c"
}
};
const ButtonEnableStyle = {
button: {
"&:hover": {
background: "lightgreen"
}
}
};
const ButtonDisableStyle = {
opacity: 0.3
};
const ButtonStyle = {
button: {
height: "2rem",
padding: "0 2rem",
border: "1px solid #003366"
}
};
const Button = (props: Props) => (
<div css={ButtonStyle}>
<div css={props.isButtonOn ? ButtonOnStyle : ButtonOffStyle}>
<div css={props.enable ? ButtonEnableStyle : ButtonDisableStyle}>
{props.enable ? (
<button onClick={() => props.onClick()}>{props.text}</button>
) : (
<button>{props.text}</button>
)}
</div>
</div>
</div>
);
export default Button;
どうです?最後のrender部分。
状態に応じてスタイルがどう変わるのかがわかりやすいすっきりとしたcss実装だと思いませんか。
ソース自体もフルconst、pure function、return文onlyで省略されたreturn表記。
音ゲーでいうとフルコンボみたいな状態で、このようなソースがすごく気持ちいいです。
スタイルをつけてみる
HTMLにおけるCSSのデザインとは長方形のレンガのブロックを組み立てていくようなものをイメージしないといけない。
そうすることによって変形に強い堅いバグが起こりにくいデザインとなる。
レンガに隙間が空いてるとずれたりする不条理もあるが、
floatとかposition: absolute;といったわたがしみたいな素材で建築する地獄と比べると天国。
floatはIEと共にHTMLの墓場に葬りましょう。
あと要素を動かしたい場合はjavascriptでやるのではなくcssアニメーションでtransformとかを使う。
間違ってもmarginとかwidthとかをアニメーションで変えてはダメだ。
これはレンガを動的に動かしているようなもので建物ごと崩れてしまう。
では例として下のイメージボードのデザインを参考にcssを実装してみよう。
/** @jsx jsx */
import { jsx, css } from "@emotion/core";
import * as React from "react";
import styled from "@emotion/styled";
import { useState, useReducer } from "react";
import Button from "./button";
const Form = () => {
const [radio, setRadio] = useState(true);
const [text1, setText1] = useState("");
const [text2, setText2] = useState("");
const [isLoading, dispatch] = useReducer(
(state: boolean, action: boolean) => action,
false
);
const [serverResult, setServerResult] = useState("");
const RadioArea = styled("div")`
margin-top: 1rem;
margin-bottom: 1rem;
input:not(:first-of-type) {
margin-left: 2rem;
}
`;
const radioButtonArea = (
<RadioArea>
<div>Select Form Style</div>
<input type="radio" onChange={() => setRadio(true)} checked={radio} />
<span>Old Style</span>
<input type="radio" onChange={() => setRadio(false)} checked={!radio} />
<span>Modan Style</span>
</RadioArea>
);
const textBox = {
div: {
"&:nth-of-type(2)": {
border: "solid 1px #000000"
}
}
};
const CreateTextBoxArea = (
props: any,
description: string,
message: string,
placeholder?: string
) => (
<div css={textBox}>
<div>{description}</div>
<div>
<input
size="40"
type="text"
{...props}
autoComplete="off"
placeholder={placeholder}
/>
</div>
<div css={{ marginLeft: "4rem" }}>{message}</div>
</div>
);
const textBoxArea1HeaderFunc = (text1: string) => {
return `textBox1`;
};
const textBoxArea1MessageFunc = (text1: string) => {
return `length=${text1.length}`;
};
const textBoxArea2HeaderFunc = (text2: string) => {
return `textBox2`;
};
const textBoxArea2MessageFunc = (text2: string) => {
return `length=${text2.length}`;
};
const textBoxArea1 = CreateTextBoxArea(
{
value: text1,
onChange: (e: any) => setText1(e.target.value)
},
textBoxArea1HeaderFunc(text1),
textBoxArea1MessageFunc(text1)
);
const textBoxArea2 = CreateTextBoxArea(
{
value: text2,
onChange: (e: any) => setText2(e.target.value)
},
textBoxArea2HeaderFunc(text2),
textBoxArea2MessageFunc(text2)
);
const buttonAreaStyle = {
height: "6rem",
display: "flex",
justifyContent: "center",
alignItems: "center"
};
const buttonArea = (
<div css={buttonAreaStyle}>
<Button
text="送信"
isButtonOn={!isLoading}
enable={!isLoading}
onClick={async () => {
dispatch(true);
const sendVal = {
radio: radio,
text1: text1,
text2: text2
};
const serverResult = await serverFunc(sendVal);
setServerResult(serverResult);
dispatch(false);
console.log(serverResult);
}}
/>
</div>
);
const formOuter = {
display: "flex",
justifyContent: "center",
alignItems: "center"
};
const formBorder = {
background: "#ffe0ff",
border: "solid 1px #a04040"
};
const formInner = {
marginLeft: "3rem",
marginRight: "3rem"
};
return (
<div css={formOuter}>
<div css={formBorder}>
<div css={formInner}>
{radioButtonArea}
{textBoxArea1}
{textBoxArea2}
{buttonArea}
</div>
</div>
</div>
);
};
export default Form;
const serverFunc = async (json: any) => {
const sleep = (msec: any) =>
new Promise(resolve => setTimeout(resolve, msec));
await sleep(1000); // サーバーからの応答待ち
return json;
};
- ボタンコンポーネントを分離してサーバーへのダミーリクエストをuseReducerで実装。
- サーバー応答のダミー関数としてただ1秒待って引数をそのまま返す関数: serverFuncを作成。
- ボタンコンポーネントにはボタンを押された時に実行される関数だけを渡す。
- ローディング中はボタンが押せないようにする。
- ローディング状態に入るのはサーバーへのリクエストを送ったとき。
- ローディング状態から抜けるのはサーバーからの応答が返ってきたとき。
- ボタンコンポーネントへは親からローディング状態かどうかを渡せばよい。
HTMLツリーに対応するcssがすぐそばにあってデザインの見通しが立ちやすいコードになってるかと思います。
TextBoxは関数化するとフォーカスが外れるのでstyled形式のcssは使えないなど制約がある。
styled形式とcssコンポーネント形式は一長一短。
キャメルケースは気持ち悪いしネストすると書きにくいというのはある。
renderはこうなる。
あとはtextBoxArea1HeaderFuncあたりをいじればリアルタイムでバリデーションを走らせることができる。
バリデーションを実装する
Old Styleの仕様を決める
石器時代っぽい仕様ってことで、こんなもんでどうだろ。
- 上のテキストボックスは半角数字10桁のみ許容する。
- 下のテキストボックスは全角数字10桁のみ許容する。
- バリデーションはサーバー側のみで行う。
- バリデーションは1つずつ行い、エラーは最初に見つかったものだけを返す。
- エラーが起こったら既に入力済みのテキストを消す。
- 入力の条件はプレースホルダーのみに書く。
Modan Styleの仕様を決める
令和の時代ならこれぐらいは盛り込もう。
- サーバー側バリデーションの条件はOld Styleと同じ。
- クライアント側の入力は半角全角の混合を許可して、サーバーに送信するときに変換する。
- 各テキストボックスでのエラーは個別にリアルタイムで出力する。
- サーバーに送る前に全てのバリデーションが通っている状態にする。
- もしサーバー側でエラーが起こっても入力済みの項目は消さない。
- 入力の条件はプレースホルダーではなく上部のヘッダー領域に書く。
サーバーからの応答に応じてフォームの内容を変更
入力を消すのはuseEffectでいけます。
import { useState, useReducer, useEffect } from "react";
useEffect(() => {
if (radio) {
setText1("");
setText2("");
}
const jsonString = JSON.stringify(serverResult, undefined, 2);
if (jsonString.length > 2) {
alert(jsonString);
}
}, [serverResult]);
上のコードでserverResultが変更されたらuseEffect内を実行するが実現できます。
useStateの関数でフォームの文字列に空文字をセットしているのでテキストがクリアされることになります。
サーバーでのバリデーション
JSONを受け取ってJSONでresultを返すのだからこんなもんだろう。
const serverFunc = async (json: any) => {
const sleep = (msec: any) =>
new Promise(resolve => setTimeout(resolve, msec));
await sleep(1000); // サーバーからの応答待ち
const jsonKeyCheck = "radio" in json && "text1" in json && "text2" in json;
if (!jsonKeyCheck) {
return { result: false, str: "不正なJSONです" };
}
const regText1 = /^[0-9]{10}$/;
const regText2 = /^[0-9]{10}$/;
if (json.radio) {
console.log(json.text1);
if (!regText1.test(json.text1)) {
return { result: false, str: `${json.text1}は半角10桁ではありません` };
}
if (!regText2.test(json.text2)) {
return { result: false, str: `${json.text2}は全角10桁ではありません` };
}
} else {
let errorStr = "";
if (!regText1.test(json.text1)) {
errorStr += `${json.text1}は半角10桁ではありません\n`;
}
if (!regText2.test(json.text2)) {
errorStr += `${json.text2}は全角10桁ではありません\n`;
}
if (errorStr.length > 0) {
return { result: false, str: `${errorStr.slice(0, -1)}` };
}
}
return { result: true, ...json };
};
バリデーション
const regText = /^[0-90-9]{10}$/;
const isValid = regText.test(text1) && regText.test(text2);
const convertHankaku = (e: string) =>
e.replace(/[A-Za-z0-9]/g, (s: string) =>
String.fromCharCode(s.charCodeAt(0) - 65248)
);
const convertZenkaku = (e: string) =>
e.replace(/[A-Za-z0-9]/g, (s: string) =>
String.fromCharCode(s.charCodeAt(0) + 65248)
);
const textBoxArea1HeaderFunc = (text1: string) => {
return radio ? "入力項目1" : "入力項目1: 10桁の数字で入力してください";
};
const textBoxArea1MessageFunc = (text1: string) => {
if (radio) {
return "";
}
if (text1.length != 10) {
return `${text1.length}文字が入力されています。`;
}
if (!regText.test(text1)) {
return "数字10桁ではありません";
}
return `length=${text1.length}`;
};
const textBoxArea2HeaderFunc = (text2: string) => {
return radio ? "入力項目2" : "入力項目2: 10桁の数字で入力してください";
};
const textBoxArea2MessageFunc = (text2: string) => {
if (radio) {
return "";
}
if (text2.length != 10) {
return `${text2.length}文字が入力されています。`;
}
if (!regText.test(text2)) {
return "数字10桁ではありません";
}
return `length=${text2.length}`;
};
const textBoxArea1 = CreateTextBoxArea(
{
value: text1,
onChange: (e: any) => setText1(e.target.value)
},
textBoxArea1HeaderFunc(text1),
textBoxArea1MessageFunc(text1),
radio ? "10桁の半角数字で入力してください" : "入力してください"
);
const textBoxArea2 = CreateTextBoxArea(
{
value: text2,
onChange: (e: any) => setText2(e.target.value)
},
textBoxArea2HeaderFunc(text2),
textBoxArea2MessageFunc(text2),
radio ? "10桁の全角数字で入力してください" : "入力してください"
);
ボタンをこんな感じにすれば、OldStyleなら全通しでModanStyleだとバリデーション通ってないと押せなくなる。
サーバーに渡す値もModanStyleのときだけ変換をかける。
<Button
text="送信"
isButtonOn={!isLoading}
enable={!isLoading && (radio || isValid)}
onClick={async () => {
dispatch(true);
const sendVal = {
radio: radio,
text1: radio ? text1 : convertHankaku(text1),
text2: radio ? text2 : convertZenkaku(text2)
};
const serverResult = await serverFunc(sendVal);
setServerResult(serverResult);
dispatch(false);
console.log(serverResult);
}}
/>
しっかり変換されてますね。
まとめ
全体的にHooksをフル活用してできている。
これだけの機能を持ったフォームなのにコンポーネントじゃないのはすごい。
2015年ぐらいから見違えるほど進歩してますね。