- 投稿日:2020-11-23T23:30:10+09:00
Reactで作成したポートフォリオサイトをNextに移行した
この記事は個人開発 Advent Calendar 2020 5日目の記事です。
昨日は@alclimbさんによる「【個人開発向け】失敗の経験と成功した人から学んだこと10選」でした。
私はあまり一般公開のアプリは作らないので、なかなか大変そうだなーと参考になりました。ありがとうございました。
半年ほど前に作成したFirebase×ReactでつくったポートフォリオサイトをNextに移行した話です。
ポートフォリオサイト概要
「Qiitaなどの外部サイトに投稿した記事」と「個人開発の製作物」をまとめたサイトがほしいなーと思い、半年ほど前にReactでポートフォリオサイト「焼きらぼ。」を作りました。
外部サイトの記事リンクを貼るだけでなく、サイト内で直接記事を投稿できるようにしています。
(ポートフォリオサイトというよりはブログに近い感じ)FirebaseとReactで作成しました。
ポートフォリオサイトの概要や設計について詳しくは焼きらぼ -概要を参照してください
課題
公開して半年ほどたちましたが、いくつか課題がでてきました
表示速度が遅い
まず表示速度が遅く、トップページの表示に1秒ほどかかっていました。
どうやら表示時にReact(クライアントサイド)でDB(Firestore)からデータ取得をしてからレンダリングしてたため、遅くなっているようでした。DBのセキュリティが弱い
Firestoreのセキュリティ設定が「認証なしで全ユーザにREADを許可」になっていたため、セキュリティが弱い状態でした。
またアクセス毎にDB接続を行っていたため、アクセス数が増えたり悪戯をされたりするとFirestoreの1日の使用量を超えてしまう可能性もありました。対応
2つの課題に対応するため、アクセス時にクライアントサイドでDB接続を行わないことにしました。
そもそも記事の投稿は私一人しか行わない上、更新頻度もあまり高くありませんでした。
そのため、記事一覧ページや記事ページは編集後(DB登録後)に静的サイトとして出力して公開しても問題ありません。
そうすると、DB接続を行わないため表示速度も早くなり、セキュリティも向上します。というわけで記事一覧ページや記事ページなどの一般公開用サイトをNextのSSGを利用して静的サイトとして公開することにしました。
NextのSSGではビルド時にDBからデータを取得し、静的サイト用のファイルを出力することができます
※記事の投稿・編集を行える管理者用サイトはDBの最新データが必要なため、認証必須にしてクライアントサイドでDB接続を行うままにしています(URL非公開)
Next(SSG)への移行
Nextへ移行する際は新規でNextのプロジェクトを作成し、一般公開用サイトをページ単位で移行しました。
移行ポイントは以下のとおりです。ルーティングの変更
Nextはpage下のフォルダ構成でルーティングを行ってくれるため、ただファイルを配置するだけでルーティング設定が行えます。
Reactで作成していた時はReactRouterDomを使ってルーティングの設定を行っていましたが、この部分は不要になりました。変更前<Route exact path='/Article/:id' component={Article} />また
https://***/Article/記事ID
といったようにURLに変数が含まれるものは、Articleフォルダ下に[id].js
のように[]
で囲ったファイル名に変更することで対応できます。初期処理の変更
ページ表示時にDBからデータを取得する処理を、
useEffect
で行っていたのを、getStaticProps
で行うように変更しました。またデータをstateで保持する必要もなくなったので削除しました。変更前useEffect(() => { // firestoreから取得 getData.then((data) => { // stateにもたせて保存していた setValues(data) }) }, []);変更後// ビルド時にサーバーサイドでDB取得する export const getStaticProps = async () => { const data= await getData() return { props: { data: data } } }getStaticPathsの実装
NextのSSGではビルド時に全ページのデータ取得を行うため、
[id].js
のようなURLに変数を含むページはgetStaticPaths
で変数が取りうる値をすべて取得する必要があります。変更後export const getStaticPaths = async() => { const ids = await getIds() return { paths: ids, fallback: false, } }リンクのhref変更
SSG機能では、
a.js
で作成されたページはa.html
としてファイル出力されます。
そのため、a.html
にアクセスしたいとき、URLはa.html
にする必要があります。※デバッグ時(Nextが動いているとき)のURLは
a
でアクセスできますが、静的サイト出力後のa.html
ファイルに対するアクセスはa.html
でないとアクセスできません。なのでリンクを貼るときはhrefに
.html
をつける必要があります。変更前<a href="a">リンク</a>変更後<a href="a.html">リンク</a>静的サイトとしてデプロイ
NextのSSGで静的サイトとしてファイルを出力するには
npm run build && next export
を行います。実行するとoutフォルダに静的サイト用のファイルが出力されます。package.jsonでコマンドを定義することもできます。、
package.json{ "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "export": "npm run build && next export" }, ...outフォルダの内容をFirebaseのホスティングにそのままアップロードすることで、静的サイトを公開することができます。
ちなみにFirebaseのホスティングは1プロジェクトで複数持てるので、一般公開用サイトと管理用サイトのドメインを分けています。
複数のドメインはFirebaseのコンソールで追加することができます。
そしてデプロイ時にドメイン名を指定することで特定のドメインにデプロイできます。firebase deploy --only hosting:ドメイン名結果
実際のページはこちらです
静的サイトなので当然ですが、ページの表示速度が上がってすぐに表示されるようになりました。
DBの無料枠も気にする必要がなくなったので安心しました。ついでに行った対応
デザインの修正
当初は少しやっつけで作成したため、半年たって見るとデザインがちょっと微妙だと思いました。
せっかく機能追加を行うということで、デザイン側も手を入れることにしました。昔のデザインはこちら
なんかガタガタしてるというかうるさいというか。。。個人的にはけっこうスッキリして良くなった…と思っています!
リファクタリング
コピペで作成したほぼ同じ内容のコンポーネントを共通コンポーネント化したりしました。
やってみた感想
ReactからNextへの移行は思ったより簡単にできてよかったです。
また機能改善のついでにデザイン周りを見直したり、気になっていたところのリファクタリングをかけることができてよかったです。一回完成させた個人開発の製作物は、長く運用していないとなかなか見直さないものですが、機能改善やリファクタリングを行うと新しい発見があって良いと思いました。また仕様や挙動がある程度決まっているため、新しい技術を試しに導入してみやすい環境だと思います。
みなさんもすでに完成済みの個人開発の製作物があれば、一度見直してみてはいかがでしょうか。
明日は@UedaTakeyukiさんによる「個人開発でも気軽に使える Copy Protection サービスをつくりました」です!お楽しみに!
- 投稿日:2020-11-23T23:21:39+09:00
React - 2つのテキストボックスの入力を相互に反映する
概要
公式サイトにすでに解説がありますが、そこ扱われているサンプルソースがやや煩雑に感じました。なので、キーとなるポイントを抽出、分かりやすく短いソースで書いてみました。
この記事のサンプルソース概要
- テキストボックスが2つあり、税抜きと税込みの価格を表示します。
- いずれのテキストボックスも入力可能で、数字をタイプすると即時反映します。 (税抜きのテキストボックスに入力すると税込みのテキストボックスに即反映される。逆も同様。)
- 同じコンポーネント(MyForm)を2つ配置してあり、入力値が他のテキストボックスに干渉しないことを確認できる。
ソースコード
index.html<!DOCTYPE html> <html><head> <meta charset="UTF-8" /> </head> <body> <div id="root"></div> <!-- Load React. --> <!-- Note: when deploying, replace "development.js" with "production.min.js". --> <script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script> <script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script> <script src="script.js"></script> </body> </html>script.js// 軽減税率(8%) const taxrate = 0.08; // テキストボックス class MyInput extends React.Component { constructor(props) { super(props); } onChange(e) { // MyInputコンポーネントを配置したときの onValChange={...} に指定したメソッドがここで呼ばれる。 this.props.onValChange(e.target.value); } render() { const value = this.props.value; return ( <input type="text" value={value} onChange={(e) => this.onChange(e)} />); } } // 価格比較フォーム class MyForm extends React.Component { constructor(props) { super(props); this.state = { value1: 0, value2: 0 }; } handleChange1(val) { this.setState({ value1: val, value2: val * (1 + taxrate) }); } handleChange2(val) { this.setState({ value1: val / (1 + taxrate), value2: val }); } render() { return ( <div> 税抜き <MyInput name="taxout" value={this.state.value1} onValChange={(e) => this.handleChange1(e)} /><br /> 税込み <MyInput name="taxin" value={this.state.value2} onValChange={(e) => this.handleChange2(e)} />(軽減税率) </div> ); } } // 画面に表示 ReactDOM.render( <div> フォーム1 <MyForm name="form1" /><br /> フォーム2 <MyForm name="form2" /><br /> </div>, document.getElementById('root') );
- 投稿日:2020-11-23T22:18:58+09:00
AWS AmplifyでReactアプリをデプロイする。
この記事のゴール
本記事は、AWS amplifyを用いて、CRA(Create React App)をデプロイする流れを記載した記事になります。
n番煎じ感ある記事ですが、備忘のために記事にしてみました。※前提
Githubアカウントを持っていて、CRAをプッシュしたリポジトリを持っていること。
はじめに
まずは、CRAでReactアプリを作成します。アプリの作成方法に関しては、今回は割愛させていただきます。
※過去にDockerでReactサンプルアプリを作成した記事を掲載させていただきます。React.js (Create React App) × TypeScript対応プロジェクトをdockerで作成する。
https://qiita.com/koh97222/items/a53cacd0ff85c896bf11手順1. AWSマネジメント コンソールにログインする。
AWSのアカウントを作成して、AWSのコンソール画面を開きます。
手順2. Amplifyで検索!
Amplify console 画面を開きます。「右上のアプリの作成」ボタンを押します。
手順3. Githubを選択し、Continue
手順4. デプロイしたいリポジトリを選択し、「次へ」ボタン押下。
手順5. ビルド設定を修正する。
ビルドの設定を修正します。
アプリの名前は任意で設定しましょう。ビルド設定のymlはテンプレートを参考にこんな感じにしてみました。
https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/build-settings.htmlamplify.ymlversion: 0.1 frontend: phases: # IMPORTANT - Please verify your build commands preBuild: commands: - cd ./front/my-app - npm install -g build: commands: - npm run build artifacts: # IMPORTANT - Please verify your build output directory baseDirectory: ./front/my-app/build files: - '**/*' cache: paths: node_modules/**/*手順6. 設定を確認後、保存してデプロイボタン押下。
ビルドとデプロイが開始されます。
ビルドの様子などは、コンソールの各タブからそれぞれ確認できます。
詰まった点
- 手順5でビルドの設定でymlファイルを修正しなければならないが、自動検出されたデフォルトで行けると勘違いしていた。(デフォルトの状態だと、amplifyコンソール上ではデプロイに成功している表示がされますが、ブラウザ上からアクセスすると、502エラーになります。)
デプロイができなかった or デプロイできたけど、画面が表示されなかった場合は、設定ファイルをしっかりと確認し、適宜修正しましょう。。
所感
今回初めてAmplifyでWebアプリをデプロイしたのですが、ブランチにプッシュしたタイミングで自動的にデプロイされたり、設定をいじるだけで簡易的に世に公開できるのはとても面白いなと感じました。もっと勉強して色々使いこなせるようになりたい。。
- 投稿日:2020-11-23T19:52:18+09:00
React Developer Toolsで全てのコンポーネントを表示
chromeの拡張機能でReact Developer Toolsはデフォルトで定義したコンポーネントしか表示しないようになっている。
タグのコンポーネント(divタグやpタグなど)も全て表示するには、「歯車マーク」→「components」で、全てのfilterを×ボタンで消すと表示されるようになる。
- 投稿日:2020-11-23T17:08:08+09:00
chrome拡張のReact Developer Toolsが効かない
local:3000でReactのページを表示しても、ReactDeveloperToolsが効かないとき
chromeの右上「︙」
→「設定」
→「拡張機能」
→React Developer Tools の詳細(chrome://extensions/?id=fmkadmapgofadopljbjfkapdkoienihi)
→「ファイルのURLのアクセスを許可する」をONにする
→Chromeを再起動
- 投稿日:2020-11-23T16:03:30+09:00
【React Hooks】 useEffectの動作タイミングをきっちり理解する
useEffectとは
- React Hooksにおいて
ライフサイクルメソッド
を関数コンポーネントで実現する仕組み。- classコンポーネントで言うcomponentDidMountなどに相当する。
- 1つの関数コンポーネントに複数記述しても良い。
サンプルコード
以下のサンプルコードを用いて解説したい。
import React, { useEffect, useState} from 'react'; import './App.css'; const App = props => { const [state, setState] = useState(props) const { id, name } = state useEffect(() => { console.log('#001:レンダリング毎に呼ばれる') }) useEffect(() => { return () => console.log('#002:アンマウント直前に呼ばれる') }) useEffect(() => { console.log('#003:マウント時とアンマウント時にのみ呼ばれる') }, []) useEffect(() => { console.log('#004:特定のstateが変更された場合のみ呼ばれる') }, [name]) return ( <> <p>id:{id} name:{name}</p> <p>id:<input value={id} onChange={e => setState({...state, id: e.target.value})} /></p> <p>name:<input value={name} onChange={e => setState({...state, name: e.target.value})} /></p> </> ) } App.defaultProps = { id: 'Id001', name: 'NoName' }レンダリング時
以下のように記述するとrenderメソッドが走る度に処理が呼ばれる。初回のレンダリング時はもちろんであるが、stateやpropsが更新された際にも呼ばれることとなる。
useEffect(() => { console.log('#001:レンダリング毎に呼ばれる') })アンマウント時
useEffect内で関数をリターンさせるとアンマウント時の処理を記述できる。
useEffect(() => { return () => console.log('#002:アンマウント直前に呼ばれる') })マウント時およびアンマウント時
空配列
[]
を第2引数に指定するとマウント・アンマウントに限定させることができる(001のように更新時には呼ばれなくなる。)useEffect(() => { console.log('#003:マウント時とアンマウント時にのみ呼ばれる') }, [])特定のstateが更新された時
特定の値が更新された場合に限定したい場合は、第2引数の配列に変数名を記載すれば良い。下記の例では
name
が更新された時(nameを保持するinputのonChangeが呼ばれた時)にのみ実行される。useEffect(() => { console.log('#004:特定のstateが変更された場合のみ呼ばれる') }, [name])
- 投稿日:2020-11-23T13:44:30+09:00
GatsbyJS上からFirestoreのデータをGraphQLで引っ張ってくる方法
GatsbyJSとFirebaseを連携させる上でどのプラグインを使ってどのように引っ張ってれば良いのかわからなかったので調べたまとめ
GatsbyJSとFirebaseは現時点では英語であっても情報がほとんどないと思うので必要な人もいると思うので記事にしました。備忘録も兼ねています。※対象とする人は以下のため説明を簡略化している部分もあり
・Firebaseを一度は触ってホスティング又は、auth認証やFirestoreのデータを引っ張ったことがある人
・GatsbyJSやGraphQLを多少なりとも触ったことがある人準備するもの
①gatsbyのプロジェクト作成
②gatsby-firesourceのインストール
③Firebase側でプロジェクト作成とFirebase Admin SDKの秘密鍵生成①gatsbyのプロジェクト作成
不要物が少なくシンプルなHello Worldのみのスターターキットを引っ張ります
gatsby new gatsby-firebase https://github.com/gatsbyjs/gatsby-starter-hello-world cd gatsby-firebasegatsby develop以下がコンソールに出力されればOKです。
You can now view gatsby-starter-hello-world in the browser. ⠀ http://localhost:8000/ ⠀ View GraphiQL, an in-browser IDE, to explore your site's data and schema ⠀ http://localhost:8000/___graphql②gatsby-firesourceのインストール
yarn add gatsby-firesource (もしくはnpm)gatsby-source-firestore はメンテされておらずフロントエンドのUdemy講座で人気のTom Phillipsさんが推奨していないためgatsby-firesource(Tom氏が作成)を使います。
③Firebase側でプロジェクト作成とFirebase Admin SDKの秘密鍵生成
ドキュメントIDは自動生成で今回はシンプルにするためフィールドは"name"と"email"だけにします。
次にFirebase Admin SDKの秘密鍵生成を生成します。
DLした秘密鍵をgatsby-firebaseのpackage.jsonと同階層の親ディレクトリに格納して名前を「firebase.json」へ改名します。
これで準備完了です。
gatsby-config.jsのファイルを編集する
plugin: [] の中に以下の内容を入力します。
{ resolve: 'gatsby-firesource', //プラグイン名 options: { credential: require("./firebase.json"), //認証情報 types: [ { type: 'User', // GraphQL上で表示される名前 collection: 'users', // 作成したコレクション名 map: doc => ({ // ドキュメントデータ name: doc.name, // ドキュメントデータのフィールドname email: doc.email, // ドキュメントデータのフィールドemail }), }, ], } }再度、立ち上げます
gatsby developGraphQLへアクセスします。
http://localhost:8000/___graphql画像のようにデータが拾えていたらOKです。もしデータが拾えてなかった場合はgatsby-config.jsのどこかが間違っているかFirestore側のドキュメントに誤りがある可能性があります。
あとはgatsby-node.jsやそれぞれのページでGraphQLを引っ張ってくればデータを勝手に扱えるようになりました。
- 投稿日:2020-11-23T09:34:48+09:00
Reactの型、ちゃんと調べる
childrenの型って何だっけ...?
とかなってしまうので、Reactのコンポーネントの型を調べてまとめます。ReactのDOMが返す型(
children
など) って?ズバリ
React.ReactNode
。ReactNodeは下記のように定義されています。
type ReactNode = ReactChild | ReactFragment | ReactPortal | boolean | null | undefinedunion型も辿っていくと、下記の定義が発見できます。
type ReactChild = ReactElement | ReactText; type ReactText = string | number; interface ReactElement< P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any> > { type: T; props: P; key: Key | null; } type ReactFragment = {} | ReactNodeArray; interface ReactNodeArray extends Array<ReactNode> {} type JSXElementConstructor<P> = | ((props: P) => ReactElement | null) | (new (props: P) => Component<P, any>); interface ReactPortal extends ReactElement { key: Key | null; children: ReactNode; }dackdive's blog様に上記を非常にわかりやすくまとめられた図がありましたので、お借りしました?♂️
それぞれの型定義について以下でより詳しく見てみます。
ReactChild(ReactElement)
type ReactChild = ReactElement | ReactText; type ReactText = string | number; interface ReactElement< P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any> > { type: T; props: P; key: Key | null; }ReactElementとはまさに、Reactの原子とも言える型ですね。
propsやkeyを持つ各コンポーネントの型です。
ちなみにJSX.Element
はReactElementのジェネリックを空で登録したものになります。declare global { namespace JSX { // tslint:disable-next-line:no-empty-interface interface Element extends React.ReactElement<any, any> { } ...ReactPortal
interface ReactPortal extends ReactElement { key: Key | null; children: ReactNode; }そもそもPortalという機能を知らなかったのでまとめます。
https://ja.reactjs.org/docs/portals.htmlポータル (portal) は、親コンポーネントの DOM 階層外にある DOM ノードに対して子コンポーネントをレンダーするための公式の仕組みを提供します。
通常の
render
メソッドで返すと、一番近い親ノードの子として登録されますが、
指定したDOMの子コンポーネントとして挿入できるメソッドのようです。
実際に公式がcodepenでDEMOを載せています。
デモでは外部のモーダルをportalを通して自分の親ではないDOM要素に生成しています。
このような場合はrefを使うor親でstateを持つしかないと思っていたので斬新な気持ちです。
次回からはこちらでモーダルを生成しようと思いました。脱線しましたが、
ReactPortal型
とはつまりこの機能のための型です。protalのコードrender() { return ReactDOM.createPortal( this.props.children, domNode ); }createPortalの型定義export function createPortal( children: ReactNode, container: Element, key?: null | string ): ReactPortal;ReactFragment
https://ja.reactjs.org/docs/fragments.html#gatsby-focus-wrapper
type ReactFragment = {} | ReactNodeArray; interface ReactNodeArray extends Array<ReactNode> {}これは要素を吐き出したくない時に使うフラグメントの型ですね。
return( <> //ここに各要素 <> )
{}
, もしくはReactNodeの配列を返します。まとめ
ReactNodeは
boolean
null
undefind
なども許容されており、
割となんでも入ってしまう緩い型なので、より厳し目にしたいときはこっちで諸々調整してあげたほうが良さそうです。参考資料
https://qiita.com/sangotaro/items/3ea63110517a1b66745b#children-%E3%81%AE%E5%9E%8B
https://dackdive.hateblo.jp/entry/2019/08/07/090000
- 投稿日:2020-11-23T03:46:51+09:00
未経験からweb系エンジニアになるための独学履歴~React+DRF+HerokuでTodoアプリを作る 製作記録~
概要
このアプリは未経験から就職を目指すプログラミングシリーズで制作したものです。
過去のシリーズ
未経験からweb系エンジニアになるための独学履歴~初めてのポートフォリオ作成記録 製作記録編~
初心者がDjangoによる6週間でチームビルディングからプロダクト公開までやるプロジェクトに参加した話
はじめに
今回のアプリを作るにあたっての根底には2020年5月~6月末まで参加させていただいたDjangoチーム開発プロジェクトで得られた体験や知見が元になっております。
主催者であるdigisaku710さんを始めプロジェクトメンバーの方々のお力あってのことです、本当にありがとうございます。また、デプロイに際して窮地に陥っていて突然のお願いにも関わらずデプロイの構成を手直ししてくださったGtca様、またReduxについてご教授頂いた世界の歪み様にも重ねて御礼申し上げます。
その他アドバイスやリリース前に試しに触ってチェックしてくれたフォロワーの方々にもここでお礼を申し上げさせていただきます、助かりました。
以下名前を挙げさせていただいた、お三方のリンクです。
Gtca様のみTwitterではなく、Qiitaの記事のページにリンクを貼らせていただきます。作ったもの
あえて他人に公開・共有するというのがコンセプトのTodoアプリです。
名前は某フェザー級日本タイトルマッチにおけるテーマから頂いています。使った技術
環境
Pipenv 仮想環境
Heroku デプロイ先
PostgreSQL Herokuからの指定
Gunicorn Herokuからの指定
Windows10home Proにしたい
フロント
- React
- React-Bootstrap(Material-UIの代用)
バックエンド(サーバーサイド)
- Django(3.1、PythonはHerokuの指定のうち3.8.6を使用)
- Django-Rest-framework
その他使ったライブラリ抜粋等は以下の設計ファイルからご覧ください。
今回使っていて非常に助かったのはRedux ToolkitとReact Hook Formでした。
いずれもう少し掘り下げて記事を書くつもりでいます。なぜDjangoとReactを選んだのか?
この記事冒頭にリンクを貼りましたが、5~6月末にかけてオンラインでDjangoでチーム開発をするという企画に参加しました。
そこで
- その際に得た体験や知見を生かして何かを作りたい。
- 当時は約1ヶ月PythonとDjangoを勉強しただけだったのでもう少しDjangoを掘り下げたい。
- この2点に加えてDjangoでの成果はチームでの成果のみなので、自分だけでDjangoを使って何かを作りたい。
以上3点を思い立ったことが要因でした。
企画をやるにあたってDjangoに触れた結果、日本語の情報量としてはPHPやLaravelと比べて遥かに劣り、ライブラリもあまり整備されたものが少なく、Django自体も色々な意味で省略されたフレームワークで、一見しただけではわからずドキュメントや海外のフォーラム・記事を探し、にらめっこしなければなりませんでしたが、それはそれとして一度基本の工程さえ押さえれば、Laravelと比べてわかりやすく開発できるなというのを魅力に感じたことと、AI開発もできるということでもしかしたらディープなものは無理でも修めていけばちょっとしたことくらいならできるのでは……? というロマンを感じていたのも大きいです。それに加えて企画終了後の反省会において
「最近はフロントの知識(TSやReactなどのフレームワーク)も多少の理解はないと辛いと聞いたのですが、JSが苦手な私には少しハードルが高いんですよね……」
という旨を相談したところ、とりあえず何でもいいので触ってみるのが1番いいよというアドバイスを頂いたので、興味があったReactも使ってみようと相成りました。
Reactを選んだ理由としては
- 将来的にはモバイルアプリの開発もやりたいと考えていたので元々React Nativeに興味があるのでそれに連なるかなと思った。
- VueやAnglerと比べると近年盛り上がっているスキルなので情報はありそうかなと思った。
という2点です。
最初の理由が大きいです、今はまずはWeb系のサービスをととっかかりにPHP・Laravel、そして今はDjangoをやっていますが、Swift UIに憧れて今ここまで勉強を続けられて来ているので、将来的にはモバイルアプリの開発もできるようになりたい身としては、当然Androidアプリの開発に繋がるReact Nativeは将来的には押さえたいと思っていてそれならばReactも……と常々考えていました。ちなみに、この頃はJSはAjaxとHTMLやCSSタグをイベントで書き換えるといった用途でしか触れていなかったのでReact他のフレームワークも、こうカッコよくページを動かしてくれるものなのだなというざっくりとしたイメージしかもってなかったのは恥ずかしいところです。
ということで今回の記事の最後の方にもあるようなチュートリアルや学習をして今回の制作に挑みました。
なぜTodoアプリなのか
これは単純明快でTodoアプリはタスクの登録、削除、一覧、更新とCRUDをやるのに1番理にかなっていると感じたからです。
また、それさえできれば例えばフレンド登録やグループ登録などの類似した機能も同時に実装できるので応用が効きやすいだろうという思惑もありました。しかも今回は最終的にDRFとReactを扱うことになり、両方とも私にとっては完全に初見の技術であるといういうことと、同時にこれも初めてである自分で曲がりなりにも要件を定義し、それに合わせて設計をして実装するという工程をきちんと踏んでいくにあたって、独創性のあるものよりも車輪の再発明をするほうが挫折する確率も少なく、情報も集まるだろうと判断したからです。
技術的なアピールとしては弱くなりますが、これらの理由によりやり遂げることがそもそも大事であると判断しました。要件定義と設計
これは今回のアプリを制作するにあたって一番最初に定義したものです。
詳細は以下のリンクから見て頂きたいと思います。続いて設計書です。
こちらはデプロイにあたってまとめ直してあります。
理由としては今回こうやって作成するのは初めてのことなので当初設計したものはかなりアバウトであったのと、後学のためにきちんと整理したものを残しておくためです。そしてご覧になられた方はわかると思いますが、この定義書で書いたことの中には実現できなかったことがあり、実装時にオミットまたは仕様を変更したところがあります。
なので、それらを含めてここでは少し補足をして行きたいと思います。Todoアプリに付加価値を加えたいと思ったこと
これについてはまず単純にTodoアプリを作るだけでは面白くない(本音: アピールにならないだろうなぁ)ということと、単なるTodoアプリは世にごまんとチュートリアルの過程であるわけで、それじゃ自分でわざわざ要件定義して……なんてことをする意味もないだろうと思ったからです。
ですが、私はアプリ開発の経験も浅く、当然現場を経験しているわけでもないのでそのあたりのアイディアにも乏しいのでどうしたものかなと悩みました。その結果、自分のタスクを他人に公開していくという方向性で考えてそうなると非公開にする機能が必要になるのではと考えたのですが、今の自分の状態では非公開の範囲(全体なのかユーザーごとなのか、はたまたタスク単位なのか……etc)を設定するコードを書くのにかなり難儀しそうだなと考え、それでは逆転の発想で
敢えて他人に見られることを前提にすればいいのでは?
ということを思い至り、冒頭にもあるように敢えて他人に公開・共有するというコンセプトで作ろうと思い至り定義と設計を進めていくことにしました。
仕様変更について
当初の設計から変わったことについて幾つか説明させていただきます。
- レーティング機能によりToDoに優先順位をつけたり、達成したToDoについて他者から評価を貰える。
↓
レーティングは実装したが、あえて用途はこちらで制限せず、ユーザーに進捗管理やタスクに対する評価等判断を任せることにしました。
後者の部分は所謂いいねですが、達成したTodoに限らず遂行中のものについてもいいねをつけられるようにしました。
これは人がなにかするにあたって1番モチベーションを挫きやすいのが誰にも興味を持ってもらえないことに気づいてしまうことだと私は考えるからです。
誰かが注目してくれているというだけでやる気は出るものですし、緊張感も出ると考えています。
- タイマーつきのToDoをクリアすると、アンロックされるToDoを設定できるようにして、タスクに対しての報酬を設定することができる。
↓
こちらは実装はしたかったのですが、当初の想定より作業が難航し処理を考える時間がなかったことと、今の自分にはそれ故に手余りになると考えてオミットしました。
目玉にしたかった機能でもあるのでかなり悔しいです。
- Todoの発信
↓
当初はソーシャルログインを実装し、それに伴ってTwitter APIを使いタスクリストのURLをツイートしたり、タスクの設定と同時にツイートもするというよく見かける機能をつけたかったのですが、DjangoとDRFとでライブラリとの兼ね合いとReadOnlyにしたCookieトークンをDjangoにキャッチさせる手段がわからず、どうしてもソーシャルログインと認証との橋渡しがうまくいかず、自力でやるにもリクエストトークンは送れたものの、キャッチしたアクセストークンをDRFに渡す処理がどうしても書けずにそれは断念しました。
余談ですが、誇張なしにまる二日ほぼ飲まず食わずでやって進捗が0だったのでこれは相当心にきました……
アドバイスやググり漁ってみたところFire baseを使うのが手っ取り早いので自分の中でまた一つマストな技術が増えてしまったなという感想とともに大いに反省しなければならない工程でした。ただ、これでは外部に公開するというコンセプトが丸潰れになってしまうので、Topページに検索フォームを作りそこにユーザー名を入れると問答無用でそのユーザーのタスクリストに飛べるようにし、さらに同じくTopページに来るたびにランダムで5人のユーザーのタスクリストへの遷移ボタンを加えるということでどうにか最低限、コンセプトは守ることができた……と思います。
- グループ機能
↓
当初はパスワード制にして、パスワードを知らないとグループに入れないという仕様にしてグループでのタスクを設定できる……といったことを考えていたのですが、実装中にとログイン・ログインユーザーのチェックという認証に加えて、さらに別の認証を追加するのは今の自分には難度が高く、これ以上時間を割くのは難しいと考えたのでオミットし、どちらかというとTwitterのフォローやリストの機能のような形で実装することになりました。
- タイマー機能
↓
前述の通り、アンロックタイマーについてはオミット、タイマー終了に際してセットしたタスクの編集ページへの遷移ボタンを表示し評価への導線とする形で実装しました。
実装中、設定したタスクとは別にタスクを指定できるようにすることでタイマー終了後に指定したタスクに遷移し新たにタイマーをセットすることができる、タスククエストのような機能を追加しようかと考えたがやはり作業時間を考えて断念。チャート図、ER図
リポジトリにあるのは当初書いたものでこちらはデプロイ後にまとめ直したものです。
M2Mフィールドは今回初めて使ったので色々苦労することになりました。
以後に記載します。使い方・機能
各機能については流れを動画にしてあります。
各機能についての意図ややりたいこと、また実装後の簡易的な自己評価は機能設計書の実装したい機能及び自己講評の項目にあります。
よろしければこれらもご覧ください。Topページの機能
自分のタスクやグループリストに飛ぶ
ユーザー名で検索して問答無用でそのユーザーのタスクリストを閲覧することができます。
Topページにはランダムで5人のユーザーへのタスクリストの遷移ボタンが現れる。
こちらはUser.objects.order_by('?')[:5]
で実装しました。会員登録からタスク追加・削除・編集
通常のTodoアプリとしての機能です。
タスクリストのレイアウトについてはReact-BootstrapのToastを使ってみました。
タスクリストでは
- タスク名
- タスク詳細から備考欄の内容
- いいね数
- 追加日
が表示されるようにしています。
Toastに収めるのに必要十分かなという量でまとめました。
また、今回タスク詳細画面は他人からは閲覧できない状態にしてあります。
ここはどうするか迷いましたが、タスクリストで得られる情報と違いがあるのは達成日とレーティングくらいなので省きました。
これはいいねがレーティングのような数値による評価でなく、「Good Job」したかどうかのような使われ方をしていると思っているからです。
達成日に関しては、以下の完了したタスクフィルタリングで終わったタスクは確認できますし、レーティングは自己評価なのでいいねの指標にするには薄いかなと判断しました。
ただし、機能が増えて必要になればこの通りではないです。レーティングの機能に関しては先の通りです。
フィルタリング
タスクにはカテゴリーを設定できるのでそれでフィルタリングするか、タスクが未完か否かでもフィルタリングできます。
ここは当初、どう実装するかどうかかなり難儀しました。
React側だけでのTodoアプリや既存のAPIと組み合わせで考えるならばなんとでも情報は転がっているのですが、Djangoとなると全然なかったのでどうしたものかと考えて見ました。
なのでまずフィルタリングとカテゴリーの機能で実装しないといけないものは何かと考えるところからはじめました。カテゴリー
- カテゴリーを追加する
- カテゴリーを削除する
フィルタリング
- 特定の条件を設定して情報を抽出する
- 抽出した情報をもとに戻す
するとカテゴリーに関してはタスク編集でタスクのカテゴリーの変更欄を作らないといけないのでそこに統合しようとなり、問題はフィルタリングでAPIから引っ張ってきた情報を弄るのか、リクエストの段階でフィルタリングをかけるのかの2択なのだなということがわかりました。
結果として、私は前者でやることにしました。タスクを引っ張ってくるためのフロント側のコードがこちらです。
折りたたみ
// タスクリスト取得 try { const response = await axios.get(get_task_readonly_listUrl); // ペジネーション関係の情報をstateに格納 get_pageNationNext(response.data.next); get_pageNationPrevious(response.data.previous); get_pageNationLastNumber(response.data.total_pages); get_pageNationCurrent(response.data.current_page); get_contentsAllCount(response.data.count); // ペジネーションの関係でr.dataのリザルトプロパティから期待するdataを返してもらう const responseMap = response.data.results.map((obj) => { return obj; }); const TaskList = _.mapKeys(responseMap, "id"); getTaskList(TaskList); setAllTasks(TaskList); } catch (error) { createAlert({ message: "タスクリストの取得に失敗しました", type: "danger", }); } finally { stopProgress(); } };これをReduxのstateに格納し、それを
Object.value
で整形し、.map(() =>)
でリスト化するという方法でタスクリストを描画しています。
フィルタリングはそのObject.value
で整形したものを利用して以下のように書いてみました。
折りたたみ
// 取得してきたタスクリストを整形、こちらをフィルタリングなどの加工に使用する。 let saveTaskList = Object.values(tasks); // 実際にフィルタリングなしのタスクリスト一覧の描画に使う変数、フィルタリングはこれと上記を入れ替える形で実装している。 let taskList = Object.values(all_tasks); // CategoryでのFilter処理 const task_category_filter = (category_name) => { // フィルタリングしているかの判断フラグを初期化(Falseにする) Unfiltered(); // フィルタリングしているかの判断フラグをTrueに Apply_Category_filter(); // stateを初期化 resetTasks(); const category_item = category_name; // フィルタリング const filtered_tasks = saveTaskList.filter( (task) => task.category === category_item ); // stateにセット setAllTasks(filtered_tasks); }; // is_CompletedがTrueのタスクをFilterする const task_is_Completed_filter = () => { Unfiltered(); Apply_is_Completed_filter(); resetTasks(); const filtered_tasks = saveTaskList.filter( (task) => task.is_Completed === true ); setAllTasks(filtered_tasks); }; // is_CompletedがFalseのタスクをFilterする const task_is_unCompleted_filter = () => { Apply_is_Completed_filter(); resetTasks(); const filtered_tasks = saveTaskList.filter( (task) => task.is_Completed === false ); setAllTasks(filtered_tasks); }; // Filterリセット const task_filter_reset = () => { Unfiltered(); resetTasks(); setAllTasks(saveTaskList); };スマートではないと感じますがこれでフィルタリングは実装できました。
ただし、これだとフィルタリングはカテゴリーとタスク状態とで併用できないのが問題です。
書いていた当時は必死になりすぎてて切羽詰まって考えが狭かったですが、それ自体は.filter
を2回やればいいだけなのでは? と今ふと思い当たってしまいました、ううむ。他人のタスクリスト・グループ
誰かのタスクリストも見れます。
いわゆるいいねをタスクに押すことができ、他人が作ったグループリストに遷移してメンバーになることができます。ここではいいね機能のためにM2Mフィールドを使った処理を書くことが必要になりました。
そもそも設計の段階でいいねってどういうテーブル設計になるんだ……? と思っていたらTwitterで中間テーブル作ってM2Mにするといいよというアドバイスを頂けたのでそう設計したのですが、いざこれを操作するとなるとどうしたものかとかなり難儀をすることになりました。というのも
class Reaction(models.Model): user = models.ForeignKey(CustomUser, blank=True, null=True, on_delete=models.CASCADE) task = models.ForeignKey("Todo", blank=True, null=True, on_delete=models.CASCADE) timestamp = models.DateTimeField(auto_now_add=True)これがいいねの情報が保管されているテーブルなのですが、いいね機能は
- ボタンを押すといいねする
- もう一度ボタンを押すといいねが解除される
- いいねされた数はいいねしたユーザーの数で決定する
以上3点から成り立つので、上記のように誰が・どのタスクにいいねをしたという情報でDBに保存しないといけないのですがどうやって中間テーブルにアクセスするのかというのが問題になりました。
情報を集めてみるとこういう時にはどうやら中間テーブルへのアクセスではなく、関数ベースのViewを使ってリレーション先であるTodoモデルを操作するということが見えてきたので以下のように処理を書きました。
折りたたみ
フロント側
// いいね const reactionPost = async (task_id) => { const id = task_id; const data = { id: id, // 'action': action }; const response = await axios.post(postReactionUrl, data); pullTaskList(); };API側
# いいねを管理するView、中間モデルを使うのでモデルに依存しないAPIViewを使う。今回は関数ベースのそれ。 @api_view(['POST']) @permission_classes([ permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]) def reaction_view(request, *args, **kwargs): """ id is required. Action Option are: Like, Unlike """ serializer = ReactionSerializer(data=request.data) pagination_class = ReactionPagination User = request.user if serializer.is_valid(raise_exception=True): data = serializer.validated_data task_id = data.get("id") # 該当タスク抽出 queryset = Todo.objects.filter(id=task_id) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset.exists(): return Response({}, status=404) # 取得してきたものをインスタンス化 obj = queryset.first() # すでにいいね済みだった場合、いいねを取り消す if User in obj.reaction_obj.all(): obj.reaction_obj.remove(request.user) like_sum = obj.reaction_obj.count() return Response(like_sum, status=200) # いいね処理 else: obj.reaction_obj.add(request.user) like_sum = obj.reaction_obj.count() return Response(like_sum, status=200) return Response({"message": "Action Success"}, status=200)フロント側からタスクのIDをAxiosでPOSTし、それを元にタスクを抽出します。
抽出したタスクからいいねにあたるフィールド(reaction_obj)にリクエストユーザーの情報があるかどうかで処理を切り替えるという処理になります。
同じようにM2Mを使っているグループ機能のうち、他人のグループへの参加・離脱もこれで実装できました。
折りたたみ
@api_view(['POST']) @permission_classes([ permissions.IsAuthenticatedOrReadOnly]) def groupJoin_view(request, *args, **kwargs): """ id is required. """ serializer = UserGroupJoin_or_ReaveRequestSerializer(data=request.data) # pagination_class = ReactionPagination User = request.user if serializer.is_valid(raise_exception=True): data = serializer.validated_data group_id = data.get("id") # 該当グループ抽出 queryset = UserGroup.objects.filter(id=group_id) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset.exists(): return Response({}, status=404) # 取得してきたものをインスタンス化 obj = queryset.first() # すでに追加済みだった場合、エラー if User in obj.members.all(): return Response({}, status=404) # 追加処理 else: obj.members.add(request.user) return Response("Request Success", status=200) @api_view(['PATCH']) @permission_classes([ permissions.IsAuthenticatedOrReadOnly]) def groupLeave_view(request, *args, **kwargs): """ id is required. """ serializer = UserGroupJoin_or_ReaveRequestSerializer(data=request.data) # pagination_class = ReactionPagination User = request.user if serializer.is_valid(raise_exception=True): data = serializer.validated_data group_id = data.get("id") # action = data.get("action") # 該当タスク抽出 queryset = UserGroup.objects.filter(id=group_id) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset.exists(): return Response({}, status=404) # 取得してきたものをインスタンス化 obj = queryset.first() # 削除処理 if User in obj.members.all(): obj.members.remove(request.user) return Response("Request Success", status=200) # ユーザーが存在しなかった場合エラー else: return Response({}, status=404)またこの処理が書けたことによってタスクの状態を変化させる処理も実装することができました、以下の通りになります。
折りたたみ
# タスクの編集・削除のためのView class TodoDetailAPIView(RetrieveUpdateDestroyAPIView): queryset = Todo.objects.all() serializer_class = TodoSerializer permission_classes = [ permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly] # パラメータ取得 def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) # PATCHのリクエストが来たときに以下の処理を追加する def patch(self, request, *args, **kwargs): data = request.data # 取得したパラメーターからタスクのpkを抽出 task_id = self.kwargs['pk'] # リクエストからタスクの完了・未完了フラグを取得 is_Completed = data.get("is_Completed") # タスクをフィルタリング queryset = Todo.objects.filter(id=task_id) # インスタンス化 obj = queryset.first() # Falseの場合はclose_datetime(タスクの完了日)をnullにする if(is_Completed == False): obj.close_datetime = None obj.save() return self.partial_update(request, *args, **kwargs) # Trueなら完了日を登録 else: obj.close_datetime = datetime.datetime.now() obj.save() return self.partial_update(request, *args, **kwargs)グループ機能
Twitterでいうフォローやリスト機能に近い機能になります。
メンバーのタスクリストへも遷移できます。こちらでもM2Mフィールドを使った処理があります、自分のグループにフォームから受け取ったユーザーネームに該当するユーザーを追加するか、削除するかという処理です
以下の通りになります。
折りたたみ
@api_view(['POST']) @permission_classes([ permissions.IsAuthenticatedOrReadOnly]) def memberAdd_view(request, *args, **kwargs): """ id and username is required. """ serializer = MemberRequestSerializer(data=request.data) # pagination_class = ReactionPagination if serializer.is_valid(raise_exception=True): data = serializer.validated_data group_id = data.get("id") member_name = data.get("username") # 該当グループ抽出 queryset = UserGroup.objects.filter(id=group_id) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset.exists(): return Response("該当するグループがありません", status=404) # 該当ユーザー抽出 queryset2 = CustomUser.objects.filter(username=member_name) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset2.exists(): return Response(data, status=404) # 取得してきたものをインスタンス化 obj = queryset.first() Member = queryset2.first() # すでに追加済みだった場合、エラー if Member in obj.members.all(): return Response({}, status=404) # 追加処理 else: obj.members.add(Member) return Response("Request Success", status=200) @api_view(['PATCH']) @permission_classes([ permissions.IsAuthenticatedOrReadOnly]) def memberDelete_view(request, *args, **kwargs): """ id and username is required. """ serializer = MemberRequestSerializer(data=request.data) # pagination_class = ReactionPagination if serializer.is_valid(raise_exception=True): data = serializer.validated_data group_id = data.get("id") member_name = data.get("username") # 該当グループ抽出 queryset = UserGroup.objects.filter(id=group_id) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset.exists(): return Response("該当するグループがありません", status=404) # 該当ユーザー抽出 queryset2 = User.objects.filter(username=member_name) # クエリセットの実行結果でタスクが取得できなかった場合 if not queryset2.exists(): return Response(data, status=404) # 取得してきたものをインスタンス化 obj = queryset.first() Member = queryset2.first() # すでに追加済みだった場合、エラー if Member in obj.members.all(): obj.members.remove(Member) return Response("Request Success", status=200) # Memberが存在しない場合、エラー else: return Response("このグループにこのユーザーは存在しません", status=404)またグループ詳細、つまりメンバーのリストの表示にも難儀しました。
以下がグループを表すテーブルとメンバーを表す中間テーブルです。
折りたたみ
class UserGroup(models.Model): members = models.ManyToManyField( CustomUser, through="UserGroupRelation", blank=True) group_name = models.CharField(max_length=255, blank=True, null=True) owner = models.ForeignKey( CustomUser, verbose_name="ユーザー", related_name="GroupOwner", blank=True, null=True, on_delete=models.CASCADE) detail = models.CharField( max_length=60, blank=True, verbose_name="Group_Detail") class Meta: db_table = "UserGroup" verbose_name = _("UserGroup") verbose_name_plural = _("グループ") def __str__(self): return self.group_name class UserGroupRelation(models.Model): customuser_obj = models.ForeignKey( CustomUser, verbose_name="ユーザー", blank=True, on_delete=models.CASCADE) UserGroup_obj = models.ForeignKey( UserGroup, verbose_name="グループ", blank=True, on_delete=models.CASCADE) joined_date = models.DateField(default=datetime.now) detail = models.CharField( max_length=64, blank=True, verbose_name="What`s Group") class Meta: db_table = "UserGroupRelation" verbose_name = _("UserGroupRelation") verbose_name_plural = _("グループ詳細")ご覧の通りメンバーの情報はM2Mで保存されますが、じゃあこれを取得して表示するにはどうすればいいのかという問題に行き当たりました。
先程のいいねの場合はreaction_objの数をカウントしたものを返してもらうことで解決しましたが今回はそうはいきません。
どうSerializerに落とし込むかと調べてみると下記の記事で紹介されていた方法で解決しました。DjangoRestFrameworkで中間テーブルをネストした形のJsonで返す
コード
# M2MのMembersフィールドを抽出したい class MemberSerializer(serializers.Serializer): username = serializers.ReadOnlyField( source="customuser_obj.username") class Meta: model = UserGroupRelation fields = ['username'] # グループのシリアライザ class UserGroupSerializer(serializers.HyperlinkedModelSerializer): url = serializers.HyperlinkedIdentityField( view_name='UserGroup_detail', format='html') owner = serializers.ReadOnlyField(source='owner.username') members = MemberSerializer(source='usergrouprelation_set', many=True) class Meta: model = UserGroup fields = ['url', 'id', 'members', 'group_name', 'owner']自分が参加しているグループ
自分が参加しているグループを見れます。
ユーザー情報
ユーザー名・パスワード・メールアドレスの変更及び退会処理ができます。
デプロイ
ここで詰みそうになりました。
個人的な事情でデプロイ先はHerokuしかなかったのですが、情報がさっぱりなくて冒頭で書いたように同じようにDRFとReactでアプリを作ってHerokuに上げたという記事を書かれていたGtca様にダメ元でお願いして事なきを得ました。結論からいうとDjangoのindex.htmlにReactを読み込ませるといったような形でデプロイをすることになりました。
以下ファイルです。
折りたたみ
index.html<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.6.2/css/bulma.min.css"> <title>Mix up Our Todo</title> </head> <body> <!-- ここにReactが入る --> <div id="root"> <!-- React --> </div> </body> <!-- Reactでbuildしたものを読み込む --> {% load static %} <script src="{% static "frontend/main.js" %}"></script> </html>上記は404のエラーページも同様の記述にしてあります。
from django.urls import re_path ,path from . import views # Reactのルーティングをそのままこちらに持ってきて紐付けている。 # re_pathの部分はReactにおいて:idや:usernameを使っている部分の表現がパスコンバーターではできないのでこちらを使っています。 urlpatterns = [ path('', views.index, name='index_page'), path('login/', views.index, name='other_page'), path('signup/', views.index, name='other_page'), path('logout/', views.index, name='other_page'), path('todo/top/', views.index, name='other_page'), path('todo/list/', views.index, name='other_page'), re_path(r'^todo/list/[^/]+/$', views.index, name='other_page'), re_path(r'^todo/delete/[0-9]+/$', views.index, name='other_page'), re_path(r'^todo/edit/[0-9]+/$', views.index, name='other_page'), re_path(r'^todo/timer/[0-9]+/$', views.index, name='other_page'), path('user_info/', views.index, name='other_page'), path('password_change/', views.index, name='other_page'), path('unsubscribe/', views.index, name='other_page'), path('user_group/top/', views.index, name='other_page'), re_path(r'^user_group/edit/[0-9]+/$', views.index, name='other_page'), re_path(r'^user_group/delete/[0-9]+/$', views.index, name='other_page'), re_path(r'^user_group/[0-9]+/members/$', views.index, name='other_page'), path('user_group/joined/', views.index, name='other_page'), re_path(r'^user_group/list/[^/]+/$', views.index, name='other_page'), ]上記はReactで直にURLを叩いた場合にReact側のルーティングに飛ぶようにするための設定です。
React側は以下のようになっています。import React from "react"; import { Switch, Route, Redirect } from "react-router-dom"; // カスタムルーティング import PrivateRoute from "./Route/PrivateRoute"; import LoginRoute from "./Route/LoginRoute"; import LogoutRoute from "./Route/LogoutRoute"; // ランディング import TopPage from "./UserComponents/TopPage"; // ユーザーに関わるルーティング import User from "./UserComponents/UserPage"; import ChangePassword from "./UserComponents/ChangePassword"; import Unsubscribe from "./UserComponents/Unsubscribe"; import Login from "./UserComponents/LoginFormContainer"; import Logout from "./UserComponents/LogoutForm"; import Register from "./UserComponents/RegisterFormLayout"; // グループに関わるルーティング import Group from "./GroupComponents/Group"; import GroupJoined from "./GroupComponents/GroupJoined"; import GroupEdit from "./GroupComponents/GroupEdit"; import Group_Public from "./GroupComponents/GroupPublic"; import Group_Detail_Public from "./GroupComponents/GroupDetail_Readonly"; // Todoに関わるルーティング import Todo from "./TodoComponents/todo"; import Todo_Public from "./TodoComponents/todo_Public"; import TodoDelete from "./TodoComponents/TodoDelete"; import TodoEdit from "./TodoComponents/TodoEdit"; import TaskTimer from "./TodoComponents/TaskTimer"; // 404 error import NoMatch from "./UserComponents/Nomatch.js" const MainContent = () => ( <Switch> <Route path="/" exact> <TopPage /> </Route> <LoginRoute path="/login" component={Login} /> <LoginRoute path="/signup" component={Register} /> <LogoutRoute path="/logout" component={Logout} /> <PrivateRoute path="/todo/top" component={Todo} /> <PrivateRoute path="/todo/list/:username" component={Todo_Public} /> <PrivateRoute path="/todo/delete/:id" component={TodoDelete} /> <PrivateRoute path="/todo/edit/:id" component={TodoEdit} /> <PrivateRoute path="/todo/timer/:id" component={TaskTimer} /> <PrivateRoute path="/user_info" component={User} /> <PrivateRoute path="/password_change" component={ChangePassword} /> <PrivateRoute path="/unsubscribe" component={Unsubscribe} /> <PrivateRoute path="/user_group/top" component={Group} /> <PrivateRoute path="/user_group/joined" component={GroupJoined} /> <PrivateRoute path="/user_group/edit/:id" component={GroupEdit} /> <PrivateRoute path="/user_group/list/:username" component={Group_Public} /> <PrivateRoute path="/user_group/:id/members" component={Group_Detail_Public} /> <Route component={NoMatch}></Route> {/* <Redirect to="/" /> */} </Switch> ); export default MainContent;反省・制作を振り返って
設計の甘さと難しさ
実は設計したときはReactの前提知識がチュートリアル程度だったのですが、私が実際に何か作業しないとものが頭に入らないのでそのまま進めたのですが、結果として
- React Hooksを使うのかRedux Hooksを使うのか
- それらに伴うコンポーネントの構成をどうするのか
↓ 例えばフォームであるならばフォームのパーツをどこまで分割して組み立てるのか
- 上記2点を考えながらもuseCallback、useEffect、useMemoなどを使った描画の最適化を図るか
といった3点がReactの肝になってくるのではないか? ということを把握できていない状態で作業を始めてしまいました。
よって全体的に見てあまりスマートではない設計になったこととuseEffectを中心とした、再描画の最適化がうまく言ってるとは言えないと個人的に見ても思います。
実際にフロントを作ろう! となった瞬間にどうするべきかわからずアドバイスを受けてReact Hookのチュートリアルやり、その上でRedux Toolkitを見つけて海外のフォーラムやドキュメント等々見ながらようやく作業を始められたという経緯もありました。
個人的にはどこまでチュートリアルをやるべきかというのは「実際に自分でなにか作ってみないとわからないこともある」という点から非常に難しい問題です。Django側は関数Viewで実装した部分などを含めて、リクエストに応じて処理をわけるとかSerializerの処理をわける……といったことをこれからはやっていってもう少しシンプルに書いていきたいというのを感じました。
そのためにはもう少しAPIViewやSerializerについてドキュメントなりソースなりを読み解いていかないといけません。認証全般、特にソーシャルログインとそれに伴う認証の実装の失敗
過去にもこういう記事を書いているように、認証周りはいつも私にとって難敵なのですが、今回もやはりそうなりました。
今回初めてDRFとReactというフロントとサーバーに分けて開発をする(いわゆるSPAの制作)ということをしたというのは先にも書いた通りなのですが、その場合はなんとフロントとサーバー側で認証の橋渡しをしないといけないということに作業中に気づくわけです。
認証なんてフレームワークがいい感じにやってくれるよな~と思っていたのでこれにはだいぶ頭を悩ませました。
どうしたものかと調べたり(使ってる認証ライブラリのリポジトリにフォーラムで議論されてるところまで見ました)、相談した結果
- トークン認証と今までのようにセッションで認証するという2パターンある
どちらにもリスクはあるが、SPAでの認証として広く紹介されているトークンをLocal Strageに入れるやり方は一番よろしくないらしい
Set-Cookie, httpOnly:true, secure:trueにしたトークンとCSRFトークンを送りつけるのがベターそう……
という結論に至り、実際そこまで設定したのですがDjangoでどうしても上記のトークン(開発中はsecure:false)を受け付けてくれず、手詰まりになってしまったので結局はSessionで認証をすることになりました(JWTトークンはライブラリの仕様で発行はされている)
また個人的にはトークンとセッション認証の併用もやりたかったのですが、上記の通りトークン周りで問題が解決できなかったのでお蔵入りとなりました……本当に認証は難しい。
また仕様変更についての項目でも書いたようにソーシャルログインでも失敗しています。
このあたりの問題はFirebaseに認証を丸投げしてしまえばいいという知見をTwitterでのアドバイス及び調べていた中でも得られたので、習得しようかなと強く思いました。
開発環境から本番環境に移した際のチューニング
仮想環境化ではそこまででもなかったのですが、本番環境で動かすとレスポンスの悪さを感じ、実際に試しに触ってもらった方にも指摘を受けました。
これはおそらくHerokuで全部動かしていることに加えて、やはりReactでbuildしたものが重すぎるのが原因だろうと踏んでいるので前述の最適化ができていないというのは大きいなということを感じました。テストとデバック
ノウハウがないので未だにできていないところ……早くここまでできるようになりたいです。
コンテンツに動きが足りない
今回はReactに慣れていないのもありますが、Material-UIを使えなかったことに加えてコンテンツに動きを出す(例えばドラックでタスクを並び替えたりすることができるとか)ということをできなかったのはかなり痛いなと感じました。
前者はスピナーを使うのに自分で手作りしないといけませんでしたし、後者に関してはデプロイした! よかったぁとなった瞬間にTL上に今回私が作ったものの上位互換のようなものを作りました! というようなツイートが来てがっくし来たのもあってすごく悔しいです……最後に
以上が今回作ったものについてのまとめになります。
上記の挙げただけでも課題は山程あり、コード見れば粗だらけ……となりますが、それでも企画で得たノウハウを少しでも活かして力押しは多々ありますが、何度も恐縮ですが、本当にDRFとReactでの開発については情報がなく、海外のフォーラム・記事・はてはライブラリのソースやリポジトリのフォーラムまで探して情報を集めていって、なんとか自分が設計したものに対して一つずつ実装し、形にはできたところだけは自分を褒めていいのかなと思いました。
今回で扱ったことについてはいくつか掘り下げないとなということも沢山あるので、その際はまたアウトプット記事を書けたらなと思います。
お暇なときで結構ですのでちらっとでも触っていただけると嬉しいです。あと、プロフィール各所にあるように現在エンジニアとして就職を目指しています。
今回に限らず、この1年色々やって記事にアウトプットもして、0から勉強して企画に参加してみたりと自走力と意欲に関しては自信を持ってアピールできると思います。
もし、興味を持たれた方がいらっしゃいましたらTwitter等々からご連絡頂けるととても嬉しいです。このアプリを作るにあたって事前にやったこと
資料集
参考資料
HookとRedux ToolkitでReact Reduxに入門する
404 page not found using Django + react-router
データベースを使う Django を Heroku にデプロイ
Django React アプリケーションの URL をマッピングする
React+axios+Material UIでスピナーとメッセージを表示する
axios、async/awaitを使ったHTTPリクエスト(Web APIを実行)
[Django REST Framework] Serializer の 使い方 をまとめてみた
【useState/ReactRedux】Reactにおける状態管理
DjangoのページをReactで作る - Webpack4
JavaScript axiosをasync、awaitとtry、catch、finallyで制御する
SPAのログイン認証のベストプラクティスがわからなかったのでわりと網羅的に研究してみた〜JWT or Session どっち?〜
- 投稿日:2020-11-23T02:48:25+09:00
【JavaScript】clientWidthプロパティでは小数値は取得できない
JSで横幅や高さを取得する処理を実装することはよくあると思います。
その際に、よく使われるのは# 横幅取得 element.clientWidth # 高さ取得 element.clientHeight上記だと思います。
ただし、clientWidth
やclientHeight
プロパティは整数値に丸め込まれてしまいます。僕はこれを知らずに
clientWidth
やclientHeight
を使ってどはまりました。
横幅や高さを取得した上で計算する際に、小数値が丸め込まれてしまうと計算される値がずれてしまいます。小数値まで含まれた値が欲しい
タイトルにもあるように、getBoundingClientRect()メソッドを使います。
# 横幅取得 element.getBoundingClientRect().width # 高さ取得 element.getBoundingClientRect().heightちなみに、
getBoundingClientRect()
は要素の横幅・高さだけでなく、ビューポートに対する位置も取得できます。まとめ
最近は、バックエンドだけでなく、フロントエンドの開発も行うようになりました。
そんなフロントエンド初心者の僕は、基本的に要素の寸法と、そのビューポートに対する位置を取得したいときは、
getBoundingClientRect()
を使えば良さそうだと思いました。
clientWidth
やclientHeight
を使うメリットなどがあれば教えてください。
- 投稿日:2020-11-23T00:29:12+09:00
【React超初心者】フォームの作成編
はじめに
皆さん初めまして。
エンジニア歴一年目、現場経験ゼロの弱小自称 Webエンジニアです。研修でVueの学習をしていたのですが
他のJSフレームワークも触ってみたくなり、本記事の作成にいたった次第でございます。拙者の予備知識
・Vueを使って開発をしたことがある
・Progateで ReactⅣまで学習済み開発環境
・MacOS Catalina バージョン10.15.7
・Node.js v12.18.4
・Npm 6.14.6今回作りたいもの
Reactの使い方を学ぶにあたって、今回は簡単なフォームを作成していくンゴね〜。
ちゃんと入力値が送信されてるか確認するためにアラート表示もさせるンゴよ〜。
プロジェクトの作成
公式さんが推奨している方法でプロジェクトを作っていくンゴね〜。
Create React App は React を学習するのに快適な環境であり、
React で新しいシングルページアプリケーションを作成するのに最も良い方法です。Create React App:
https://ja.reactjs.org/docs/create-a-new-react-app.html#create-react-appReactプロジェクトの作成$ npx create-react-app react-form-appnpxってナンジャイナって思ったけど、npm(5.6以上)入ってたら使えるツールらしい。
プロジェクトの起動
Reactプロジェクトの起動$ cd react-form-app $ npm startなんか自動でブラウザ開いてシャレ乙な画面でてきた【感動!!】
お次は、お好きなIDE(統合開発環境)でプロジェクトのファイルを開いて
さっそく実装していくンゴよ〜。フォームの作成
公式さんのやり方を参考にめちゃくちゃ簡単なフォームを作ります。
フォーム:https://ja.reactjs.org/docs/forms.html#gatsby-focus-wrapperApp.jsimport React from 'react' class App extends React.Component { render() { return ( <div className="App"> <form> <label> 名前: <input type="text" name="name" /> </label> <input type="submit" value="送信" /> </form> </div> ) } } export default Appinputタグは
<input />
というふうに後ろに/
に付けなきゃらしい。
これがJSX記法というやつか!!onChangeイベント
送信後も値が入力フォームに残るようにしたい場合は、
入力フォームのinputタグにonChangeイベント
を指定し、入力値を取得する。Apps.jsconstructor(props) { super(props); this.state = {name: ''}; this.handleChange = this.handleChange.bind(this); //これが無いと何かエラー出る } handleChange(event) { this.setState({name: event.target.value}); //event.target.valueで入力値を取得し、stateを更新 } render() { return ( <div className="App"> <form> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) }
constructor()
はライフサイクルメソッドです。
render()
より先に呼び出されるやつですね。( Vueでいうcreated()
みたいなやつか!)onSubmitイベント
これだけじゃまだ送信後に入力値は残らない。
フォームの送信時の処理は formタグにonSubmitイベント
を指定する。Form.jshandleSubmit(event) { alert(this.state.name + 'さん!!'); //アラートを表示 event.preventDefault(); } render() { return ( <div className="App"> <form onSubmit={this.handleSubmit}> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) }
preventDefault()
はイベントをキャンセルするメソッド。
今回であれば、submitイベントが持つページの移動をキャンセルしています。まとめ
・もっと深い内容の記事書きたかったンゴ…(初投稿だから許して)
・Vueが如何にJS弱者に優しいフレームワークだったかわかったApp.jsimport React from 'react' class App extends React.Component { constructor(props) { super(props); this.state = {name: ''}; this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } handleChange(event) { this.setState({name: event.target.value}); } handleSubmit(event) { alert(this.state.name + 'さん!!'); event.preventDefault(); } render() { return ( <div className="App"> <form onSubmit={this.handleSubmit}> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) } } export default App参考記事
・Reactの問い合わせフォームの作成
・Reactで Uncaught TypeError: Cannot read property 'setState' of undefined と怒られる場合の対処法
・preventDefault()について
- 投稿日:2020-11-23T00:29:12+09:00
【React】超初心者:フォームの作成編
はじめに
皆さん初めまして。
エンジニア歴一年目、現場経験ゼロの弱小自称 Webエンジニアです。研修で Vue を使った開発をしていたのですが
他の JS フレームワークも触ってみたくなり、本記事の作成にいたった次第でございます。拙者の予備知識
・Vue を使って開発をしたことがある
・Progate で ReactⅣ まで学習済み開発環境
・MacOS Catalina バージョン10.15.7
・Node.js v12.18.4
・Npm 6.14.6今回作りたいもの
React の使い方を学ぶにあたって、今回は簡単なフォームを作成していくンゴね〜。
ちゃんと入力値が送信されてるか確認するためにアラート表示もさせるンゴよ〜。
プロジェクトの作成
公式さんが推奨している方法でプロジェクトを作っていくンゴね〜。
Create React App は React を学習するのに快適な環境であり、
React で新しいシングルページアプリケーションを作成するのに最も良い方法です。Create React App:
https://ja.reactjs.org/docs/create-a-new-react-app.html#create-react-appReactプロジェクトの作成$ npx create-react-app react-form-appnpxってナンジャイナって思ったけど、npm(5.6以上)入ってたら使えるツールらしい。
プロジェクトの起動
Reactプロジェクトの起動$ cd react-form-app $ npm startなんか自動でブラウザ開いてシャレ乙な画面でてきた【感動!!】
お次は、お好きな IDE (統合開発環境)でプロジェクトのファイルを開いて
さっそく実装していくンゴよ〜。フォームの作成
公式さんのやり方を参考にめちゃくちゃ簡単なフォームを作ります。
フォーム:https://ja.reactjs.org/docs/forms.html#gatsby-focus-wrapperApp.jsimport React from 'react' class App extends React.Component { render() { return ( <div className="App"> <form> <label> 名前: <input type="text" name="name" /> </label> <input type="submit" value="送信" /> </form> </div> ) } } export default Appinputタグは
<input />
というふうに後ろに/
に付けなきゃらしい。
これがJSX記法というやつか!!onChangeイベント
送信後も値が入力フォームに残るようにしたい場合は、
入力フォームのinputタグにonChangeイベント
を指定し、入力値を取得する。Apps.jsconstructor(props) { super(props); this.state = {name: ''}; this.handleChange = this.handleChange.bind(this); //これが無いと何かエラー出る } handleChange(event) { this.setState({name: event.target.value}); //event.target.valueで入力値を取得し、stateを更新 } render() { return ( <div className="App"> <form> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) }
constructor()
はライフサイクルメソッドです。
render()
より先に呼び出されるやつですね。( Vueでいうcreated()
みたいなやつか!!)onSubmitイベント
これだけじゃまだ送信後に入力値は残らない。
フォームの送信時の処理は formタグにonSubmitイベント
を指定する。Form.jshandleSubmit(event) { alert(this.state.name + 'さん!!'); //アラートを表示 event.preventDefault(); } render() { return ( <div className="App"> <form onSubmit={this.handleSubmit}> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) }
preventDefault()
はイベントをキャンセルするメソッド。
今回であれば、submitイベントが持つページの移動をキャンセルしています。まとめ
・もっと深い内容の記事書きたかったンゴ…(初投稿だから許して)
・Vueが如何にJS弱者に優しいフレームワークだったかわかったApp.jsimport React from 'react' class App extends React.Component { constructor(props) { super(props); this.state = {name: ''}; this.handleChange = this.handleChange.bind(this); this.handleSubmit = this.handleSubmit.bind(this); } handleChange(event) { this.setState({name: event.target.value}); } handleSubmit(event) { alert(this.state.name + 'さん!!'); event.preventDefault(); } render() { return ( <div className="App"> <form onSubmit={this.handleSubmit}> <label> 名前: <input type="text" value={this.state.name} onChange={this.handleChange} /> </label> <input type="submit" value="送信" /> </form> </div> ) } } export default App参考記事
・Reactの問い合わせフォームの作成
・Reactで Uncaught TypeError: Cannot read property 'setState' of undefined と怒られる場合の対処法
・preventDefault()について
- 投稿日:2020-11-23T00:17:48+09:00
Next.js に TypeScript を導入したら Any が許容されていた話
概要
Next.js
にTypeScript
を導入したら設定がゆるふわだったので修正したという話アプリケーションの作成と TypeScript の導入
まずはアプリケーションを作成する。
$ npx create-next-app next-ts
次に必要なパッケージをインストールする。
$ yarn add -D typescript @types/react @types/react-dom @types/node続いて拡張子を変更する。
$ mv pages/index.js pages/_app.tsx $ mv pages/_app.js pages/_app.tsx $ mv pages/api/hello.js pages/api/hello.ts最後にアプリを起動する。
$ yarn dev
すると自動で下記ファイルが作成される。
next-env.d.ts/// <reference types="next" /> /// <reference types="next/types/global" />tsconfig.json{ "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] }無事に http://lobalhost:3000 で画面表示されて完了(?)。
Any が許容されていたデフォルト設定
「あれ?何か違和感。」
とりあえず適当なファイルを確認する。_app.tsximport '../styles/globals.css' function MyApp({ Component, pageProps }) { return <Component {...pageProps} /> } export default MyApp「そういえば引数の型を指定してしない。
any
で許容されている。」
システムが許しても私は許さない。設定ファイルを確認する。tsconfig.json{ "compilerOptions": { ・・・ "strict": false ・・・ } }「なぜデフォルトが
strict: false
なのだろうか。」
strict: true
に修正する。これでnoImplicitAny
などの設定が有効化される。
暗黙的なany
が 無事にエラー になったので各ファイルも修正する。_app.tsximport '../styles/globals.css' import { AppProps } from 'next/app' function MyApp({ Component, pageProps }: AppProps) { return <Component {...pageProps} /> } export default MyApphello.tsimport { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => { res.statusCode = 200 res.json({ name: 'John Doe' }) }
TypeScript
の恩恵は有り難く享受していきたい。参考文献
- 投稿日:2020-11-23T00:17:48+09:00
Next.js に TypeScript を導入したのに Any が許容されていた話
概要
Next.js
にTypeScript
を導入したら設定がゆるふわだったので修正したという話アプリケーションの作成と TypeScript の導入
まずはアプリケーションを作成する。
$ npx create-next-app next-ts
次に必要なパッケージをインストールする。
$ yarn add -D typescript @types/react @types/react-dom @types/node続いて拡張子を変更する。
$ mv pages/index.js pages/_app.tsx $ mv pages/_app.js pages/_app.tsx $ mv pages/api/hello.js pages/api/hello.ts最後にアプリを起動する。
$ yarn dev
すると自動で下記ファイルが作成される。
next-env.d.ts/// <reference types="next" /> /// <reference types="next/types/global" />tsconfig.json{ "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": false, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve" }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx" ], "exclude": [ "node_modules" ] }無事に http://lobalhost:3000 で画面表示されて完了(?)。
Any が許容されていたデフォルト設定
「あれ?何か違和感。」
とりあえず適当なファイルを確認する。_app.tsximport '../styles/globals.css' function MyApp({ Component, pageProps }) { return <Component {...pageProps} /> } export default MyApp「そういえば引数の型を指定してしない。
any
で許容されている。」
システムが許しても私は許さない。設定ファイルを確認する。tsconfig.json{ "compilerOptions": { ・・・ "strict": false ・・・ } }「なぜデフォルトが
strict: false
なのだろうか。」
strict: true
に修正する。これでnoImplicitAny
などの設定が有効化される。
暗黙的なany
が 無事にエラー になったので各ファイルも修正する。_app.tsximport '../styles/globals.css' import { AppProps } from 'next/app' function MyApp({ Component, pageProps }: AppProps) { return <Component {...pageProps} /> } export default MyApphello.tsimport { NextApiRequest, NextApiResponse } from 'next' export default (req: NextApiRequest, res: NextApiResponse) => { res.statusCode = 200 res.json({ name: 'John Doe' }) }
TypeScript
の恩恵は有り難く享受していきたい。参考文献