20191223のReactに関する記事は16件です。

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

○○年後の開始月/日を取得

startOfendOf は非常に便利だと思います。
応用すれば、今月の初日や、現在時刻から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環境ではエラーとなるケースに直面しました。
どの環境でも同じように動くように実装する必要性を強く感じました。

参考

逆引きLuxon集 〜moment.jsから乗り換えるために〜

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 オフライン)を検知して、端末がオンラインになったら自動的にローカルデータをクラウドに同期し、データの競合をいい感じに解決してくれます。

image.png

2. デベロッパーフレンドリーなインターフェース

デベロッパーフレンドリーなインターフェースでGraphQLに詳しくなくてもAppSyncからデータを操作できます。GraphQLの操作を抽象化した関数を組み合わせることでAppSyncに対してクエリを発行できるようになりました。

スクリーンショット 2019-12-23 18.08.59.png

クラウドへのデータ同期、及び競合検知

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を選択するのが良いかと思われます。

スクリーンショット 2019-12-23 20.34.26.png

デベロッパーフレンドリーなインターフェース

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の補完機能を用いながら実装を行うことができるようになります。
スクリーンショット 2019-12-23 22.58.24.png

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の方が補完は強力です。

スクリーンショット 2019-12-23 22.38.44.png

まとめ

いかがでしょうか。DataStoreを用いればオフライン時のアプリの実装が非常に簡単になります。また、AppSyncへのデータ操作が関数で記述できることにより、非常に効率的に開発を行うことができるようになりました。Amplifyを使って開発をされている方は、是非DataStoreの導入も検討いただけると良いと思います!

おわり

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

エンジニア向け特化型SNSを開発しています

これは、ひとり開発 Advent Calendar 2019 の23日目の記事です。

はじめに

新卒( 2019年12月で退社 & 転職活動始めます!)でエンジニアをやっている筆者が、友人と一緒にここ数ヶ月開発しているサービスについて書きたいと思います。ちなみに、2020年1月中にα版をリリース予定、3月1日にはこのサービス開発についてまとめた本を技術書展で出版します。みなさん、技術書展の会場でお会いしましょう。

開発中サービスについて

エンジニアのためのプラットフォーム: 「Jeeek (ジーク) 」

サービス名についてですが、色々あってとりあえずこの名前にしています。由来はあるのですが今回の記事では省略します。

概要を最初にざっくり言うと、エンジニアの活動 (学習・転職) を支援するSNSライクのプラットフォームです。詳しくは、以下で述べていきます。

Why?<なぜ作ったのか>

まずは、なぜJeeek(ジーク)を開発・リリースしようと考えたのかを書いていきます。

活動共有、コミュニティ形成の需要があると感じたから

Twitterでは、 「#100DaysOfCode」 や 「#未経験エンジニアと繋がりたい」 といったハッシュタグをよく見かけた時期がありました。これらは、自身の活動の共有を行いたい・共有することで自分にプレッシャーをかけ継続させたい、同じような境遇の人同士で切磋琢磨し合いながら成長していきたい、などの想いがあると思います。また、他分野からIT業界への流入、プログラミング義務教育化など、時代の潮流と共にITエンジニアの増加が想定されます。

しかし、エンジニアが対象とする技術分野は幅が広く、初学者にとっては情報の取捨選択が難しいこともあり、初期は何から勉強をしたら良いのか、また実際に手を動かした後も何が分かっていないのか分からないといった状態が生まれやすいです。経験の浅いエンジニアと経験豊富なエンジニアの間に存在する情報の非対称性を狙って不当に高額なプログラミングスクールなどの情弱ビジネスも横行しています。これでは、たとえITエンジニアの人口が増加したとしても脱初心者にとても時間がかかってしまいます。こういった現状を変え、エンジニアの活動がより身近で楽しいものになったり、相互に刺激し合う環境がオンラインで構築できたり、駆け出しエンジニアの勉強コンサルにもなるようなサービスがあったら面白いのではないかと考えサービス開発を始めました。

How?<どう実現するのか>

