20210509のReactに関する記事は13件です。

React × TypeScript × AmplifyでちょっとしたSNS風アプリを作ってみる

概要 前々からAWS Amplifyが気になっており、GWにまとまった時間が取れたので実践してみたところ、驚くほど簡単かつ素早く開発できたのでメモ書き。 今回はちょっとしたSNS風っぽいアプリを作ってみました。 完成イメージ 何をもってSNSアプリとするかは意見が分かれるかもしれませんが、ここではとりあえず 認証機能(サインアップ、サインイン、サインアウト) 投稿機能(Post、Comment) 画像アップロード機能 といったどこにでもあるような簡易的な機能を実装していきます。 これらがあればユーザー間で最低限のコミニュケーションは取れるであろうという想定のもとです。 使用技術 React TypeScript AWS Amplify なお、Amplifyの設定などについては「Amplify CLI」というツールを使います。インストール方法や初期設定についてはググれば他にたくさん記事が出てくるのでそちらを参照していただければと思います。 参照記事: Amplify CLI をインストールしてユーザーを設定する手順 ※本記事ではすでにAmplify CLIのインストール初期設定が済んでいる前提で話を進めます。 実装 Amplify CLIの準備ができたらアプリケーション側の実装に入ります。 Reactアプリを作成 何はともあれまずは「create-react-app」コマンドでReactアプリを作成。 $ npx create-react-app react-amplify-sns --template typescript $ cd react-amplify-sns 不要なファイルを削除 この辺についてはお好みですが、デフォルトで生成されたファイルの中にはこの先使う事の無いものがいくつか含まれているので削除しておきます。 $ rm src/App.css src/App.test.tsx src/logo.svg src/reportWebVitals.ts src/setupTests.ts ↑に伴い、以下の2ファイルを変更してください。 ./src/index.tsx import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) ./src/App.tsx import React from "react" const App: React.FC = () => { return ( <h1>Hello World!</h1> ) } export default App ここで一旦、動作確認してみましょう。 $ yarn start localhost:3000 にアクセスして「Hello World!」と返ってくればOKです。 Amplifyを導入 ここからAmplify CLIの出番です。 $ amplify init 「amplify init」コマンドを実行すると初期設定のために対話形式で色々な質問をされるので回答していきます。 ? Enter a name for the project reactamplifysns // プロジェクト名を入力(任意) ? Enter a name for the environment dev // 環境名を入力(今回は「dev」) ? Choose your default editor: (Use arrow keys) ❯ Visual Studio Code Android Studio Xcode (Mac OS only) Atom Editor Sublime Text IntelliJ IDEA Vim (via Terminal, Mac OS only) // エディタを指定(任意) ? Choose the type of app that you're building (Use arrow keys) android flutter ios ❯ javascript // どんなタイプのアプリケーションか選択(今回は「javascript」) ? What javascript framework are you using (Use arrow keys) angular ember ionic ❯ react react-native vue none // どのフレームワークを使うか(今回は「react」) ? Source Directory Path: src ? Distribution Directory Path: build ? Build Command: npm run-script build ? Start Command: npm run-script start // この辺は全てデフォルトのまま入力 ? Select the authentication method you want to use: (Use arrow keys) ❯ AWS profile AWS access keys // プロファイル(「.aws/config」「.aws/credentials」に記載されているユーザー情報を使うか、アクセスキー・シークレットアクセスキーを直接打ち込んで使うか、どちらかを選択。(今回はプロファイル) 参照記事: AWS プロファイルの設定方法 すると初期化が始まるので、終わるまで待ちましょう。(数分かかる場合もあり) Adding backend environment dev to AWS Amplify Console app: ********* ⠙ Initializing project in the cloud... ...省略... ✔ Successfully created initial AWS cloud resources for deployments. ✔ Initialized provider successfully. Initialized your environment successfully. Your project has been successfully initialized and connected to the cloud! Some next steps: "amplify status" will show you what you've added already and if it's locally configured or deployed "amplify add <category>" will allow you to add features like user login or a backend API "amplify push" will build all your local backend resources and provision it in the cloud "amplify console" to open the Amplify Console and view your project status "amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud Pro tip: Try "amplify add api" to create a backend API and then "amplify publish" to deploy everything こんな感じで「Successfully」と返ってくれば成功です。 react-amplify-sns ├── amplify │   ├── backend │   │   ├── amplify-meta.json │   │   ├── backend-config.json │   │   └── tags.json │   ├── cli.json │   ├── README.md │   └── team-provider-info.json ├── package.json ├── public │   ├── favicon.ico │   ├── index.html │   ├── logo192.png │   ├── logo512.png │   ├── manifest.json │   └── robots.txt ├── src │   ├── App.tsx │   ├── aws-exports.js │   ├── index.css │   ├── index.tsx │   └── react-app-env.d.ts ├── .gitignore ├── package.json ├── README.md ├── tsconfig.json └── yarn.lock ルートディレクトリ直下に「amplify」、srcディレクトリ内に「aws-exports.js」がそれぞれ自動生成されている事を確認してください。 ./src/aws-exports.js /* eslint-disable */ // WARNING: DO NOT EDIT. This file is automatically generated by AWS Amplify. It will be overwritten. const awsmobile = { "aws_project_region": "ap-northeast-1" } export default awsmobile なお、「aws-exports.js」にはAWSの各リソースを使用するための重要な情報(ユーザープールID、GraphQLエンドポイント、S3バケット名など)が随時書き込まれていくため、取り扱いには注意しましょう。 うっかりGitHubなどにアップしてしまうと面倒な事になります。 認証機能を作成 Amplifyの導入が終わったので、まずは認証機能(サインアップ・サインイン・サインアウト)を作成していきましょう。 $ amplify add auth 参照記事: Authentication with Amplify 初期化の際と同様、「amplify add auth」を実行すると設定のための質問をいくつかされるのでそれぞれ回答していきます。 Using service: Cognito, provided by: awscloudformation The current configured provider is Amazon Cognito. Do you want to use the default authentication and security configuration? ❯ Default configuration Default configuration with Social Provider (Federation) Manual configuration I want to learn more. // とりあえず「Default configuration」でOK Warning: you will not be able to edit these selections. How do you want users to be able to sign in? (Use arrow keys) ❯ Username Email Phone Number Email or Phone Number I want to learn more. // サインイン(時に使用する値を選択(今回は「Username」) Do you want to configure advanced settings? (Use arrow keys) ❯ No, I am done. Yes, I want to make some additional changes. // より詳細な設定を行うかどうか(今回はもうこれで十分なので「No」) ここまで入力すると、認証用の各AWSリソースを作成するためのファイルが自動で生成され始めます。 Successfully added auth resource reactamplifysns******** locally Some next steps: "amplify push" will build all your local backend resources and provision it in the cloud "amplify publish" will build all your local backend and frontend resources (if you have hosting category added) and provision it in the cloud 準備が完了したら、「amplify push」コマンドで適用。 ✔ Successfully pulled backend environment dev from the cloud. Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ----------------------- | --------- | ----------------- | | Auth | reactamplifysns******** | Create | awscloudformation | ? Are you sure you want to continue? (Y/n) ...省略... ⠼ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔ All resources are updated in the cloud 大体2〜3分でリソースの作成が終わると思います。 AWSコンソール画面から「Cognito」へと進み、ユーザープールが作成されていればOK。 $ yarn add aws-amplify @aws-amplify/ui-react ここからはReactアプリ側の実装に入ります。上記コマンドを実行してAmplify用のライブラリをインストールした後、「./src/App.tsx」を次のように編集します。 ./src/App.tsx import React from "react" import Amplify from "aws-amplify" import { AmplifyAuthenticator, AmplifySignUp, AmplifySignOut } from "@aws-amplify/ui-react" import awsconfig from "./aws-exports" Amplify.configure(awsconfig) const App: React.FC = () => { return ( <AmplifyAuthenticator> <AmplifySignUp slot="sign-up" formFields={[ { type: "username" }, { type: "email" }, { type: "password" } ]} > </AmplifySignUp> <h1>You have successfully signed in</h1> <AmplifySignOut /> </AmplifyAuthenticator> ) } export default App この状態で localhost:3000 にアクセスすると、良い感じのサインアップ・サインイン画面が表示されるはず。 サインアップ サインイン 試しにユーザー作成してみましょう。 デフォルトの設定だとサインアップ時に認証コードが発行されるようになっているので、メールアドレス宛に届いたものを入力します。 認証に成功するとこんな感じの画面に移行されるでしょう。(逆にサインインするまでこのページへ飛ぶ事はできなくなりました。) 念のため、サインアウトできるかどうかも確認しておいてください。 Cognitoのユーザープールからもユーザーが作成された事を確認できます。 これでひとまず認証機能の作成は完了です。 Material-UIを導入 一区切りついたので、この辺でUIを整える用のライブラリとして「Material-UI」を導入しておきましょう。 $ yarn add @material-ui/core@next @material-ui/icons@next @material-ui/lab@next @material-ui/styled-engine @emotion/react @emotion/styled react-router-dom @types/react-router-dom ついこの間、最新バージョンであるv5が発表されたので今回はそちらを試してみます。(ライブラリ名の後に「@next」を付けると最新版がインストールできるみたいです。) $ mkdir src/components $ mkdir src/components/layouts $ touch src/components/layouts/Header.tsx $ touch src/components/layouts/Wrapper.tsx ./src/components/layouts/Header.tsx import React, { useContext } from "react" import { Link } from "react-router-dom" import { makeStyles, Theme } from "@material-ui/core/styles" import AppBar from "@material-ui/core/AppBar" import Toolbar from "@material-ui/core/Toolbar" import Typography from "@material-ui/core/Typography" import Button from "@material-ui/core/Button" import IconButton from "@material-ui/core/IconButton" import MenuIcon from "@material-ui/icons/Menu" import { Auth } from "aws-amplify" import { UserContext } from "../../App" const useStyles = makeStyles((theme: Theme) => ({ iconButton: { marginRight: theme.spacing(2), }, title: { flexGrow: 1, textDecoration: "none", color: "inherit" } })) const Header: React.FC = () => { const { setCurrentUser } = useContext(UserContext) const classes = useStyles() // サインアウトボタンを設置 const signout = () => { Auth.signOut().catch((err: any) => console.log(err)) setCurrentUser(undefined) } return ( <> <AppBar position="static"> <Toolbar> <IconButton edge="start" className={classes.iconButton} color="inherit" > <MenuIcon /> </IconButton> <Typography component={Link} to="/" variant="h6" className={classes.title} > Sample </Typography> <Button onClick={signout} color="inherit" > Sign Out </Button> </Toolbar> </AppBar> </> ) } export default Header ./src/components/layouts/Wrapper.tsx import React from "react" import { Container, Grid } from "@material-ui/core" import { makeStyles } from "@material-ui/core/styles" import Header from "./Header" const useStyles = makeStyles(() => ({ container: { margin: "3rem 0 4rem" } })) type WrapperProps = { children: React.ReactElement } const Wrapper: React.FC<WrapperProps> = ({ children }) => { const classes = useStyles() return ( <> <header> <Header /> </header> <main> <Container maxWidth="lg" className={classes.container}> <Grid container direction="row" justifyContent="center"> <Grid item> {children} </Grid> </Grid> </Container> </main> </> ) } export default Wrapper ./src/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router } from "react-router-dom" import Amplify, { Auth } from "aws-amplify" import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react" import {AuthState, onAuthUIStateChange} from "@aws-amplify/ui-components" import awsconfig from "./aws-exports" import Wrapper from "./components/layouts/Wrapper" // Cognitoで作成したユーザー情報 type User = { id: string, username: string, attributes: { email: string sub: string // いわゆるUID的なもの(一意の識別子) } } // 認証済みユーザーの情報はグローバルで取り扱いたいのでContextを使用 export const UserContext = createContext({} as { userInfo: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<object | undefined>> }) Amplify.configure(awsconfig) const App: React.FC = () => { const [authState, setAuthState] = useState<AuthState>() const [currentUser, setCurrentUser] = useState<object | undefined>() const [userInfo, setUserInfo] = useState<User>() const getUserInfo = async () => { const currentUserInfo = await Auth.currentUserInfo() setUserInfo(currentUserInfo) } useEffect(() => { return onAuthUIStateChange((nextAuthState, authData) => { setAuthState(nextAuthState) setCurrentUser(authData) }) }, []) useEffect(() => { getUserInfo() }, []) // 未認証のユーザーはサインインページへ飛ばされるようにする return authState === AuthState.SignedIn && currentUser ? ( <Router> <UserContext.Provider value={{ userInfo, setCurrentUser }}> <Wrapper> <h1>You have successfully signed in</h1> </Wrapper> </UserContext.Provider> </Router> ) : ( <AmplifyAuthenticator> <AmplifySignUp slot="sign-up" formFields={[ { type: "username" }, { type: "email" }, { type: "password" } ]} > </AmplifySignUp> </AmplifyAuthenticator> ) } export default App 先ほどよりもだいぶ良い感じの見た目になりましたね。 各種API(Post、Comment)とデータベースを作成 ここからはバックエンド部分の作成に入ります。今回は投稿(Post)およびコメント(Comment)の取得・作成・削除ができるようにするところを目標としましょう。 $ amplify add api 参照記事: Create the GraphQL API 例の如く対話形式での質問が始まるのでそれぞれ回答していきます。 ? Please select from one of the below mentioned services: (Use arrow keys) ❯ GraphQL REST // GraphQLとREST、どちらの形式でAPIを作成するか(今回は「GraphQL」) ? Provide API name: reactamplifysns // API名(任意) ? Choose the default authorization type for the API API key ❯ Amazon Cognito User Pool IAM OpenID Connect // APIを使用するユーザーの認証方法(今回は「Amazon Cognito User Pool」) ? Do you want to configure advanced settings for the GraphQL API (Use arrow keys ) ❯ No, I am done. Yes, I want to make some additional changes. // デフォルトの設定で十分なので「No」 ? Do you have an annotated GraphQL schema? No // 「No」 ? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description) ❯ One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”) Objects with fine-grained access control (e.g., a project management app with owner-based authorization) // PostとCommentの関係は1対多なので「One-to-many relationship」を選択 The following types do not have '@auth' enabled. Consider using @auth with @model - Blog - Post - Comment Learn more about @auth here: https://docs.amplify.aws/cli/graphql-transformer/auth GraphQL schema compiled successfully. Edit your schema at /Users/kazama_t/Workspace2/react-amplify-sns/amplify/backend/api/reactamplifysns/schema.graphql or place .graphql files in a directory at /Users/kazama_t/Workspace2/react-amplify-sns/amplify/backend/api/reactamplifysns/schema ? Do you want to edit the schema now? (y/N) N // 後で編集するので「N」 すると「./amplify/backend/api//schema.graphql」というファイルが自動生成されるので、次のように書き換えてください。 ./amplify/backend/api//schema.graphql type Post @model @key( name: "SortByCreatedAt" fields: ["status", "createdAt"] queryField: "listPostsSortedByCreatedAt" ) { id: ID! content: String! owner: String! image: String status: PostStatus! comments: [Comment] @connection(keyName: "byPost", fields: ["id"]) createdAt: AWSDateTime } enum PostStatus { published unpublished } type Comment @model @key( name: "byPost", fields: ["postId", "content"] ) { id: ID! postId: ID! content: String! owner: String! post: Post @connection(fields: ["postId"]) } 「@connection」を付けるといわゆるリレーション的なものが張れるみたいです。 参照記事: https://docs.amplify.aws/cli/graphql-transformer/connection また、どうやらAmplifyで自動されたクエリ(listPosts)だとデータを作成日時の順で取得できないみたいあので、「listPostsSortedByCreatedAt」というクエリを手動で追加しました。 参照記事: Amplify&GraphQLでデータを取得するときにソートするために $ amplify push 「amplify push」を実行します。 ✔ Successfully pulled backend environment dev from the cloud. Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ----------------------- | --------- | ----------------- | | Api | reactamplifysns | Create | awscloudformation | | Auth | reactamplifysns******** | No Change | awscloudformation | ? Are you sure you want to continue? Yes // 特に問題無ければ「Yes」 ? Do you want to generate code for your newly created GraphQL API Yes ? Choose the code generation language target javascript ❯ typescript flow // 今回はTypeScriptを使用しているので「typescript」 ? Enter the file name pattern of graphql queries, mutations and subscriptions sr c/graphql/**/*.ts // デフォルトのまま回答でOK Do you want to generate/update all possible GraphQL operations - queries, muta tions and subscriptions Yes // GraphQlを実行するためのコードを自動生成しても良いかどうか(「Yes」でOK) ? Enter maximum statement depth [increase from default if your schema is deeply nested] 2 // ネストの深さ(今回は「2」) ? Enter the file name for the generated code src/types/index.ts // 自動生成される型定義ファイルの名前(今回は「src/types/index.ts」) ここまで入力するとAWSリソース(AppSync、DynamoDBなど)の作成が始まるので2〜3分ほど待ちましょう。 ⠧ Updating resources in the cloud. This may take a few minutes... ...省略... ✔ Generated GraphQL operations successfully and saved at src/graphql ✔ Code generated successfully and saved in file src/types/index.ts ⠴ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔ All resources are updated in the cloud GraphQL endpoint: https://************.appsync-api.ap-northeast-1.amazonaws.com/graphql こんな感じのメッセージが表示されれば成功です。 ├── src │   ├── App.tsx │   ├── aws-exports.js │   ├── components │   │   └── layouts │   │   ├── Header.tsx │   │   └── Wrapper.tsx │   ├── graphql │   │   ├── mutations.ts │   │   ├── queries.ts │   │   ├── schema.json │   │   └── subscriptions.ts │   ├── index.css │   ├── index.tsx │   ├── react-app-env.d.ts │   └── types |    └── index.ts 新たに「graphql」「types」というディレクトリが作られているのも確認してください。 mutations.ts 作成・更新・削除系のクエリ queries.ts 取得系のクエリ subscriptions.ts 購読系(データ情報変更の受け取り)のクエリ 基本的には自動生成されたものを使用すれば事足りますが、今回実装する内容においては一部書き足し・修正が必要なのでそれぞれ変更してください。 ./src/graphql/queries.ts // listPostsSortedByCreatedAtを以下のように修正 export const listPostsSortedByCreatedAt = /* GraphQL */ ` query ListPostsSortedByCreatedAt( $status: PostStatus $createdAt: ModelStringKeyConditionInput $sortDirection: ModelSortDirection $filter: ModelPostFilterInput $limit: Int $nextToken: String ) { listPostsSortedByCreatedAt( status: $status createdAt: $createdAt sortDirection: $sortDirection filter: $filter limit: $limit nextToken: $nextToken ) { items { id content owner image status comments { items { id postId content owner } nextToken } likes { items { id postId owner } nextToken } createdAt updatedAt } nextToken } } `; ./src/types/index.ts // Postの型定義を以下のように変更 export type Post = { __typename: "Post", id?: string, content?: string, owner?: string, image?: string | null, status?: PostStatus, comments?: ModelCommentConnection | null, // nullを許容 createdAt?: string | null, updatedAt?: string } // Commentの型定義を以下のように変更 export type Comment = { __typename: "Comment", id?: string, postId?: string, content?: string, owner?: string, post?: Post | null, // nullを許容 createdAt?: string, updatedAt?: string, } // CreatePostInputの型定義を以下のように変更 export type CreatePostInput = { id?: string | null, content: string, owner: string | undefined, // undefinedを許容 image?: string | null, status: PostStatus, createdAt?: string | null, } // CreateCommentInputの型定義を以下のように変更 export type CreateCommentInput = { id?: string | null, postId: string | undefined, // undefinedを許容 content: string, owner: string | undefined, // undefinedを許容 } // ...省略... // 以下の型定義を追記 export type User = { id: string, username: string, attributes: { email: string sub: string } } export type OnCreatePostSubscriptionData = { value: { data: OnCreatePostSubscription } } export type OnDeletePostSubscriptionData = { value: { data: OnDeletePostSubscription } } export type OnCreateCommentSubscriptionData = { value: { data: OnCreateCommentSubscription } } export type OnDeleteCommentSubscriptionData = { value: { data: OnDeleteCommentSubscription } } 一通りの準備ができたので、投稿(Post)とコメント(Comment)ができるように実装していきます。 $ mkdir src/components/post $ touch src/components/post/PostList.tsx $ touch src/components/post/PostItem.tsx $ touch src/components/post/PostForm.tsx $ touch src/components/post/CommentItem.tsx $ touch src/components/post/CommentForm.tsx ./src/components/post/PostList.tsx import React, { useEffect, useState } from "react" import API, { graphqlOperation } from "@aws-amplify/api" import { makeStyles, Theme } from "@material-ui/core/styles" import Box from "@material-ui/core/Box" import Button from "@material-ui/core/Button" import RotateRightIcon from "@material-ui/icons/RotateRight" import AutorenewIcon from "@material-ui/icons/Autorenew" import PostForm from "./PostForm" import PostItem from "./PostItem" import { listPostsSortedByCreatedAt } from "../../graphql/queries" import { onCreatePost, onDeletePost } from "../../graphql/subscriptions" import { Post, ListPostsSortedByCreatedAtQuery, OnCreatePostSubscriptionData, OnDeletePostSubscriptionData } from "../../types/index" const useStyles = makeStyles((theme: Theme) => ({ box: { marginTop: "2rem", width: 320 } })) const PostList: React.FC = () => { const classes = useStyles() const [loading, setLoading] = useState<boolean>(false) const [posts, setPosts] = useState<Post[]>([]) const [nextToken, setNextToken] = useState<string | null | undefined>(null) const getPosts = async () => { const result = await API.graphql({ query: listPostsSortedByCreatedAt, variables: { status: "published", sortDirection: "DESC", limit: 5, // 一度のリクエストで取得可能な件数(この辺はお好みで) nextToken: nextToken } }) if ("data" in result && result.data) { const data = result.data as ListPostsSortedByCreatedAtQuery if (data.listPostsSortedByCreatedAt) { setPosts(data.listPostsSortedByCreatedAt.items as Post[]) setNextToken(data.listPostsSortedByCreatedAt.nextToken) } } } // 追加で投稿(Post)を取得するための関数(ページネーション) const loadMore = async () => { setLoading(true) const result = await API.graphql({ query: listPostsSortedByCreatedAt, variables: { status: "published", sortDirection: "DESC", limit: 5, nextToken: nextToken } }) if ("data" in result && result.data) { const data = result.data as ListPostsSortedByCreatedAtQuery if (data.listPostsSortedByCreatedAt) { const items = data.listPostsSortedByCreatedAt.items as Post[] setPosts((prev) => [...prev, ...items]) setNextToken(data.listPostsSortedByCreatedAt.nextToken) } } setLoading(false) } // subscribe = データ変更情報をリアルタイムで取得・反映 const subscribeCreatedPost = () => { const client = API.graphql(graphqlOperation(onCreatePost)) if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: OnCreatePostSubscriptionData) => { if (data.onCreatePost) { const createdPost: Post = data.onCreatePost setPosts((prev) => [createdPost, ...prev]) } } }) } } const subscribeDeletedPost = () => { const client = API.graphql(graphqlOperation(onDeletePost)) if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: OnDeletePostSubscriptionData) => { if (data.onDeletePost) { const deletedPost: Post = data.onDeletePost setPosts((prev) => prev.filter(post => post.id !== deletedPost.id)) } } }) } } useEffect(() => { getPosts() subscribeCreatedPost() subscribeDeletedPost() }, []) return ( <> <PostForm /> { posts?.map((post: Post) => { return ( <PostItem key={post.id} post={post} /> )} )} { nextToken !== null ? <Box className={classes.box} textAlign="center" > <Button variant="outlined" color="primary" size="small" startIcon={loading ? <RotateRightIcon /> : <AutorenewIcon />} onClick={loadMore} > { loading ? "Now loading..." : "Load More..." } </Button> </Box> : null } </> ) } export default PostList nextToken: ページネーションのために利用する値 参照記事: GraphQL pagination この値がnullでない限りは次のページがあるという判断になるみたいです。今回はそれを利用してページネーション的な機能を実装しました。 ./src/components/post/PostItem.tsx import React, { useState, useEffect, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import clsx from "clsx" import Card from "@material-ui/core/Card" import CardHeader from "@material-ui/core/CardHeader" import CardMedia from "@material-ui/core/CardMedia" import CardContent from "@material-ui/core/CardContent" import CardActions from "@material-ui/core/CardActions" import Avatar from "@material-ui/core/Avatar" import IconButton from "@material-ui/core/IconButton" import Typography from "@material-ui/core/Typography" import FavoriteBorderIcon from "@material-ui/icons/FavoriteBorder" import FavoriteIcon from "@material-ui/icons/Favorite" import ShareIcon from "@material-ui/icons/Share" import DeleteOutlineOutlinedIcon from "@material-ui/icons/DeleteOutlineOutlined" import ExpandMoreIcon from "@material-ui/icons/ExpandMore" import Collapse from "@material-ui/core/Collapse" import API, { graphqlOperation } from "@aws-amplify/api" import { deletePost } from "../../graphql/mutations" import { UserContext } from "../../App" import { Post, Comment, OnCreateCommentSubscriptionData, OnDeleteCommentSubscriptionData } from "../../types/index" import { onCreateComment, onDeleteComment } from "../../graphql/subscriptions" import CommentForm from "./CommentForm" import CommentItem from "./CommentItem" const useStyles = makeStyles((theme: Theme) => ({ card: { width: 320, marginTop: "2rem", transition: "all 0.3s", "&:hover": { boxShadow: "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)", transform: "translateY(-3px)" } }, deleteBtn: { marginLeft: "auto" }, expandBtn: { marginLeft: "auto" }, expand: { transform: "rotate(0deg)", marginLeft: "auto", transition: theme.transitions.create("transform", { duration: theme.transitions.duration.shortest }) }, expandOpen: { transform: "rotate(180deg)" } })) type PostItemProps = { post: Post } const PostItem: React.FC<PostItemProps> = ({ post }) => { const { userInfo } = useContext(UserContext) const classes = useStyles() const [likes, setLikes] = useState<boolean>(false) const [comments, setComments] = useState<(Comment | null)[] | null | undefined>([]) const [expanded, setExpanded] = useState(false) const handleDeletePost = async (id: string | undefined) => { if (!id) return try { await API.graphql(graphqlOperation(deletePost, { input: { id: id } })) } catch (err: any) { console.log(err) } } const getComments = () => { setComments(post.comments?.items) } const subscribeCreatedComment = () => { const client = API.graphql(graphqlOperation(onCreateComment)) if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: OnCreateCommentSubscriptionData) => { if (data.onCreateComment) { const createdComment: Comment = data.onCreateComment setComments((prev) => prev?.length? [createdComment, ...prev] : [createdComment, ...[]]) } } }) } } const subscribeDeletedComment = () => { const client = API.graphql(graphqlOperation(onDeleteComment)) if ("subscribe" in client) { client.subscribe({ next: ({ value: { data } }: OnDeleteCommentSubscriptionData) => { if (data.onDeleteComment) { const deletedComment: Comment = data.onDeleteComment setComments((prev) => prev?.filter((comment) => comment?.id !== deletedComment.id)) } } }) } } useEffect(() => { getComments() subscribeCreatedComment() subscribeDeletedComment() }, []) return ( <> <Card className={classes.card}> <CardHeader avatar={ <Avatar> U </Avatar> } // 投稿主と認証済みユーザーが一致する場合に削除ボタンを表示 action={post.owner === userInfo?.attributes.sub ? <div className={classes.deleteBtn}> <IconButton onClick={() => handleDeletePost(post.id)} > <DeleteOutlineOutlinedIcon /> </IconButton> </div> : null } title={userInfo?.username} /> { post.image ? <CardMedia component="img" src={post.image} alt="post-img" /> : null } <CardContent> <Typography variant="body2" color="textSecondary" component="span"> { post.content?.split("\n").map((content: string, index: number) => { return ( <p key={index}>{content}</p> ) }) } </Typography> </CardContent> <CardActions disableSpacing> { likes ? <IconButton onClick={() => setLikes(false)}> <FavoriteIcon /> </IconButton> : <IconButton onClick={(e) => setLikes(true)}> <FavoriteBorderIcon /> </IconButton> } <IconButton> <ShareIcon /> </IconButton> <div className={classes.expandBtn}> <IconButton className={clsx(classes.expand, { [classes.expandOpen]: expanded, })} onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label="show more" > <ExpandMoreIcon /> </IconButton> </div> </CardActions> <Collapse in={expanded} timeout="auto" unmountOnExit> <CardContent> <CommentForm postId={post?.id} /> { comments?.map((comment) => { return ( <CommentItem key={comment?.id} comment={comment} /> )} )} </CardContent> </Collapse> </Card> </> ) } export default PostItem ./src/components/post/PostForm.tsx import React, { useState, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Button from "@material-ui/core/Button" import CreateIcon from "@material-ui/icons/Create" import API, { graphqlOperation } from "@aws-amplify/api" import { createPost } from "../../graphql/mutations" import { CreatePostInput, PostStatus } from "../../types/index" import { UserContext } from "../../App" const useStyles = makeStyles((theme: Theme) => ({ form: { display: "flex", flexWrap: "wrap", width: 320 }, inputFileBtn: { marginTop: "10px" }, submitBtn: { marginTop: "10px", marginLeft: "auto" }, box: { margin: "2rem 0 4rem", width: 320 }, preview: { width: "100%" } })) const PostForm: React.FC = () => { const { userInfo } = useContext(UserContext) const classes = useStyles() const [content, setContent] = useState<string>("") const handleCreatePost = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() if (!content) return const data: CreatePostInput = { content: content, owner: userInfo?.attributes.sub, status: PostStatus.published } try { await API.graphql(graphqlOperation(createPost, { input: data })) setContent("") } catch (err: any) { console.log(err) } } return ( <> <form className={classes.form} noValidate onSubmit={handleCreatePost}> <TextField placeholder="Hello World!" variant="outlined" multiline fullWidth rows="4" value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setContent(e.target.value) }} /> <div className={classes.submitBtn}> <Button type="submit" variant="contained" size="large" color="inherit" disabled={!content || content.length > 140} startIcon={<CreateIcon />} className={classes.submitBtn} > Post </Button> </div> </form> </> ) } export default PostForm ./src/components/post/CommentItem.tsx import React, { useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import Card from "@material-ui/core/Card" import CardHeader from "@material-ui/core/CardHeader" import CardContent from "@material-ui/core/CardContent" import Avatar from "@material-ui/core/Avatar" import IconButton from "@material-ui/core/IconButton" import Typography from "@material-ui/core/Typography" import DeleteOutlineOutlinedIcon from "@material-ui/icons/DeleteOutlineOutlined" import API, { graphqlOperation } from "@aws-amplify/api" import { deleteComment } from "../../graphql/mutations" import { UserContext } from "../../App" import { Comment } from "../../types/index" const useStyles = makeStyles((theme: Theme) => ({ card: { width: "100%", marginTop: "2rem", transition: "all 0.3s", "&:hover": { boxShadow: "1px 0px 20px -1px rgba(0,0,0,0.2), 0px 0px 20px 5px rgba(0,0,0,0.14), 0px 1px 10px 0px rgba(0,0,0,0.12)", transform: "translateY(-3px)" } }, delete: { marginLeft: "auto" } })) type CommentItemProps = { comment: Comment | null } const CommentItem: React.FC<CommentItemProps> = ({ comment }) => { const { userInfo } = useContext(UserContext) const classes = useStyles() const handleDeleteComment = async (id: string | undefined) => { if (!id) return try { await API.graphql(graphqlOperation(deleteComment, { input: { id: id } })) } catch (err: any) { console.log(err) } } return ( <> <Card className={classes.card}> <CardHeader avatar={ <Avatar> U </Avatar> } action={comment?.owner === userInfo?.attributes.sub ? <div className={classes.delete}> <IconButton onClick={() => handleDeleteComment(comment?.id)} > <DeleteOutlineOutlinedIcon /> </IconButton> </div> : null } title={userInfo?.username} /> <CardContent> <Typography variant="body2" color="textSecondary" component="span"> { comment?.content?.split("\n").map((content: string, index: number) => { return ( <p key={index}>{content}</p> ) }) } </Typography> </CardContent> </Card> </> ) } export default CommentItem ./src/components/post/CommentForm.tsx import React, { useState, useContext } from "react" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Button from "@material-ui/core/Button" import SmsOutlinedIcon from "@material-ui/icons/SmsOutlined" import API, { graphqlOperation } from "@aws-amplify/api" import { createComment } from "../../graphql/mutations" import { CreateCommentInput } from "../../types/index" import { UserContext } from "../../App" const useStyles = makeStyles((theme: Theme) => ({ form: { display: "flex", flexWrap: "wrap", width: "100%" }, submitBtn: { marginTop: "10px", marginLeft: "auto" } })) type CommentFormProps = { postId: string | undefined } const CommentForm: React.FC<CommentFormProps> = ({ postId }) => { const { userInfo } = useContext(UserContext) const classes = useStyles() const [content, setContent] = useState<string>("") const handleCreateComment = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() if (!content) return const data: CreateCommentInput = { postId: postId, content: content, owner: userInfo?.attributes.sub } try { await API.graphql(graphqlOperation(createComment, { input: data })) setContent("") } catch (err: any) { console.log(err) } } return ( <> <form className={classes.form} noValidate onSubmit={handleCreateComment}> <TextField placeholder="Hello World!" variant="outlined" multiline fullWidth rows="4" value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setContent(e.target.value) }} /> <div className={classes.submitBtn}> <Button type="submit" variant="contained" size="large" color="inherit" disabled={!content || content.length > 140} startIcon={<SmsOutlinedIcon />} className={classes.submitBtn} > Comment </Button> </div> </form> </> ) } export default CommentForm ./src/App.tsx import React, { useState, useEffect, createContext } from "react" import { BrowserRouter as Router, Switch, Route } from "react-router-dom" import Amplify, { Auth } from "aws-amplify" import { AmplifyAuthenticator, AmplifySignUp } from "@aws-amplify/ui-react" import awsconfig from "./aws-exports" import Wrapper from "./components/layouts/Wrapper" import PostList from "./components/post/PostList" import { User } from "./types/index" export const UserContext = createContext({} as { currentUser: User | undefined setCurrentUser: React.Dispatch<React.SetStateAction<User | undefined>> }) Amplify.configure(awsconfig) const App: React.FC = () => { const [currentUser, setCurrentUser] = useState<User>() const getCurrentUser = async () => { const currentUserInfo = await Auth.currentUserInfo() setCurrentUser(currentUserInfo) } useEffect(() => { getCurrentUser() }, []) return ( <AmplifyAuthenticator> <AmplifySignUp slot="sign-up" formFields={[ { type: "username" }, { type: "email" }, { type: "password" } ]} > </AmplifySignUp> <Router> <UserContext.Provider value={{ currentUser, setCurrentUser }}> <Wrapper> <Switch> <Route exact path="/" component={PostList} /> </Switch> </Wrapper> </UserContext.Provider> </Router> </AmplifyAuthenticator> ) } export default App 現段階だとこんな感じになっていればOKです。 投稿(Post)が作成・削除できるかどうか コメント(Comment)が作成・削除できるかどうか を確認してください。 画像アップロード機能を作成 特に問題無ければ最後に画像アップロード機能を追加していきます。 $ amplify add storage 参照記事: Storage with Amplify おなじみの対話形式で質問が始まるので回答していってください。 ? Please select from one of the below mentioned services: (Use arrow keys) ❯ Content (Images, audio, video, etc.) NoSQL Database // 画像などをアップロードしたいので「Content」 ? Please provide a friendly name for your resource that will be used to label th is category in the project: reactamplifysnsstorage // Storageに付けるラベル名(任意) ? Please provide bucket name: reactamplifysnss3 // S3バケット名(任意) ? Who should have access: (Use arrow keys) ❯ Auth users only Auth and guest users // 利用できるユーザー(認証済みのユーザーのみに絞りたいので「Auth users only 」) ? What kind of access do you want for Authenticated users? (Press <space> to sel ect, <a> to toggle all, <i> to invert selection) ❯◉ create/update ◉ read ◉ delete // 認証済みのユーザーが実行可能なアクション(「a」キーを押すと全て選択可能なのでそうする) ? Do you want to add a Lambda Trigger for your S3 Bucket? No // Lambdaトリガーを付与するかどうか(今回はLmabdaを使用しないので「No」) Successfully added resource reactamplifysnsstorage locally If a user is part of a user pool group, run "amplify update storage" to enable IAM group policies for CRUD operations Some next steps: "amplify push" builds all of your local backend resources and provisions them in the cloud "amplify publish" builds all of your local backend and front-end resources (if you added hosting category) and provisions them in the cloud こんな感じのメッセージが返ってくれば準備完了です。 $ amplify push 「amplify push」コマンドを実行します。 ✔ Successfully pulled backend environment dev from the cloud. Current Environment: dev | Category | Resource name | Operation | Provider plugin | | -------- | ----------------------- | --------- | ----------------- | | Storage | reactamplifysnsstorage | Create | awscloudformation | | Auth | reactamplifysns******** | No Change | awscloudformation | | Api | reactamplifysns | No Change | awscloudformation | ? Are you sure you want to continue? Yes ...省略... ⠋ Updating resources in the cloud. This may take a few minutes...⠋ Uploading fil✔ All resources are updated in the cloud こんな感じのメッセージが返ってくればOK。 S3バケットも新規に作成されていますね。 なお、このままの状態だと外部からのアクセスができないので、「アクセス許可」内のバケットポリシーに以下のJSONを記述してください。 { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::<バケット名>/public/images/*" } ] } これでひとまず設定完了です。 あとはReactアプリ側のコードを追記していきましょう。 $ yarn add @types/uuid 画像をS3にアップロードする際、ファイル名が被らないようランダムな識別子を付与したいので上記のライブラリをインストールします。 インストールが終わったら、「./src/components/post/PostForm.tsx」を次のように編集してください。 ./src/components/post/PostForm.tsx import React, { useCallback, useState, useContext } from "react" import { experimentalStyled as styled } from "@material-ui/core/styles" import { makeStyles, Theme } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" import Button from "@material-ui/core/Button" import Box from "@material-ui/core/Box" import IconButton from "@material-ui/core/IconButton" import PhotoCameraIcon from "@material-ui/icons/PhotoCamera" import CreateIcon from "@material-ui/icons/Create" import CancelIcon from "@material-ui/icons/Cancel" import API, { graphqlOperation } from "@aws-amplify/api" import { createPost } from "../../graphql/mutations" import { v4 as uuid } from "uuid" import awsconfig from "../../aws-exports" import { Storage } from "aws-amplify" import { CreatePostInput, PostStatus } from "../../types/index" import { UserContext } from "../../App" // S3のバケット名などを取得 const { aws_user_files_s3_bucket_region: region, aws_user_files_s3_bucket: bucket } = awsconfig const useStyles = makeStyles((theme: Theme) => ({ form: { display: "flex", flexWrap: "wrap", width: 320 }, inputFileBtn: { marginTop: "10px" }, submitBtn: { marginTop: "10px", marginLeft: "auto" }, box: { margin: "2rem 0 4rem", width: 320 }, preview: { width: "100%" } })) const Input = styled("input")({ display: "none" }) const borderStyles = { bgcolor: "background.paper", border: 1, } const PostForm: React.FC = () => { const { userInfo } = useContext(UserContext) const classes = useStyles() const [content, setContent] = useState<string>("") const [file, setFile] = useState<File>() const [preview, setPreview] = useState<string>("") const uploadImage = useCallback((e) => { const file = e.target.files[0] setFile(file) }, []) // 画像プレビュー機能 const previewImage = useCallback((e) => { const file = e.target.files[0] setPreview(window.URL.createObjectURL(file)) }, []) const handleCreatePost = async (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault() if (!content) return const data: CreatePostInput = { content: content, owner: userInfo?.attributes.sub, status: PostStatus.published } if (file) { const { name: fileName, type: mimeType } = file const key: string = `images/${uuid()}_${fileName}` // 最終的な保存先 const imageUrl: string = `https://${bucket}.s3.${region}.amazonaws.com/public/${key}` try { await Storage.put(key, file, { contentType: mimeType }) data.image = imageUrl setFile(undefined) } catch (err: any) { console.log(err) } } try { await API.graphql(graphqlOperation(createPost, { input: data })) setContent("") setPreview("") } catch (err: any) { console.log(err) } } return ( <> <form className={classes.form} noValidate onSubmit={handleCreatePost}> <TextField placeholder="Hello World!" variant="outlined" multiline fullWidth rows="4" value={content} onChange={(e: React.ChangeEvent<HTMLInputElement>) => { setContent(e.target.value) }} /> <div className={classes.inputFileBtn}> <label htmlFor="icon-button-file"> <Input accept="image/*" id="icon-button-file" type="file" onChange={(e: React.ChangeEvent<HTMLInputElement>) => { uploadImage(e) previewImage(e) }} /> <IconButton color="inherit" component="span"> <PhotoCameraIcon /> </IconButton> </label> </div> <div className={classes.submitBtn}> <Button type="submit" variant="contained" size="large" color="inherit" disabled={!content || content.length > 140} startIcon={<CreateIcon />} className={classes.submitBtn} > Post </Button> </div> </form> { preview ? <Box sx={{ ...borderStyles, borderRadius: 1, borderColor: "grey.400" }} className={classes.box} > <IconButton color="inherit" onClick={() => setPreview("")} > <CancelIcon /> </IconButton> <img src={preview} alt="preview-img" className={classes.preview} /> </Box> : null } </> ) } export default PostForm 最終的にこんな感じの挙動になっていれば完成です。 あとがき 以上、多少雑な説明になってしまいましたが、Amplifyを使ってそれっぽいアプリをサクッと作ってみました。 ある程度の流れ(「amplify add ◯◯(追加したい機能)」→「amplify push ◯◯(追加したい機能)」)さえ掴めればだいぶ高速で開発を進める事はできると思うので、今後も色々試してみたいと思います。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React、Next.jsなぜページ遷移にはaタグではなく、Linkを使うのか

