- 投稿日:2019-12-23T23:42:50+09:00
JavaScript の日付処理は luxon が便利
はじめに
JavaScript で日付処理といえば moment.js が有名かと思います。
そんな中、私の関わるプロジェクトでは使用するライブラリを、 moment.js -> date-fns -> luxon と変更してきました。
そして今現在は luxon を使っています。luxon は使っていて便利だな、と感じるライブラリです。
ここでは、これまで使用した処理の中で、私が便利だと思った機能をいくつか紹介します。
(基本的なものは割愛します。下記の参考リンクにもまとめられています。)実際に使っている処理
DateTime 型を作成
これは基本的なものですが、これがないと始まらないので記載しておきました。
import { DateTime } from 'luxon'; // ISO format DateTime.fromISO('2019-12-23T12:00:00.000000'); // JavaScript Date format DateTime.fromJSDate(new Date()); // SQL format DateTime.fromSQL('2017-05-15 09:12:34');表示用に出力
toLocalString
が個人的に好きです。
ローカライズにも対応しているのがありがたい。const dateTime = DateTime.fromISO( ... ); // ISO 形式で出力 dateTime.toISO(); // 指定したフォーマット形式で出力 dateTime.toFormat('h:mma, dd-MM-yyyy'); // 定められたフォーマットで出力(ローカライズ対応) dateTime.toLocaleString({ ...DateTime.DATE_MED }); /* * DATETIME_FULL * DATETIME_FULL_WITH_SECONDS * DATETIME_HUGE * DATETIME_HUGE_WITH_SECONDS * DATETIME_MED * DATETIME_MED_WITH_SECONDS * DATETIME_SHORT * DATETIME_SHORT_WITH_SECONDS * DATE_FULL * DATE_HUGE * DATE_MED * DATE_SHORT * TIME_24_SIMPLE * TIME_24_WITH_LONG_OFFSET * TIME_24_WITH_SECONDS * TIME_24_WITH_SHORT_OFFSET * TIME_SIMPLE * TIME_WITH_LONG_OFFSET * TIME_WITH_SECONDS * TIME_WITH_SHORT_OFFSET */ // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat dateTime.toLocaleString({ year: 'numeric', month: 'short', day: 'numeric' }),○○分以内に発生したイベントの判断
現在時刻との比較はよく使うので
diffNow
は便利ですね。if (DateTime.fromISO(...).diffNow('minutes').minutes >= -3) { ... } if (DateTime.fromISO(...).diff(..., 'minutes').minutes >= 60) { ... } // DateTime.fromISO(...).diffNow().minutes とした場合、返り値が 0 になりました。 // これは DateTime オブジェクトは millisecond がデフォルトのためです。 // 単位を分にする場合は diffNow('minutes') と記載する必要があります。祝日の判断
営業日判断などで利用します。
holidays.find((holiday) => { return holiday.hasSame(DateTime.local(), 'day'); } !== undefined○○年後の開始月/日を取得
startOf
とendOf
は非常に便利だと思います。
応用すれば、今月の初日や、現在時刻から00分や00秒の取得、といったこともできます。// 15年後の同月一日を取得(現在が2019年12月なら2034/12/1) DateTime.utc().plus({ years: 15 }).startOf('month'); // 15年後の開始日を取得(現在が2019年12月なら2034/1/1) DateTime.utc().plus({ years: 15 }).startOf('year');ハマったところ
diff
/diffNow
フォーマットを指定しないと、milliseconds 以外の値が返ってこない、という状況で最初ハマりました。
差分を取得しているつもりなのに、if文が正しく動いてくれません。
分で評価したい場合は、diffNow('minutes').minutes
とする必要があります。ユニットテスト
我々のプロジェクトでは、サーバサイドでも日付時刻計算に luxon を使っています。
ローカルでテストコードを実行してユニットテスト成功していたものが、CI/CD環境ではエラーとなるケースに直面しました。
どの環境でも同じように動くように実装する必要性を強く感じました。参考
- 投稿日:2019-12-23T23:07:33+09:00
AWS Ampliy の DataStore を使ったら AppSync のデータアクセスがめちゃくちゃ楽になった
概要
AWS Amplify Advent Calendar 2019、23日目は 今年のre:Inventで発表されたAmplifyのDataStoreについてです。本記事ではサンプルコードの実装にReactを用いていますが、基本的な考え方はVueなどの他のフレームワークでも同じです。
想定読者
- Amplify 使ったことがある
- AppSync 使ったことがある
Amplify DataStore の概要
まずは簡単に DataStore についておさらいします。DataStore は AWS AppSync に接続するための新たなインターフェースです。DataStore は以下のような特徴を持ちます。
1. クラウドへのデータ同期、及び競合検知
端末のネットワーク状況(オンライン or オフライン)を検知して、端末がオンラインになったら自動的にローカルデータをクラウドに同期し、データの競合をいい感じに解決してくれます。
2. デベロッパーフレンドリーなインターフェース
デベロッパーフレンドリーなインターフェースでGraphQLに詳しくなくてもAppSyncからデータを操作できます。GraphQLの操作を抽象化した関数を組み合わせることでAppSyncに対してクエリを発行できるようになりました。
クラウドへのデータ同期、及び競合検知
DataStoreを用いることで、オフライン時のアプリの開発を簡素化することができます。
DataStoreはローカルストレージとクラウドの両方にデータの書き込みを試みます。端末がオフラインの場合、リクエストを失敗させることなく、ローカルストレージにのみデータ書き込みを行い、オンライン復旧後にクラウドへのデータ同期を行います。オフライン期間にクラウド上のデータとローカルストレージのデータで差分が発生した場合は、データのマージ、データの競合検知を自動的に行います。そしてこれらの挙動は全て隠蔽されており、開発者はこれらの挙動について、意識をする必要はありません!!
DataStoreでは、データの競合時の動作を以下の3つから選択できるようになっています。
- Auto Merge (デフォルト)
- Optimistic Concurrency
- Custom Lambda
データの競合が起こった時の挙動1(Auto Merge)
データの競合が起こった際の挙動はデフォルトではAutomergeが選択されています。
以下の例ではClienrt A、Client Bがオフライン中に同時にデータの更新を行った場合を想定します。DataStoreは互いの更新内容を確認し、自動でデータをマージしクラウド上のデータと各クライアントのローカルストレージ上のデータに対し更新を行います。// 元のデータ { id: 'id_001', name: 'Andy', hobby: [], _version: 1, _lastChangedAt: 1577001382093, _deleted: false }// Client A がオフライン中にローカルで変更したデータ { id: 'id_001', name: 'Andy', hobby: [baseball], // Client A が hobbyに baseballを追加 _version: 2, _lastChangedAt: 1577001382094, _deleted: false }// Client B がオフライン中にローカルで変更したデータ { id: 'id_001', name: 'Jeff', // Client B が nameを Jeffに変更 hobby: [tennis], // Client B が hobbyに tennisを追加 _version: 2, _lastChangedAt: 1577001382095, _deleted: false }この場合、Client A と Client Bの更新は矛盾なく実施することができるため、以下のようなデータにマージされます。
{ id: 'id_001', name: 'Jeff', // Client B の変更が反映 hobby: [baseball, tennis], // Client A,B の変更を反映 _version: 2, _lastChangedAt: 1577001382524, _deleted: false }データの競合が起こった時の挙動2(Optimistic Concurrency)
Optimistic Concurrencyでは、データの競合を検知した際に、単純にクライアントからのリクエストを拒否します。クライアントは、再度、クラウド上のデータを正としてデータの更新処理を行う必要があります。
データの競合が起こった時の挙動3(Custom Lambda)
Custom Lambdaでは競合が起こった際に、独自に定義したLambda関数を起動させることができます。Custom Lambdaを用いることで、より柔軟なデータ競合時の処理を実装することが可能です。
データの競合戦略の変更はAmplify CLIから行うことができます。特に指定がなければAuto Mergeを選択するのが良いかと思われます。
デベロッパーフレンドリーなインターフェース
DataStoreを用いたAppSyncのデータアクセス方法
先ほども言及したように、DataStoreの登場で、デベロッパーはGraphQLを記述しなくてもAppSync経由でデータストアにアクセスできるようになりました。
例えば、以下のようなスキーマがあった場合に、
enum PostStatus { ACTIVE INACTIVE } type Post @model { id: ID! title: String! rating: Int! status: PostStatus! }評価(rating)が4以上の投稿を抽出するには以下のように記述することができます。
import { Post } from "./models"; const posts = await DataStore.query(Post, c => c.rating("gt", 4));第一引数には、取得する対象のデータモデルのClassを指定し、第二引数には取得するデータの検索条件を指定します。
さらに複雑な条件を記述することもできます。たとえば、以下の例では、「ratingが4以上、もしくは、ステータスがACTIVEな投稿」を取得しています。import { Post } from "./models"; const posts = await DataStore.query(Post, c => c.or( c => c.rating("gt", 4).status("eq", PostStatus.ACTIVE) ));ちなみにこれと同じクエリをGraphQLで発行すると以下のようになります。
const posts = await API.graphql(graphqlOperation(` query listPosts { listPosts( filter: { or: { rating: { gt: 4 } status: { eq: ACTIVE } } } ) { items { id title rating } } } `));どうでしょうか。このようにGraphQLに精通していなくてもAppSyncに対しクエリを発行することができます。
静的型付け言語の恩恵
関数でクエリの操作を行えるようになったことで、TypeScriptやKotlin、Swiftといった静的型付け言語の恩恵を受けやすくなります。今まではGraphQLのクエリを文字列で記述していたのに対し、DataStoreでは関数でクエリを組み立てていくため、IDEの補完機能を用いながら実装を行うことができるようになります。
Amplify CLI の
codegen
というコマンドを実行することで、作成したモデル定義からAppSyncへのデータアクセスの実装に必要なソースコードを自動で生成してくれます。このコマンドを用いることで、静的型付けに必要なコードも自動で出力してくれます。先ほどのモデルを定義した状態で、
codegen
コマンドを発行すると、以下のようなコードが出力されます。import { ModelInit, MutableModel, PersistentModelConstructor } from "@aws-amplify/datastore"; export enum PostStatus { ACTIVE = "ACTIVE", INACTIVE = "INACTIVE" } export declare class Post { readonly id: string; readonly title: string; readonly rating: number; readonly status: PostStatus | keyof typeof PostStatus; constructor(init: ModelInit<Post>); static copyOf(source: Post, mutator: (draft: MutableModel<Post>) => MutableModel<Post> | void): Post; }型定義されたClassをimportすることで、先ほどのようにIDEの機能を活用しながら実装を進めることが可能です。
上記のソースはTypeScriptを用いることを前提としています。TypeScriptを用いない場合であってもある程度補完は効きますが、やはりTypeScriptの方が補完は強力です。
まとめ
いかがでしょうか。DataStoreを用いればオフライン時のアプリの実装が非常に簡単になります。また、AppSyncへのデータ操作が関数で記述できることにより、非常に効率的に開発を行うことができるようになりました。Amplifyを使って開発をされている方は、是非DataStoreの導入も検討いただけると良いと思います!
おわり
- 投稿日:2019-12-23T23:01:16+09:00
エンジニア向け特化型SNSを開発しています
これは、ひとり開発 Advent Calendar 2019 の23日目の記事です。
はじめに
新卒( 2019年12月で退社 & 転職活動始めます!)でエンジニアをやっている筆者が、友人と一緒にここ数ヶ月開発しているサービスについて書きたいと思います。ちなみに、2020年1月中にα版をリリース予定、3月1日にはこのサービス開発についてまとめた本を技術書展で出版します。みなさん、技術書展の会場でお会いしましょう。
開発中サービスについて
エンジニアのためのプラットフォーム: 「Jeeek (ジーク) 」
サービス名についてですが、色々あってとりあえずこの名前にしています。由来はあるのですが今回の記事では省略します。
概要を最初にざっくり言うと、エンジニアの活動 (学習・転職) を支援するSNSライクのプラットフォームです。詳しくは、以下で述べていきます。
Why?<なぜ作ったのか>
まずは、なぜJeeek(ジーク)を開発・リリースしようと考えたのかを書いていきます。
活動共有、コミュニティ形成の需要があると感じたから
Twitterでは、 「#100DaysOfCode」 や 「#未経験エンジニアと繋がりたい」 といったハッシュタグをよく見かけた時期がありました。これらは、自身の活動の共有を行いたい・共有することで自分にプレッシャーをかけ継続させたい、同じような境遇の人同士で切磋琢磨し合いながら成長していきたい、などの想いがあると思います。また、他分野からIT業界への流入、プログラミング義務教育化など、時代の潮流と共にITエンジニアの増加が想定されます。
しかし、エンジニアが対象とする技術分野は幅が広く、初学者にとっては情報の取捨選択が難しいこともあり、初期は何から勉強をしたら良いのか、また実際に手を動かした後も何が分かっていないのか分からないといった状態が生まれやすいです。経験の浅いエンジニアと経験豊富なエンジニアの間に存在する情報の非対称性を狙って不当に高額なプログラミングスクールなどの情弱ビジネスも横行しています。これでは、たとえITエンジニアの人口が増加したとしても脱初心者にとても時間がかかってしまいます。こういった現状を変え、エンジニアの活動がより身近で楽しいものになったり、相互に刺激し合う環境がオンラインで構築できたり、駆け出しエンジニアの勉強コンサルにもなるようなサービスがあったら面白いのではないかと考えサービス開発を始めました。
How?<どう実現するのか>
次に、「じゃあどうやってそれを実現していこうと考えているの?」ということについてです。基本的に、現在実装中のものはエンジニア向けに特化したSNSというイメージで、今後これに機能を追加しながらエンジニアの活動全体を支援するプラットフォームにまで拡張していく予定です。
ユーザーの活動をSNSライクのタイムラインで共有します
メインとなるのがタイムライン機能です。普段の活動をテンプレートベースのシンプルな投稿で共有します。例えば、『◯◯技術書の1章を読んだ』『◯◯のイベントに参加した』などです。また、エンジニアの活動はインターネットにアウトプットされることが多いことから、外部サービスでの活動を自動収集および自動投稿も可能です。外部サービス (GitHub, Qiita, connpassなど) のAPIを利用して連携させることで、外部サービスでのアクティビティを自動で取得しタイムラインで共有します。例えば、GitHubと連携済みユーザーであれば『新しく1つのcommitをしました』のような投稿を自動で行うことができます。
また、日々の投稿のログ (投稿に設定するタグ) から、ユーザーのスキルスタックを可視化・共有します。そして、それらに基づいて特定の技術でのユーザーランキングも表示します。
What?<ユーザーはどうなるのか>
次に、「Jeeek(ジーク)のユーザーはどういうことが嬉しくて利用するの?」ということについて書いていきます。
他のエンジニアの活動を自身の活動に役立てることができる
タイムラインで活動を共有することで、他のエンジニアのアクティブな活動を知ることができ、切磋琢磨する環境ができたり、良質な記事・文献にアクセスしやすくなります。また初学者にとっては、ロールモデルを発見できる可能性が高まり、自身の勉強の指針にもなります。これに加えて、技術ベースでランキングが確認できるので、自身の立ち位置を客観的に認識することげできます。
また、ユーザーのスキルスタックが公開されるので、スカウトする/されるの機会を作ります。これらのスキルスタックは日々の活動に紐づいて自動生成されるので、定期的に手動でアップデートする必要もありません。
ちなみに、スキルスタック自動生成 + 企業とマッチングについては、LAPRASさんのサービスが有名です。これに対して、Jeeek(ジーク)はより粒度の細かい活動を共有したり、和気あいあいとしたアクティブなコミュニティ形成を実現したいため、SNSの機能に重きを置いています。
開発環境
最後に、Jeeek(ジーク)の開発環境について簡単に紹介します。
技術スタック
使用技術やツールなどは全て上の図に載っている通りです。
インフラにGCP、バックエンドにGo、フロントエンドにTypeScript × Reactを使っています。ちなみに僕はフロントエンドを担当しており、TypeScript, React, Redux, Redux-Saga, Figmaなどを使用しています。おわりに
プライベート開発では、サービス企画からスプリントプランニング、UI設計、実装、リリーススケジュール、リリース後のアップデートおよびマーケティングまで全て自分たちで考えながら実行・経験できることがとても面白いと感じています。また、それと同時にその大変さも感じ、実際にリリースや運用を行っている全個人開発者のことをリスペクトするようになりました。Jeeek(ジーク)も僕個人もまだまだ道半ばですが、引き続き精進していきます。冒頭でも述べましたが、これに関連した本を技術書展で出版するので、次は3月1日の技術書展でお会いしましょう!
- 投稿日:2019-12-23T23:01:16+09:00
React × Firebase でエンジニア向け特化型SNSを開発しています
これは、ひとり開発 Advent Calendar 2019 の23日目の記事です。
はじめに
新卒( 2019年12月で退社 & 転職活動始めます!)でエンジニアをやっている筆者が、友人と一緒にここ数ヶ月開発しているサービスについて書きたいと思います。ちなみに、2020年1月中にα版をリリース予定、3月1日にはこのサービス開発についてまとめた本を技術書展で出版します。みなさん、技術書展の会場でお会いしましょう。
開発中サービスについて
エンジニアのためのプラットフォーム: 「Jeeek (ジーク) 」
サービス名についてですが、色々あってとりあえずこの名前にしています。由来はあるのですが今回の記事では省略します。
概要を最初にざっくり言うと、エンジニアの活動 (学習・転職) を支援するSNSライクのプラットフォームです。詳しくは、以下で述べていきます。
Why?<なぜ作ったのか>
まずは、なぜJeeek(ジーク)を開発・リリースしようと考えたのかを書いていきます。
活動共有、コミュニティ形成の需要があると感じたから
Twitterでは、 「#100DaysOfCode」 や 「#未経験エンジニアと繋がりたい」 といったハッシュタグをよく見かけた時期がありました。これらは、自身の活動の共有を行いたい・共有することで自分にプレッシャーをかけ継続させたい、同じような境遇の人同士で切磋琢磨し合いながら成長していきたい、などの想いがあると思います。また、他分野からIT業界への流入、プログラミング義務教育化など、時代の潮流と共にITエンジニアの増加が想定されます。
しかし、エンジニアが対象とする技術分野は幅が広く、初学者にとっては情報の取捨選択が難しいこともあり、初期は何から勉強をしたら良いのか、また実際に手を動かした後も何が分かっていないのか分からないといった状態が生まれやすいです。経験の浅いエンジニアと経験豊富なエンジニアの間に存在する情報の非対称性を狙って不当に高額なプログラミングスクールなどの情弱ビジネスも横行しています。これでは、たとえITエンジニアの人口が増加したとしても脱初心者にとても時間がかかってしまいます。こういった現状を変え、エンジニアの活動がより身近で楽しいものになったり、相互に刺激し合う環境がオンラインで構築できたり、駆け出しエンジニアの勉強コンサルにもなるようなサービスがあったら面白いのではないかと考えサービス開発を始めました。
How?<どう実現するのか>
次に、「じゃあどうやってそれを実現していこうと考えているの?」ということについてです。基本的に、現在実装中のものはエンジニア向けに特化したSNSというイメージで、今後これに機能を追加しながらエンジニアの活動全体を支援するプラットフォームにまで拡張していく予定です。
ユーザーの活動をSNSライクのタイムラインで共有します
メインとなるのがタイムライン機能です。普段の活動をテンプレートベースのシンプルな投稿で共有します。例えば、『◯◯技術書の1章を読んだ』『◯◯のイベントに参加した』などです。また、エンジニアの活動はインターネットにアウトプットされることが多いことから、外部サービスでの活動を自動収集および自動投稿も可能です。外部サービス (GitHub, Qiita, connpassなど) のAPIを利用して連携させることで、外部サービスでのアクティビティを自動で取得しタイムラインで共有します。例えば、GitHubと連携済みユーザーであれば『新しく1つのcommitをしました』のような投稿を自動で行うことができます。
また、日々の投稿のログ (投稿に設定するタグ) から、ユーザーのスキルスタックを可視化・共有します。そして、それらに基づいて特定の技術でのユーザーランキングも表示します。
What?<ユーザーはどうなるのか>
次に、「Jeeek(ジーク)のユーザーはどういうことが嬉しくて利用するの?」ということについて書いていきます。
他のエンジニアの活動を自身の活動に役立てることができる
タイムラインで活動を共有することで、他のエンジニアのアクティブな活動を知ることができ、切磋琢磨する環境ができたり、良質な記事・文献にアクセスしやすくなります。また初学者にとっては、ロールモデルを発見できる可能性が高まり、自身の勉強の指針にもなります。これに加えて、技術ベースでランキングが確認できるので、自身の立ち位置を客観的に認識することげできます。
また、ユーザーのスキルスタックが公開されるので、スカウトする/されるの機会を作ります。これらのスキルスタックは日々の活動に紐づいて自動生成されるので、定期的に手動でアップデートする必要もありません。
ちなみに、スキルスタック自動生成 + 企業とマッチングについては、LAPRASさんのサービスが有名です。これに対して、Jeeek(ジーク)はより粒度の細かい活動を共有したり、和気あいあいとしたアクティブなコミュニティ形成を実現したいため、SNSの機能に重きを置いています。
開発環境
最後に、Jeeek(ジーク)の開発環境について簡単に紹介します。
技術スタック
使用技術やツールなどは全て上の図に載っている通りです。
インフラにGCP、バックエンドにGo、フロントエンドにTypeScript × Reactを使っています。ちなみに僕はフロントエンドを担当しており、TypeScript, React, Redux, Redux-Saga, Figmaなどを使用しています。おわりに
プライベート開発では、サービス企画からスプリントプランニング、UI設計、実装、リリーススケジュール、リリース後のアップデートおよびマーケティングまで全て自分たちで考えながら実行・経験できることがとても面白いと感じています。また、それと同時にその大変さも感じ、実際にリリースや運用を行っている全個人開発者のことをリスペクトするようになりました。Jeeek(ジーク)も僕個人もまだまだ道半ばですが、引き続き精進していきます。冒頭でも述べましたが、これに関連した本を技術書展で出版するので、次は3月1日の技術書展でお会いしましょう!
- 投稿日:2019-12-23T22:35:28+09:00
React Suspenseによって変わるデータ取得の世界観
はじめに
待ちに待った(?) Suspense for Data FetchingがExperimentalとして先行公開されました。
https://reactjs.org/docs/concurrent-mode-suspense.html
React.Suspense
自体は以前からあり、Data Fetchingについてはどういうものになるか憶測していたのですが、その憶測を上回る状態になって驚きつつ期待もしています。本投稿では思ったことを雑多に書いていきます。
Render-as-You-Fetch
React Suspenseは仕組み自体は簡単(promiseをthrowできるだけ)なのですが、それによってコーディングパターンは大きく変わります。それがRender-as-You-Fetchというパターンで紹介されています。Suspenseを使う上で、必ずしもこのパターンに従う必要はないのですが、このパターンは様々なメリットがあります。
Suspenseを利用するメリットとしてよく言われるのは、loading stateをまとめて扱えることです。これは、シンプルかつ明確なメリットであり、UXを簡単に改善することができます。また、Render-as-You-Fetchでは、データ取得のタイミングが早いため、アプリによってはUXに影響する改善が見られるかもしれません。
一方で、Render-as-You-FetchではDX改善もされることが期待されます。
もうuseEffectは使わない
React hooksが登場して、非同期処理特にデータ取得については、useEffectを使うことがベストプラクティスとして紹介されてきました。しかし、Suspense for Data Fetchingの登場により、それは過去の話になりそうです。Suspenseではデータ取得のためにuseEffectは使いません。
非同期処理にuseEffectを使うことは有効ではありますが、第二引数のdepsを正しく設定する必要があると言うのが悩ましい問題でありました。間違えると無限ループになったり、exhaustive-depsのルールに従う場合でも思い通りにならなかったりすることがありました。Suspenseではデータ取得はrenderの前に開始されるものであり、depsに頼ることはなくなります。useEffectの利用シーンはいわゆる副作用に限定され、より健全な形になるかもしれません。
react hooksからのthrow promise
Suspenseに対応する文脈で、react hooksでデータ取得を開始して同時にthrow promiseする手法がありますが、これはRender-as-You-Fetchではありません。renderが開始してからデータ取得しているからです。その手法自体に問題があるわけではありませんが、本質的にはSuspenseのパワーを部分的にしか使えていない形になります。ただ、実際は多くのケースでは、renderはそこまで遅くないので、UXには影響しない可能性があります。React.lazyと併用する場合以外は、Render-as-You-FetchによるUXの恩恵は少ないかもしれません。
Render-as-You-FetchによるDXのメリット
一言で言うと、fetchで取得したリモートデータとローカルのデータをほぼ区別することなくコーディングできるのです。コンポーネントのrenderにおいて、リモートデータがまだ取得中なのか取得済みなのかを区別する必要がなくなります。リモートデータは素直に外から与えられるpropsの一つになるか、内部で持つstateの一つになるだけです。
今までのデータ取得の考え方とは異なるので、全く新しいパターンとして認識する必要があります。
完全にシームレスにするにはProxyなどを使う必要があるのですが、そうでなくても、loading stateを無視できると言う点でリモートデータをローカルデータのように扱えるので、メリットがあります。
今後の展望
Render-as-You-Fetchは今後ベストプラクティスを確立していく段階だと思います。現状では、Relayが先行していますが、今後様々な提案がなされるのではないでしょうか。
私自身もこの新しいパターンに対応するライブラリを開発しています。
https://github.com/dai-shi/react-suspense-fetch
codesandboxで動作するリンクも用意していますので、興味ある方はいじってみてください。
おわりに
Suspense for Data Fetchingが来ると分かっている中、React hooksでデータ取得をするためのライブラリreact-hooks-asyncを開発していたのですが、あまりの変わり様に驚いています。いい意味で。react-hooks-asyncの活用の場もないことはないのですが、やはり今後はSuspenseの可能性を探求していきたいと思います。
- 投稿日:2019-12-23T22:04:55+09:00
<day4>Webアプリ開発するまで続ける開発日誌
今日やること
前回は体力が尽きましてきりが悪いところで死んでしまいましたが
チュートリアルのReact Router編の最後までやります(宣言)
とは言っても残っているコンテンツは2つ!・React Navigate
・URL情報の取得ぱぱぱっとやっちゃいましょう
独り言
ProgateにReactあったんですね
みつけて一瞬でやりました。これ復習になってとってもよかったです。
内容的には日誌day3くらいまでのものがProgateでフォローされています。
大切なのは復習ですね。React Navigateとは?
React Navigateとはページ遷移をスムーズにするもの、とのこと。例の如くやってから考えましょね〜。
まずはhistoryというpropsについて知る必要があるそうです。
this.props.historyの関数一覧
・this.props.history.push
前いたページを履歴に追加してブラウザの戻るボタンで戻ることができる。
・this.props.history.replace
画面を置換して画面遷移するためブラウザの戻るボタンは使用不可。まずは使ってみましょう。pushの方をやってみます。
src/js/pages/Layout.jsimport React from "react"; import { Link, withRoiuter } from "react-router-dom"; class Layout extends React.Component { navigate() { console.log(this.props.history); this.props.history.push("/"); } render() { return( <div> <h1>KillerNews.net</h1> {this.props.children} <Link to="archives" class="btn btn-danger">archives</Link> <Link to="settings" class="btn btn-success">setting</Link> <button class="btn btn-info" onClick={this.navigate.bind(this)}>featured</button> </div> ); } } export default withRouter(Layout);これでhttp://localhost:8080/をみてみると...
FeatureボタンやSettingボタンを押し、ブラウザの戻るボタンを押すと前押したものに戻っていることがわかります。
※withRouterというやつがいきなり出てきて「誰?!」って思ったのでちょっと調べてみたら、Routerのページ遷移にはLinkとhandleがあって、handleでやるときはwitRouterを使う必要があるらしいです。
では続いてreplaceを使ってみましょう。
さっきのLayout.js
のpush("/");
と書いていたところをreplace("/");
に置き換えてブラウザをみてみましょう。featuredボタンを押した時の画面は履歴に残っていません。
以上がNavigateの紹介です。
次のレッスンのためにpushにしておいてください。Getパラメータ
他ページからReactアプリに画面遷移してきた時に見せ方を変えたい時はRouterの
Getパラメータ
を使用してURLクエリ文字列を整形、取得するらしいっす。(ごめんなさい、あまり理解できていません。。)src/js/pages/client.jsimport React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter as Router, Route } from "react-router-dom"; import Layout from "./pages/Layout"; import Featured from "./pages/Featured"; import Archives from "./pages/Archives"; import Settings from "./pages/Settings"; const app = document.getElementById('app'); ReactDOM.render( <Router> <Layout> <Route exact path="/" component={Featured}></Route> <Route exact path="/archives" component={Archives}></Route> <Route path="/archives/:articles" component={Archives}></Route> <Route path="/settings" component={Settings}></Route> </Layout> </Router>, app);チュートリアルの例をそのまま引用すると
http://localhost:8080/archives/some-article とURLが入力された時に"some-article"の部分をプログラム内で使いたい場合、:変数名
という形式で取得することができます。URLパラメータを正規表現で指定
this.props.match.params.mode
という変数名で"/settings/main"か"/settings/extra"のときだけ動作するコンポーネントを定義していきます。src/js/pages/client.jsimport React from "react"; import ReactDOM from "react-dom"; import { BrowserRouter as Roputer, Route } from "react-router-dom"; import Layout from "./pages/Layout"; import Featured from "./pages/Featured"; import Archives from "./pages/Archives"; import Settings from "./pages/Settings"; const app = document.getElementById('app'); ReactDOM.render( <Router> <Layout> <Route exact path="/" component={Featured}></Route> <Route path="/archives/:article" component={Archives}></Route> <Route path="/settings/:mode(main|extra)" component={Settinsg}></Route> </Layout> </Router>, app);このように定義することでSettingコンポーネントで
this.props.match.params.mode
で値を参照することができるようになるようです。main,extraボタンを追加してブラウザのhttp://localhost:8080/ へアクセスすると、for expertsと表示されるようになっていればURL情報を取得できています。
クエリストリングとは?
引用...
クエリストリングとはブラウザなどでURLをhttp://localhost:8080/archives?date=today&filter=hotと入力した時に、パラメータとして渡す`?date=today&filter=hot`のkey=valueの値のことらしいです!
ふぇーなるほど、URLに値を渡すことができるんですね初めて知った!まずはトップ画面にクエリストリングを含んだリンクボタンを追加します。
src/js/pages/Layout.jsimport React from "react"; import { Link, withRouter } from "react-router-dom"; class Layout extends React.Component { navigate() { console.log(this.props.history); this.props.history.push("/"); } render() { return ( <div> <h1>KillerNews.net</h1> {thi.props.children} <Link to="/archives/some-other-articles?date=yesterday&filter=none" class="btn btn-warning">archives (some other articles)</Link> <Link to="/archives?date=today&filter=hot" class="btn btn-danger">archives</Link> <Link to="/settings/main" class="btn btn-success">settings</Link> <Link to="/settings/extra" class="btn btn-success">settings (extra)</Link> <button class="btn btn-info" onClick={this.navigate.bind(this)}>feathred</button> </div> ); } } export default withRouter(Layout);つづいて
Archives.js
にクエリストリングを解析してその結果を表示させましょう。src.js/pages/Archives.jsimport React from "react"; export default class Archives extends React.Component { render() { const query = new URLSearchParams(this.props.location.search) let message = (this.props.match.params.article ? this.props.match.params.article + ", " + "date=" + query.get("date") + ", filter=" query.get("filter"); return ( <h1>Archives ({message})</h1> ); } }さてブラウザをみてみましょう
Archives (date=yesterday, filter=none)
などと表示されたら完了!URLのクエリストリングを画面に持ってこれていますね
取得の方法もconst query = new URLSearchParams(this.props.location.search)
でクエリ定数にURLのパラメータを入れ、query.get("date")
でdateの値を取得していますね。なんてわかりやすい独り言(終)
理解できなかったところちょっとずつ出てきているので
あと1つ(flux編)くらいおわったらもう一度やり直してみることにします。
思ったよりか時間かかりますね?
最近はプロコン仲間ともくもく会やることにハマっていまして、
それから朝勉会をやりたいのでFacebookかなんかで朝公開もくもく会をやろうかななどと考えております。
Facebookはこちら
zoom開いておくだけでも、ちょっと布団で動画見よ〜っていうだらけがなくなるので良いですね以降の記事
作成中...(12/23)
- 投稿日:2019-12-23T18:33:08+09:00
Reactで削除したliタグをsetStateで消そうとしたら苦労した件
やろうとしたこと
配列peopleに入ってる人たちをliで表示して、
削除ボタン押したら、フロントから消し去る
(ちなみに、サーバサイドはGraphQL。今回は特に関係ないので割愛)peopleは、こんな感じで定義してあるよ。
const [people, setPeople] = useState<string[]>([]);
return ( <> <ul> {people.map(person => ( <li onClick={handleDelete}>{person}</li> ) )} </ul> </> );かなり簡略化して書きました。まあ多少はね?
mapでpersonをリストで表示させる。
で、personをクリックするとhandleDeleteが呼ばれてその中でサーバサイドのデータが削除され
フロントからも消えるはず、はずだったwpeopleという配列はコンポーネントが描画されるときに
GraphQLのフックが起動して取得した配列を
setPeople(pickUpPeople)みたいな感じで突っ込んでいるのですが・・・
このフックは最初に描画した時、1度しか呼ばれない。つまり、サーバサイドではデータは消えているけど
フロント側は何も変化がないということになる。
(もちろん、F5で更新すれば消えている)失敗例
「せや、デリートボタン押したときに
配列にフィルターかけてsetPeopleしなおせばええんや!」具体的には、
setPeople(pickedUpPeople.filter(person => person !== デリートしたpersonのid))
要するに、peopleにデリートしたidに合致しないやつだけピックアップしてセットする。
という意味。結果
「テストデータが2つ・・・1つを削除・・・を消えた!!」
「よし、もう一つも削除・・・!?・・・さっき消したやつ戻ってきた・・・」
「連打してみよ・・・交互に消したはずのデータがひょこひょこ出てくるンゴ^q^」なぜかというと、
フックで取ってきたpickedUpPeople配列からはデータが消えているわけではないので
クリックされたやつ以外が表示されてしまう・・・
つまり、別なのを削除するとそれは消えるけど、前のやつがリバイブする
成功例
delete押されたidをステートで管理して、そこに足していけばええんや!
なので、さっきのコードをこんな感じにする。
* ...deletedListは配列の中身をバラして並べる、という意味ですconst[deletedList, setDeletedList] = useState<string[]>([]) setPeople( pickedUpPeople.filter( person => ![デリートしたpersonのID, ...deletedList].includes(person.id), ), ); setDeletedList([デリートしたpersonのID, ...deletedList]);こうすれば、一回消したidがどんどんListに追加されていくので
フックで取得した全体と消し去ったListのXORを取ることで
消されていない奴らだけ表示できた!もっとお利口な方法があるんだろうけど、
元のコードを崩さずにやろうとしたらこうなりました(小並感)まとめ
・Reactでliを扱う時は、filterをうまく使う
・弾くべきデータをリストとして、別のステートに保持しておくと便利
- 投稿日:2019-12-23T17:53:03+09:00
ReactアプリケーションにSentryを導入してみる(Webpack使用)
SentryをReactアプリケーションに導入してみます。前半はReactに限らず、ブラウザ上で動かすJSに共通する設定になります。
設定に必要な基本的な情報はすでに取得している状態を想定して進めていきます。
最低限の使用の場合は、@sentry/browser
をインストールし、以下のコードをJSの最上位ファイルに追加するだけです。$ npm install @sentry/browserApp.jsimport * as Sentry from '@sentry/browser'; Sentry.init({ dsn: "ここにプロジェクトのDSN" }); throw new Error('error'); // 適当にエラーをスローしてみるローカルで動かしてみると、問題なくSentryのissuesの中にエラーイベントが入りました。
このままでも使えるのですが、ソースマップファイルやリリース情報をSentryに送信してログをより見やすくするために、Webpackの設定を追加してみます。
公式のドキュメントに従いますが、必要のない設定項目は割愛しています。
まずは.sentryclirc
というファイルを追加して、Webpackでビルドする時にSentryのプロジェクトにアクセスできるようにします。.sentryclirc[auth] token=Authトークン [defaults] org=組織名 project=プロジェクト名公式のWebpackプラグインをインストールして、
$ npm install --save-dev @sentry/webpack-pluginwebpack.config.jsconst SentryWebpackPlugin = require('@sentry/webpack-plugin'); ... plugins: [ new SentryWebpackPlugin({ include: "build/static/js" }), ... ] ...Webpackの
plugins
に追加します。ここでinclude
に、ソースマップファイルを送信してほしいJSのディレクトリを指定します。
デフォルトではディレクトリ内のnode_modules
以外の全てのJS/CSSファイルが送信されます。そのほかのオプションはこちら。
オプション一覧の最初の行を確認してください。このオプションにrelease
文字列を渡せば任意のリリース情報を設定できますが、デフォルトではgitのコミットIDがそのままリリース情報として使用されるようです。この段階でもしgitの設定をしていなければ、
git init
からadd
、commit
まで一通りしてから、Webpackでビルドしてみます。Creating an optimized production build... > Analyzing 6 sources > Rewriting sources > Adding source map references > Bundled 6 files for upload > Uploaded release files to Sentry > File upload complete Source Map Upload Report Minified Scripts ~/2.0f707d42.chunk.js (sourcemap at 2.0f707d42.chunk.js.map) ~/main.663c8045.chunk.js (sourcemap at main.663c8045.chunk.js.map) ~/runtime-main.d5ffe654.js (sourcemap at runtime-main.d5ffe654.js.map) Source Maps ~/2.0f707d42.chunk.js.map ~/main.663c8045.chunk.js.map ~/runtime-main.d5ffe654.js.map Compiled with warnings.こんな感じでコンソールにソースマップの情報が表示されます。
あとは何かしらサーバーにデプロイしてみてください。これでSentryの準備は完了です。デプロイしたページを開いて、エラーを発生させてみると、
まず、
RELEASE
欄にちゃんとコミットIDが入っているのがわかります。ソースマップが送信できているので、エラーの発生箇所も確認できます。
ReactのError Boundaryでレポートダイアログを使用してみる
Sentryには、エラーが発生した時にユーザーからフィードバックを得るためのフォームを表示する機能が用意されています。
User Feedback
https://docs.sentry.io/enriching-error-data/user-feedback/?platform=javascriptこれをReactのError Boundaryで使用する例がドキュメントに載っていたので、試してみました。
https://docs.sentry.io/platforms/javascript/react/#error-boundariesSentryBoundary.jsimport React, { Component } from 'react'; import * as Sentry from '@sentry/browser'; class SentryBoundary extends Component { constructor(props) { super(props); this.state = { eventId: null }; } // エラーが発生した時にstateを更新する static getDerivedStateFromError(error) { return { hasError: true }; } // エラーが発生した時にSentryのscope(エラーの情報をまとめるもの)を作成 componentDidCatch(error, errorInfo) { Sentry.withScope((scope) => { scope.setExtras(errorInfo); // エラーの情報をセット const eventId = Sentry.captureException(error); // Sentryにエラーを送信し、IDを取得 this.setState({ eventId }); // エラーIDをstateにセット }); } render() { if (this.state.hasError) { // エラーが発生したら画面にボタンを表示する return ( <button onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId })} // Sentryのダイアログを表示 > エラーを報告する </button> ); } // 何もない場合はそのまま子要素を表示 return this.props.children; } } export default SentryBoundaryError Boundary自体はReactの機能(実装パターン)です。
App.js<SentryBoundary> <ChildComponent /> </SentryBoundary>使用する単位は任意ですが、このように子要素をError Boundaryで囲っておいて、子要素のレンダリングで何かしらエラーが発生した時に代りに別の要素を表示したりすることができます。
この例では、エラーが発生した時に「エラーを報告する」ボタンを表示し、ボタンが押されたらSentryのダイアログを表示するようにしています。
実際にError Boundaryの子要素でエラーが発生するようにしてみます。
例えば下記のように、Reactのレンダリングとは関係ないところでエラーを発生させてもError Boundaryはcatchしません。
ChildComponent.jsimport React from 'react'; function ChildComponent() { window.setTimeout(() => { throw new Error('error test'); }, 1000); return ( <div> エラーを発生させます... </div> ); } export default ChildComponent;このように、Reactのレンダリングが不可能になるエラーが発生した場合にError Boundaryがエラーをcatchします。
(例えば、returnするJSXの中で存在しないfunctionを叩く)ChildComponent.jsimport React from 'react'; function ChildComponent() { return ( <div> エラーを発生させます... {window.foo()} </div> ); } export default ChildComponent;
起動してみると、「エラーを発生させます...」という文言の代わりにボタンが表示されました。これを押すと、このようなSentryのダイアログが表示されます。
名前とメールアドレス、詳細を入力して送信してみると...
Sentryのエラー詳細の方にUser Feedbackがちゃんと追加されました。
ダイアログの文言なども変更できるようです。
https://docs.sentry.io/enriching-error-data/user-feedback/?platform=javascript#customizing-the-widget言語は自動的にブラウザの言語から設定されるようですが、一番大きなタイトルが英語のままだったので、オプションを渡してみます。ユーザーの名前・メールアドレスは最初から指定すれば最初から入力しておけるようです。
SentryBoundary.js... <button onClick={() => Sentry.showReportDialog({ eventId: this.state.eventId, title: "すみません、問題が発生しました", user: { email: "test@test.com", name: "React 和子" } })} > エラーを報告する </button> ...問題なく設定できました。
ダイアログにはclassがセットされているので、見た目のカスタマイズもCSSで簡単にできそうです。
以上、ReactNativeとWebでSentryを触ってみました。特に嵌るところもなく、スムーズに導入できました。
- 投稿日:2019-12-23T17:45:10+09:00
【ReactBootstrap×TypeScript】ReactBootstrapをTypeScript版のReactに導入する
やりたいこと
CSSフレームワークのBootstrapをReactのプロジェクトで使いたかったが、
あーしてもこーしても上手く使えなかったので調べたところReactBootstrapなるものがあるらしい。
今回はReactBootstrapを導入するところまでまとめる。色々インストールする
①create-react-appでTS版のインストール
こっちのサイト参考⇒https://create-react-app.dev/docs/adding-typescript/npx create-react-app my-app --template typescript npm install --save typescript @types/node @types/react @types/react-dom @types/jest正直、公式そのままである。
②
次にBootstrapもインストールしておく。
こっちのサイト参考⇒https://create-react-app.dev/docs/adding-bootstrap/#using-a-custom-themeTS版もインストールすることを忘れないように。
npm install --save bootstrap @types/bootstrap npm install --save node-sass @types/node-sass③
で、ReactBootstrapをいよいよインストールする
こっちのサイト参考⇒https://react-bootstrap.github.io/getting-started/introduction/npm install react-bootstrap @types/react-bootstrapボタンを配置して、導入できたか確かめる
①
index.tsxにBootstrapのcssをインポートさせておく。src/index.tsximport React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; import 'bootstrap/dist/css/bootstrap.min.css'; //←ここにとりあえず追加 ReactDOM.render(<App />, document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();②
App.tsxにボタンを配置してみるsrc/App.tsximport React from 'react'; import logo from './logo.svg'; import './App.css'; import { Button } from 'react-bootstrap'; //ここにボタンコンポーネント追加 const App: React.FC = () => { return ( <div className="App"> <header className="App-header"> <img src={logo} className="App-logo" alt="logo" /> <p> Edit <code>src/App.tsx</code> and save to reload. </p> <a className="App-link" href="https://reactjs.org" target="_blank" rel="noopener noreferrer" > Learn React </a> {/*↓↓↓↓↓ここにとりあえずボタン置いてみる*/} <Button variant="primary">青いボタン</Button> </header> </div> ); } export default App;実際に動かしてみる
npm run startこれで、ポート3000にアクセスして、青いボタンが表示されていれば、ReactBootstrapの導入は完了です。
自分もようやくBootstrapで作ったポートフォリオサイトのモックを移植できる・・・・・・。
- 投稿日:2019-12-23T15:13:18+09:00
技術的負債を段階的に返済するためにReactをWebComponentsとして他のフレームワークに組み込む方法
技術的負債を返済するブリッジとしてWebComponentsを使うという手法の提案と、具体例として、AngularJSの中にWebComponentsとしてReactを組み込む方法について、この記事で解説します。
祭り化アドベントカレンダーです
この記事は#祭り化 Advent Calendar 2019の12/23日分です。株式会社マツリカ社員たちのエモい記事がいっぱい登場してる中ですが、僕にとっての祭り化状態はやっぱり技術について考えアウトプットしてる時なので、エモとか考えずに、技術系記事でいきます。
ちなみに、最近社内のごく一部の人に流行の兆しを見せているホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具の記事を書くことも一瞬考えましたが、今回は技術ネタができあがったので、技術的な記事でいきます。
※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト
ホットサンドも焼けます。
Amazonで2000円未満で買えるので、是非買って、様々なものを焼きましょう。
技術的負債を段階的に返済しよう
ウェブ開発をする貴方のお手元には、jQueryやAngularJS(つまりAngularの1.x系)など、石器時代のごとき古代の遺産が残っていませんか?傾向として、フルスタックフレームワークや、逆に詳細に踏み込みすぎるライブラリは、技術的負債になりやすいものです。
技術的負債はビジネス価値を生み出す開発の脚を引っ張るため、価値を生み出す為にも技術的負債の返済が大切になりますが、技術的負債を返している過程自体はビジネス価値を持ちません。このため技術的負債を一気に返すというのは難しいものです。
そこでビジネス価値を生み出しつつ技術的負債を解消し、さらなるビジネス価値を生み出すという、段階的な工程が望ましいということになります。
たとえば、AngularJSのもたらす圧倒的な苦痛と闘っているとします。既にある膨大な資産を読み解こうにも、VSCodeのIntelliSenseの恩恵にもあずかれず、E2Eテストもなく、当然ユニットテストもなく、ドキュメントもなく、型定義もない、そんな状況は、おそらく色々なところにあるでしょう。
限られた人員で、全部をReact(Vueかもしれません)に置き換えるというのは、誠に残念ながら、先ほど述べた通り非現実的です。そのため次善策としては、一部を置き換えるということになります。
一部を置き換える為の方法としてAngularJSとReactの別々のアプリケーションとして作ったうえで、URLで振り分けるというやり方もありますが、段階的な技術的負債の返済としてはもう少し粒度を下げたいものです。
ブリッジ技術としてWebComponentsを使おう
WebComponentsは疎結合にできる仕組みだといえます。フレームワークやライブラリという詳細からは独立しているためです。
ヒントはWeb Componentsを利用した段階的AngularJS脱出作戦 - builderscon tokyo 2019と、デザインシステムにおけるフロントエンド - LINE DEVELOPER DAY 2019の2つのセッションです。
前者はAngularJSからAngularへの移行手順としてWebComponentsを使うもので、後者はReact, Vueの間で共通のUIパーツを使う為にWebComponents/LitElementを使うものです。
共通することは、WebComponentsというスタンダードな技術を使って、異なるフレームワークを繋ぐブリッジとしている点です。
たとえば最新のReact HooksやNuxt.jsでイケてるシステム・アプリケーションを作成したとして、将来的に別の何かに置き換えるべきタイミングがきたときも、同じやり方が使えます。
WebComponentsをブリッジ技術として使い、少しずつ置き換えて、換骨奪胎が完遂すれば、古い資産及びWebComponentsは役目を終え、消滅することになります。
WebComponents
ここでいうWebComponentsは、具体的には、カスタムエレメントとシャドウDOMです。(ただし、この記事ではシャドウDOMに関してはあまり踏み込みません)
一時期はWebComponentsといえばPolymerでしたが、既にPolymerは開発を終え、LitElement及びlit-htmlが使われることが増えました。
しかし、WebComponentsの構成技術は既にほとんどのウェブブラウザに組み込まれている標準であるため、LitElementやlit-htmlを使っても、使わなくてもかまいません。今回の目的にはlit-htmlはそぐわないため使いません。
AngularJSにReactを組み込む
お待たせしました。ここからが本番です。
AngularJSのコードは https://angularjs.org/ 公式にあるTODOアプリをサンプルとして使います。このTODOの各アイテムをReact化します。(粒度が小さすぎるのはサンプルなので勘弁してください。)
ただし、色々なセットアップで楽をするために、一度
create-react-app
を使って React のセットアップをします。$ yarn create react-app react-webcomponents-example --template typescript $ cd react-webcomponents-example $ yarn add angular@^1.7.9 $ yarn add styled-components @types/styled-components $ rm -rf src/*AngularJS@^1.7.9 と styled-componentsを追加でインストールしています。
サンプルのソースは不要なため、一度消します。
.prettierrc{ "semi": false, "tabWidth": 2, "printWidth": 76, "singleQuote": true }個人的な好みによりこれを追加しておきます。
AngularJS側
public/index.html<!DOCTYPE html> <html ng-app="ReactAngularJSApp"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> </head> <body> <h2>Todo</h2> <div ng-controller="TodoListController as todoList"> <span >{{todoList.remaining()}} of {{todoList.todos.length}} remaining</span > [ <a href="" ng-click="todoList.archive()">archive</a> ] <ul class="unstyled"> <todo-item ng-repeat="todo in todoList.todos" label="{{todo.text}}" done="{{todo.done}}" ng-on-change="todoList.change(event, $index)" > </todo-item> </ul> <form ng-submit="todoList.addTodo()"> <input type="text" ng-model="todoList.todoText" size="30" placeholder="add new todo here" /> <input class="btn-primary" type="submit" value="add" /> </form> </div> </body> </html>AngularJSのテンプレートを使ったHTMLです。AngularJS公式のサンプルと大きく違うのは、
<todo-item>
というタグです。オリジナルコード<li ng-repeat="todo in todoList.todos"> <label class="checkbox"> <input type="checkbox" ng-model="todo.done"> <span class="done-{{todo.done}}">{{todo.text}}</span> </label> </li>オリジナルはこのようなコードでしたが、todo-itemというカスタムエレメントに置き換えています。
src/index.tsimport './web-components' const angular = require('angular') angular.module('ReactAngularJSApp', []).controller('TodoListController', [ function() { // @ts-ignore var todoList = this todoList.todos = [ { text: 'learn AngularJS', done: true }, { text: 'build an AngularJS app', done: false } ] todoList.addTodo = function() { todoList.todos.push({ text: todoList.todoText, done: false }) todoList.todoText = '' } todoList.remaining = function() { var count = 0 angular.forEach(todoList.todos, function(todo: any) { count += todo.done ? 0 : 1 }) return count } todoList.archive = function() { var oldTodos = todoList.todos todoList.todos = [] angular.forEach(oldTodos, function(todo: any) { if (!todo.done) todoList.todos.push(todo) }) } todoList.change = function(ev: Event, index: number) { todoList.todos[index].done = !todoList.todos[index].done } } ])これが、新たなエントリポイントとなる
src/index.ts
です。AngularJS公式のJSコードと違うのは、TypeScriptとしてエラーになる部分を潰したことと、import './web-components'
というインポート文と、todoList.change
です。さきほどのテンプレートでは、
<todo-item ng-repeat="todo in todoList.todos" label="{{todo.text}}" done="{{todo.done}}" ng-on-change="todoList.change(event, $index)" > </todo-item>というように、
ng-on-change="todoList.change(event, $index)"
という属性を指定しています。ngOnディレクティブは、イベントハンドラを登録するためのものです。このコードであれば、
change
というイベントによりtodoList.change
が呼び出されるためtodoList.todos
の中身を更新できるようになります。ただし
ngOn
は AngularJS 1.7.x のディレクティブであるため、それより前のバージョンでは、自前でディレクティブを作成する必要があります。.directive('ngOn', [ function() { return { restrict: 'A', compile: function(elements: any, attrs: any) { const s = attrs.ngOn.replace(/"/g, '\\"') var ngOn = JSON.parse(s) return function(scope: any, element: any) { Object.keys(ngOn).forEach(eventName => { element.on(eventName, function(event: Event) { scope.$evalAsync(ngOn[eventName], { event }) }) }) } } } } ])たとえばこのようなディレクティブです。
ng-on='{"change": "todoList.change(event, $index)"}'
のようにして利用します。https://www.npmjs.com/package/ng-on を参考にしていますが、仕様が気にくわなかったので本来のngOnディレクティブの仕様に近い形に作り替えています。ここまでが、AngularJSのコードです。
Reactのコードを書く
src/components/todo-item/index.tsximport React from 'react' import styled from 'styled-components' export type Props = { label: string done: boolean } const DoneLabel = styled.span` text-decoration: line-through; color: gray; ` const TodoItem: React.FC<Props> = ({ label, done }) => { return ( <li> <label> <input type="checkbox" checked={done} onChange={() => {}} /> {done ? <DoneLabel>{label}</DoneLabel> : <span>{label}</span>} </label> </li> ) } export default TodoItemあまり変哲もないコードです。本来の公式サンプルのテンプレートで削った部分をReactで書き直しただけです。
唯一特殊な点としては、
input
のonChange={() => {}}
です。checked
を指定している場合にはセットでonChange
が必須であるためダミーを指定しています。Event.preventDefault
をしておらず、change
イベントがそのまま飛ぶため、AngualarJS側でイベントをngOn
ディレクティブでキャッチしています。場合によっては、イベントハンドラを真面目に書いて、カスタムイベントを飛ばすといったことも必要になるかもしれません。
カスタムエレメントを作成する
src/components/todo-item/web-components.tsimport React from 'react' import ReactDOM from 'react-dom' import TodoItem, { Props } from './index' class TodoItemWC extends HTMLElement { _props: Props = { label: '', done: false } static _conv = { label: (v: string) => v, done: (v: string) => v === 'true' } static get observedAttributes() { return Object.keys(this._conv).filter(key => !key.startsWith('on')) } attributeChangedCallback(name: string, prev: any, next: string) { // @ts-ignore this._props[name] = TodoItemWC._conv[name](next) this.render() } render() { ReactDOM.render(React.createElement(TodoItem, this._props), this) } } customElements.define('todo-item', TodoItemWC)カスタムエレメントは、
HTMLElement
を継承し、特定のメソッドを実装したクラスです。
static get observedAttributes
で自分の属性の変化を検知したいという宣言をするattributeChangeCallback
メソッドで、属性の変更を元にReactコンポーネントに渡すプロパティを更新するconnectedCallback
およびdisconnectedCallback
メソッドで、Reactのライフサイクルでいうマウント・アンマウントの処理を行う。ReactDOM.render
で Reactコンポーネントをレンダリングするちなみに今回はシャドウDOMを使っていません。
シャドウDOMは、アプリケーション全体のDOMとは独立した世界になるため、様々なグローバルリソースとかち合わないという大きな利点があるのですが、問題もあります。
たとえば、シャドウDOM内ではそのままではStyledComponentが使えません(グローバルなCSS定義をして参照しようとしてしまうため)
問題が出たときにシャドウDOMを使うようにしておけば大丈夫だと思います。(WebComponents詳しい方のご意見をお待ちしております)
ここから順にコードを解説します。
class TodoItemWC extends HTMLElement {HTMLElementを継承してカスタムエレメントを作成します。仕様上クラスである必要があるようです。
_props: Props = { label: '', done: false }Reactコンポーネントに渡すプロパティの初期値です。
static _conv = { label: (v: string) => v, done: (v: string) => v === 'true' }カスタムエレメントの属性は全て文字列であるため、それ以外のものはいい感じにデシリアライズする必要があります。オブジェクトであれば、
JSON.parse
が使えるでしょう。数値ならNumber.parseInt
かNumber.parseFloat
が必要になります。static get observedAttributes() { return Object.keys(this._conv).filter(key => !key.startsWith('on')) }
onChange
などハンドラをReactコンポーネントで指定している場合、それをカスタムエレメントとしては使いたくないため、filter
で弾いています。先ほどのAngularJSのときに説明したように、生じるイベントを制御する必要があるときには、カスタムエレメントの定義クラスでイベントをpreventしたり、カスタムイベントを発生してthis.dispatchEvent
することになるでしょう。attributeChangedCallback(name: string, prev: any, next: string) { // @ts-ignore this._props[name] = TodoItemWC._conv[name](next) this.render() }
static get observedAttributes
で返した配列をキーに持つ属性が指定・変更されたときに呼び出されるコールバックです。最初の呼び出し時はprev
にはnull
が入っていますが、それ以外の場合やnextにはstring
が入ります。
this._props
を更新しthis.render
を呼び出しています。render() { ReactDOM.render(React.createElement(TodoItem, this._props), this) }
ReactDOM.render
で自分自身をマウントポイントとしてレンダリングしています。customElements.define('todo-item', TodoItemWC)定義したカスタムエレメントを実際に使えるようにしています。
ここまででカスタムエレメントの定義が完了しました。
あとは、
src/index.ts
から、このsrc/components/todo-item/web-components.ts
をimportするだけです。src/web-components.tsimport './components/todo-item/web-components'まとめ
技術的負債を一気に返済するのはたいていの場合しんどいため、段階的に返済する方が望ましいケースが多いでしょう。
WebComponents (カスタムエレメントやシャドウDOM)を使うと、フレームワーク・ライブラリに依存しない疎結合なブリッジが可能となります。
今回の事例では、AngularJSにReactを組み込んでみました。React側ではもちろんReact Hooksを使う事もできます。
- イベントの扱いはカスタムエレメントやシャドウDOMの都合で少し面倒
- シャドウDOMを使う場合、styled-componentsのようにCSSに干渉しようとすると頑張る必要がある
- カスタムエレメントでは属性が全て文字列であるためデシリアライズが必要となる
など、少しだけ面倒も伴います。今回の記事ではシャドウDOMを使っていませんが、外との境界線をより強固にするためにはシャドウDOMも検討する必要があるかもしれません。
今回は、アトミックデザインでいうAtom単位でカスタムエレメントにしていますが、現実的なところでいえばMolecules以上の単位になるでしょう。
まだ
ReactDOM.render
を使ったカスタムエレメントを大量に定義していませんが、もしかしたらメモリや速度で問題が生じる可能性もあるため、検証は必要かもしれません。ホットサンドメーカーはアーティファクトである
ホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具があります。※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト
Amazonで2000円未満で買えるので、是非買いましょ?
- 投稿日:2019-12-23T13:06:06+09:00
2019年12月版 AWS Amplify + AppSync + Cloud9 + React.js でとりあえず動くまでの手順
目的
- Cloud9上での aws amplify, aws appsync を利用する開発環境の構築
- React上での subscription による リアルタイムデータ配信アプリの構築
- amplify の機能による S3+CloudFlont でのアプリのデプロイ
Vue.js バージョンはこちら
前提
- 利用するリージョンは ap-northeast-1
- CodeCommitのリポジトリ名は amplify_test_react
- amplifyで利用するiamユーザは amplify-test-user
- アプリ名はmytodo
- api名はmytodoapi
注意
この投稿は @aws_amplify/api, @aws_amplify/pubsub パッケージを利用しています。
aws_amplify, aws_amplify_vue パッケージと同時に利用することはできません。
AWSKinesisFirehoseProvider.js あたりでエラーが発生する場合はパッケージの混同を疑ってみてください。構築手順
- CodeCommitへのGitリポジトリ作成
* 'amplify_test_react'を使用- Cloud9の開発環境作成
* aws consoleで作成- Cloud9の一時認証の利用を解除
- ツールバーのAWS Cloud9でメニューを開く
- Preferencesを開く
- AWS SETTINGSを開く
- Credentialsを開く
- AWS managed temporary credentials をオフにする
- 開発環境とGitリポジトリの連携
$ git clone ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/amplify_test_react
- nodeのインストール
最新、または、好みのバージョンを確認$ nvm ls-remote
指定したバージョンでインストールを実行
$ nvm install 指定のバージョン- aws amplify cli のインストール
$ npm install -g @aws-amplify/cli- amplify の初期設定
- configureを実行
$ amplify configure
- 管理者アカウントでaws consoleをブラウザで開く
Follow these steps to set up access to your AWS account: Sign in to your AWS administrator account: https://console.aws.amazon.com/ Press Enter to continue
- 利用するリージョンを選択、ユーザ名を入力
出力されたurlをaws cosoleを開けたブラウザで開いてユーザを作成
デフォルトの値は入っているので作成を完了するだけSpecify the AWS Region ? region: ap-northeast-1 Specify the username of the new IAM user: ? user name: amplify-test-user Complete the user creation using the AWS console https://console.aws.amazon.com/iam/home?region=undefined#/users$new?step=final&accessKey&userNames=amplify-test-user&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess Press Enter to continue- ユーザ作成で得ることのできた accessKeyId と secretAccessKey を入力
Enter the access key of the newly created user: ? accessKeyId: ******************** ? secretAccessKey: **************************************** This would update/create the AWS Profile in your local machine ? Profile Name: default Successfully set up the new user.
- react のインストールとプロジェクトの作成
$ npx create-react-app mytodo $ cd mytodo- アプリケーションを実行して確認
$ npm start
メニューの Preview -> Preview Running Application を実行
Cloud9内のブラウザでReactのセットアップが完了していることを確認- SecurityErrorが表示される場合
以下のエラーが表示される場合は、React Scriptの問題
SecurityError: Failed to construct 'WebSocket': An insecure WebSocket connection may not be initiated from a page loaded over HTTPS.対応するには React Script 3.3.0 を利用しないようにする
以下の通り React Script 3.2.0 を利用するにうにpackage.jsonを修正してインストールするpackage.json- "react-scripts": "3.3.0" + "react-scripts": "3.2.0"- aws-amplify をインストール
$ npm install @aws-amplify/api $ npm install @aws-amplify/pubsub $ npm install aws-amplify-react- amplify の初期設定
$ amplify init Note: It is recommended to run this command from the root of your app directory ? Enter a name for the project mytodo ? Enter a name for the environment dev ? Choose your default editor: Vim (via Terminal, Mac OS only) ? Choose the type of app that you're building javascript Please tell us about your project ? What javascript framework are you using react ? Source Directory Path: src ? Distribution Directory Path: build ? Build Command: npm run-script build ? Start Command: npm run-script start- amplify の初期設定確認
$ amplify status Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ------------- | --------- | --------------- |- api の追加準備
$ amplify add api ? Please select from one of the below mentioned services: GraphQL ? Provide API name: mytodoapi ? Choose the default authorization type for the API API key ? Enter a description for the API key: ? After how many days from now the API key should expire (1-365): 365 ? Do you want to configure advanced settings for the GraphQL API No, I am done. ? Do you have an annotated GraphQL schema? No ? Do you want a guided schema creation? Yes ? What best describes your project: Single object with fields (e.g., “Todo” with ID, name, description) ? Do you want to edit the schema now? No- api の追加内容確認
$ amplify status Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ------------- | --------- | ----------------- | | Api | mytodoapi | Create | awscloudformation |- バックエンドに api の追加内容を反映
$ amplify push Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ------------- | --------- | ----------------- | | Api | mytodoapi | Create | awscloudformation | ? Are you sure you want to continue? Yes ? Do you want to generate code for your newly created GraphQL API Yes ? Choose the code generation language target javascript ? Enter the file name pattern of graphql queries, mutations and subscriptions src/graphql/**/*.js ? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions Yes ? Enter maximum statement depth [increase from default if your schema is deeply nested] 2- mytodo を修正
- App.js を修正
App.jsimport React, { useEffect, useReducer } from 'react'; import API, { graphqlOperation } from '@aws-amplify/api'; import PubSub from '@aws-amplify/pubsub'; import { createTodo } from './graphql/mutations'; import { listTodos } from './graphql/queries'; import awsconfig from './aws-exports'; import './App.css'; // Configure Amplify API.configure(awsconfig); PubSub.configure(awsconfig); // Action Types const QUERY = 'QUERY'; const initialState = { todos: [], }; const reducer = (state, action) => { switch (action.type) { case QUERY: return {...state, todos: action.todos}; default: return state; } }; async function createNewTodo() { const todo = { name: "New Todo Title", description: "do something at " + Date() } await API.graphql(graphqlOperation(createTodo, { input: todo })); } function App() { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { async function getData() { const todoData = await API.graphql(graphqlOperation(listTodos)); dispatch({ type: QUERY, todos: todoData.data.listTodos.items }); } getData(); }, []); return ( <div> <div className="App"> <button onClick={createNewTodo}>Add Todo</button> </div> <div> {state.todos.length > 0 ? state.todos.map((todo) => <p key={todo.id}>{todo.name} : {todo.description}</p>) : <p>Add Todo</p> } </div> </div> ); } export default App;- 実行してブラウザでTodoが追加されることを確認
Add Todoボタンをクリックしたあと、リロードすることでTodoが追加されていることが確認できる
- subscription を利用してみる
- App.js を修正
App.jsimport React, { useEffect, useReducer } from 'react'; import API, { graphqlOperation } from '@aws-amplify/api'; import PubSub from '@aws-amplify/pubsub'; import { createTodo } from './graphql/mutations'; import { listTodos } from './graphql/queries'; import { onCreateTodo } from './graphql/subscriptions'; import awsconfig from './aws-exports'; import './App.css'; // Configure Amplify API.configure(awsconfig); PubSub.configure(awsconfig); // Action Types const QUERY = 'QUERY'; const SUBSCRIPTION = 'SUBSCRIPTION'; const initialState = { todos: [], }; const reducer = (state, action) => { switch (action.type) { case QUERY: return {...state, todos: action.todos}; case SUBSCRIPTION: return {...state, todos:[...state.todos, action.todo]}; default: return state; } }; async function createNewTodo() { const todo = { name: "New Todo Title", description: "do something at " + Date() } await API.graphql(graphqlOperation(createTodo, { input: todo })); } function App() { const [state, dispatch] = useReducer(reducer, initialState); useEffect(() => { async function getData() { const todoData = await API.graphql(graphqlOperation(listTodos)); dispatch({ type: QUERY, todos: todoData.data.listTodos.items }); } getData(); const subscription = API.graphql(graphqlOperation(onCreateTodo)).subscribe({ next: (eventData) => { const todo = eventData.value.data.onCreateTodo; dispatch({ type: SUBSCRIPTION, todo }); } }); return () => subscription.unsubscribe(); }, []); return ( <div> <div className="App"> <button onClick={createNewTodo}>Add Todo</button> </div> <div> {state.todos.length > 0 ? state.todos.map((todo) => <p key={todo.id}>{todo.name} : {todo.description}</p>) : <p>Add Todo</p> } </div> </div> ); } export default App;- 実行して2つのブラウザで同期的にTodoが追加されることを確認
- Hosting の準備
$ amplify add hosting ? Select the environment setup: DEV (S3 only with HTTP) ? hosting bucket name mytodo-20191223025707-hostingbucket ? index doc for the website index.html ? error doc for the website index.html- Hosting の追加内容の確認
$ amplify status Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | --------------- | --------- | ----------------- | | Hosting | S3AndCloudFront | Create | awscloudformation | | Api | mytodoapi | No Change | awscloudformation |- バックエンドに Hosting の追加内容を反映
$ amplify push Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | --------------- | --------- | ----------------- | | Hosting | S3AndCloudFront | Create | awscloudformation | | Api | mytodoapi | No Change | awscloudformation | ? Are you sure you want to continue? Yes- バックエンドの Hosting を公開
$ amplify publish
- PublishされたURLを2つのブラウザで開いて同期的にTodoが追加されることを確認
- 作成した amplify のリソースを削除
$ amplify delete
amplify delete した後、再度 amplify init で作成し直す場合は、aws-exports.js ファイルを削除しておく。init や add で更新されない。
amplify init したアプリを deleteせずに再度initする場合、CloudFormation上から削除できなくなるので、注意。
参考
- 投稿日:2019-12-23T10:37:04+09:00
Redux Toolkit で Redux の煩わしさから解放される
この記事はReact Advent Calendar 2019 の19日目の記事です。(遅れてすいません)
前日の18日目は React Context APIを使った非同期通信のハンドリング でした。今回は Redux Toolkit について紹介をします。
Redux Toolkit とは?
Redux Toolkit
は、現在 Redux のメインメンテナーの一人である、Mark Erikson 氏が作成したものです。
2019年10月に v1 がリリースされました。こちらの Idiomatic Redux: Redux Toolkit 1.0 で作成した意図が書かれています。
そこには、2018年に@acemarke/redux-starter-kit
(Redux Toolkitの1つ前の名前) としてパッケージが公開される前にRedux
上で行われた議論が語られています。
同記事にはRedux Starter Kit
のビジョン も掲載されています。
- Make it easier to get started with Redux
- Simplify common tasks
- Opinionated defaults guiding towards "best practices"
- Provide solutions to make people stop using the word "boilerplate"
要はベストプラクティスを使用したテンプレートを用いて、
Redux
を使いやすく、簡素なものにするということです。Redux Toolkit の技術
公式ドキュメントの What's Included にもだいたい記載されているのですが、Redux Toolkit はこのような技術構成になっています。
TypeScript
製Redux DevTools Extension
でデフォルトで同梱- middleware は redux-thunk
- immer を使用した、
immutable
な状態変更- Reselect を使用した、
memoized
機能Redux Toolkit を使ってみる
公式ドキュメントの Basic Tutorial: Introducing Redux Toolkit を使用して、
Redux Toolkit
を使用した場合と、そうでない場合を見比べてみます。// Redux Toolkit を使用していない場合 function counter(state, action) { if (typeof state === 'undefined') { return 0 } switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 default: return state } var store = Redux.createStore(counter) document.getElementById('increment').addEventListener('click', function() { store.dispatch({ type: 'INCREMENT' }) })// Redux Toolkit を使用した場合 // createSlice を使用 // { // name : string, // reducer : ReducerFunction, // actions : Object<string, ActionCreator>, // } // を返す関数 const counterSlice = createSlice({ name: 'counter', // reducers名 initialState: 0, // initialState reducers: { // reducers increment: state => state + 1, decrement: state => state - 1 } }) // configureStore を使用、middleware や devTools の設定済み const store = configureStore({ reducer: counterSlice.reducer }) document.getElementById('increment').addEventListener('click', () => { store.dispatch(counterSlice.actions.increment()) })とても簡素なものになりました。
この状態で、Redux DevTools Extension
も使用ができます。
今回使用したのは createSlice() と configureStore() になります。
そのほかにも Action を生成する createAction() や Reducer を生成する createReducer() などが存在します。
詳しくは、公式ドキュメントのAPI Reference
に載っているので一読するとよいです。その他
Redux Toolkit
はドキュメントがかなり整っているので、先ほど紹介した Basic Tutorial 以外にも、TODO アプリケーションを例にした Intermediate Tutorialや Git Hub Issue アプリを作成する Advanced Tutorial のチュートリアルがあります、英語ですがとても分かりやすので、Google翻訳を使用し、コードの写経をするだけでも理解が深まるかと思います。チュートリアル以外にも Usage With TypeScript で TyepScript の使用していく例もあるので、TypeScript を使用して Redux アプリケーションを作成する方は一読しておくとよいです。
最後に
Redux Toolkit
はRedux
を使用するすべての方にオススメです!
Redux
公式が推奨するアプローチをまとめた Redux Style Guide でも使用することを勧めています。
今までRedux
を使用する場合は、煩わしいことも多かったのですが、Redux Toolkit
を使用すれば、その煩わしさからも解放されると思います。
- 投稿日:2019-12-23T09:00:35+09:00
待ち時間に雪を降らせたい(React)
はじめに
こんにちは @hey3 です。
この記事はACCESS Advent Calendar 2019 23日目の記事です。
Qiita に記事を投稿するのが初めてなので緊張しています。。今回の記事はクリスマス間近と言う事で、ただただやってみたかった事をやっているだけです。(実用性皆無です)(ネタです)
※ネタで1枠使ってしまって申し訳無い気持ちはあります。概要
クリスマス近いし、非同期の更新処理中にコンポーネントに雪を降らせたい
(本来待ち時間は無い方が良いんですけどね。。)構成
サクッと非同期処理を用意するために
React v16.6
以降に追加されたlazy
とSuspense
を用いました。
lazy
とSuspense
に関しては深く触れないため、知りたい場合は Google 先生にお願いしてください。
styling には、styled-component
を用いました。
あと、気持ち程度のTypeScript
です。実際にやってみた
所々怒られそうなコードが出てくるけど、しょうがないよね。。( 'ω')
指摘ありましたら、どんどん教えてください!デモ用に枠を作る
// LoadingArea/index.tsx import React, { lazy, Suspense } from 'react' import styled from 'styled-components' const LazyComponent = lazy(() => import('../../components/Lazy')) const LoadingArea = styled.div` width: 800px; height: 500px; ` export default () => ( <LoadingArea> <Suspense fallback={<SnowLoading />}> <LazyComponent /> </Suspense> </LoadingArea> )将来的には汎用的に使えるようにしたいけど、今回は横800px, 縦500pxに固定します。
Suspense
のfallback
にはSnowLoading
コンポーネント(後述)を指定します。雪を降らせるための背景を作る
今回は
radial-gradient
を使って雪が降ってそうな背景を作っていきます。// SnowLoading/index.tsx import React from 'react' import styled from 'styled-components' const Container = styled.div` display: grid; justify-content: center; align-content: center; width:100%; height:100%; background: radial-gradient(farthest-corner at 50% 50%,#7397a1 1%,#3f2c41 100%); position: relative; ` export default () => ( <Container /> )寒さが伝わってきて如何にも雪が降ってそうな見た目ですね( 'ω')
本題の雪を作っていく
雪は
position:absolute
で降らせていきます。// Snow/index.tsx import React from 'react' import styled, { css, keyframes } from 'styled-components' interface Props { left: number delay: number } const fall = keyframes` 0% { top: 0; opacity: 0; } 100% { top: 95%; opacity: 1; } ` const swing = keyframes` 0% { transform: translateX(0px); } 50% { transform: translateX(70px); } 100% { transform: translateX(0px); } ` const snowAnimation = (delay: number) => css` ${swing} 2s infinite ease-in-out, ${fall} 5s infinite linear ${delay}s; ` const Snow = styled.div<{left: number, delay: number}>` position: absolute; color: green; opacity: 0; left: ${({ left }) => left}%; z-index: 1000; animation: ${({ delay }) => snowAnimation(delay)}; &:after { content: "\\2744"; } ` export default ({ left, delay }: Props) => ( <Snow left={left} delay={delay} /> )雪が落ちるアニメーションと揺れるアニメーションに分けて
@keyframes
を用意します。
落ちるアニメーションでopacity
を変えてあげると幻想的ですね( 'ω')
div
タグ一つに対して一つの雪結晶を表現しています。
降らせる位置(left)と降らせるタイミング(delay)を props で渡す事で、親コンポーネント側で各雪結晶のアニメーションをずらす事ができます。
この辺はstyled-component
を使うとやりやすいですね。ちなみに、今回使用した雪結晶はこちら ❄「❄」 です。
実際に雪を生成してみます。
先ほどのSnowLoading
コンポーネントを以下のように修正します。// SnowLoading/index.tsx ... import Snow from '../Snow' ... export default () => ( <Container> {[...Array(30)].map(index => <Snow key={index} left={Math.floor(Math.random() * 90)} delay={Math.floor(Math.random() * 20)}/> )} </Container> )やってはいけなそうな匂いがしますが、今回は目を瞑りましょう( ˘ω˘ )
これである程度ランダムに30個の雪結晶を降らせます。
実行したものがこちらです。(容量圧縮したので荒いです)
おー、雪が振りましたね。( 'ω')
満足です。
ただ、これだと非同期処理中だと言う事がわかりずらいですね。
雪を積もらせていきます。雪を積もらせていく
こんなもんで良いでしょう
// PileUpSnow/index.tsx import React from 'react' import styled, { css, keyframes } from 'styled-components' const pileUp = keyframes` 0% { height: 0; } 100% { height: 100%; } ` const pileUpAnimation = () => css` ${pileUp} 7s infinite ease-out; ` const PileUpSnow = styled.div` position: absolute; bottom: 0; width: 100%; background: aliceblue; z-index: 2000; animation: ${pileUpAnimate}; ` export default () => ( <PileUpSnow /> )才能が無く向いてなさそうな感じがしますね。。
Loading っぽさを追加する
// Loading/index.tsx import React from 'react' import styled, { css, keyframes } from 'styled-components' const loading = keyframes` 0% { transform: translate(0,0); } 50% { transform: translate(0,15px); } 100% { transform: translate(0,0); } ` const loadingAnimation = delay => css` ${loading} .6s infinite linear ${delay}; ` const Container = styled.div` position: absolute; z-index: 3000; top: 0; left: 0; right: 0; bottom: 0; margin: auto; width: 5rem; height: 1rem; ` const Point = styled.div` display: inline-block; width: 1rem; height: 1rem; margin-right: 0.3rem; border-radius: 1.2rem; background-color: #4b9cdb; &:nth-last-child(1) { animation: ${loadingAnimation('.1s')}; } &:nth-last-child(2) { animation: ${loadingAnimation('.2s')}; } &:nth-last-child(3) { animation: ${loadingAnimation('.3s')}; } ` export default () => ( <Container> <Point /> <Point /> <Point /> </Container> )こんなのを真ん中に配置しています。
loading on loading みたいでダサさが際立ちました。( 'ω')成果物
画質も出来も荒いですが、このようなものを作成しました。(もはや雪があまり見えないですね( 'ω'))
まとめ
クリスマス間近と言う事で、 loading 中に雪を降らせてみました。
loading すると重くなります。
本記事であまり触れていないですが、 React Suspense に関しても、「なるほど」と言う感想を得る事ができました。
スキルに関しても、これはいかんなと思えたのでもっと勉強しないといけないですね。そもそも、雪が積もるほどのパフォーマンスを出してはいけない。( 'ω')
明日は @KensukeTakahara さんの投稿です!
React, TypeScript の投稿らしいです。楽しみですね!(直前に変な投稿ですみません。。)
- 投稿日:2019-12-23T04:23:43+09:00
失恋駆動開発: ReactでサクッとTinder風モックを作る
こちらの記事はNIJIBOX Advent Calendar 2019の23日目の記事です。(遅刻してごめんなさい・・・)
前置き
こんにちは、ニジボックスのフロントエンドチームのこじこじです。
あっという間にクリスマスですね。街はキラキラ、大人も子どもも恋人たちも浮かれてしまう楽しい季節です。突然ですが、失恋しました
そんな楽しい季節がやってきたにも関わらず、少し前に失恋しました。
クリスマスまでもう時間がない・・・!
ということで、クリスマスに自尊心を高めて過ごせるTinder風モックをサクッと作ってみることにしました。
落ち込んでいる暇はないのです。いのち短し、作れよ乙女。作りたいもの
- なんか私めっちゃいいねきてる・・・モテ期なのでは・・・?!と思うためだけのTinder風webアプリ
- クリスマスに暇を潰せるレベルでいいので今回はモックアップの作成
書くこと
- Reactで簡単にTinder風UIを実装する方法
- サクッとモックを作りたい時に便利なツールの紹介
仕様技術・ツールなど
- CodeSandbox
- React - v15.6.2 ※バージョンが古い理由は後述します・・
- React-swipe-card - v0.1.4
- Random User Generator
- 失恋に負けない気持ち(お好みで)
実装
1. サクッと開発環境構築: CodeSandbox
今回のテーマは「サクッと」モックを作成することなので、開発環境はオンラインIDEのCodeSandboxを利用しました。
無料かつ会員登録も不要で、2クリックで開発環境が整ってしまいます。1-1. CodeSandboxにアクセスし、「Create a Sandbox, it's free」をクリック
会員登録はgithub連携でサクッと行うこともできますが、CodeSandbox上でコードを書くだけならば会員登録をしなくてもOKです。1-2. テンプレートを選択
実装に用いるテンプレートを選択します。今回はReactを選択。2. サクッとTinder風UI実装: react-swipe-card
さて、いよいよTinder風UIの実装です。
左右のスワイプを判定・処理するコードをスクラッチで書くのはかなりしんどい・・・
でも心配ご無用。今回はReactでTinder風のスワイプをサクッと実装できちゃうReact-swipe-cardを使います。ここで注意事項
このReact-swipe-card、残念ながらlast updateが3年前で、React v16へのアップデートに対応しておらず、v16以上だとエラーでこけてしまいます・・・。
今回はクリスマスまでにサクッとつくることを優先でv15で実装を進めました。
(余談ですがこんな場合もCodeSandboxはパッケージのバージョンをプルダウンですぐ切り替えできちゃうからとってもベンリ)2-1. React-swipe-cardをインストール
CodeSandboxなら、npmパッケージのインストールも「Add Dependency」ボタンをクリックして
インストールしたいパッケージを検索・選択するだけでOK。お手軽すぎる〜。
2-2. カードのコンポーネントを作成
React-swipe-cardではCardsとCardの2つのコンポーネントが用意されています。
それぞれpropsで以下の設定を渡すことができます。Cardsコンポーネント
- onEnd: 全てのカードをスワイプした時に実行する関数
- alertLeft: 左にスワイプした際に表示するコンポーネント
- alertRight: 右にスワイプした際に表示するコンポーネント
- alertTop: 上にスワイプした際に表示するコンポーネント
- alertBottom: 下にスワイプした際に表示するコンポーネント
Cardコンポーネント
- onSwipeLeft: 左にスワイプした時に実行する関数
- onSwipeRight: 右にスワイプした時に実行する関数
- onSwipeTop: 上にスワイプした時に実行する関数
- onSwipeBottom: 下にスワイプした時に実行する関数
今回はCardBoardという名前でカードのコンポーネントを作成することにします。
カードを右にスワイプしたら「マッチしました!」のアラートが表示され、
全てスワイプしきると「いいねはありません」のアラートが表示されます。CardBoard.jsimport React from "react"; import Cards, { Card } from "react-swipe-card"; // 左にスワイプした時に表示する要素 const CustomAlertLeft = () => <span>ごめんあそばせ</span>; // 右にスワイプした時に表示する要素 const CustomAlertRight = () => <span>よろこんで</span>; // 全てのカードをスワイプした後の処理 const showEndMessage = () => { alert("いいねはありません"); }; // 左にスワイプした時の処理 const handleSwipeLeft = () => { return; }; // 右にスワイプした時の処理 const handleSwipeRight = () => { alert("マッチしました!"); }; // 表示するユーザー情報 const data = ["反町●史", "向●理", "大沢た●お"] const CardBoard = () => { return ( <Cards alertRight={<CustomAlertRight />} alertLeft={<CustomAlertLeft />} onEnd={showEndMessage} className="master-root" > {data.map((item, i) => { return ( <Card key={i} onSwipeLeft={handleSwipeLeft} onSwipeRight={handleSwipeRight} > <h2>{item}</h2> </Card> ); })} </Cards> ); }; export default CardBoard;左にスワイプしていますが反町●史も好きです。
デモはこちら
3. サクッとダミーユーザー生成: Random User Generator
それっぽさを出すために、ユーザーの画像と名前を表示しましょう。
大量のダミーのユーザー情報が欲しい・・・
そんな時に使いたいのがRandom User Generator。なんと無料でランダムなユーザー情報を返してくれるAPIです。
しかもその情報量は名前、サムネイル画像(サイズ違い3種)、住所からログインID・パスワードまで豊富・・・!今回はパラメーターとして
gender=male
とresults=114
を渡して、男性の情報のみ114件を返すようにリクエストしました。
表示件数が114件なのはキリのいい数字だとなんとなくそれっぽさが出ないという理由だけです。
モテ期を感じることを最優先にしているのでパフォーマンスは考慮していません。
これを先ほど作ったCardBoardコンポーネントに渡してあげればOK。App.jsimport React from "react"; import axios from "axios"; import CardBoard from "./CardBoard"; import handleResponse from "../utility/handleResponse"; class App extends React.Component { constructor(props) { super(props); this.state = { userList: [], userCount: "" }; this.getUserInfo = this.getUserInfo.bind(this); } getUserInfo() { return axios .get("https://randomuser.me/api/", { params: { results: 114, gender: "male" } }) .then(result => handleResponse(result)) .then(result => { const userList = []; for (let i = 0; i < result.length; i++) { userList.push({ name: result[i].name.last, img: result[i].picture.large, age: result[i].dob.age }); } const userCount = userList.length; this.setState({ userList, userCount }); }); } componentDidMount() { this.getUserInfo(); } render() { return ( <div className="App"> <h1> あなたに{this.state.userCount}人から <br /> いいねが届いています </h1> <CardBoard userList={this.state.userList} /> </div> ); } } export default App;CardBoardコンポーネントの方も、propsを受け取ってカードを表示するように書き換えます。
CardBoard.jsimport React from "react"; import Cards, { Card } from "react-swipe-card"; // 左にスワイプした時に表示する要素 const CustomAlertLeft = () => <span>ごめんあそばせ</span>; // 右にスワイプした時に表示する要素 const CustomAlertRight = () => <span>よろこんで</span>; // 全てのカードをスワイプした後の処理 const showEndMessage = () => { alert("いいねはありません"); }; // 左にスワイプした時の処理 const handleSwipeLeft = () => { return; }; // 右にスワイプした時の処理 const handleSwipeRight = () => { alert("マッチしました!"); }; - // 表示するユーザー情報 - const data = ["反町●史", "向●理", "大沢た●お"] - - const CardBoard = () => { + const CardBoard = props => { return ( <Cards alertRight={<CustomAlertRight />} alertLeft={<CustomAlertLeft />} onEnd={showEndMessage} className="cards" > - {data.map((item, i) => { + {props.userList.map((item, i) => { return ( <Card key={i} onSwipeLeft={handleSwipeLeft} onSwipeRight={handleSwipeRight} > - <h2>{item}</h2> + <div className="card__img"> + <img src={item.img} alt="" /> + </div> + <div className="card__info"> + <div className="card__name">{item.name}</div> + <div className="card__age">{item.age}歳</div> + </div> </Card> ); })} </Cards> ); }; export default CardBoard;あとはスタイルを整えて、こんな感じになりました。
国際色豊かにモテ期が来たようです。コードはこちら
まとめ
- サクッとモックアップやプロトタイプを作成したい場合はCodeSandboxが便利
- サクッとダミーユーザー情報が欲しい場合はRandom User Generatorがおすすめ
- サクッとTinder風UIをReactで実装したいならReact-swipe-cardが簡単だが、React v16以上で使えないため要検討(次の機会に調査します・・・)
- サクッと自尊心が高まるかどうかは自分次第
- 投稿日:2019-12-23T02:44:44+09:00
[React] 本の内容通りに環境構築してたらめちゃくちゃつまづいた
※ご覧いただいてる方々に諸注意です。投稿主はプログラミング・環境構築含め独学のため、誤解している点や間違っている点が多くある場合があります。もし、私が間違った知識・情報をドヤ顔で記載していた場合、優しくご指摘いただくようよろしくお願いします。
※この投稿は、本及び著者に対する文句などのネガティブなものではありません。
※投稿主の環境はwindows10、各バージョンは2019/12/23時点で最新のやつになってます。発売から2年たってるのに全然コード通らん・・・
Reactの勉強を始めようと思って手に取ったこちらの本。↓
作りながら学ぶReact入門
2年前くらいの本なんですが、評判が良いので購入。
しかし、環境構築時点でエラー発生。本の通りに進めているんですが解決できず・・・
環境の変化はやすぎ問題。というのはおいといて、本題に入りましょう。
あ、ちなみにこの投稿に乗せるURLを取ってくるときにamazonのレビュー見て気が付いたんですが、
同じところでつまづいてる人、やっぱりいましたね!!(しかも1年前やーん)
これはひょっとして需要ある記事になるかな?(伸びろ...!!伸びろッ!!)npm start実行時のエラー内容
こんな感じでした。
自分は、このエラー解決するために要した時間は実に"5時間"
笑っちゃいますよね・・・ほんとにあきらめてやろうかと思いましたわ・・・
自分はモダンJSの環境構築どころかJSの知識すらうっすらとしか知らないので、原因の解説などは一切できません!!手探りで解決したのでその方法をお教えしますね~~解決方法は、ずばりモジュールの追加とファイル内容の書き換え!!
それでは必要なモジュールをひょいっと追加していきましょう。
コマンドプロンプト$ npm i @babel/preset-env $ npm i @babel/preset-react次に.babelrcの中身を書き換えます。
.babelrc(書き換え前){ "presets":["env","react"] }.babelrc(書き換え後){ "presets": ["@babel/preset-env", "@babel/preset-react"] }これでなぜか直りました・・・これいじるために5時間使ったのしんどすぎる・・
何が起こったのかよくわかりませんが直ったので万事OK!
とりあえず環境構築終わらせたい、という方はこんな感じでやってみてくださいな!!
現場からは以上です~~
- 投稿日:2019-12-23T00:00:12+09:00
Reactでgprc-webを使った簡易チャットを作成してみた
作ったもの
Reactとgrpc-webでこのような簡易チャットを作成してみました。
作成してみたコード↓
以前やってみた、こちらの投稿で開発環境を作成しています
gRPC-Web Hello World Guideをやってみた
全体
simplechat.protosyntax = "proto3"; package simplechat; service ChatCaller { rpc AddChat (ChatDataRequest) returns (SuccessReply); rpc RepeatChat(RepeatChatRequest) returns (stream ChatListReply); rpc TypingChat(TypingUserRequest) returns (SuccessReply); } message ChatData { int32 user_id = 1; string text = 3; } message ChatDataRequest { ChatData chat_data = 1; } message SuccessReply { bool result = 1; } message RepeatChatRequest { } message ChatListReply { repeated ChatData chat_data = 1; repeated int32 typing_user_id = 2; } message TypingUserRequest { int32 user_id = 1; }server.jsconst PROTO_PATH = __dirname + "/simplechat.proto"; const grpc = require("grpc"); const protoLoader = require("@grpc/proto-loader"); const packageDefinition = protoLoader.loadSync( PROTO_PATH, {keepCase: true, longs: String, enums: String, defaults: true, oneofs: true }); const protoDescriptor = grpc.loadPackageDefinition(packageDefinition); const simplechat = protoDescriptor.simplechat; /** * チャットデータ * * @typedef {Object} chatData * * @property {number} userId * @property {string} text * @property {Date} postDate */ /** * @type {chatData[]} chatDataList */ let chatDataList = []; /** * 新しく追加されたチャットデータ * * @type {chatData[]} latestChatDataList */ let latestChatDataList = []; /** * 入力中のユーザーのID * * @type {Set<number>} typingUserIds */ let typingUserIds = new Set; (async function repeat() { while (true) { let lastGetDate = new Date; await new Promise(resolve => setTimeout(resolve, 2000)); // 最新のチャットのみを保存しておく latestChatDataList = []; for (const { userId, text, postDate } of chatDataList) { if (lastGetDate.getTime() <= postDate.getTime()) { latestChatDataList.push({ user_id: userId, text }); continue; } break; } // 入力中のユーザー初期化 typingUserIds = new Set; } })(); // チャット追加 function doAddChat(call, callback) { const { chat_data: { user_id: userId, text } } = call.request; chatDataList = [ { userId, text, postDate: new Date }, ...chatDataList, ]; callback(null, { result: true }); } // チャット取得 async function doRepeatChat(call) { // 接続開始時に全てのチャットの内容を取得 call.write({ chat_data: [...chatDataList].reverse() .map(({ channel, userId: user_id, text }) => ({ channel, user_id, text })), }); // 接続している間はループ、接続が切れたらループを抜ける while (!call.cancelled) { await new Promise(resolve => setTimeout(resolve, 2000)); call.write({ chat_data: [...latestChatDataList].reverse(), typing_user_id: [...typingUserIds] }); } } // 入力しているユーザー追加 function doTypingChat(call, callback) { typingUserIds.add(call.request.user_id); callback(null, { result: true }); } function getServer() { const server = new grpc.Server(); server.addService(simplechat.ChatCaller.service, { addChat: doAddChat, repeatChat: doRepeatChat, typingChat: doTypingChat, }); return server; } if (require.main === module) { const server = getServer(); server.bind("0.0.0.0:9090", grpc.ServerCredentials.createInsecure()); server.start(); } exports.getServer = getServer;App.jsximport React, { useState, useMemo, useCallback, useEffect, Fragment, useRef } from "react"; import "./index.css"; const { ChatData, ChatDataRequest, RepeatChatRequest, TypingUserRequest } = require("../generate/simplechat_pb.js"); const { ChatCallerClient } = require("../generate/simplechat_grpc_web_pb.js"); const users = [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Carol" }, ]; /** * @see {@link https://github.com/bhaskarGyan/use-throttle} */ function useThrottle(value, limit) { const [throttledValue, setThrottledValue] = useState(value); const lastRan = useRef(Date.now()); useEffect(() => { const handler = setTimeout(function () { if (Date.now() - lastRan.current >= limit) { setThrottledValue(value); lastRan.current = Date.now(); } }, limit - (Date.now() - lastRan.current)); return () => { clearTimeout(handler); }; }, [value, limit]); return throttledValue; } const App = () => { const [userId, setUserId] = useState(1); const [text, setText] = useState(""); const [chatDataList, setChatDataList] = useState([]); const chatEl = useRef(null); const [typingUserIds, setTypingUserIds] = useState([]); const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []); // チャットに新規投稿する const callAddChatData = useCallback(async postText => { const chatData = new ChatData; const request = new ChatDataRequest; chatData.setUserId(userId); chatData.setText(postText) request.setChatData(chatData); await new Promise( resolve => client.addChat(request, {}, (err, response) => { resolve(response.getResult()); }) ); setText("") }, [userId]); // 新規チャットがあれば取得するためストリームを開く useEffect(() => { const stream = client.repeatChat(new RepeatChatRequest, {}); stream.on("data", response => { setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]); setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList()); }); const handleBeforeunload = () => stream.cancel(); window.addEventListener("beforeunload", handleBeforeunload); return () => { window.removeEventListener("beforeunload", handleBeforeunload) }; }, []); // チャットに新着があれば下にスクロール useEffect(() => { chatEl.current.scrollTop = chatEl.current.scrollHeight }, [chatDataList.length]) // 入力しているユーザーの情報を追加 const throttleText = useThrottle(text, 700) useEffect(() => { const request = new TypingUserRequest; request.setUserId(userId); if (throttleText.trim()) { client.typingChat(request, {}, () => {}); } }, [throttleText]); const typingUserInfo = useMemo(() => { const typingOtherUserIds = typingUserIds.filter(typingUserId => typingUserId !== userId) if (typingOtherUserIds.length === 0) { return ''; } if (typingOtherUserIds.length === 1) { const user = users.find(user => user.id === typingOtherUserIds[0]); return `${user.name}が入力しています`; } if (typingOtherUserIds.length > 1) { return '複数人が入力しています'; } }, [userId, typingUserIds]); return ( <> <h1>Simple Chat</h1> <div className="flex"> <div className="left"> <select value={userId} onChange={e => setUserId(Number(e.target.value))}> {users.map(({ id, name }) => <option key={id} value={id}>{name}</option>)} </select> </div> <div className="right" ref={chatEl}> {chatDataList.map((chatData, i) => ( <Fragment key={i}> <p> <strong> {users.find(user => user.id === chatData.getUserId()).name} </strong> </p> <p>{chatData.getText()}</p> <hr /> </Fragment> ))} </div> </div> <form onSubmit={e => { e.preventDefault(); callAddChatData(text); }}> <input value={text} onChange={e => setText(e.target.value)} /> <button type="submit">送信</button> {typingUserInfo} </form> </> ); }; export default App;1. チャットに追加する部分
simplechat.protoservice ChatCaller { rpc AddChat (ChatDataRequest) returns (SuccessReply); // ... } message ChatData { int32 user_id = 1; string text = 3; } message ChatDataRequest { ChatData chat_data = 1; } message SuccessReply { bool result = 1; }簡単にチャットを作るだけなので、トップレベルに宣言した変数の
chatDataList
の配列にチャット内容を追加していきます。server.js/** * チャットデータ * * @typedef {Object} chatData * * @property {number} userId * @property {string} text * @property {Date} postDate */ /** * @type {chatData[]} chatDataList */ let chatDataList = []; // ... // チャット追加 function doAddChat(call, callback) { const { chat_data: { user_id: userId, text } } = call.request; chatDataList = [ { userId, text, postDate: new Date }, ...chatDataList, ]; callback(null, { result: true }); }フォームがサブミットされた時に、チャットを投稿します。
リクエストする内容はprotoで定義した変数名に合わせる形でセットしました。(protoでuser_id
と定義した場合はchatData.setUserId(userId);
)App.jsxconst App = () => { const [userId, setUserId] = useState(1); const [text, setText] = useState(""); // ... const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []); // チャットに新規投稿する const callAddChatData = useCallback(async postText => { const chatData = new ChatData; const request = new ChatDataRequest; chatData.setUserId(userId); chatData.setText(postText) request.setChatData(chatData); await new Promise( resolve => client.addChat(request, {}, (err, response) => { resolve(response.getResult()); }) ); setText("") }, [userId]); // ... return ( <> // ... <form onSubmit={e => { e.preventDefault(); callAddChatData(text); }}> <input value={text} onChange={e => setText(e.target.value)} /> <button type="submit">送信</button> {typingUserInfo} </form> </> ); };2. チャットを取得する部分
simplechat.protoservice ChatCaller { // ... rpc RepeatChat(RepeatChatRequest) returns (stream ChatListReply); // ... } message RepeatChatRequest { } message ChatListReply { repeated ChatData chat_data = 1; repeated int32 typing_user_id = 2; }
latestChatDataList
に新しく更新されたチャットの内容だけを保存しておいて、ストリームで新しいのがあればクライアントに送信します。ストリームでクライアントとの接続が切れているかどうかの場合の判定は、
ServerWritableStream.cancelled
で判定で切るようでした↓
https://github.com/grpc/grpc-node/blob/master/packages/grpc-native-core/index.d.tsserver.js// ... /** * @type {chatData[]} chatDataList */ let chatDataList = []; /** * 新しく追加されたチャットデータ * * @type {chatData[]} latestChatDataList */ let latestChatDataList = []; // ... (async function repeat() { while (true) { let lastGetDate = new Date; await new Promise(resolve => setTimeout(resolve, 2000)); // 最新のチャットのみを保存しておく latestChatDataList = []; for (const { userId, text, postDate } of chatDataList) { if (lastGetDate.getTime() <= postDate.getTime()) { latestChatDataList.push({ user_id: userId, text }); continue; } break; } // ... } })(); // ... // チャット取得 async function doRepeatChat(call) { // 接続開始時に全てのチャットの内容を取得 call.write({ chat_data: [...chatDataList].reverse() .map(({ channel, userId: user_id, text }) => ({ channel, user_id, text })), }); // 接続している間はループ、接続が切れたらループを抜ける while (!call.cancelled) { await new Promise(resolve => setTimeout(resolve, 2000)); call.write({ chat_data: [...latestChatDataList].reverse(), typing_user_id: [...typingUserIds] }); } }
useEffect
の第2引数に[]
を指定して、最初にレンダリングされた時だけと指定できるので、そこでstream
でサーバーからのデータをリッスンするようにしました。App.jsxconst App = () => { // ... const [chatDataList, setChatDataList] = useState([]); const chatEl = useRef(null); const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []); // ... // 新規チャットがあれば取得するためストリームを開く useEffect(() => { const stream = client.repeatChat(new RepeatChatRequest, {}); stream.on("data", response => { setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]); setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList()); }); const handleBeforeunload = () => stream.cancel(); window.addEventListener("beforeunload", handleBeforeunload); return () => { window.removeEventListener("beforeunload", handleBeforeunload) }; }, []); // チャットに新着があれば下にスクロール useEffect(() => { chatEl.current.scrollTop = chatEl.current.scrollHeight }, [chatDataList.length]) // ... return ( <> <h1>Simple Chat</h1> <div className="flex"> <div className="right" ref={chatEl}> {chatDataList.map((chatData, i) => ( <Fragment key={i}> <p> <strong> {users.find(user => user.id === chatData.getUserId()).name} </strong> </p> <p>{chatData.getText()}</p> <hr /> </Fragment> ))} </div> </div> // ... </> ); };3. 「
Bobが入力中です
」と表示させる部分simplechat.protoservice ChatCaller { // ... rpc TypingChat(TypingUserRequest) returns (SuccessReply); } // ... message TypingUserRequest { int32 user_id = 1; }入力中のユーザーIDをSetで保存しておきます
server.js// ... /** * 入力中のユーザーのID * * @type {Set<number>} typingUserIds */ let typingUserIds = new Set; // ... (async function repeat() { while (true) { // ... // 入力中のユーザー初期化 typingUserIds = new Set; } })(); // ... // 入力しているユーザー追加 function doTypingChat(call, callback) { typingUserIds.add(call.request.user_id); callback(null, { result: true }); } // ...ユーザーが入力中に一定間隔でサーバーに通信して入力中のユーザー情報を送信します。
App.jsx/** * @see {@link https://github.com/bhaskarGyan/use-throttle} */ function useThrottle(value, limit) { const [throttledValue, setThrottledValue] = useState(value); const lastRan = useRef(Date.now()); useEffect(() => { const handler = setTimeout(function () { if (Date.now() - lastRan.current >= limit) { setThrottledValue(value); lastRan.current = Date.now(); } }, limit - (Date.now() - lastRan.current)); return () => { clearTimeout(handler); }; }, [value, limit]); return throttledValue; } const App = () => { // ... const [typingUserIds, setTypingUserIds] = useState([]); const client = useMemo(() => new ChatCallerClient("http://localhost:8080"), []); // ... // 新規チャットがあれば取得するためストリームを開く useEffect(() => { const stream = client.repeatChat(new RepeatChatRequest, {}); stream.on("data", response => { setChatDataList(oldChatDataList => [...oldChatDataList, ...response.getChatDataList()]); setTypingUserIds(oldTypingUserIds => response.getTypingUserIdList()); }); const handleBeforeunload = () => stream.cancel(); window.addEventListener("beforeunload", handleBeforeunload); return () => { window.removeEventListener("beforeunload", handleBeforeunload) }; }, []); // ... // 入力しているユーザーの情報を追加 const throttleText = useThrottle(text, 700) useEffect(() => { const request = new TypingUserRequest; request.setUserId(userId); if (throttleText.trim()) { client.typingChat(request, {}, () => {}); } }, [throttleText]); const typingUserInfo = useMemo(() => { const typingOtherUserIds = typingUserIds.filter(typingUserId => typingUserId !== userId) if (typingOtherUserIds.length === 0) { return ''; } if (typingOtherUserIds.length === 1) { const user = users.find(user => user.id === typingOtherUserIds[0]); return `${user.name}が入力しています`; } if (typingOtherUserIds.length > 1) { return '複数人が入力しています'; } }, [userId, typingUserIds]); return ( <> // ... <form onSubmit={e => { e.preventDefault(); callAddChatData(text); }}> <input value={text} onChange={e => setText(e.target.value)} /> <button type="submit">送信</button> {typingUserInfo} </form> </> ); };
最後までみていただいてありがとうございました。m(_ _)m