次に、「じゃあどうやってそれを実現していこうと考えているの?」ということについてです。基本的に、現在実装中のものはエンジニア向けに特化したSNSというイメージで、今後これに機能を追加しながらエンジニアの活動全体を支援するプラットフォームにまで拡張していく予定です。

ユーザーの活動をSNSライクのタイムラインで共有します

スクリーンショット 2019-12-23 21.41.52.png

メインとなるのがタイムライン機能です。普段の活動をテンプレートベースのシンプルな投稿で共有します。例えば、『◯◯技術書の1章を読んだ』『◯◯のイベントに参加した』などです。また、エンジニアの活動はインターネットにアウトプットされることが多いことから、外部サービスでの活動を自動収集および自動投稿も可能です。外部サービス (GitHub, Qiita, connpassなど) のAPIを利用して連携させることで、外部サービスでのアクティビティを自動で取得しタイムラインで共有します。例えば、GitHubと連携済みユーザーであれば『新しく1つのcommitをしました』のような投稿を自動で行うことができます。

また、日々の投稿のログ (投稿に設定するタグ) から、ユーザーのスキルスタックを可視化・共有します。そして、それらに基づいて特定の技術でのユーザーランキングも表示します。

What?<ユーザーはどうなるのか>

次に、「Jeeek(ジーク)のユーザーはどういうことが嬉しくて利用するの?」ということについて書いていきます。

他のエンジニアの活動を自身の活動に役立てることができる

タイムラインで活動を共有することで、他のエンジニアのアクティブな活動を知ることができ、切磋琢磨する環境ができたり、良質な記事・文献にアクセスしやすくなります。また初学者にとっては、ロールモデルを発見できる可能性が高まり、自身の勉強の指針にもなります。これに加えて、技術ベースでランキングが確認できるので、自身の立ち位置を客観的に認識することげできます。

また、ユーザーのスキルスタックが公開されるので、スカウトする/されるの機会を作ります。これらのスキルスタックは日々の活動に紐づいて自動生成されるので、定期的に手動でアップデートする必要もありません。

ちなみに、スキルスタック自動生成 + 企業とマッチングについては、LAPRASさんのサービスが有名です。これに対して、Jeeek(ジーク)はより粒度の細かい活動を共有したり、和気あいあいとしたアクティブなコミュニティ形成を実現したいため、SNSの機能に重きを置いています。

開発環境

最後に、Jeeek(ジーク)の開発環境について簡単に紹介します。

技術スタック

スクリーンショット 2019-12-23 8.01.22.png

使用技術やツールなどは全て上の図に載っている通りです。
インフラにGCP、バックエンドにGo、フロントエンドにTypeScript × Reactを使っています。ちなみに僕はフロントエンドを担当しており、TypeScript, React, Redux, Redux-Saga, Figmaなどを使用しています。

おわりに

プライベート開発では、サービス企画からスプリントプランニング、UI設計、実装、リリーススケジュール、リリース後のアップデートおよびマーケティングまで全て自分たちで考えながら実行・経験できることがとても面白いと感じています。また、それと同時にその大変さも感じ、実際にリリースや運用を行っている全個人開発者のことをリスペクトするようになりました。Jeeek(ジーク)も僕個人もまだまだ道半ばですが、引き続き精進していきます。冒頭でも述べましたが、これに関連した本を技術書展で出版するので、次は3月1日の技術書展でお会いしましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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ライクのタイムラインで共有します

スクリーンショット 2019-12-23 21.41.52.png

メインとなるのがタイムライン機能です。普段の活動をテンプレートベースのシンプルな投稿で共有します。例えば、『◯◯技術書の1章を読んだ』『◯◯のイベントに参加した』などです。また、エンジニアの活動はインターネットにアウトプットされることが多いことから、外部サービスでの活動を自動収集および自動投稿も可能です。外部サービス (GitHub, Qiita, connpassなど) のAPIを利用して連携させることで、外部サービスでのアクティビティを自動で取得しタイムラインで共有します。例えば、GitHubと連携済みユーザーであれば『新しく1つのcommitをしました』のような投稿を自動で行うことができます。

また、日々の投稿のログ (投稿に設定するタグ) から、ユーザーのスキルスタックを可視化・共有します。そして、それらに基づいて特定の技術でのユーザーランキングも表示します。