next.jsではなぜaタグではなくLinkを使うのかを先輩エンジニアに教えてもらったので備忘録 結論から言うと、 ページの読み込みを早くするため reactの状態変化を維持したままページ遷移を行うため 説明すると、 aタグはサーバーから別のページを取得するリンク Linkタグは同一ページ内で見た目だけ変更するリンク出そう。 Linkタグで同一ページ内で一部分だけ変更する場合、 サーバーとの通信が省略されるのでページの読み込みが格段に早くなる。 そして、ページが更新されてしまった場合reactの状態(state)がリセットされてしまうけど、 Linkタグで同一ページ内で一部分だけ変更する場合、状態(state)が維持されたまま次のページに移動できる。 と言うことみたいです。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VS Codeの.jsx拡張子のファイルのアイコンをReact("javascriptreact")に変えたいとき

はじめに まず、今回はコーディングとはあまり関係なく、何方かと言えば環境設定の話です。 タイトル通りですが、VS Codeの.jsx拡張子のファイルのアイコンをReactに変えたくなって悶々としておりました。 なんでこんな状況になったかというと、今学習しているUdemyのコースで講師の.jsxファイルのアイコンと自分の.jsxファイルのアイコンが何故か違ったからです。 ↓講師の画面 ↓自分の画面 こっちは.jsも.jsxのファイルも同じアイコンなのに… 自分のもReactのアイコンにしたい…(だって見た目かっこいいもん) 解決策 ということで、いろいろと調べながら試してみました。 (意外に日本語の情報はありませんでした。多分使っているエクステンションが違うのかな。それともわざわざ書くまでもないから記事になってないのかな…) もったいぶらずに結論からいうとsetting.jsonファイルで以下の記述を入れればいいんですね。 { "files.associations": { "*.js": "javascript", "*.jsx": "javascriptreact", "*.ts": "typescript", "*.tsx": "typescriptreact" } これで、ファイルを見てみると... できましたっ! VS Code画面左下の設定(⌘,) > 検索画面で associations と記入すると前述のsetting.jsonの該当箇所を見つけることができます。 ちなみにはじめはvscode-iconsのエクステンションをインストールしているので、それ関連の設定を変更することを試してみてましたが、、、ダメでした。(以下試した設定) { "workbench.iconTheme": "vscode-icons", "vsicons.associations.files": [ { "*.js": "javascript", "*.jsx": "javascriptreact",` "*.ts": "typescript", "*.tsx": "typescriptreact", } ] } 参考にしたGitHubのvscode-iconsのレポジトリのIssueによると、どうやらVS CodeにインストールしていたBabel JavaScriptエクステンションの設定が干渉しているようでした。 URL: https://github.com/vscode-icons/vscode-icons/issues/859 自分のエディタで確認してみると、画面右下のとこで確かにファイル名の言語拡張子がBabel JavaScriptとして認識されていました。 ちなみに設定を上記の解決できた記述に直したら、JavaScript Reactにちゃんと変わっていました。 ちなみにvscode-iconsの設定でもカスタマイズ設定でできるみたいです。どうやら私がはじめに試した記法は間違っていたみたいですが、解決したのでその正しい記法は試していません。興味がある方はリンク先のイシューを見てみてください(該当のところのスクショを貼っておきます) URL: https://github.com/vscode-icons/vscode-icons/issues/859 参考記事 GitHub vscode-icons/vscode-icons After last update individual icons for jsx are gone #859 URL: https://github.com/vscode-icons/vscode-icons/issues/859 フロントエンド強化月間参加中!! またQiitaのフロントエンド強化月間にも参加中です! なんというタイミング...これを機にフロントエンド技術を一気に学んでいきます。 コチラ→Qiita フロントエンド強化月間
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

create-react-app直後にやる環境構築の備忘録

はじめに 毎回何をやったか忘れてしまうので私のCreate React App直後に毎回やっている環境構築の備忘録になります。覚え書きのため時期によって内容がころころ変わるかもしれません。。。 React TypeScript をベースに、プロジェクトによって下記を追加しています。 Redux Tool kit Storybook Jest CSS modules emotion styled-components ESlintについてはCRAデフォルトのままの利用。 StylelintについてはCRA、emotion、styled-componentsとVSCodeのそれぞれの兼ね合いなのか、利用するバージョンによって色々とエラーが出てしまっているので一旦保留しています。。 0.GitHubで新規リポジトリの作成 プロジェクトのルールによって作成。 1.create-react-app まずはプロジェクト作成。 基本的にTypeScriptで構築するので--template typescript必須で作成。 $ yarn create react-app project-name --template typescript or $ npx create-react-app project-name --template typescript Reduxを使うときは、Redux Took Kitを用意してくれるredux-typescriptテンプレートが便利です。 最近のアップデートでcreateAsyncThunkのサンプルやテストコードも入ってますし、型を扱うのに便利なusAppSelectorとuseAppDispatchも用意してくれています。 $ yarn create react-app project-nam --template redux-typescript or $ npx create-react-app project-nam --template redux-typescript パッケージのアップデート テンプレートに利用されているパッケージ類が古い場合もあるので、一通り最新にアップデートします。 デフォルトのcreate-react-appのものに比べて外部のtemplateはちょっと古めのパッケージのことが多いです。 # yarn upgrade-interactive --latest 2.tsconfig.jsonの設定追加 個人的な好みですが、tsconfig.jsonにbaseUrlを設定しています。 baseUrlを設定することで、相対パスではなくルート相対パス風に記載ができるようになります。 tsconfig.json "compilerOptions": { "baseUrl": "." // 追加 } 上記の場合はプロジェクトルートからからsrc/ファイル名というように記載できます。 some-components.tsx import { Hoge } from 'src/components/atoms/Button; 3.Storybookのセットアップ Storybookをインストールします。 $ npx -p @storybook/cli sb init create-react-appの利用しているbabelとStorybookの利用するbabelとの兼ね合い?なのかjestやstorybook起動時にエラーが出るので、メッセージの指示にしたがって.envファイルに下記を追記します。 .env SKIP_PREFLIGHT_CHECK=true 4.package.jsonの編集 起動スクリプトにeslintを追加、jestのオプションを追加します。(--env=jsdomは不要かも?) --verboseを付けるとターミナル表示にテスト項目のdescribeやtestの名称も表示されるようになります。 package.json "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom --verbose", // オプション変更 "eject": "react-scripts eject", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", // 追加 "lint:fix": "eslint --fix 'src/**/*.{js,jsx,ts,tsx}'", // 追加 "storybook": "start-storybook -p 6006 -s public", "build-storybook": "build-storybook -s public" }, 5.テスト用ツールの追加 カスタムフックのテスト用にreact-hooks-testing-libraryを追加します。 $ yarn add -D @testing-library/react-hooks react-test-renderer プロジェクトによってはAPIテスト用にmswやaxiosもインストール。 6.CSS環境の構築 CSS modulesを利用の場合 css modulesでSASSを利用したいためsass環境をインストールします。 $ yarn add -D sass リセットCSS Create React Appには、normalize.cssが付いてくるのでそちらを利用。 emotionを利用の場合 $ yarn add @emotion/react Create React App、TypeScript環境で利用するにはひと手間必要で、css Prop利用の場合は、ソースの先頭に下記1行を追記する必要があります。 /** @jsxImportSource @emotion/react */ リセットCSS emotion-resetを利用。 $ yarn add emotion-reset emotion-resetでは、box-sizing: border-box;が設定されていないので好みで下記のようにGlobalに設定しておく。 src/App.tsx /** @jsxImportSource @emotion/react */ import emotionReset from 'emotion-reset'; import { Global, css } from '@emotion/react'; import HomeContainer from 'src/components/pages/HomeContainer'; const App: React.VFC = () => { return ( <> <Global styles={css` ${emotionReset} *, *::after, *::before { box-sizing: border-box; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; font-smoothing: antialiased; } `} /> <HomeContainer /> </> ); }; export default App; 自分のvscode環境でシンタックスハイライトがおかしくなる。。。 ...WIP styled-componentsを利用の場合 パッケージのインストール $ yarn add styled-components $ yarn add -D @types/styled-components リセットCSS styled-resetでは、box-sizing: border-box;が設定されていないので好みで下記のようにGlobalStyleに設定しておく。 src/App.tsx import React from 'react'; import { createGlobalStyle } from 'styled-components'; import reset from 'styled-reset'; const GlobalStyle = createGlobalStyle` ${reset} /* other styles */ *, *::after, *::before { box-sizing: border-box; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; font-smoothing: antialiased; } `; const App: React.VFC = () => { return ( <> <GlobalStyle /> <div>App</div> </> ); }; export default App; ...WIP 7.index.htmlなどpublicディレクトリ下のファイル修正 デフォルトで同梱されている静的なファイルたちを忘れないうちに最低限な部分だけ書き換えておきます。 public/index.html <!DOCTYPE html> <html lang="ja"> <head> <meta charset="utf-8" /> <link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> <meta name="description" content="Web site created using create-react-app" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <title>Weather App</title> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> </body> </html> 下記はそれぞれ用意するサイトに合わせて作成、差し替え。 logo.192.png logo.512.png favicon.svg ogp.ong faviconについてはIE非対応ですが、ダークテーマに対応できるsvg画像を用意しています。 さいごに プロジェクトによって環境も多少は異なりますが、最近の自分環境のご紹介と備忘録でした。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

第 4 章 TypeScript で型をご安全に メモ

型アノテーション(Type Annotation) TypeScript では value: Type というフォーマットで宣言時の変数 に型の注釈がつけられる アノテー ションによって静的に型付けされた情報はコンパイル時のチェックに用いられ、書かれたコード中 に型の不整合があるとコンパイルエラーになる $ ts-node >let n : number = 3; >n = 'foo'; [eval].ts:2:1 - error TS2322: Type '"foo"' is not assignable to type 'number'. > if (n) console.log('`n` is truthy'); `n` is truthy $ node > const s = '123'; >constn=456; >s*3 369 >246/s 2 >s+n '123456' $ ts-node > const s = '123'; >const n = 456; >s*3 [eval].ts:4:1 - error TS2362: The left-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. >246/s [eval].ts:4:1 - error TS2363: The right-hand side of an arithmetic operation must be of type 'any', 'number', 'bigint' or an enum type. >s+n '123456' JavaScript には『暗黙の型変換』という機能があって、この例のように文字列データを算術演算しようとすると、勝手にその変数の型を数値型に変換してしまう TypeScript では、数値型と互換性のない型のデータは算術演算が許されないのでコンパイルエラーになる + は加算ではなく文字列の連結として評価される 型推論(Type Inference) コンパイラがその文脈からその型を推測できる場合は、型ア ノテーションを省略しても自動的に補完して解釈してくれる JavaScript と TypeScript のちがいはほぼ型システムだけ 型の種類 プリミティブ型は JavaScript と共通した次の 7 種類 ・Boolean 型 ...... true および false の 2 つの真偽値を扱うデータ型。型名は boolean ・Number 型 ...... 数値を扱うためのデータ型。型名は number ・BigInt 型 ...... number 型では表現できない大きな数値(253 以上)を扱う型。型名は bigint ・String 型 ...... 文字列を扱うためのデータ型。型名は string ・Symbol 型 ......「シンボル値」という固有の識別子を表現する値の型。型名は symbol ・Null 型 ...... 何のデータも含まれない状態を明示的に表す値。型名は null ・Undefined 型 ......「未定義」であることを表す値。型名は undefined 配列の型 //型名の後ろに [] をつけると、その型データの配列になる const numArr: number[] = [1, 2, 3]; //配列は Array オブジェクトとして定義する別の書き方もできる //ジェネリクス(Generics) const strArr: Array<string> = ['one', 'two', 'three']; オブジェクトの型定義 const red: { rgb: string, opacity: number } = { rgb: 'ff0000', opacity: 1 }; TypeScript ではオブジェクトの型に名前をつけることができるようになってる。それが『インターフェース(Interface)』と呼ばれる interface Color { readonly rgb: string; opacity: number; name?: string; } const turquoise: Color = { rgb: '00afcc', opacity: 1 }; turquoise.name = 'Turquoise Blue'; turquoise.rgb = '03c1ff'; // error TS2540: Cannot assign to 'rgb' because it is a read-only property. readonly 修飾子を つけたプロパティは書き換え不可になる プロパティ名の末尾に ? をつけると、そのプロ パティは省略可能になる interface Status { level: number; maxHP: number; maxMP: number; [attr: string]: number; } const myStatus: Status = { level: 99, maxHP: 999, maxMP: 999, attack: 999, defense: 999, }; 4 つめが任意のキーのプロパティ値を定義してる。これは『インデック スシグネチャ(Index Signature)』というもので、これのおかげで attack や defense というプロパテ ィを追加できてる。 インデックスシグネチャのキーに使える型は、文字列と数値の 2 種類のみ enum 型 TypeScript の enum はデフォルトでは数値で、かつ型安全が保証されない バージ ョン 2.4.0 から導入された文字列 enum を使えばその問題は解決する >enumPet{ > Cat='Cat', > Dog='Dog', > Rabbit='Rabbit', >} > let Tom: Pet = Pet.Cat; > Tom = 'Hamster'; [eval].ts:7:1 - error TS2322: Type '"Hamster"' is not assignable to type 'Pet'. > Tom = 'Dog'; [eval].ts:8:1 - error TS2322: Type '"Dog"' is not assignable to type 'Pet'. リテラル型 リテラル型は単独ではあまり使いみちがないんだけど、演算子 | で並べることによってあ たかも列挙型のように扱える これは『共用体型』というものの合わせ技 > let Mary: 'Cat' | 'Dog' | 'Rabbit' = 'Cat'; > Mary = 'Rabbit'; > Mary = 'Parrot'; [eval].ts:5:1 - error TS2322: Type '"Parrot"' is not assignable to type '"Cat" | "Dog" | "Rabbit"'. 文字列リテラル型は文字列 enum と比べてもシンプルに記述できて扱いやすく、JavaScript へのコ ンパイル後のコードもより短くなるというメリットがある リテラル型には文字列だけじゃなく、値が数値に限定された『数値リテラル型』もある。 タプル型 TypeScript には個々の要素の型と、その順番や要素数に制約を設けられる特殊な配列 const charAttrs: [number, string, boolean] = [1, 'patty', true]; タプルの定義にもレストパラメータが使える const spells: [number, ...string[]] = [7, 'heal', 'sizz', 'snooz']; 2021 年 2 月リリースの TypeScript 4.2 で導入されたばかりのものなので、使うとき はバージョンに気をつけて タプルの使い方 > const userAttrs: [number, string, boolean] = [1, 'patty', true]; > const [id, username, isAdmin] = userAttrs; >console.log(id,username,isAdmin); //1 patty true any 型 any で定義された変数は、その名のとおりいかなる型の値でも受けつけるようになる > let val: any = 100; 100 > val = 'buz'; > val = null; any で定義するとそこだけ JavaScript に差し戻すようなものといえる unknown型 any の型 安全版で、任意の型の値を代入できる点は同じ、それ自体は何のプロパティもプロトタイプメソッドも持たない型 never型 何者も代入できない型 関数の型定義 TypeScriptではコンパイラオプションに noImplicitAnyが指定されてないと、引数の型定義がなくても暗黙の内にany型があてがわれてコンパイルが通ってしまうので、まずは設定ファイルの tsconfig.json でそのオプションを有効に TypeScript では戻り値の型は型推論が有効な場合には省略もできるけど、引数の型は必ず指定する必要がある 何も返さない関数の戻り値型は void になる // function declaration statement { function add(n: number, m: number): number { return n + m; } console.log(add(2,4)); //6 } // function keyword expression { const add = function(n: number, m: number): number { return n + m; }; console.log(add(5,7)); //12 } // arrow function expression { const add = (n: number, m: number): number => n + m; const hello = (): void => { console.log('Hello!'); }; console.log(add(8,1)); //9 hello(); // Hello! } 引数 と戻り値をまとめて定義する方法もある。関数を『呼び出し可能オブジェクト(Callable Object)』 として定義するもの // callable object type { interface NumOp { (n: number, m: number): number; } const add: NumOp = function (n, m) { return n + m; }; const subtract: NumOp = (n, m) => n - m; console.log(add(1, 2)); // 3 console.log(subtract(7,2)); //5 } // in-line { const add: (n: number, m: number) => number = function (n, m) { return n + m; }; const subtract: (n: number, m: number) => number = (n, m) => n - m; console.log(add(3, 7)); // 10 console.log(subtract(10, 8)); // 2 } 前者がインターフェースとして呼び出し可能オブジェクトを定義し、それを関数式に適用したも の。後者はそれをアロー型アノテーションによってインラインで行ってる 関数の型宣言にジェネリクスを用いる記法 > const toArray = <T>(arg1: T, arg2: T): T[] => [arg1, arg2]; > toArray(8, 3); [8,3] > toArray('foo', 'bar'); [ 'foo', 'bar' ] > toArray(5, 'bar'); [eval].ts:4:12 - error TS2345: Argument of type '"bar"' is not assignable to parameter of type 'number'. 型引数(Type Parameter) 数に渡す引数と同じで、任意の型を <> によって引数と して渡すことで、その関数の引数や戻り値の型に適用できるようになる 最初の例では T は 型推論によって number、次の例では string になってる 最後のは引 数 arg1 と arg2 の型が任意のものに統一されてないのでエラー データの型に束縛されないよう型を抽象化してコードの再利用性を向上させつつ、 静的型付け言語の持つ型安全性を維持するプログラミング手法を『ジェネリックプログラミング (Generic Programming)』と呼ぶ。 そして型引数を用いて表現するデータ構造のことを『ジェネリクス(Generics)』っていう TypeScript でも可変長引数はちゃんと型安全に扱える > const toArrayVariably = <T>(...args: T[]): T[] => [...args]; > toArrayVariably(1, 2, 3, 4, 5); [1,2,3,4,5] > toArrayVariably(6, '7', 8); [eval].ts:3:20 - error TS2345: Argument of type '"7"' is not assignable to parameter of type 'number'. TypeScript でのクラスの扱い TypeScript のクラスと JavaScript のク ラスは、一見似ているようで異なる部分がそこそこある。 class Rectangle { readonly name = 'rectangle'; sideA: number; sideB: number; constructor(sideA: number, sideB: number) { this.sideA = sideA; this.sideB = sideB; } getArea = (): number => this.sideA * this.sideB; } TypeScript ではメンバー変数は、クラスの最初で その宣言をしておく必要がある 『プロパティ初期化子(Property Initializer)』という機能 コンストラクタに引 数がないクラスでは、インスタンスの初期化をこれだけで済ませてコンストラクタを省略できる 宣言時に readonly 修飾子を付けることで、そのメンバー変数を変更不可にもできる 『アクセス修飾子(Acdess Modifier)』 宣言時につけることでそのメンバーのアクセシビリティをコントロールできる アクセス修飾子のバリエーションは次のとおり ・public ...... 自クラス、子クラス、インスタンスすべてからアクセス可能。デフォルトではすべ てのメンバーがこの public になる ・protected ...... 自クラスおよび子クラスからアクセス可能。インスタンスからはアクセス不可 ・private...... 自クラスからのみアクセス可能。子クラスおよびインスタンスからはアクセス不可 TypeScript でのクラスの扱い 今日のオブジェクト指向プログラミングでは、『継承よりも合成Compostion over Inheritance)』のスタイルが優勢になってる class Square extends Rectangle { readonly name = 'square'; side: number; constructor(side: number) { super(side, side); } } 前者の継承で書いてるほう Square は暗黙の内に不必要な公開メンバー変数 sideA と sideB まで 継承してしまっていて、それがのちのちバグを生む芽になりかねない。また getArea() メソッドが 完全に共有されているため、親クラスの実装を不用意に変更できず、継続的なコード改善の障害に なる。つまり保守性が悪い class Square { readonly name = 'square'; side: number; constructor(side: number) { this.side = side; } getArea = (): number => new Rectangle(this.side, this.side).getArea(); } 後者の合成の例では、Rectangle クラスを独立したただの部品として扱ってる ただその API としての入力と出力の仕様を知って さえいればいい。そして依存がないゆえに Rectangle クラスの内部の変更に Square クラスが影響さ れることはない。個々のモジュールの独立性が高く、より保守性にすぐれたコード React でもコンポーネントをクラスで作成するときは、継承を避けるよう公式ドキュメントに書 かれてる クラスの 2 つの顔 クラスの型を抽象化して定義する方法が、TypeScript には 2 つある。 abstract 修飾子 を用いて抽象クラスを定義するもの 継承そのものがダメというより、抽象クラスはその定義に実装を含むことができてしまうからだ よ。避けるべきは実装を伴った継承で、実装を伴わずに型だけを適用したいの。そこで 2 つめの選択肢、インターフェースを使う interface Shape { readonly name: string; getArea: () => number; } interface Quadrangle { sideA: number; sideB?: number; sideC?: number; sideD?: number; } class Rectangle implements Shape, Quadrangle { readonly name = 'rectangle'; sideA: number; sideB: number; constructor(sideA: number, sideB: number) { this.sideA = sideA; this.sideB = sideB; } getArea = (): number => this.sideA * this.sideB; } JavaScript では関数は第一級オブジェクトでプロパティの値にできるから、インターフェー スでもそのまま関数の型を定義すれば、それがそのメンバーメソッドの型になる Quadrangle インターフェースの getArea の定義にアロー構文を使ったけど、getArea(): number という書き方もできる そして実はこの 2 つの定義のやり方には微妙に差分がある アロー構文だと『オーバーロード(Overload)』ができない class Point { x: number = 0; y: number = 0; } const pointA = new Point(); const pointB: Point = { x: 2, y: 4 }; interface Point3d extends Point { z: number = 0; } const pointC: Point3d = { x: 5, y: 5, z: 10 }; インターフェースはクラスみたいに extendsで拡張できる TypeScript でクラスを定義すると、実際には 2 つの宣言が同時に実行されてる ひとつ はそのクラスインスタンスのインターフェース型宣言。もうひとつはコンストラクタ関数 の宣言 クラスは TypeScript にとって、型でもあり関数でもあるという二重の存在 型エイリアス VS インターフェース インターフェースはオブジェクトとクラスの型が定義できる TypeScript にはもうひとつ、任意の型に別名を与えて再利用できる型エイリアス(Type Alias)というものがある type Unit = 'USD' | 'EUR' | 'JPY' | 'GBP'; type TCurrency = { unit: Unit; amount: number; }; interface ICurrency { unit: Unit; amount: number; } const priceA: TCurrency = { unit: 'JPY', amount: 1000 }; const priceB: ICurrency = { unit: 'USD', amount: 10 }; 新しい型を作成しているのではなく、無名の文字列リテラル型 にそれを参照するための別名 Unit を与えてる コンパイラはそれを区別してる TCurrency のほうは構造まで表示されてますけど、ICurrency は名前だけしか表示されませんね。 このちがいって? これは型そのものに名前がついてるかどうかによるちがい インターフェース文は型の宣言 なので、その型には本来の名前が与えられる。いっぽう型エイリアスの構文はすでに無名で作られてしまった型に参照のための別名を与えているものなので、その型には本来の名前がないまま セミコロンの有無も同様で、関数宣言とインタ ーフェース構文はブロック {} で終わる文なのでセミコロンが不要。関数式と型エイリアスの構文 は最終的に代入文になるのでセミコロンが必要になる インターフェースは、よくいえば拡張に対してオープンな性質がある interface User { name: string; } interface User { age: number; } interface User { species: 'rabbit' | 'bear' | 'fox' | 'dog'; } const rolley: User = { name: 'Rolley Cocker', age: 8, species: 'dog', }; 同じプロパティ値を別の型として上書き定義はできないし、他のプロパティはそのままなので再 宣言ではない あくまで新しいプロパティの型定義が追加されていくだけ 普通に React でアプリケーショ ンを開発するのなら一貫して型エイリアスだけを使っていればいいと思う。そのほうが interface と type の構文が混在することもなくシンプルに書ける。 共用体型と交差型 TypeScript では既存の型を組み合わせて、より複雑な型を表現できる そのひとつ、まずは共用体型(Union Types、交差型) $ ts-node > let id: number | string = 208239; >id 208239 > id = 'a6ba7fb9-8435-4226-804e-387f3d2e53a7'; >id' a6ba7fb9-8435-4226-804e-387f3d2e53a7' 演算子 | で型を並べることで、それらの内のいずれかの型が適用される複合的な型になる オブジェクト型も共用体型に適用できるんだけど、これも基本は文字列リテラル型と同じで、 単にその並べられたオブジェクトの型のいずれかが適用される typeA={ foo: number; bar?: string; }; type B = { foo: string }; type C = { bar: string }; type D = { baz: boolean }; typeAorB=A|B; //{foo:number|string;bar?:string} typeAorC=A|C; //{foo:number;bar?:string}or{bar:string} typeAorD=A|D; //{foo:number;bar?:string}or{baz:boolean} 交差型(Intersection Types、インターセクション型) 演算子 & で並べていく 共用体型が『A または B』と適用範囲を増やしていくのに対して、 交差型は『A かつ B』と複数の型をひとつに結合させるもの 用途としては、もっぱらオブ ジェクト型の合成に使われる type A = { foo: number }; type B = { bar: string }; typeC={ foo?: number; baz: boolean; }; typeAnB=A&B; //{foo:number,bar:string} typeAnC=A&C; //{foo:number,baz:boolean} typeCnAorB=C&(A|B); // { foo: number, baz: boolean } or { foo?: number, bar: string, baz: boolean } 内部のプロパティの型がひとつずつマージされる感じ AnC 型の foo プロパティのように、同じ型でありながら必須と省略可能が交差したら、必 須のほうが優先される もし同じプロパティで型が共通点のないものだった場合はnever 型になる type Unit = 'USD' | 'EUR' | 'JPY' | 'GBP'; interface Currency { unit: Unit; amount: number; } interface IPayment extends Currency { date: Date; } type TPayment = Currency & { date: Date; }; const date = new Date('2020-09-01T12:00+0900'); const payA: IPayment = { unit: 'JPY', amount: 10000, date }; const payB: TPayment = { unit: 'USD', amount: 100, date }; extends によるインターフェースの拡張と同等のことが、交差型を使えばできる 結合させた型をこうやって型エイリアスにしてしまえば、結果的には同じになる ただ同名のプロパティが交差した場合、インターフェース拡張では互換性のない型だとコンパイル エラーになるという挙動のちがいがある 型の Null 安全性を保証する TypeScript はデフォルトの設定ではすべての型に null と undefined を代入できてしまう TypeScript で厳密に null や undefined を他の型から区別するためには、コンパイラオプション strictNullChecksを設定する必要がある あえて null を許容したい場合は共用体型で明示的に表現する > let foo: string | null = 'fuu'; > foo = null; type Resident = { familyName: string; lastName: string; mom?: Resident; }; const getMomName = (resident: Resident): string => resident.mom.lastName; const patty = { familyName: 'Hope-Rabbit', lastName: 'patty' }; getMomName(patty); getMomName を定義してる行、resident.mom のところにエラーがでる Resident 型の mom プロパティは省略可能なので undefined へのアクセスになる可能性があって、 コンパイルするまでもなく VS Code が教えてくれてる - const getMomName = (resident: Resident): string => resident.mom.lastName; + const getMomName = (resident: Resident): string => resident.mom!.lastName プロパティアクセスの前に ! が追加 非 Null アサーション演算子(Non-Null Assertion Operator)といって、『ここには絶対に null も undefined も入りませんよ』とコンパイラを強引に黙らせるもの ただこれはせっかくの null 安 全性を壊すもので、実際に値が null や undefined だったら実行時エラーになる。だからよっぽどの 保証がない限り使うべきじゃない 型表現に使われる演算子 typeof 演算子 型のコンテキストで用いると変数から型を抽出 console.log(typeof100); //'number' const arr = [1, 2, 3]; console.log(typeofarr); //'object' type NumArr = typeof arr; const val: NumArr = [4, 5, 6]; const val2:NumArr=['foo','bar','baz']; //compileerror! NumArr は number[] だから、この型を適用した変数に文字列の配列を入れようとする とコンパイルエラーになる 自分でいちいち型を定義しなくても、型推論で既存の変数から抜き出せる in 演算子 constobj={a:1,b:2,c:3}; console.log('a' in obj); // true for(constkeyinobj){console.log(key);} //abc type Fig = 'one' | 'two' | 'three'; type FigMap = { [k in Fig]?: number }; const figMap: FigMap = { one: 1, two: 2, three: 3, }; figMap.four=4; //compileerror! 通常の式では指定した値がオブジェクトのキーとして存在するか どうかの真偽値を返す for...in 文ではオブジェクトからインクリメンタルにキーを抽出する のに使われる 型コンテキストでは、列挙された型の中から各要素の型の値を抜き出して マップ型(Mapped Types)というものを作る keyof演算子 通常の式では使えず、型コンテキストのみで用いられる演算子 オブジェクトの型からキーを抜き出してくる const permissions = { r: 0b100, w: 0b010, x: 0b001, }; type PermsChar=keyoftypeofpermissions; //'r'|'w'|'x' const readable: PermsChar = 'r'; const writable: PermsChar = 'z'; // compile error! typeof と合わせると、既存のオブジェクトからキーの型を抽出できる インデックスアクセス演算子 [] const permissions = { r: 0b100 as const, w: 0b010 as const, x: 0b001 as const, }; type PermsChar = keyof typeof permissions; // 'r' | 'w' | 'x' typePermsNum=typeofpermissions[PermsChar]; //1|2|4 キーの型を渡すとプロパティ値の 型が返ってくる Const アサーション(Const Assertions) as const の構文 定数としての型注釈を付与する 条件付き型とテンプレートリテラル型 extends キーワ ードは型引数の表現にも適用できる const override = <T, U extends T>(obj1: T, obj2: U): T & U => ({ ...obj1, ...obj2, }); override({a:1},{a:24,b:8}); //{a:24,b:8} override({ a: 2 }, { x: 73 }); // compile error! ここでの extends は、関数 override() の第 2 引数 obj2 の型を定義している型引数 U が第 1 引数 の型 obj1 の型 T と同じか拡張したものでなければならないことを示唆するもの その条件 に従わない引数を渡そうとするとコンパイルで弾かれる 条件付き型(Conditional Types) extends キーワードは、三項演算子を併用することで任意の条件による型の割り振りができる 型 T が 型 U を拡張していた場合は型 X を、それ以外の場合は型 Y となる型の記述 T extends U ? X:Y これはオブジェクトの型から任意のプロパティの型を抽出したりするとき なんかに使える type User = { id: unknown }; type NewUser = User & { id: string }; type OldUser = User & { id: number }; type Book = { isbn: string }; type IdOf<T> = T extends User ? T['id'] : never; type NewUserId = IdOf<NewUser>; //string type OldUserId = IdOf<OldUser>; //number type BookId = IdOf<Book>; // never infer type Flatten<T> = T extends Array<infer U> ? U : T; const num = 5; const arr = [3, 6, 9]; type A= Flatten<typeof arr>; //number type N= Flatten<typeof num>; //number 型 T が何らかの型の配列だった場合、その配列の中身の型を infer U で型 U として 取得し、出力の型として使ってる。配列じゃなかった場合はそのままその型が出力される テンプレートリテラル型(Template Literal Types JavaScript のテンプレートリテラルによる文字列を型として扱うことができる type DateFormat = `${number}-${number}-${number}`; const date1: DateFormat = '2020-12-05'; const date2:DateFormat='Dec.5,2020'; //compileerror! 組み込みユーティリティ型 TypeScript ではユーティリティ型を最初から言語レベルでいくつも提供してくれてる よく使われそうなものを紹介 各プロパティの属性をまとめて変更する 類 のもの ・Partial ...... T のプロパティをすべて省略可能にする ・Required ...... T のプロパティをすべて必須にする ・Readonly ...... T のプロパティをすべて読み取り専用にする オブジェクトの型からプロパティを取捨選 択する性質のユーティリティ型 ・Pick ...... T から K が指定するキーのプロパティだけを抽出する ・Omit ...... T から K が指定するキーのプロパティを省く type Todo = { title: string; description: string; isDone: boolean; }; type PickedTodo = Pick<Todo, 'title' | 'isDone'>; type OmittedTodo = Omit<Todo, 'description'>; 列挙的な型を加工するユーティリティ型 ・Extract ...... T から U の要素だけを抽出する ・Exclude ...... T から U の要素を省く type Permission = 'r' | 'w' | 'x'; type RW1 = Extract<Permission, 'r' | 'w'>; type RW2 = Exclude<Permission, 'x'>; 任意の型から null と undefined だけを省いて null 非許容にするためのユーティリティ型 ・NonNullable ...... T から null と undefined を省く type T1 = NonNullable<string | number | undefined>; type T2 = NonNullable<number[] | null | undefined>; const str:T1=undefined; //compileerror! const arr: T2 = null; // compile error! 列挙タイプの型をキーとしたオブジェクト の型を作成するもの ・Record ...... K の要素をキーとしプロパティ値の型を T としたオブジェクトの型を作成する type Animal = 'cat' | 'dog' | 'rabbit'; type AnimalNote = Record<Animal, string>; const animalKanji: AnimalNote = { cat: '猫', dog: '犬', rabbit: '兎', }; 関数を扱うユーティリティ型 ・Parameters ...... T の引数の型を抽出し、タプル型で返す ・ReturnType ...... T の戻り値の型を返す const f1 = (a: number, b: string) => { console.log(a, b); }; const f2 = () => ({ x: 'hello', y: true }); type P1=Parameters<typeoff1>; //[number,string] type P2=Parameters<typeoff2>; //[] type R1=ReturnType<typeoff1>; //void type R2=ReturnType<typeoff2>; //{x:string;y:boolean} 今となっては正直 React 開発で使う機会はあんまりないかも 文字列リテラル型と組み合わせて便利に使える型 ・Uppercase ...... T の各要素の文字列をすべて大文字にする ・Lowercase ...... T の各要素の文字列をすべて小文字にする ・Capitalize ...... T の各要素の文字列の頭を大文字にする ・Uncapitalize ...... T の各要素の文字列の頭を小文字にする type Company = 'Apple' | 'IBM' | 'GitHub'; type C1 = Lowercase<Company>; // 'apple' | 'ibm' | 'github' type C2 = Uppercase<Company>; // 'APPLE' | 'IBM' | 'GITHUB' type C3 = Uncapitalize<Company>; //'apple'|'iBM'|'gitHub' type C4 = Capitalize<C3>; // 'Apple' | 'IBM' | 'GitHub' 関数のオーバーロード TypeScript では同じ名前の関数でも型が異なる宣言を重複させることでオーバーロードができる。 class Brooch { pentagram = 'Silver Crystal'; } type Compact = { silverCrystal: boolean; }; class CosmicCompact implements Compact { silverCrystal = true; cosmicPower = true; } class CrisisCompact implements Compact { silverCrystal = true; moonChalice = true; } function transform(): void; function transform(item: Brooch): void; function transform(item: Compact): void; function transform(item?: Brooch | Compact): void { if (item instanceof Brooch) { console.log('Mooncrystalpower ,makeup!!'); } else if (item instanceof CosmicCompact) { console.log('Mooncosmicpower ,makeup!!!'); } else if (item instanceof CrisisCompact) { console.log('Mooncrisis ,makeup!'); } else if (!item) { console.log('Moonprisimpower ,makeup!'); } else { console.log('Itemisfake... '); } } transform(); transform(new Brooch()); transform(new CosmicCompact()); transform(new CrisisCompact()); as による型アサーション 開発者が型を断定して、それをコンパイラに押し付けること 型アサーションは根拠なく開発者の判断がまかりとおる、型安全性がまったく保証されない方法 型アサーションは本当に最後の手段 型アサーションは型キャストとは別物 const n=123; const s1 = String(n); console.log(typeof s1); string const s2 = n as string; [eval].ts:4:12 - error TS2352: Conversion of type 'number' to type 'string' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. 前半で行ってるのが型キャストで、これは異なるデータ型の値を任意の型にコンバートするもの 型アサーションはあくまでコンパイラによる型の解釈が変わるだけであって、実際の値 が変化するわけじゃない (someValue as unknown) as SomeType のようにいったん unkonwn 型を挟む形で二重アサーションを行えばコンパイルは通ってしまう const str = (123 as unknown) as string; str.split(','); str.split(','); ^ Uncaught TypeError: n.split is not a function 型ガードでスマートに型安全を保証する const foo: unknown = '1,2,3,4'; if (typeof foo === 'string') { console.log(foo.split(',')); } console.log(foo.split(',')); //compileerror! typeof によって string 型だと判断されたブロック内では、変数 foo に string のプロトタイプメ ソッドである split() が使えてる あるスコープ内での型を保証するチェックを行う 式のことを型ガード(Type Guards)という 他の型の場合だけど、クラスのインスタンスなら instanceof が使える class Base { common = 'common'; } class Foo extends Base { foo = () => { console.log('foo'); } } class Bar extends Base { bar = () => { console.log('bar'); } } const doDivide = (arg: Foo | Bar) => { if (arg instanceof Foo) { arg.foo(); arg.bar(); //compileerror! } else { arg.bar(); arg.foo(); //compileerror! } console.log(arg.common); }; doDivide(new Foo()); doDivide(new Bar()); ユーザー定義の型ガード(User-Defined Type Guards) type User = { username: string; address: { zipcode: string; town: string } }; const isUser = (arg: unknown): arg is User => { const u = arg as User; return ( typeof u?.username === 'string' && typeof u?.address?.zipcode === 'string' && typeof u?.address?.town === 'string' ); }; const u1: unknown = JSON.parse('{}'); const u2: unknown = JSON.parse('{ "username": "patty", "address": "Maple Town" }'); const u3: unknown = JSON.parse( '{ "username": "patty", "address": { "zipcode": "111", "town": "Maple Town" } }', ); [u1, u2, u3].forEach((u) => { if (isUser(u)) { console.log(`${u.username} lives in ${u.address.town}`); }else{ console.log("It's not User"); console.log(`${u.username}livesin${u.address.town}`); //compileerror! } }); 関数 isUser() の戻り値の型定義が arg is User という見慣れない記述になってる 型述語(Type Predicate)という表現で、この関数が true を返す場合に引数 arg の型が User であ ることがコンパイラに示唆される これが型ガードに使える やりすぎた型表現は可読性の低い『型パズル』と揶揄されることもあるけど、実行時エラーを防 ぐことができるとかユニットテストを削減できるといったメリットと照らし合わせて、有効に使っ ていくべき TypeScript のインポート/エクスポート TypeScript における import と export の書き方は JavaScript のところで説明したのとほぼ同じ 異なるのがインポートに指定するパスでの拡張子の扱い TypeScript ではインポートの際 に読み込むファイルの拡張子を省略できる というより、拡張子を書くとエラーになる import bar from './bar'; 次の順にモジュールを探索していく 1. src/bar.ts 2. src/bar.tsx 3. src/bar.d.ts 4. src/bar/package.json の types または typings プロパティで設定されている型定義ファイル 5. src/bar/index.ts 6. src/bar/index.tsx 7. src/bar/index.d.ts TypeScript では、同じ名前空間の中に『変数宣言空間(Variable Declaration Space)』と『型宣言空間(Type Declaration Space)』という 2 つの宣言空間が存在していて、名前の管理が別々になっている そのため変数や関数と型で同一の名前を持つことができる const rate: { [unit: string]: number } = { USD: 1, EUR: 0.9, JPY: 108, GBP: 0.8, }; type Unit = keyof typeof rate; type Currency = { unit: Unit; amount: number; }; const Currency = { exchange: (currency: Currency, unit: Unit): Currency => { const amount = currency.amount / rate[currency.unit] * rate[unit]; return { unit, amount }; }, }; export { Currency }; 型エイリアスとオブジェクトが、同じ名前で定義されている場合 同時に両方ともエクスポートされる TypeScript 3.9 から『型の みのインポート(Type-Only Imports)』と『型のみのエクスポート(Type-Only Exports)』という構文が追加された。同じ名前でエクスポートされた型とオブジェクトから型だけをインポートしたり、 最初からあえて型だけをエクスポートしたりといったふうに使う JavaScript モジュールを TypeScript から読み込む npm のリポジトリで提供されている多くのパッケージは、たとえコードが TypeScript で書かれているものでも、TypeScript のままで配布されているものはあまりない JavaScript にコンパイル済みのファイルと、『宣言ファイル(Declaration File)』 という TypeScript の型情報を定義したファイルをパッケージングして配布する 型定義ファイル declare class Brooch { pentagram: string; } declare type Compact = { silverCrystal: boolean; }; declare class CosmicCompact implements Compact { silverCrystal: boolean; cosmicPower: boolean; } declare class CrisisCompact implements Compact { silverCrystal: boolean; moonChalice: boolean; } declare function transform(): void; declare function transform(item: Brooch): void; declare function transform(item: Compact): void; export { transform, Brooch, CosmicCompact, CrisisCompact }; TypeScript から JavaScript モジュールをただインポートすると、実装だけがあって型がない状態になる TypeScript のコンパイラにこういう変数とか関数がこういう型で存在してるよと教えてあげて、宣言空間にそれらを定義するための構文がこの declare 既存の JavaScript モジュールに型情報を付加する形の宣言のことを、通常の宣言と区別してアンビエン ト宣言(Ambient Declarations)っていう 型定義ファイルはどのように探索されるか 型定義ファイルのプロジェクトとの関連付けの方法は2つある 1.JavaScript ファイルと同じ階層に同じ名前で .d.ts 拡張子の型定義ファイルを置く 2.パッケージルートの package.json に型定義ファイルはこれですよと書いておくこと(Immer) package.json で types または typings プロパティに型定義ファイルをパス付きで設定しておくと、 TypeScript がそれを見つけてくれる "name": "immer", "version": "4.0.2", "description": "Create your next immutable state by mutating the current one", "main": "dist/immer.js", "umd:main": "dist/immer.umd.js", "unpkg": "dist/immer.umd.js", "jsdelivr": "dist/immer.umd.js", "module": "dist/immer.module.js", "jsnext:main": "dist/immer.module.js", "react-native": "dist/immer.module.js", "types": "./dist/immer.d.ts", 3.公式が型ファイルを提供してくれていないパッケージの場合 ・DefinitelyTypedを使う ・ Microsoft の『TypeSearch』で検索して使う 4.野良の型定義ファイルや自作の型定義ファイルを適用したい場合 ・src/ ディレクト リに適当な名前で .d.ts ファイルを置く TypeScript のコンパイルオプション tsconfig.json は TypeScript プロジェクトのコンパイラ設定を保存しておくため のファイル コンパイルが実行される際、デフォルトではプロジェクトルートから親ディレクトリ へさかのぼっていって最初に見つかった tsconfig.json ファイルが読み込まれ、そこに記述されて いる設定がコンパイラオプションとして有効になる このファイルは create-react-app コマンドでテンプレートに TypeScript を指定してプロジ ェクトを新規作成したときにも作られてる { "compilerOptions": { "target": "es5", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "forceConsistentCasingInFileNames": true, "noFallthroughCasesInSwitch": true, "module": "esnext", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" }, "include": [ "src" ] } strict というオプションが trueにすると次のオプションたちがまとめて有効にされる ・noImplicitAny ...... 暗黙的に any が指定されている式や宣言があればエラーになる ・noImplicitThis ...... this が暗黙的に any を表現していればエラーになる ・alwaysStrict ...... すべてのソースファイルの先頭に 'use strict' が記述されているものとみな し、ECMAScript の strict モードでパースする ・strictBindCallApply ...... bind()、call()、apply() メソッド使用時に、その関数に渡される引数 の型チェックを行う ・strictNullChecks ...... 他のすべての型から null および undefined が代入不可になる ・strictFunctionTypes ...... 関数の引数の型チェックが「共変的(Bivariant)」ではなく、「反変的(Contravariant)」に行われるようになる ・strictPropertyInitialization ...... 宣言だけで初期化されないクラスプロパティ(=メンバー変数)があるとエラーになる strict オプションを有効にすることは、 TypeScript の公式でも推奨されてる tsconfig.json のカスタマイズ ・target これはコンパイル先の JavaScript のバージョンを指定するもの。CRA のデフォルト 設定では es5 になってる、ECMAScript 5 にコンパイルされる ・lib コンパイルに含めるライブラリを指定する React では DOM 操作は必須なのでそのライブラリ dom と dom.iterable、それから最新の ECMAScript 構文をサポートするライブラリ esnext が含まれるように設定されてる ・module コンパイル後のモジュール構文をどのモジュールシステム形式にするか設定するもの ・noEmit ファイルを出力しないようにするオプション、これが true になってるのは、現行の CRA によるプロジェクト設定では tsc は構文チェックしか行わず、実際の TypeScript のコンパイルは Babel が行ってるため ・jsx JSX 構文をそのままにしておくか React の構文に書き換えるかを指定するためのオプ ション ・include プロパティ コンパイル対象となるファイルを指定するためのもの ひと昔前までは開発しやすくするため にそこそこ手を入れる必要があった。 でも今の設定はかなり完成度が高いので、問題なくほぼそのまま使える + "baseUrl": "src", + "downlevelIteration": true ・baseUrl モジュールのインポートのパス指定に絶対パスを使えるようにしつつ、その起点 となるディレクトリを指定するオプション この設定だけど、VS Code と相性が悪いのか新規に作成したファイルでは絶対パス指定を認識してくれないことがある 次のいずれかを行えば、新規ファイルと か移動直後のファイルでも絶対パスを認識してくれる ・shift + command + P(※ Windows では Shift + Ctrl + P)または F1 キーでコマンドパレットを 開き、>type を入力するとリストアップされる「TypeScript: Reload Project」を選択・実行 ・コンソールから touch tsconfig.json を実行 ・downlevelIteration フラグ コンパイルターゲットが ES5 以前 に設定されている場合でも、ES2015 から導入された各種イテレータ周りの便利な記述を ES5 以下でも実行できるよううまいこと書き下してくれるオプション 参考書籍
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RealtimeDatabase読み込みメソッド

3種類取得方法があるので、違いを記録する。 読み込み onメソッド 第一引数にvalueを設定すると、データベースが追加される度に差分の読み込みを実行する。 更新が発生した場合は、オブジェクト全体の再取得が実行される。 スナップショットにはキー名やバリュー等が格納される。 データが無くなった場合はnullを返す。 第一引数にchild_addedを設定すると、子要素が追加された場合に同期を行う。更新に対しては同期しない。 第一引数にchild_changedを設定すると、子要素の変更に対して同期を行う。 第一引数にchild_removedを設定すると、子要素の削除に対して同期を行う。 参考 本記事は本家のYOUTUBEチャンネルを参考に作成しています。 getメソッド 一度だけ読み込みを行う。 変更イベントの監視なし データベースサーバーからデータを取得する。 なんらかの理由で値を返せない場合は、クライアントがローカルキャッシュを調べ、それでも値が見つからなければエラーを返す。 必要以上に使用すると、帯域幅の使用が増加し、パフォーマンスの低下する場合があるが、リアルタイム リスナーを使用することで回避することが可能。 onceメソッド 一度だけ読み込みを行う。 変更イベントのイベント監視あり 更新された値の確認は行わない。 ローカルキャッシュからデータを取得できる。 頻繁な変更やアクティブなリッスンを行うことは想定していないデータ読み込みに対して有効。 参考 FireBase本家
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

RealtimeDatabase読み込み・書き込みメソッド

Firestoreでは、小さいデータを頻繁に読み書きする用途にコスト的に向いておらず、googleが推奨していない方のRealtimeDatabaseを使用することになったため、読み書きメソッドについて記録する。 共通知識 Jsonツリーのデータベース構造になっている。 常に対象パスのサブツリーにもアクセス(R/W)する。 読み込み onメソッド 第一引数にvalueを設定すると、データベースが追加される度に差分の読み込みを実行する。 更新が発生した場合は、オブジェクト全体の再取得が実行される。 スナップショットにはキー名やバリュー等が格納される。 データが無くなった場合はnullを返す。 childイベント(第一引数) child_added:子要素が追加に対してリッスンする。更新に対しては同期しない。 スナップショットには、新しい子を含むデータが渡される。 child_changed:子要素の変更に対してリッスンする。 子ノードの子孫に対する変更もリッスン対象となる。 スナップショットには、更新された子を含むデータが渡される。 child_removed:子要素の削除に対してリッスンする。 直接の子のみがリッスン対象となる。 スナップショットには、更新された子を含むデータが渡される。 child_moved:項目順変更に対して同期を行う。原因となった child_changed イベントに後続する。 参考 本記事は本家のYOUTUBEチャンネルを参考に作成しています。 getメソッド 一度だけ読み込みを行う。 変更イベントの監視なし データベースサーバーからデータを取得する。 なんらかの理由で値を返せない場合は、クライアントがローカルキャッシュを調べ、それでも値が見つからなければエラーを返す。 必要以上に使用すると、帯域幅の使用が増加し、パフォーマンスの低下する場合があるが、リアルタイム リスナーを使用することで回避することが可能。 onceメソッド 一度だけ読み込みを行う。 変更イベントのイベント監視あり 更新された値の確認は行わない。 ローカルキャッシュからデータを取得できる。 頻繁な変更やアクティブなリッスンを行うことは想定していないデータ読み込みに対して有効。 書き込み 共通事項 共通の内容として、nullを指定すると削除することができる。 第二引数にerrorが格納され、成功・失敗を判定することができる。 setメソッド 特定の参照に保存できる。 指定パスにある既存のデータが置換される。 子ノードも置換されるため、階層化されたパスの場合は指定パス以下全てデータを書き込むデータを与える必要がある。つまり誤ってルートパスのみに書き込んだらツリーの内容が全て消える。 pushメソッド リストへのデータ追加用メソッド。 push() によって生成されアイテムは自動的に時系列で並べ替えられる。 追加した要素の子ノードに一意の ID が生成される。 第二引数にerrorを返す。 updataメソッド 指定パスを更新できる。 置換ではないため、親要素をupdateしても子ノードは消えない。 原子性が保証され、すべての更新が成功するか、すべての更新が失敗するかのどちらかとなる。 transactionメソッド 同時変更によって破損する可能性があるデータを操作する場合に使用する。 書き込みはupdateが実行される。 削除 removeメソッド 特記事項なし 書き込みメソッドでも代替できる。 参考 FireBase本家
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

モダンWebフロント個人的ピックアップ #2 - アーキテクチャ編

はじめに 今回はフロントエンドアーキテクチャを中心にピックアップしてみました。後ほど紹介する記事のなかで「フロントエンドにおいても現代設計論が適応できる時代になっている」と言うことを知り、重点的に取り上げてみようと思いました。自分自身も将来はフロントエンドの設計が適切にできるようになりたいと感じました。 前回の記事↓ 知識の露出・共有を適切にしてクリーンな設計をしよう 個人的まとめ ※個人的な解釈も含まれます。間違っていたらご指摘頂けると嬉しいです。 クリーンな設計をしよう ≒ 技術的負債を少なくしよう 技術的負債とは「保守や機能追加が高コストになったシステム」 技術的負債が貯まる原因は密結合の範囲が広い。知識を露呈しすぎている 適切な広さの密結合はメリットも多いため、密結合と疎結合を使い分けることが重要 良くない例 データを直接読み書きする -> 読み書きするコードはデータ構造の知識を持っていることになるので、そのデータ構造が変更されたら、そのデータ構造にアクセスしているコードを探し出して変更する必要があるため改修が大変。 責務が複数存在している -> 関数が複数の責務を持っていると言うことは、その分その関数を使っているコードが多く存在すると言うことになる。つまり、この関数を改修しようとすると、影響範囲が広くなるため改修コストが大きい。 関数に書かれたコードが知識として露出される -> 関数に書かれたコードが知識として露出されていると、コピペなどによって技術負債が貯まる原因となる。実装されたコードを隠すためにインターフェースを用いて露出する知識と、そうではない知識を明確に分離する。 実装が使う側の知識を持っている -> 実装が関数やクラスを使う側の知識を持っていると、改修した時に使う側が影響を受ける可能性があるため改修コストが高くなる。 良くない例のように知識が露呈してしまうと、密結合になり、モジュール同士が複雑に絡み合ったガチガチの状態でアプリが完成して改修が大変になってしまうため、知識を不必要に露呈しないように設計するのが重要だと感じました。 宣言的UIはReact Hooksで完成に至り、現代的設計論が必須の時代になる 個人的まとめ ※個人的な解釈を含みます。間違っていたらご指摘頂けると嬉しいです。 React Hooksと宣言的UIによって大規模開発に用いられる設計論に対応できるようになり、技術的負債の抑制が可能になった(広範囲の密結合を防げる) ある程度以上の規模で開発するなら設計論をうまく使い設計しないと、技術的負債を抱え込む React Hooks以後の宣言的UIならば完全に設計論に対応できる 宣言的UIにより以前のDOMの状態にアクセスする必要性が無くなった -> 状態管理とDOMが切り離された 状態管理とDOMが切り離されたことによって、状態管理に設計論を適応できるようになった。代表例がFlux -> 「このDOMにアクセスして状態を更新して」といったようなことをしなくて良い。つまり状態に関する実装が特定のUI、DOMに依存せずに実装することが可能になった。状態の更新に関する処理は特定のDOMに依存しないため、使い回すことができる。 Hooksによりローカルの状態を持つ関数をできるようになった -> Hooks以前はローカルの状態(フォームの値など)に関する処理は特定のコンポーネントに依存する形でしか記述ができなかった。HOCなどを用いれば切り離せるが、Reactのシンプルさを失う。Hooksという解決方法が登場したことによって、UIとローカルの状態管理が綺麗に分離されたため、設計論を適応できるようになった。 Clean Architecture for React 個人的まとめ SOLIDの原則 SOLIDの原則はモジュール間の接続に関する設計原則。SOLIDの原則を満たしていることは、Clean Architectureを実現する上で重要 単一責任の原則(SRP): モジュールはたった一つのアクターに対して責務を負うべきである(DRYの原則を適応するときは、アクターが同じかを確認するべき) オープン・クローズドの原則(OCP): 機能追加などの拡張の際に既存のコードを修正する必要がなく(オープン)、修正する際に他のモジュールに影響がない(クローズド)べき リスコフの置換原則(LSP): SがTの派⽣ならば、Tが使われている箇所をSに置き換えても同じ振る舞いをするべき インターフェース分離の原則(ISP): 一つのインターフェースに全てを詰め込むのではなく、クライアントが必要とする機能のみを提供するべき 依存関係逆転の原則(DIP): ソースコードの依存関係が(具象ではなく)抽象だけを参照するべき 補足: 下の図1上の円の内側の安定なコードから外側の不安定なコードに依存すると、依存している内側まで不安定になってしまうため、依存関係を逆転するべきという考え 図1 Clean Architecture 核となるビジネスドメインを他の依存からいかに守るかを重視する エンティティ: 企業全体の最重要ビジネスルールをカプセル化したもの。その企業が取り組んでいるビジネスのルールを表す ユースケース: アプリケーション固有のビジネスルールを扱う。エンティティを操作しながらアプリケーションが必要とする処理を行う DIP違反をする箇所はメインコンポーネント(main関数など)にまとめる Dependency Injection DIコンテナなどを使わずにDIを行うと、DIする度に具象クラスをインスタンス化する必要があるため、結局依存することになる(具象クラスをimport箇所が依存している箇所) DIコンテナを使うと、依存する箇所を一箇所にまとめることができる TypeScriptにはInversifyJS、TSyringeなどのDIライブラリがある SPA Viewは変更されやすい(不安定な)ためClean Architectureにおいて円の一番外側に位置する SPAはViewに相当する SPAには複雑な設計が求められるようになってきたため、本来プロダクト全体を設計する手法であるClearn Architectureのような現代的な設計論を適用する余地が生まれた SPAにおける円の中心はビューモデル。ビューモデルはエンティティを表示のために変換したもの ユースケースはビューモデルを操作しながら表示に必要な処理を行う ぼくのかんがえたフロントエンドアーキテクチャ kichionさんが考案した実際に現代的設計論を適用したフロントエンドアーキテクチャです。 僕自身もフロントエンドアーキテクチャを少し考えたりしています。今後の記事で書こうと思っているので頑張って完成させたいと思います?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React状態管理ライブラリJotaiとValtioのGitHubスター数推移を見てみた

これを見ると、jotaiの順調な伸びに比べるとvaltioの伸びが良くないように見えます。valtioはv1リリースして、jotaiはまだv1リリースに到達していないのですが。ちなみに、valtioの初回リリースのスター数の伸びは凄まじかったです。このグラフでもほぼ直角に上がっているように見えますね。 それぞれのリンクはこちら。 どちらが好みですか?「React Fan」というコミュニティのSlackでjotaiやvaltioに関する質問や雑談ができますので、よろしければご参加ください。 React開発者向けオンラインサロン「React Fan」の入り口ページ React開発者向けオンラインサロン「React Fan」のTwitterアカウント
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React-Reduxとfirebaseで作成したアプリに全文検索機能を導入してみた

はじめに  Qiita初投稿になりますので稚拙な文章、またコードもお見苦しい点が多いと思いますがご容赦ください。 またご指摘やご指導くださると幸いです。 自己紹介  私は昨年末からエンジニアを志し、現在勉強しつつ未経験からの転職を行っている20代後半の看護師です。最近はモダンフロント開発を行えるようになるべくReact-Redux及びFirebaseを用いてアプリの作成を行なっています。 環境及び参考教材 create-react-app redux material-ui YOUTUBE とらゼミ トラハックのエンジニア学習講座 Qiita Firestore だけで Algolia を使わず全文検索 導入 ポートフォリオ作成に当たり、全文検索機能を追加しようと調べているとFirestore(Firebaseにおけるデータベース)には全文検索機能は提供されておらず、公式のドキュメントではAlgoliaの使用を推奨していました。 Algolia 調べてみるととてもイケているサービスですが、従量課金制で今回作成するポートフォリオには向いていないと判断しました。そこで何か他に良い方法はないかと調べてみると、上記の@oukayukaさんの「Firestore だけで Algolia を使わず全文検索」を見つけました。読んでみるとAlgoliaを使用せずn-grumというというものを使用することで全文検索を実装しているようでした。記事の中には実装の概要は記載されていますが、詳しい実装方法やサンプルコードなどはなくそちらは@oukayukaさん著書のりあクト! Firebaseで始めるサーバーレスReact開発を読んでね!という形でした。(自分は新装版のReact×Typescriptの本は拝読済み)自分はガッツリ初心者であるため中々に難しい課題であると思いますが、チャレンジしてみることとしました。  またReact-Reduxについては虎ハックさんの「日本一わかりやすいReact-Redux実践編」を用いて学習させていただいた。 n-grumとは n-gramとは、任意の文書や文字列などにおける任意のn文字が連続した文字列のことである。 1文字続きのものはunigram、2文字続きのものはbigram、3文字続きのものはtrigram、と特に呼ばれ、4文字以上のものは、単に4-gram、5-gramと表現されることが多い。                 辞典・百科事典の検索サービス - Weblio辞書より引用 例えばbigrumは「"注射は痛い"」 => 「"注射","射は","は痛","痛い"」のように区切られる。 本題 実施項目 1.textinputで受け取った名前をn-grum関数で分割し、firestoreにマップ型で保存する 2.検索用のフォームを作成する 3.検索フォームに入力された値(以下search)を用いてページ遷移する 4.searchをn-grum関数で分割し、firestoreにクエリを投げる 実践  まずはn-grum関数を作成することから始めます。 nGrum.js const nGrum = (name,n) => { const searchGrums = new Map(); for(let i=0; i<name.length; i++){ const results = [name.substr(i,n)] results.map(result => { searchGrums.push(result) }) }return searchGrums }; 以上。もっとリファクタリングできると思いますが、今はまだこれで十分...(つーかこれが限界) この関数でnameをby-grumでFirestoreに保存する。のですがここでエラーが... Map型はサポートされていないデータ型だと怒られてしまいます。なので nGrum.js const nGrum = (name,n) => { const searchGrums = new Map(); for(let i=0; i<name.length; i++){ const results = [name.substr(i,n)] results.map(result => { searchGrums.push(result) }) }return searchGrums }; "ここから追加" const strMapToObj = (strMap) => { let obj = Object.create(null); for (let [k,v] of strMap) { obj[k] = v; } return obj; } オブジェクト型に変換して保存を再度試みると成功しました。 これで1.の項目は完了です。 文を簡潔に説明するのが下手で、長くなってしまいましたのでこの記事は前編ということにします。 次回は後編ということで残りの2〜4.を実装していこうと思います。 参考 Firestore だけで Algolia を使わず全文検索
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React-Reduxとfirebaseで作成したアプリに全文検索機能を導入してみた 前編

はじめに  Qiita初投稿になりますので稚拙な文章、またコードもお見苦しい点が多いと思いますがご容赦ください。 またご指摘やご指導くださると幸いです。 自己紹介  私は昨年末からエンジニアを志し、現在勉強しつつ未経験からの転職を行っている20代後半の看護師です。最近はモダンフロント開発を行えるようになるべくReact-Redux及びFirebaseを用いてアプリの作成を行なっています。 環境及び参考教材 create-react-app redux material-ui YOUTUBE とらゼミ トラハックのエンジニア学習講座 Qiita Firestore だけで Algolia を使わず全文検索 導入 ポートフォリオ作成に当たり、全文検索機能を追加しようと調べているとFirestore(Firebaseにおけるデータベース)には全文検索機能は提供されておらず、公式のドキュメントではAlgoliaの使用を推奨していました。 Algolia 調べてみるととてもイケているサービスですが、従量課金制で今回作成するポートフォリオには向いていないと判断しました。そこで何か他に良い方法はないかと調べてみると、上記の@oukayukaさんの「Firestore だけで Algolia を使わず全文検索」を見つけました。読んでみるとAlgoliaを使用せずn-grumというというものを使用することで全文検索を実装しているようでした。記事の中には実装の概要は記載されていますが、詳しい実装方法やサンプルコードなどはなくそちらは@oukayukaさん著書のりあクト! Firebaseで始めるサーバーレスReact開発を読んでね!という形でした。(自分は新装版のReact×Typescriptの本は拝読済み)自分はガッツリ初心者であるため中々に難しい課題であると思いますが、チャレンジしてみることとしました。  またReact-Reduxについては虎ハックさんの「日本一わかりやすいReact-Redux実践編」を用いて学習させていただいた。 n-grumとは n-gramとは、任意の文書や文字列などにおける任意のn文字が連続した文字列のことである。 1文字続きのものはunigram、2文字続きのものはbigram、3文字続きのものはtrigram、と特に呼ばれ、4文字以上のものは、単に4-gram、5-gramと表現されることが多い。                 辞典・百科事典の検索サービス - Weblio辞書より引用 例えばbigrumは「"注射は痛い"」 => 「"注射","射は","は痛","痛い"」のように区切られる。 本題 実施項目 1.textinputで受け取った名前をn-grum関数で分割し、firestoreにマップ型で保存する 2.検索用のフォームを作成する 3.検索フォームに入力された値(以下search)を用いてページ遷移する 4.searchをn-grum関数で分割し、firestoreにクエリを投げる 実践  まずはn-grum関数を作成することから始めます。 nGrum.js const nGrum = (name,n) => { const searchGrums = new Map(); for(let i=0; i<name.length; i++){ const results = [name.substr(i,n)] results.map(result => { searchGrums.push(result) }) }return searchGrums }; 以上。もっとリファクタリングできると思いますが、今はまだこれで十分...(つーかこれが限界) この関数でnameをby-grumでFirestoreに保存する。のですがここでエラーが... Map型はサポートされていないデータ型だと怒られてしまいます。なので nGrum.js const nGrum = (name,n) => { const searchGrums = new Map(); for(let i=0; i<name.length; i++){ const results = [name.substr(i,n)] results.map(result => { searchGrums.push(result) }) }return searchGrums }; "ここから追加" const strMapToObj = (strMap) => { let obj = Object.create(null); for (let [k,v] of strMap) { obj[k] = v; } return obj; } オブジェクト型に変換して保存を再度試みると成功しました。 これで1.の項目は完了です。 文を簡潔に説明するのが下手で、長くなってしまいましたのでこの記事は前編ということにします。 次回は後編ということで残りの2〜4.を実装していこうと思います。 参考 Firestore だけで Algolia を使わず全文検索
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【React】コード分割による表示パフォーマンス改善

Create-React-Appのプロジェクトをビルドする際、プロジェクトが大きいとWebpackでバンドルされたファイルも大きくなってしまいます。 バンドルファイルのサイズが大きいと表示パフォーマンスに影響がでてしまい、ReactでSPAを作成した意味が薄れてしまいます。 そんな問題を解決するために、コード分割(Code Splitting)という方法が使われているということを知りました。 今のところ現場で使うことはなさそうなのですが、今後の仕事のために備忘録としてまとめました。 Create-React-Appプロジェクトのビルド Create-React-Appで生成したプロジェクトでビルドを実行すると、buildフォルダにいろんなファイルが出力されます。 publicフォルダの内容がbuildフォルダにコピーされているのですが、build/index.htmlの中身をみると、public/index.htmlには記述されていなかったscriptタグがbodyタグの末尾に挿入されています。 つまり、ウェブサーバにbuildフォルダを配置したとき、なにかしらのjsファイルが読み込まれReactのコンポーネントが表示されるようになっています。 読み込まれるjsファイル(static/js/の中身)には以下のものがあります。 [number].[hash].chunk.js: srcディレクトリ配下でインポートしたnode_modulesのコードが含まれます。 main.[hash].chunk.js: App.jsなどのsrcディレクトリ配下のアプリケーションのコード含まれます。 dev-toolのNetworkタブをみてみると、こんな感じで読み込まれています。 コード分割(Code Splitting) コード分割は、バンドルファイルを分けてページロードを早くするための手法です。 コード分割を行っていない場合、ページを開いたときにプロジェクトのすべてのコード(=表示しないページを含んだコード)が含まれたバンドルファイルを読み込んでいます。 つまり、表示に関係のある部分のコードを別ファイルに分割して読み込めれば、その分表示が早くなることになります。 React.lazy コード分割を行うために、React.lazy関数を使用します。 この関数を使うことで、動的インポートを通常のコンポーネントとしてレンダーすることができます。 //動的インポート import HomePage from './pages/homepage/homepage.component'; //React.lazy const HomePage = lazy(() => import('./pages/homepage/homepage.component')); 例えば、以下の'/'パスを開いてみると、React.lazyのHomePageの場合では、レンダー時点でHomePageを除いたバンドルファイル(main.chunk.js)を読み込みます。 その後に、<Route exact path="/" component={HomePage} />でHomePageのバンドルファイル(0.chunk.js)が読み込まれます。 return ( <div> <GlobalStyle /> <Header /> <Switch> <Route exact path="/" component={HomePage} /> <Route path="/shop" component={ShopPage} /> <Route exact path="/checkout" component={CheckoutPage} /> <Route exact path="/signin" render={() => props.currentUser ? ( <Redirect to="/" /> ) : ( <SignInAndSignUpPage /> ) } /> </Switch> </div> ); ただ、React.lazy関数(Promiseを返す)をそのまま読み込もうとするとエラーがでます。 そのため、HomePageを取得するまでの間はloadingを出すように、Suspenseでラッピングしてあげます。 <Suspense fallback={<div>...loading</div>}> <Route exact path="/" component={HomePage} /> ​</Suspense> また、HomePage以外のページについても分割するときは、Suspenseをラッピングする範囲を変えれば大丈夫です。 const HomePage = lazy(() => import('./pages/homepage/homepage.component')); const ShopPage = lazy(() => import('./pages/shop/shop.component')); const SignInAndSignUpPage = lazy(() => import('./pages/sign-in-and-sign-up/sign-in-and-sign-up.component') ); const CheckoutPage = lazy(() => import('./pages/checkout/checkout.component')); return ( <div> <GlobalStyle /> <Header /> <Switch> <ErrorBoundary> <Suspense fallback={<div>...loading</div>}> <Route exact path="/" component={HomePage} /> <Route path="/shop" component={ShopPage} /> <Route exact path="/checkout" component={CheckoutPage} /> <Route exact path="/signin" render={() => props.currentUser ? ( <Redirect to="/" /> ) : ( <SignInAndSignUpPage /> ) } /> </Suspense> </ErrorBoundary> </Switch> </div> ); Error boundary 以上のコードでSuspenseをさらにErrorBoundaryでラッピングしていますが、これでコンポーネント内で発生したJavaScriptエラーが起こったときに、フォールバック用のUIを表示させることができます。 おわりに むやみやたらとコード分割をするのは悪手で、dev-toolなどで現状のパフォーマンスを計測してから最適化は行うべきじゃないとのことです。 参考資料
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

next.jsでreactのcreateContextをviewとデータを切り離して綺麗に書く方法

開発環境: macOSX catalina バージョン10.15.7 visualStudioCode react next.js createContextを綺麗に書く方法を教わったので、備忘録 以下のような書き方をして、Contextを定義する そうすることによって、viewとデータを切り離すことができ、可読性が高まるとのこと import React, { useReducer, createContext } from "react"; import reducer from "./reducer"; const tagList = []; const MyContext = createContext(""); const Store = ({ children }) => { const [state, dispatch] = useReducer(reducer, tagList); return ( <MyContext.Provider value={{ state, dispatch }}> {children} </MyContext.Provider> ); }; export { MyContext, Store }; 以下のようにContextを下位コンポーネントにつなぐ import "../styles/globals.css"; import React from "react"; import { Store } from "../store/store"; function MyApp({ Component, pageProps }) { return ( <Store> <Component {...pageProps} /> </Store> ); } export default MyApp;
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む