What?<ユーザーはどうなるのか>

次に、「Jeeek(ジーク)のユーザーはどういうことが嬉しくて利用するの?」ということについて書いていきます。

他のエンジニアの活動を自身の活動に役立てることができる

タイムラインで活動を共有することで、他のエンジニアのアクティブな活動を知ることができ、切磋琢磨する環境ができたり、良質な記事・文献にアクセスしやすくなります。また初学者にとっては、ロールモデルを発見できる可能性が高まり、自身の勉強の指針にもなります。これに加えて、技術ベースでランキングが確認できるので、自身の立ち位置を客観的に認識することげできます。

また、ユーザーのスキルスタックが公開されるので、スカウトする/されるの機会を作ります。これらのスキルスタックは日々の活動に紐づいて自動生成されるので、定期的に手動でアップデートする必要もありません。

ちなみに、スキルスタック自動生成 + 企業とマッチングについては、LAPRASさんのサービスが有名です。これに対して、Jeeek(ジーク)はより粒度の細かい活動を共有したり、和気あいあいとしたアクティブなコミュニティ形成を実現したいため、SNSの機能に重きを置いています。

開発環境

最後に、Jeeek(ジーク)の開発環境について簡単に紹介します。

技術スタック

スクリーンショット 2019-12-23 8.01.22.png

使用技術やツールなどは全て上の図に載っている通りです。
インフラにGCP、バックエンドにGo、フロントエンドにTypeScript × Reactを使っています。ちなみに僕はフロントエンドを担当しており、TypeScript, React, Redux, Redux-Saga, Figmaなどを使用しています。

おわりに

プライベート開発では、サービス企画からスプリントプランニング、UI設計、実装、リリーススケジュール、リリース後のアップデートおよびマーケティングまで全て自分たちで考えながら実行・経験できることがとても面白いと感じています。また、それと同時にその大変さも感じ、実際にリリースや運用を行っている全個人開発者のことをリスペクトするようになりました。Jeeek(ジーク)も僕個人もまだまだ道半ばですが、引き続き精進していきます。冒頭でも述べましたが、これに関連した本を技術書展で出版するので、次は3月1日の技術書展でお会いしましょう!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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の可能性を探求していきたいと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

<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.js
import 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.jspush("/");と書いていたところをreplace("/");に置き換えてブラウザをみてみましょう。

featuredボタンを押した時の画面は履歴に残っていません。

以上がNavigateの紹介です。
次のレッスンのためにpushにしておいてください。

Getパラメータ

他ページからReactアプリに画面遷移してきた時に見せ方を変えたい時はRouterのGetパラメータを使用してURLクエリ文字列を整形、取得するらしいっす。(ごめんなさい、あまり理解できていません。。)

src/js/pages/client.js
import 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.js
import 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.js
import 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.js
import 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)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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が呼ばれてその中でサーバサイドのデータが削除され
フロントからも消えるはず、はずだったw

peopleという配列はコンポーネントが描画されるときに
GraphQLのフックが起動して取得した配列を
setPeople(pickUpPeople)みたいな感じで突っ込んでいるのですが・・・
このフックは最初に描画した時、1度しか呼ばれない

つまり、サーバサイドではデータは消えているけど
フロント側は何も変化がないということになる。
(もちろん、F5で更新すれば消えている)

失敗例

「せや、デリートボタン押したときに
配列にフィルターかけてsetPeopleしなおせばええんや!」

具体的には、
setPeople(pickedUpPeople.filter(person => person !== デリートしたpersonのid))
要するに、peopleにデリートしたidに合致しないやつだけピックアップしてセットする。
という意味。

結果

「テストデータが2つ・・・1つを削除・・・を消えた!!」
「よし、もう一つも削除・・・!?・・・さっき消したやつ戻ってきた・・・」
「連打してみよ・・・交互に消したはずのデータがひょこひょこ出てくるンゴ^q^」

なぜかというと、
フックで取ってきたpickedUpPeople配列からはデータが消えているわけではないので
クリックされたやつ以外が表示されてしまう・・・
つまり、別なのを削除するとそれは消えるけど、前のやつがリバイブする
image.png

成功例

delete押されたidをステートで管理して、そこに足していけばええんや!
なので、さっきのコードをこんな感じにする。
* ...deletedListは配列の中身をバラして並べる、という意味です

const[deletedList, setDeletedList] = useState<string[]>([])
setPeople(
  pickedUpPeople.filter(
    person =>
      ![デリートしたpersonID, ...deletedList].includes(person.id),
  ),
);
setDeletedList([デリートしたpersonID, ...deletedList]);

こうすれば、一回消したidがどんどんListに追加されていくので
フックで取得した全体と消し去ったListのXORを取ることで
消されていない奴らだけ表示できた!

もっとお利口な方法があるんだろうけど、
元のコードを崩さずにやろうとしたらこうなりました(小並感)

まとめ

・Reactでliを扱う時は、filterをうまく使う
・弾くべきデータをリストとして、別のステートに保持しておくと便利

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ReactアプリケーションにSentryを導入してみる(Webpack使用)

React Native版の記事はこちらです。

SentryをReactアプリケーションに導入してみます。前半はReactに限らず、ブラウザ上で動かすJSに共通する設定になります。

設定に必要な基本的な情報はすでに取得している状態を想定して進めていきます。
最低限の使用の場合は、@sentry/browserをインストールし、以下のコードをJSの最上位ファイルに追加するだけです。

$ npm install @sentry/browser
App.js
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: "ここにプロジェクトのDSN"
});

throw new Error('error'); // 適当にエラーをスローしてみる

Error__error.png

ローカルで動かしてみると、問題なくSentryのissuesの中にエラーイベントが入りました。

このままでも使えるのですが、ソースマップファイルやリリース情報をSentryに送信してログをより見やすくするために、Webpackの設定を追加してみます。

公式のドキュメントに従いますが、必要のない設定項目は割愛しています。
まずは.sentryclircというファイルを追加して、Webpackでビルドする時にSentryのプロジェクトにアクセスできるようにします。

.sentryclirc
[auth]
token=Authトークン
[defaults]
org=組織名
project=プロジェクト名

公式のWebpackプラグインをインストールして、

$ npm install --save-dev @sentry/webpack-plugin
webpack.config.js
const 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からaddcommitまで一通りしてから、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の準備は完了です。

デプロイしたページを開いて、エラーを発生させてみると、

Error__error.png

まず、RELEASE欄にちゃんとコミットIDが入っているのがわかります。

Error__error.png

ソースマップが送信できているので、エラーの発生箇所も確認できます。

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-boundaries

SentryBoundary.js
import 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 SentryBoundary

Error Boundary自体はReactの機能(実装パターン)です。

App.js
<SentryBoundary>
  <ChildComponent />
</SentryBoundary>

使用する単位は任意ですが、このように子要素をError Boundaryで囲っておいて、子要素のレンダリングで何かしらエラーが発生した時に代りに別の要素を表示したりすることができます。

この例では、エラーが発生した時に「エラーを報告する」ボタンを表示し、ボタンが押されたらSentryのダイアログを表示するようにしています。

実際にError Boundaryの子要素でエラーが発生するようにしてみます。

例えば下記のように、Reactのレンダリングとは関係ないところでエラーを発生させてもError Boundaryはcatchしません。

ChildComponent.js
import 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.js
import React from 'react';

function ChildComponent() {
  return (
    <div>
      エラーを発生させます...
      {window.foo()}
    </div>
  );
}

export default ChildComponent;

React_App_と_sentry-test____works_tamaki_sentry-test__-_____src_ChildComponent_js__sentry-test_.png
起動してみると、「エラーを発生させます...」という文言の代わりにボタンが表示されました。

これを押すと、このようなSentryのダイアログが表示されます。
React_App.png

名前とメールアドレス、詳細を入力して送信してみると...

Sentryのエラー詳細の方にUser Feedbackがちゃんと追加されました。
TypeError__window_foo_is_not_a_function.png

ダイアログの文言なども変更できるようです。
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>
...

React_App.png

問題なく設定できました。

React_App.png

ダイアログにはclassがセットされているので、見た目のカスタマイズもCSSで簡単にできそうです。

以上、ReactNativeとWebでSentryを触ってみました。特に嵌るところもなく、スムーズに導入できました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【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-theme

TS版もインストールすることを忘れないように。

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.tsx
import 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.tsx
import 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で作ったポートフォリオサイトのモックを移植できる・・・・・・。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

技術的負債を段階的に返済するためにReactをWebComponentsとして他のフレームワークに組み込む方法

技術的負債を返済するブリッジとしてWebComponentsを使うという手法の提案と、具体例として、AngularJSの中にWebComponentsとしてReactを組み込む方法について、この記事で解説します。

祭り化アドベントカレンダーです

この記事は#祭り化 Advent Calendar 2019の12/23日分です。株式会社マツリカ社員たちのエモい記事がいっぱい登場してる中ですが、僕にとっての祭り化状態はやっぱり技術について考えアウトプットしてる時なので、エモとか考えずに、技術系記事でいきます。

ちなみに、最近社内のごく一部の人に流行の兆しを見せているホットサンドメーカーという、肉や魚を焼いたり、なぜかホットサンドも焼ける、神の与えたもうた最強の調理器具の記事を書くことも一瞬考えましたが、今回は技術ネタができあがったので、技術的な記事でいきます。

※ホットサンドメーカーは、肉や魚や肉まんなどを焼ける万能調理器具である。むしろアーティファクト

EMIjl98UcAAt2Py-orig.jpg

ホットサンドも焼けます。

EL4sT77U8AAXna--orig.jpg

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.ts
import './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(/&quot;/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.tsx
import 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で書き直しただけです。

唯一特殊な点としては、inputonChange={() => {}}です。checkedを指定している場合にはセットでonChangeが必須であるためダミーを指定しています。Event.preventDefaultをしておらず、changeイベントがそのまま飛ぶため、AngualarJS側でイベントを ngOn ディレクティブでキャッチしています。

場合によっては、イベントハンドラを真面目に書いて、カスタムイベントを飛ばすといったことも必要になるかもしれません。

カスタムエレメントを作成する

src/components/todo-item/web-components.ts
import 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.parseIntNumber.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.ts
import './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円未満で買えるので、是非買いましょ?

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

2019年12月版 AWS Amplify + AppSync + Cloud9 + React.js でとりあえず動くまでの手順

目的

  1. Cloud9上での aws amplify, aws appsync を利用する開発環境の構築
  2. React上での subscription による リアルタイムデータ配信アプリの構築
  3. amplify の機能による S3+CloudFlont でのアプリのデプロイ
Vue.js バージョンはこちら

前提

  1. 利用するリージョンは ap-northeast-1
  2. CodeCommitのリポジトリ名は amplify_test_react
  3. amplifyで利用するiamユーザは amplify-test-user
  4. アプリ名はmytodo
  5. api名はmytodoapi

注意

この投稿は @aws_amplify/api, @aws_amplify/pubsub パッケージを利用しています。
aws_amplify, aws_amplify_vue パッケージと同時に利用することはできません。
AWSKinesisFirehoseProvider.js あたりでエラーが発生する場合はパッケージの混同を疑ってみてください。

構築手順


  1. CodeCommitへのGitリポジトリ作成

    * 'amplify_test_react'を使用

  2. Cloud9の開発環境作成

    * aws consoleで作成

  3. Cloud9の一時認証の利用を解除

    1. ツールバーのAWS Cloud9でメニューを開く
    2. Preferencesを開く
    3. AWS SETTINGSを開く
    4. Credentialsを開く
    5. AWS managed temporary credentials をオフにする

  4. 開発環境とGitリポジトリの連携
    $ git clone ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/amplify_test_react
    

  5. nodeのインストール
    最新、または、好みのバージョンを確認
    $ nvm ls-remote
    

    指定したバージョンでインストールを実行

    $ nvm install 指定のバージョン
    

  6. aws amplify cli のインストール
    $ npm install -g @aws-amplify/cli
    

  7. amplify の初期設定
    1. configureを実行
      $ amplify configure
      

    2. 管理者アカウントで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
      

    3. 利用するリージョンを選択、ユーザ名を入力

      出力された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
      

    4. ユーザ作成で得ることのできた 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.
      

  8. react のインストールとプロジェクトの作成
    $ npx create-react-app mytodo
    $ cd mytodo
    

  9. アプリケーションを実行して確認
    $ npm start
    

    メニューの Preview -> Preview Running Application を実行

    Cloud9内のブラウザでReactのセットアップが完了していることを確認

  10. 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"
    

    参考: create-react-appで作ったアプリがhttpsだと動かない

  11. aws-amplify をインストール
    $ npm install @aws-amplify/api
    $ npm install @aws-amplify/pubsub
    $ npm install aws-amplify-react
    

  12. 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
    

  13. amplify の初期設定確認
    $ amplify status
    
    Current Environment: dev
    
    | Category | Resource name | Operation | Provider plugin |
    | -------- | ------------- | --------- | --------------- |
    

  14. 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
    

  15. api の追加内容確認
    $ amplify status
    
    Current Environment: dev
    
    | Category | Resource name | Operation | Provider plugin   |
    | -------- | ------------- | --------- | ----------------- |
    | Api      | mytodoapi     | Create    | awscloudformation |
    

  16. バックエンドに 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
    

  17. mytodo を修正

    1. App.js を修正
      App.js
      import 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;
      

    2. 実行してブラウザでTodoが追加されることを確認

      Add Todoボタンをクリックしたあと、リロードすることでTodoが追加されていることが確認できる

  18. subscription を利用してみる

    1. App.js を修正
      App.js
      import 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. 実行して2つのブラウザで同期的にTodoが追加されることを確認

  19. 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
    

  20. Hosting の追加内容の確認
    $ amplify status
    
    Current Environment: dev
    
    | Category | Resource name   | Operation | Provider plugin   |
    | -------- | --------------- | --------- | ----------------- |
    | Hosting  | S3AndCloudFront | Create    | awscloudformation |
    | Api      | mytodoapi       | No Change | awscloudformation |
    

  21. バックエンドに 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
    

  22. バックエンドの Hosting を公開
    $ amplify publish
    

  23. PublishされたURLを2つのブラウザで開いて同期的にTodoが追加されることを確認

  24. 作成した amplify のリソースを削除
    $ amplify delete
    

    amplify delete した後、再度 amplify init で作成し直す場合は、aws-exports.js ファイルを削除しておく。init や add で更新されない。

    amplify init したアプリを deleteせずに再度initする場合、CloudFormation上から削除できなくなるので、注意。

  25. 参考

    1. 【お手軽ハンズオンで AWS を学ぶ】AWS Amplify で Todo アプリを作ろう! AWS AppSync & Amazon DynamoDB によるリアルタイムメッセージング

    2. AWS Amplify Getting Start

    3. create-react-appで作ったアプリがhttpsだと動かない

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

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 ToolkitRedux を使用するすべての方にオススメです!

Redux 公式が推奨するアプローチをまとめた Redux Style Guide でも使用することを勧めています。

今まで Redux を使用する場合は、煩わしいことも多かったのですが、Redux Toolkit を使用すれば、その煩わしさからも解放されると思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

待ち時間に雪を降らせたい(React)

はじめに

こんにちは @hey3 です。
この記事はACCESS Advent Calendar 2019 23日目の記事です。
Qiita に記事を投稿するのが初めてなので緊張しています。。

今回の記事はクリスマス間近と言う事で、ただただやってみたかった事をやっているだけです。(実用性皆無です)(ネタです)
※ネタで1枠使ってしまって申し訳無い気持ちはあります。

概要

クリスマス近いし、非同期の更新処理中にコンポーネントに雪を降らせたい
(本来待ち時間は無い方が良いんですけどね。。)

構成

サクッと非同期処理を用意するために React v16.6 以降に追加された lazySuspense を用いました。
lazySuspense に関しては深く触れないため、知りたい場合は 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に固定します。
Suspensefallback には 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 />
)

出来たのがこちら
snow_background.png

寒さが伝わってきて如何にも雪が降ってそうな見た目ですね( 'ω')

本題の雪を作っていく

雪は 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 を使うとやりやすいですね。

ちなみに、今回使用した雪結晶はこちら &#x2744「❄」 です。

実際に雪を生成してみます。
先ほどの 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個の雪結晶を降らせます。
実行したものがこちらです。(容量圧縮したので荒いです)
snow_animation-compressor.gif

おー、雪が振りましたね。( 'ω')
満足です。
ただ、これだと非同期処理中だと言う事がわかりずらいですね。
雪を積もらせていきます。

雪を積もらせていく

こんなもんで良いでしょう

// 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 />
)

snow_piling_up-compressor.gif

才能が無く向いてなさそうな感じがしますね。。

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.gif

こんなのを真ん中に配置しています。
loading on loading みたいでダサさが際立ちました。( 'ω')

成果物

画質も出来も荒いですが、このようなものを作成しました。(もはや雪があまり見えないですね( 'ω'))

snow_loading.gif

まとめ

クリスマス間近と言う事で、 loading 中に雪を降らせてみました。
loading すると重くなります。
本記事であまり触れていないですが、 React Suspense に関しても、「なるほど」と言う感想を得る事ができました。
スキルに関しても、これはいかんなと思えたのでもっと勉強しないといけないですね。

そもそも、雪が積もるほどのパフォーマンスを出してはいけない。( 'ω')

明日は @KensukeTakahara さんの投稿です!
React, TypeScript の投稿らしいです。楽しみですね!(直前に変な投稿ですみません。。)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

失恋駆動開発: ReactでサクッとTinder風モックを作る

こちらの記事はNIJIBOX Advent Calendar 2019の23日目の記事です。(遅刻してごめんなさい・・・)

前置き

こんにちは、ニジボックスのフロントエンドチームのこじこじです。
あっという間にクリスマスですね。街はキラキラ、大人も子どもも恋人たちも浮かれてしまう楽しい季節です。

突然ですが、失恋しました

そんな楽しい季節がやってきたにも関わらず、少し前に失恋しました。
クリスマスまでもう時間がない・・・!
ということで、クリスマスに自尊心を高めて過ごせるTinder風モックをサクッと作ってみることにしました。
落ち込んでいる暇はないのです。いのち短し、作れよ乙女。

作りたいもの

  • なんか私めっちゃいいねきてる・・・モテ期なのでは・・・?!と思うためだけのTinder風webアプリ
  • クリスマスに暇を潰せるレベルでいいので今回はモックアップの作成

書くこと

  • Reactで簡単にTinder風UIを実装する方法
  • サクッとモックを作りたい時に便利なツールの紹介

仕様技術・ツールなど

実装

1. サクッと開発環境構築: CodeSandbox

今回のテーマは「サクッと」モックを作成することなので、開発環境はオンラインIDEのCodeSandboxを利用しました。
無料かつ会員登録も不要で、2クリックで開発環境が整ってしまいます。

1-1. CodeSandboxにアクセスし、「Create a Sandbox, it's free」をクリック

codesandbox.png
会員登録はgithub連携でサクッと行うこともできますが、CodeSandbox上でコードを書くだけならば会員登録をしなくてもOKです。

1-2. テンプレートを選択

codesandbox2.png
実装に用いるテンプレートを選択します。今回はReactを選択。

codesandbox2_1.png
これで環境構築は完了。あとはコードを書くのみです。

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」ボタンをクリックして
codesandbox3.png
インストールしたいパッケージを検索・選択するだけでOK。お手軽すぎる〜。
codesandbox5.png

2-2. カードのコンポーネントを作成

React-swipe-cardではCardsとCardの2つのコンポーネントが用意されています。
それぞれpropsで以下の設定を渡すことができます。

Cardsコンポーネント

  • onEnd: 全てのカードをスワイプした時に実行する関数
  • alertLeft: 左にスワイプした際に表示するコンポーネント
  • alertRight: 右にスワイプした際に表示するコンポーネント
  • alertTop: 上にスワイプした際に表示するコンポーネント
  • alertBottom: 下にスワイプした際に表示するコンポーネント

Cardコンポーネント

  • onSwipeLeft: 左にスワイプした時に実行する関数
  • onSwipeRight: 右にスワイプした時に実行する関数
  • onSwipeTop: 上にスワイプした時に実行する関数
  • onSwipeBottom: 下にスワイプした時に実行する関数

今回はCardBoardという名前でカードのコンポーネントを作成することにします。
カードを右にスワイプしたら「マッチしました!」のアラートが表示され、
全てスワイプしきると「いいねはありません」のアラートが表示されます。

CardBoard.js
import 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;

以下のようなモックが出来上がりました。
demo.mov.gif

左にスワイプしていますが反町●史も好きです。

デモはこちら

3. サクッとダミーユーザー生成: Random User Generator

それっぽさを出すために、ユーザーの画像と名前を表示しましょう。

大量のダミーのユーザー情報が欲しい・・・
そんな時に使いたいのがRandom User Generator

なんと無料でランダムなユーザー情報を返してくれるAPIです。
しかもその情報量は名前、サムネイル画像(サイズ違い3種)、住所からログインID・パスワードまで豊富・・・!

今回はパラメーターとしてgender=maleresults=114を渡して、男性の情報のみ114件を返すようにリクエストしました。
表示件数が114件なのはキリのいい数字だとなんとなくそれっぽさが出ないという理由だけです。
モテ期を感じることを最優先にしているのでパフォーマンスは考慮していません。
これを先ほど作ったCardBoardコンポーネントに渡してあげればOK。

App.js
import 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.js
import 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;

あとはスタイルを整えて、こんな感じになりました。
sample2.gif
国際色豊かにモテ期が来たようです。

コードはこちら

まとめ

  • サクッとモックアップやプロトタイプを作成したい場合はCodeSandboxが便利
  • サクッとダミーユーザー情報が欲しい場合はRandom User Generatorがおすすめ
  • サクッとTinder風UIをReactで実装したいならReact-swipe-cardが簡単だが、React v16以上で使えないため要検討(次の機会に調査します・・・)
  • サクッと自尊心が高まるかどうかは自分次第
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[React] 本の内容通りに環境構築してたらめちゃくちゃつまづいた

※ご覧いただいてる方々に諸注意です。投稿主はプログラミング・環境構築含め独学のため、誤解している点や間違っている点が多くある場合があります。もし、私が間違った知識・情報をドヤ顔で記載していた場合、優しくご指摘いただくようよろしくお願いします。
※この投稿は、本及び著者に対する文句などのネガティブなものではありません。
※投稿主の環境はwindows10、各バージョンは2019/12/23時点で最新のやつになってます。

発売から2年たってるのに全然コード通らん・・・

Reactの勉強を始めようと思って手に取ったこちらの本。↓
作りながら学ぶReact入門
2年前くらいの本なんですが、評判が良いので購入。
しかし、環境構築時点でエラー発生。本の通りに進めているんですが解決できず・・・
環境の変化はやすぎ問題。というのはおいといて、本題に入りましょう。
あ、ちなみにこの投稿に乗せるURLを取ってくるときにamazonのレビュー見て気が付いたんですが、
229bbf2661cd93e6aa08a44ab13ec775.png
同じところでつまづいてる人、やっぱりいましたね!!(しかも1年前やーん)
これはひょっとして需要ある記事になるかな?(伸びろ...!!伸びろッ!!)

npm start実行時のエラー内容

a9a8a282286dde137f10836d5b55a7d9.png
こんな感じでした。
自分は、このエラー解決するために要した時間は実に"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!
8fa84c367881a392a7f36ae46afa5efe.png

とりあえず環境構築終わらせたい、という方はこんな感じでやってみてくださいな!!
現場からは以上です~~

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactでgprc-webを使った簡易チャットを作成してみた

作ったもの

Reactとgrpc-webでこのような簡易チャットを作成してみました。

HcFeXRcpqs.gif

作成してみたコード↓


以前やってみた、こちらの投稿で開発環境を作成しています

gRPC-Web Hello World Guideをやってみた


全体

simplechat.proto
syntax = "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.js
const 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.jsx
import 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.proto
service 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.jsx
const 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.proto
service 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.ts

server.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.jsx
const 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.proto
service 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

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む