tag:crieit.net,2005:https://crieit.net/tags/React/feed
「React」の記事 - Crieit
Crieitでタグ「React」に投稿された最近の記事
2023-04-11T23:36:31+09:00
https://crieit.net/tags/React/feed
tag:crieit.net,2005:PublicArticle/18423
2023-04-11T23:29:51+09:00
2023-04-11T23:36:31+09:00
https://crieit.net/posts/simple-hr-reporter
SmartHRと連携して人事データを可視化するツールを作ってみた
<p>個人開発でSmartHRと連携したレポーティングツールを作成してみました。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://simple-hr-reporter.saikicks.net/report"><strong>人事データ簡易分析ツール</strong></a></p>
<h2 id="概要"><a href="#%E6%A6%82%E8%A6%81">概要</a></h2>
<p>このツールはSmartHRと連携してAPI経由でデータを取得して可視化を行うためのレポーティングツールです。</p>
<p>SmartHRではフリープラン、労務管理プランでは分析レポートの利用が有料プランに切り替えるなどの対応を行わないと利用できません。</p>
<p>オプション機能として提供されていますが、そこまで細かく分析するほどでは無く現状の人事情報を可視化したい、みたいな緩い使い方でも許容できるユーザーさんを対象に簡易的なグラフ出力を提供します。</p>
<p>利用にはテナントIDとアクセストークンを利用しますが、サイト内にも記載がある通り<strong>このツールはテナントIDやアクセストークンを保存したりはしません。データ取得時のみに利用します</strong>。</p>
<p>そのため画面をリロードしたりするともちろん情報は失われます。</p>
<p>再度表示したい場合は都度テナントID、アクセストークンはをもう一度入力していただく手間はありますが…</p>
<h2 id="技術構成"><a href="#%E6%8A%80%E8%A1%93%E6%A7%8B%E6%88%90">技術構成</a></h2>
<ul>
<li>React</li>
<li>rebass</li>
<li>styled-component</li>
<li>recharts</li>
<li>TypeScript</li>
<li>Vite</li>
<li>VItest</li>
<li>Axios</li>
<li>CloudFlare Pages</li>
</ul>
<h2 id="レポート出力結果"><a href="#%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88%E5%87%BA%E5%8A%9B%E7%B5%90%E6%9E%9C">レポート出力結果</a></h2>
<p>出力結果は以下のような感じでシンプルに出力しています。</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324202730.png" alt="image" /></p>
<p>もしご興味あれば是非使ってみてください。</p>
<p>次にテナントIDとアクセストークンについて簡単に説明します。</p>
<h2 id="テナントIDについて"><a href="#%E3%83%86%E3%83%8A%E3%83%B3%E3%83%88ID%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">テナントIDについて</a></h2>
<p>テナントIDはSmartHRのURLに含まれている以下の部分の文字列です。</p>
<pre><code>[https://[テナントID].smarthr.jp/](https://[テナントID].smarthr.jp/)
</code></pre>
<h2 id="アクセストークンについて"><a href="#%E3%82%A2%E3%82%AF%E3%82%BB%E3%82%B9%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">アクセストークンについて</a></h2>
<p>アクセストークンは管理画面より作成を行うため以下に手順を載せておきます。</p>
<p>SmartHRにログインした後、右上のユーザー名の部分をクリックするとメニューが表示されます。</p>
<p>その中の「共通設定」をクリックしてください。</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324201148.png" alt="image" /></p>
<p>次に左側メニューから「外部システム連携」をクリックします。</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324201203.png" alt="image" /></p>
<p>API連携より「アクセストークン」をクリックします。</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324201159.png" alt="image" /></p>
<p>「新規発行」をクリックします</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324201155.png" alt="image" /></p>
<p>「アクセストークン名」を入力します。利用する用途を名前にしておくと良いです。</p>
<p>例えば「分析レポート」とか…</p>
<p>必須項目の入力が埋まると「登録」ボタンがクリックできるようになるので登録するとアクセストークンが払い出されます。</p>
<p><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_saiki/20230324/20230324201151.png" alt="image" /></p>
<p>これらのテナントIDとアクセストークンを利用して本ツールにて入力して「出力」ボタンをクリックしてもらうとレポートが出力されます。</p>
<p>まだまだ表示がおかしい部分があったり、追加したい機能等があるので引き続き開発を進めていきたいと思います!</p>
<p>採用計画や人事データを活用して分析を行いたい方は是非活用してみてください。</p>
Sassy_1123
tag:crieit.net,2005:PublicArticle/18238
2022-07-14T23:32:07+09:00
2022-07-14T23:32:07+09:00
https://crieit.net/posts/use-clipboard-api-by-react-20220714
React (create-react-app) で Clipboard API にアクセスするために localhost:3000 をhttpsにする
<h2 id="経緯"><a href="#%E7%B5%8C%E7%B7%AF">経緯</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/flu_bit/items/659a59260446117e9548">クリップボードにテキストをコピーするボタンの実装 - Qiita</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://mseeeen.msen.jp/copy-text-to-clipboard-with-javascript-in-2021/">JavaScript でクリップボードにテキストをコピーする (2021年版) | MSeeeeN | 大阪発 IT メディア by MSEN</a></li>
</ul>
<p>React アプリ(<code>create-react-app</code>(<code>react-scripts</code>))で Clipboard API を使おうとしたところ、次のエラーが発生してしまいました。</p>
<blockquote>
<p>Uncaught TypeError: Cannot read property ‘writeText’ of undefined ...</p>
</blockquote>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://owatata.com/2020/10/06/%E3%80%90javascript%E3%80%91navigator-clipboard-writetex%E3%81%A7-uncaught-typeerror-cannot-read-property-writetext-of-undefined/">【javascript】navigator.clipboard.writeTextで Uncaught TypeError: Cannot read property ‘writeText’ of undefined – オワタ太のブログ</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://propg.ee-mall.info/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0/javascript/js-http%E7%92%B0%E5%A2%83%E3%81%A7%E3%81%AFnavigator-clipboard-writetext%E3%81%8C%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AB%E3%81%AA%E3%82%8B/">[JS] http環境ではnavigator.clipboard.writeTextがエラーになる - プロプログラマ -Flex,Air,C#,Oracle,HTML5+JS-</a></li>
</ul>
<p>調べてみたところ、 https でないと使用できないようです。</p>
<p>そこで通常 <code>npm start</code> で自動起動してくる <code>http://localhost:3000/</code> を <code>https://localhost:3000/</code> にする必要に迫られたので、その方法を書き留めておきます。</p>
<h2 id="対処"><a href="#%E5%AF%BE%E5%87%A6">対処</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/sutara79/items/21a068494bc3a08a4803">XAMPP for WindowsでSSLを有効にする - Qiita</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://kdnakt.hatenablog.com/entry/2020/06/07/170000">Create React Appで作成したアプリをローカルのMacBook上でHTTPSで動作させる - kdnakt blog</a></li>
</ul>
<p>今回は XAMPP の入っているPCでそのオレオレSSL証明書が既にあったので、それを流用します。</p>
<p>証明書を <code>server.crt</code>、 秘密鍵を <code>server.key</code> という名前でコピーして、 <code>create-react-app</code> プロジェクト配下に設置します(今回は <code>ssl/</code> ディレクトリを切りました)。</p>
<p>次に、 <code>.env</code> に次の3行を足して再度 <code>npm start</code> します。</p>
<pre><code>HTTPS=true
SSL_CRT_FILE=./ssl/server.crt
SSL_KEY_FILE=./ssl/server.key
</code></pre>
<p>これで <code>https://localhost:3000/</code> でアプリにアクセスでき、無事 Clipboard API も動かすことができました。</p>
<h2 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h2>
<h3 id="Clipboard API"><a href="#Clipboard+API">Clipboard API</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/flu_bit/items/659a59260446117e9548">クリップボードにテキストをコピーするボタンの実装 - Qiita</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://mseeeen.msen.jp/copy-text-to-clipboard-with-javascript-in-2021/">JavaScript でクリップボードにテキストをコピーする (2021年版) | MSeeeeN | 大阪発 IT メディア by MSEN</a></li>
</ul>
<h3 id="Uncaught TypeError: Cannot read property ‘writeText’ of undefined"><a href="#Uncaught+TypeError%3A+Cannot+read+property+%E2%80%98writeText%E2%80%99+of+undefined">Uncaught TypeError: Cannot read property ‘writeText’ of undefined</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://owatata.com/2020/10/06/%E3%80%90javascript%E3%80%91navigator-clipboard-writetex%E3%81%A7-uncaught-typeerror-cannot-read-property-writetext-of-undefined/">【javascript】navigator.clipboard.writeTextで Uncaught TypeError: Cannot read property ‘writeText’ of undefined – オワタ太のブログ</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://propg.ee-mall.info/%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0/javascript/js-http%E7%92%B0%E5%A2%83%E3%81%A7%E3%81%AFnavigator-clipboard-writetext%E3%81%8C%E3%82%A8%E3%83%A9%E3%83%BC%E3%81%AB%E3%81%AA%E3%82%8B/">[JS] http環境ではnavigator.clipboard.writeTextがエラーになる - プロプログラマ -Flex,Air,C#,Oracle,HTML5+JS-</a></li>
</ul>
<h3 id="https化"><a href="#https%E5%8C%96">https化</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/sutara79/items/21a068494bc3a08a4803">XAMPP for WindowsでSSLを有効にする - Qiita</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://kdnakt.hatenablog.com/entry/2020/06/07/170000">Create React Appで作成したアプリをローカルのMacBook上でHTTPSで動作させる - kdnakt blog</a></li>
</ul>
arm-band
tag:crieit.net,2005:PublicArticle/17962
2022-02-02T08:58:21+09:00
2022-02-02T08:58:21+09:00
https://crieit.net/posts/React-async-await
Reactでasync/awaitだけで確認ダイアログを出せるようにする
<p>今回はNext.jsですが、Reactで確認ダイアログを出す時、confirm関数的なものをawaitで呼べたら楽ちんだよなあと思うのでその実装方法を書きます。そのページに別途ダイアログ用のテンプレートは記述しない方法です。</p>
<p>例えば下記のような感じです。</p>
<pre><code class="typescript">const onClick = useCallback(async () => {
const isConfirmed = await confirm('タイトル', 'OKですか?')
if (!isConfirmed) {
return
}
doSomething()
}, [])
</code></pre>
<h2 id="ダイアログの状態管理を作成"><a href="#%E3%83%80%E3%82%A4%E3%82%A2%E3%83%AD%E3%82%B0%E3%81%AE%E7%8A%B6%E6%85%8B%E7%AE%A1%E7%90%86%E3%82%92%E4%BD%9C%E6%88%90">ダイアログの状態管理を作成</a></h2>
<p>まずはダイアログの状態管理を行います。今回は状態管理としてRecoilを使っています。下記のような <code>useConfirmDialog.ts</code> を作成します。</p>
<pre><code class="typescript">import { useCallback } from 'react'
import { atom, useRecoilState } from 'recoil'
type OpenArgs = {
title?: string
body: React.ReactNode
}
type DialogProps = OpenArgs & {
resolve: (value: boolean) => void
}
const dialogPropsState = atom<DialogProps>({
key: 'confirmDialog/dialogProps',
default: undefined,
})
export function useConfirmDialog() {
const [dialogProps, setDialogProps] = useRecoilState(dialogPropsState)
const close = useCallback(
(result: boolean) => {
console.log({ dialogProps })
if (dialogProps.resolve) {
dialogProps.resolve(result)
}
setDialogProps(undefined)
},
[dialogProps]
)
function open({ title, body }: OpenArgs): Promise<boolean> {
return new Promise((resolve) => {
setDialogProps({
title,
body,
resolve,
})
})
}
return { isOpen: !!dialogProps, open, close, dialogProps }
}
</code></pre>
<p>ダイアログが開いているかの <code>isOpen</code>、開くための関数 <code>open</code>、閉じるための <code>close</code>、ダイアログの設定である <code>dialogProps</code> を出力します。</p>
<p>ここで重要なのがPromiseのresolveをdialogPropsとして保存しているところです。これを保存しておき、ダイアログが終わるタイミングでそのresolveを呼び出すことで呼び出し元のawaitを終了させることができます。</p>
<p>ちなみにこのdialogPropsという形ではなく、タイトルや内容、resolveをそれぞれのstateにすることも考えられますが、これはだめでした。なぜかresolveが勝手にundefinedになってしまい即awaitが終了してしまいます。何か勝手に解放されてしまうのでしょうか。ということでここが一番の肝でした。</p>
<h2 id="ダイアログの表示を行う"><a href="#%E3%83%80%E3%82%A4%E3%82%A2%E3%83%AD%E3%82%B0%E3%81%AE%E8%A1%A8%E7%A4%BA%E3%82%92%E8%A1%8C%E3%81%86">ダイアログの表示を行う</a></h2>
<p>ダイアログ自体は ConfirmDialogProvider というコンポーネントを作り、アプリケーション全体を囲むレイアウトとして設置しておきます。</p>
<p>Next.jsであれば <code>pages/_app.tsx</code> に下記のような感じです。</p>
<pre><code class="jsx">function MyApp({ Component, pageProps }) {
const colors = useColors()
return (
<RecoilRoot>
<ConfirmDialogProvider>
<Component {...pageProps} />
</ConfirmDialogProvider>
</RecoilRoot>
)
}
</code></pre>
<p>ConfirmDialogProviderは下記のような感じです。Chakra UIのダイアログを使った例です。</p>
<pre><code class="jsx">import { useConfirmDialog } from '@/hooks/dialog/useConfirmDialog'
import { useLocale } from '@/hooks/useLocale'
import {
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Button,
} from '@chakra-ui/react'
import { useRef } from 'react'
type Props = {
children: React.ReactNode
}
export default function ConfirmDialogProvider(props: Props) {
const { isOpen, close, dialogProps } = useConfirmDialog()
const { title, body } = dialogProps ?? {}
const cancelRef = useRef()
return (
<>
{props.children}
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={() => close(false)}
>
<AlertDialogOverlay>
<AlertDialogContent>
{title && (
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title}
</AlertDialogHeader>
)}
<AlertDialogBody mt={title ? undefined : 4}>{body}</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={() => close(false)}>
Cancel
</Button>
<Button colorScheme="teal" onClick={() => close(true)} ml={3}>
OK
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
)
}
</code></pre>
<h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2>
<p>これで確認が楽ちんになりました。</p>
だら@Crieit開発者
tag:crieit.net,2005:PublicArticle/17615
2021-08-25T22:14:35+09:00
2021-08-29T20:33:12+09:00
https://crieit.net/posts/publish-blog-by-gatsvyjs-vercel-20210825
Gatsby.js + Vercel でブログを作ってみる
<p>兼ねてより試してみたかった Gatsby.js + Vercel の組み合わせに着手してみました。</p>
<h2 id="1. Gatsby.js のインストールと Gitリポジトリ の準備"><a href="#1.+Gatsby.js+%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB%E3%81%A8+Git%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA+%E3%81%AE%E6%BA%96%E5%82%99">1. Gatsby.js のインストールと Gitリポジトリ の準備</a></h2>
<p>まずは Gatsby.js のプロジェクトを作って Gitホスティングサービス に push します。</p>
<pre><code class="bash">> yarn init
## 略
success Saved package.json
Done in 2.33s.
</code></pre>
<p>適当なディレクトリで <code>package.json</code> を生成。</p>
<pre><code class="bash">> yarn add gatsby-cli
## 略
Done in 26.05s.
</code></pre>
<p>続いて <code>gatsby-cli</code> をインストールします。一応グローバルではなくローカルで。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.gatsbyjs.com/static/203f352fa59959029a106ffe821ad0bb/85e35/93d59e2b139dc14ee2118ff7410333c9.webp">: Gatsby Starter | Gatsby</a></li>
</ul>
<p>今回のテーマはこれにします。</p>
<pre><code class="bash">> gatsby new mptotkb https://github.com/renyuanz/leonids
╔════════════════════════════════════════════════════════════════════════╗
║ ║
║ Gatsby collects anonymous usage analytics ║
║ to help improve Gatsby for all users. ║
║ ║
║ If you'd like to opt-out, you can use `gatsby telemetry --disable` ║
║ To learn more, checkout https://gatsby.dev/telemetry ║
║ ║
╚════════════════════════════════════════════════════════════════════════╝
info Creating new site from git: https://github.com/renyuanz/leonids.git
Cloning into 'mptotkb'...
remote: Enumerating objects: 60, done.
remote: Counting objects: 100% (60/60), done.
remote: Compressing objects: 100% (44/44), done.
Receiving objects: 100% (60/60), 542.69 KiB | 5.32 MiB/s, done.eceiving objects: 100% (60/60)
success Created starter directory layout
info Installing packages...
info Preferred package manager set to "npm"
## 略
Your new Gatsby site has been successfully bootstrapped. Start developing it by running:
cd mptotkb
gatsby develop
</code></pre>
<p>プロジェクト作成完了。</p>
<pre><code class="bash">> cd mptotkb
> gatsby develop
## 略
info
Hi from the Gatsby maintainers! Based on what we see in your site, these coming
features may help you. All of these can be enabled within gatsby-config.js via
flags (samples below)
Preserve webpack's Cache (https://github.com/gatsbyjs/gatsby/discussions/28331), which changes Gatsby's cache clearing behavior to not clear webpack's
cache unless you run "gatsby clean" or delete the .cache folder manually.
Here's how to try it:
module.exports = {
flags: { PRESERVE_WEBPACK_CACHE: true },
plugins: [...]
}
⠀
You can now view gatsby-starter-leonids in the browser.
⠀
http://localhost:8000/
⠀
View GraphiQL, an in-browser IDE, to explore your site's data and schema
⠀
http://localhost:8000/___graphql
⠀
Note that the development build is not optimized.
To create a production build, use gatsby build
⠀
success Building development bundle - 48.261s
</code></pre>
<p>OK。完了しました。</p>
<p>最初は手始めということでこの状態でいったん push しておきたいと思います。 Gitリポジトリ に登録するのは親ディレクトリではなく作成したプロジェクト (今回は <code>mptotkb</code> 下) です。</p>
<p><a href="https://crieit.now.sh/upload_images/c767731cd51656a0324d46f4f80e7af261263f776ecdc.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/c767731cd51656a0324d46f4f80e7af261263f776ecdc.jpg?mw=700" alt="GitHub でリポジトリを作成" /></a></p>
<p>GitHub でリポジトリを作成して、このリポジトリに先ほどのプロジェクトを push 。ちなみにリポジトリは public である必要があります。</p>
<h2 id="2. Vercel と Gitホスティングサービス の連携"><a href="#2.+Vercel+%E3%81%A8+Git%E3%83%9B%E3%82%B9%E3%83%86%E3%82%A3%E3%83%B3%E3%82%B0%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9+%E3%81%AE%E9%80%A3%E6%90%BA">2. Vercel と Gitホスティングサービス の連携</a></h2>
<p>次に Vercel でアカウントを作ります。</p>
<p><a href="https://crieit.now.sh/upload_images/77ce0ff86a6de3b873bb06d7f1de0c8e61263f822ba39.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/77ce0ff86a6de3b873bb06d7f1de0c8e61263f822ba39.jpg?mw=700" alt="Vercel で Sign Up をクリック" /></a></p>
<p>Sign Up をクリック。</p>
<p><a href="https://crieit.now.sh/upload_images/43666c5f16041fc59fdb8d593a9b955561263f8d448dd.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/43666c5f16041fc59fdb8d593a9b955561263f8d448dd.jpg?mw=700" alt="GitHub と連携" /></a></p>
<p>今回は GitHub を使用しているので「Continue with GitHub」を選択。</p>
<p><a href="https://crieit.now.sh/upload_images/1f94d557606bdff1448bcb340a9d56b861263f98c6f6f.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/1f94d557606bdff1448bcb340a9d56b861263f98c6f6f.jpg?mw=700" alt="Vercel のアクセスを認証して権限を付与" /></a></p>
<p>「Authorize Vercel」で Vercel のアクセスを認証します。</p>
<p><a href="https://crieit.now.sh/upload_images/0e40bf8259a51bb7dea9189426fb3f5961263faa0614f.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/0e40bf8259a51bb7dea9189426fb3f5961263faa0614f.jpg?mw=700" alt="Vercel にログイン成功" /></a></p>
<p>ログインできました。</p>
<h2 id="3. リポジトリのインポート"><a href="#3.+%E3%83%AA%E3%83%9D%E3%82%B8%E3%83%88%E3%83%AA%E3%81%AE%E3%82%A4%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%88">3. リポジトリのインポート</a></h2>
<p><a href="https://crieit.now.sh/upload_images/9b1b79698c430d0d958f3d770e4c62bf61263fbda9d17.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/9b1b79698c430d0d958f3d770e4c62bf61263fbda9d17.jpg?mw=700" alt="ダッシュボードから「New Project」" /></a></p>
<p>一度中断してしまったのでダッシュボードから。ここからであれば「New Project」で次へ進みます。</p>
<p><a href="https://crieit.now.sh/upload_images/490785baeef1275ccb89cdb6d4a5db7861263fc869e4e.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/490785baeef1275ccb89cdb6d4a5db7861263fc869e4e.jpg?mw=700" alt="Import Git Repository から「Add GitHub Org or Account」" /></a></p>
<p>Import Git Repository から「Add GitHub Org or Account」。</p>
<p><a href="https://crieit.now.sh/upload_images/92eb299d6668bdba87d40c089976f9fc61263fd566948.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/92eb299d6668bdba87d40c089976f9fc61263fd566948.jpg?mw=700" alt="インポートするリポジトリを選択" /></a></p>
<p>インポートするリポジトリを選択します。今回は1つのリポジトリだけに Vercel に連携させます。</p>
<p><a href="https://crieit.now.sh/upload_images/95606ef51dd06912c5fe460301a1d09361263fe080aa9.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/95606ef51dd06912c5fe460301a1d09361263fe080aa9.jpg?mw=700" alt="リポジトリの確認" /></a></p>
<p>リポジトリの確認です。使用しているフレームワークが自動判別されるのはありがたいですね。</p>
<p><a href="https://crieit.now.sh/upload_images/676cf8e4e4edee1f6c692cd0a445524e61263fea8e98f.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/676cf8e4e4edee1f6c692cd0a445524e61263fea8e98f.jpg?mw=700" alt="チームの作成は「Skip」" /></a></p>
<p>次の画面でフローに沿って設定を進めていきます。最初に Vercel のチームを作成する画面が有効になっていますが、今回は個人なので「Skip」。</p>
<p><a href="https://crieit.now.sh/upload_images/fa75aa33dba45c6b8029ab0a0a3695bc61263ff3507ed.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/fa75aa33dba45c6b8029ab0a0a3695bc61263ff3507ed.jpg?mw=700" alt="Configure Project" /></a></p>
<p>プロジェクトの設定。出力の設定や環境変数を設定できますが、今回はそのままで。</p>
<p><a href="https://crieit.now.sh/upload_images/64a16b6c3867805df977e0ac9704d57361263ffb9d76a.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/64a16b6c3867805df977e0ac9704d57361263ffb9d76a.jpg?mw=700" alt="デプロイ中" /></a></p>
<p>デプロイ中。</p>
<p><a href="https://crieit.now.sh/upload_images/eb14aeca4ce6114b32dd4f90697d064361264004d26ec.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/eb14aeca4ce6114b32dd4f90697d064361264004d26ec.jpg?mw=700" alt="デプロイされた画面を実際に確認" /></a></p>
<p>少し待つとデプロイが完了します。そこでURLから実際の画面を表示させると……きちんとデプロイされていました!</p>
<p>ここまでで Gatsby.js + Vercel でブログを作成することができました。</p>
<p>今後の課題としては以下の通り。</p>
<ul>
<li>サイト名や著者情報といった基本情報のカスタマイズ</li>
<li>どうやらブログ記事の Markdown ファイルを生成する CLI やユーティリティはなさそうで、手作業で記事をちまちま作っていくのは手間なので記事リソースを別の場所 (<a href="https://crieit.net/posts/gatsby-vercel-microcms-cooprate-20210829-1">Headless CMS</a> とか) で管理して <a href="https://crieit.net/posts/gatsby-vercel-microcms-cooprate-20210829-2">JAMStack にする</a></li>
</ul>
<p>あとは折角なので GraphQL にも少しは触ってみたいところですね。</p>
<h2 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h2>
<h3 id="Gatsby.js + Vercel"><a href="#Gatsby.js+%2B+Vercel">Gatsby.js + Vercel</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://webcraftlog.net/gatsbyjs-site-deploy-to-vercel/">【ZEIT Now】Gatsbyサイトを無料サーバーVercelで公開する方法を徹底解説 | WebCraftLog</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://zenn.dev/k1_style/scraps/07cce0dd3611e3">Gatsby + microcms + Vercel でブログを作って公開したい</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://vercel.com/docs/git">Vercel – Git - Vercel Documentation</a>
<ul>
<li>参考記事とは画面構成が変わっていたため、最終的には Vercel のドキュメントが頼りでした</li>
</ul></li>
</ul>
<h3 id="Gatsby.js のテーマ"><a href="#Gatsby.js+%E3%81%AE%E3%83%86%E3%83%BC%E3%83%9E">Gatsby.js のテーマ</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.gatsbyjs.com/starters/?c=Blog">Gatsby Starters: Library | Gatsby</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://www.gatsbyjs.com/starters/renyuanz/leonids">: Gatsby Starter | Gatsby</a></li>
</ul>
<h3 id="Vercel"><a href="#Vercel">Vercel</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://dev.classmethod.jp/articles/vercel/">NetlifyキラーのVercelでウェブサイトをホストしたら簡単すぎて笑顔になった | DevelopersIO</a></li>
</ul>
<h3 id="Gatsby.js"><a href="#Gatsby.js">Gatsby.js</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/k-penguin-sato/items/7554e5e7e90aa10ae225">GatsbyとNetlifyで簡単にブログを作成 - Qiita</a></li>
</ul>
arm-band
tag:crieit.net,2005:PublicArticle/16708
2021-03-01T21:16:04+09:00
2021-03-01T21:18:22+09:00
https://crieit.net/posts/React-Native-Realm
React NativeでローカルデータベースRealmを使ったスマホアプリをつくる
<p><a href="https://crieit.now.sh/upload_images/6edea335353bb5281df61e17ca2f1cab603cd2c9beea3.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/6edea335353bb5281df61e17ca2f1cab603cd2c9beea3.png?mw=700" alt="アイキャッチ" /></a></p>
<blockquote>
<p>本記事は<a target="_blank" rel="nofollow noopener" href="https://soji.dev/blog/realm-in-react-native">ブログ記事</a>の転載です。</p>
</blockquote>
<p>ユーザーが作成するデータをスマホにのみ保存し、サーバー上には保存しない React Native アプリを開発する場合、スマホ内のローカルデータベースにデータを保存することになります。本記事では、ローカルデータベースの1つである <a target="_blank" rel="nofollow noopener" href="https://realm.io/">Realm</a> を使った React Native アプリの開発方法を紹介します。</p>
<h2 id="なぜサーバーではなくスマホに保存するのか"><a href="#%E3%81%AA%E3%81%9C%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%8F%E3%82%B9%E3%83%9E%E3%83%9B%E3%81%AB%E4%BF%9D%E5%AD%98%E3%81%99%E3%82%8B%E3%81%AE%E3%81%8B">なぜサーバーではなくスマホに保存するのか</a></h2>
<p>最近はFirebaseのようなmBaaS (Mobile Backend as a Service) が登場したことで、アプリ開発者がインフラを触るハードルが下がってきています。それでもなおサーバーではなくスマホにデータベースを保存するモチベーションとして、以下が挙げられます。</p>
<ul>
<li>サーバーの障害によってアプリが使えなくなることがない</li>
<li>インターネットが使えない場所でもアプリを利用できる</li>
<li>アプリ開発者がサーバーを維持管理する必要がなくなる
<ul>
<li>FirebaseのようなmBaaSでも、ユーザー増に伴い金銭的な負担が生じる</li>
</ul></li>
<li>大規模なデータ流出は起こらない
<ul>
<li>アプリ開発者のもとにユーザーのデータが集まらないため、データ流出の危険性は下がる</li>
</ul></li>
</ul>
<h2 id="使用フレームワーク・ライブラリ"><a href="#%E4%BD%BF%E7%94%A8%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%BB%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA">使用フレームワーク・ライブラリ</a></h2>
<p>本記事では以下のフレームワーク、ライブラリを利用します。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://reactnative.dev/">React Native</a>
<ul>
<li>クロスプラットフォーム対応のスマホアプリを開発できるフレームワーク。</li>
</ul></li>
<li><a target="_blank" rel="nofollow noopener" href="https://realm.io/">Realm</a>
<ul>
<li>オフラインデータベース。開発元はMongoDB社により2019年買収。MongoDB社が提供する<a target="_blank" rel="nofollow noopener" href="https://www.mongodb.com/realm">MongoDB Realm</a>(mBaaS)を使うことで、複数端末間でデータベースを同期することもできるが、本記事の対象外。</li>
</ul></li>
</ul>
<h2 id="SQLiteとの比較"><a href="#SQLite%E3%81%A8%E3%81%AE%E6%AF%94%E8%BC%83">SQLiteとの比較</a></h2>
<p>スマホ内にデータベースを保存する方法として、RealmのほかにSQLiteがあります。RealmとSQLiteをざっくり比較すると、以下のようになります。</p>
<ul>
<li>RealmはSQLiteより軽い
<ul>
<li>Realmでは、クエリの結果は遅延読み込みされる</li>
</ul></li>
<li>RealmではSQLを書く必要がない</li>
<li>ただし、RealmはExpoで使えない(SQLiteはExpoで使える)</li>
</ul>
<h2 id="本記事の前提"><a href="#%E6%9C%AC%E8%A8%98%E4%BA%8B%E3%81%AE%E5%89%8D%E6%8F%90">本記事の前提</a></h2>
<p>まだReact Native 開発環境のセットアップをしていない場合、<a target="_blank" rel="nofollow noopener" href="https://reactnative.dev/docs/environment-setup">Setting up the development environment(React Nativeドキュメント)</a>にある "React Native CLI Quickstart" の内容を読み、指示に従ってください。</p>
<p>また、本記事の内容は、以下の環境で動作確認しています。</p>
<ul>
<li>macOS Big Sur 11.2.1</li>
<li>React Native 0.63.4</li>
<li>Node.js v14.16.0</li>
<li>Xcode 12.4</li>
<li>Simulator 上の iPhone 11 / iOS 14.4</li>
</ul>
<h2 id="Realmの準備"><a href="#Realm%E3%81%AE%E6%BA%96%E5%82%99">Realmの準備</a></h2>
<h3 id="React Native アプリの作成"><a href="#React+Native+%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E4%BD%9C%E6%88%90">React Native アプリの作成</a></h3>
<p>ここでは、MyRealmAppという名前のReact Nativeアプリを作成してみます。なお、ExpoではRealmを使うことができませんので注意してください。</p>
<pre><code class="bash">$ npx react-native init MyRealmApp
</code></pre>
<h3 id="Realmのインストール"><a href="#Realm%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB">Realmのインストール</a></h3>
<pre><code class="bash">$ cd MyRealmApp
$ npm install realm
$ cd ios && pod install && cd ..
</code></pre>
<p>PCにインストールされているNode.jsのバージョンが古いとエラーになることがあります。</p>
<p>もし、上記の<code>npm install realm</code>コマンドを実行した結果、<code>The N-API version of this Node instance is (数値). This module supports N-API version(s) (数値). This Node instance cannot run this module.</code> というエラーが表示された場合、Node.js のバージョンを上げてください。</p>
<h2 id="スキーマの定義とRealmの初期化"><a href="#%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E%E3%81%AE%E5%AE%9A%E7%BE%A9%E3%81%A8Realm%E3%81%AE%E5%88%9D%E6%9C%9F%E5%8C%96">スキーマの定義とRealmの初期化</a></h2>
<p>本記事では例としてタスク管理アプリを設計してみます。タスクを表現する<code>Task</code> クラスと、各 Task にはサブタスクを設定できるよう、<code>SubTask</code> クラスを定義します。</p>
<p>まず、作成した React Native プロジェクト内に src フォルダを作成します。src フォルダは、 <code>App.js</code> と同階層のフォルダに作成してください。次に、src フォルダ内に以下のファイルを作成します。ファイル名は <code>realm.js</code> とします。</p>
<h4 id="./src/realm.js"><a href="#.%2Fsrc%2Frealm.js">./src/realm.js</a></h4>
<pre><code class="js">// Taskの定義
const taskSchema = {
name: 'Task',
primaryKey: '_id',
properties: {
_id: 'objectId', // 'string' や 'int' でも OK
name: 'string',
description: 'string?', // ?をつけると optional
isDone: 'bool',
createdAt: 'date',
subTasks: 'SubTask[]', // クラス名 + '[]' で1対多のリレーションを設定できる
},
};
// SubTaskの定義
const subTaskSchema = {
name: 'SubTask',
primaryKey: '_id',
properties: {
_id: 'objectId',
name: 'string',
isDone: 'bool',
createdAt: 'date',
},
};
// Realmの初期化
export const openRealm = () => {
const config = {
schema: [taskSchema, subTaskSchema],
schemaVersion: 1, // スキーマを変更したらインクリメントする(後述)
};
return new Realm(config);
};
export {BSON} from 'realm';
</code></pre>
<h2 id="データベースの操作"><a href="#%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9%E3%81%AE%E6%93%8D%E4%BD%9C">データベースの操作</a></h2>
<p>React NativeでRealmを使う前に、まずRealm単体でデータベースを操作する方法を見ていきます。</p>
<p>このセクションのコードは、作成した React Native プロジェクトのフォルダに入っている <code>App.js</code> 内の適当な場所に書いた上で <code>npx react-native run-ios</code> コマンドを実行すると Simulator 上で動作確認できます。</p>
<p>(<code>npx react-native run-ios</code> コマンドがエラーで失敗する場合、<a target="_blank" rel="nofollow noopener" href="https://stackoverflow.com/questions/66019068/event2-event-config-h-file-not-found">Flipperのバージョンを変更してみてください(Stack Overflow 英語版)</a>)</p>
<h3 id="オブジェクトの新規作成"><a href="#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E6%96%B0%E8%A6%8F%E4%BD%9C%E6%88%90">オブジェクトの新規作成</a></h3>
<pre><code class="js">import {openRealm, BSON} from './src/realm';
const realm = openRealm();
// 更新系はすべて realm.write(() => { }) (=トランザクション)内に書く
realm.write(() => {
// サブタスクを作成しない場合
realm.create('Task', {
_id: new BSON.ObjectId(),
name: 'タスクの名前',
isDone: false,
createdAt: new Date(),
});
// サブタスクを作成する場合
realm.create('Task', {
_id: new BSON.ObjectId(),
name: 'タスクの名前',
isDone: false,
createdAt: new Date(),
subTasks: [
{
_id: new BSON.ObjectId(),
name: 'サブタスクの名前',
isDone: false,
createdAt: new Date(),
},
],
});
});
</code></pre>
<h3 id="オブジェクトの取得"><a href="#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E5%8F%96%E5%BE%97">オブジェクトの取得</a></h3>
<p><code>filtered</code> で利用可能なクエリについては<a target="_blank" rel="nofollow noopener" href="https://docs.mongodb.com/realm/react-native/query-engine/">公式ドキュメント</a>を参照してください。</p>
<pre><code class="js">import {openRealm} from './src/realm';
const realm = openRealm();
// タスクを全部取得
const tasks = realm.objects('Task');
console.log(tasks[0].name); // 「タスクの名前」と表示される
console.log(tasks[1].subTasks[0].name); // 「サブタスクの名前」と表示される
// フィルタの例 — 完了しているタスクのみ取得
const done = tasks.filtered('isDone == true');
console.log(`完了しているタスクは${done.length}件です。`);
// ソートの例 — 名前順で取得
const sorted = tasks.sorted('name');
console.log(sorted[0].name);
</code></pre>
<h3 id="オブジェクトの更新"><a href="#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E6%9B%B4%E6%96%B0">オブジェクトの更新</a></h3>
<p>JavaScriptのオブジェクトを操作するのと同じ方法でRealm上のデータを更新できます。</p>
<pre><code class="js">import {openRealm} from './src/realm';
const realm = openRealm();
realm.write(() => {
// 更新対象のタスク
const task = realm.objects('Task')[0];
// 更新
task.name = '新しいタスクの名前';
task.isDone = !task.isDone;
});
</code></pre>
<h3 id="オブジェクトの削除"><a href="#%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%81%AE%E5%89%8A%E9%99%A4">オブジェクトの削除</a></h3>
<pre><code class="js">import {openRealm} from './src/realm';
const realm = openRealm();
realm.write(() => {
// 削除対象のタスク
const task = realm.objects('Task')[0];
if (task) {
// まずサブタスクを削除
realm.delete(task.subTasks);
// 削除
realm.delete(task);
}
});
</code></pre>
<h2 id="React NativeでRealmを使う"><a href="#React+Native%E3%81%A7Realm%E3%82%92%E4%BD%BF%E3%81%86">React NativeでRealmを使う</a></h2>
<p>React Nativeアプリ内でTaskを操作できるようにするためのコンテキストと React Hook を作ります。</p>
<p>以下の例は、GitHubの <a target="_blank" rel="nofollow noopener" href="https://github.com/mongodb-university/realm-tutorial-react-native">mongodb-university/realm-tutorial-react-native</a> リポジトリ内のコードを参考に、本記事用に書き換えたものです。</p>
<p>まず<code>src</code> フォルダ内に<code>providers</code> フォルダを作成します。<code>providers</code>フォルダ内に<code>TaskProvider.js</code>という名前でファイルを作成し、以下のコードを書きます。このコードの詳細については、コード中のコメントを参照してください。</p>
<h4 id="./src/providers/TasksProvider.js"><a href="#.%2Fsrc%2Fproviders%2FTasksProvider.js">./src/providers/TasksProvider.js</a></h4>
<pre><code class="jsx">import React, {useContext, useState, useEffect, useRef} from 'react';
import {openRealm, BSON} from '../realm';
const TasksContext = React.createContext(null);
const TasksProvider = ({children}) => {
const [tasks, setTasks] = useState([]);
const realmRef = useRef(null);
useEffect(() => {
realmRef.current = openRealm();
const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
setTasks(tasks);
// Task のデータが更新されたら setTasks する
tasks.addListener(() => {
const tasks = realmRef.current.objects('Task').sorted('createdAt', true);
setTasks(tasks);
});
return () => {
// クリーンアップ
if (realmRef.current) {
realmRef.current.close();
}
};
}, []);
// タスクの新規作成
const createTask = (newTaskName) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
projectRealm.create('Task', {
_id: new BSON.ObjectId(),
name: newTaskName || '新しいタスク',
isDone: false,
createdAt: new Date(),
});
});
};
// タスクの isDone を更新する
const setIsTaskDone = (task, isDone) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
task.isDone = isDone;
});
};
// タスクを削除する
const deleteTask = (task) => {
const projectRealm = realmRef.current;
projectRealm.write(() => {
projectRealm.delete(task);
});
};
// useTasks フックで Task を操作できるようにする
return (
<TasksContext.Provider
value=<span>{</span><span>{</span>
createTask,
deleteTask,
setIsTaskDone,
tasks,
<span>}</span><span>}</span>>
{children}
</TasksContext.Provider>
);
};
// Task を操作するための React Hook
const useTasks = () => {
const task = useContext(TasksContext);
if (task == null) {
throw new Error('useTasks() called outside of a TasksProvider?');
}
return task;
};
export {TasksProvider, useTasks};
</code></pre>
<p>以上で作成したTasksProviderをApp.jsに追加し、useTasksフックをコンポーネント内で使ってみます。ここでは、<code>src</code> フォルダ内に<code>components</code> フォルダを作成し、その中に<code>Main.js</code> というファイルでコンポーネントを定義します。</p>
<h4 id="./App.js"><a href="#.%2FApp.js">./App.js</a></h4>
<pre><code class="jsx">import React from 'react';
import {TasksProvider} from './src/providers/TasksProvider';
import {Main} from './src/components/Main';
import {StatusBar} from 'react-native';
const App = () => {
return (
<TasksProvider>
<StatusBar barStyle="light-content" />
<Main />
</TasksProvider>
);
};
export default App;
</code></pre>
<h4 id="./src/components/Main.js"><a href="#.%2Fsrc%2Fcomponents%2FMain.js">./src/components/Main.js</a></h4>
<pre><code class="jsx">import React, {useState, useCallback} from 'react';
import {
SafeAreaView,
FlatList,
View,
Text,
TouchableOpacity,
TextInput,
KeyboardAvoidingView,
StyleSheet,
TouchableWithoutFeedback,
Keyboard,
} from 'react-native';
import {useTasks} from '../providers/TasksProvider';
// コンポーネント間の余白を作るための関数
const spacer = (size) => {
return <View style=<span>{</span><span>{</span>height: size, width: size<span>}</span><span>}</span> />;
};
export const Main = () => {
const [inputText, setInputText] = useState('');
const {createTask, deleteTask, setIsTaskDone, tasks} = useTasks();
const onSubmitEditing = useCallback(
(event) => {
setInputText(event.nativeEvent.text);
createTask(inputText);
setInputText('');
},
[inputText, setInputText, createTask],
);
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}>
<View style={styles.header}>
<SafeAreaView />
<View style={styles.headerContent}>
{spacer(15)}
<Text accessibilityRole="header" style={styles.headerTitle}>
My Tasks
</Text>
{spacer(15)}
<TextInput
placeholder="Add Task..."
onSubmitEditing={onSubmitEditing}
value={inputText}
onChange={(e) => setInputText(e.nativeEvent.text)}
style={styles.headerInput}
/>
{spacer(24)}
</View>
</View>
<TouchableWithoutFeedback onPress={() => Keyboard.dismiss()}>
{tasks.length > 0 ? (
<FlatList
style={styles.tasksContainer}
contentContainerStyle={styles.tasksContentContainer}
data={tasks}
keyExtractor={(item) => item._id.toHexString()}
renderItem={({item}) => (
<View style={styles.taskItem}>
<TouchableOpacity
style={styles.chechboxContainer}
onPress={() => setIsTaskDone(item, !item.isDone)}>
<View
style={[
styles.checkbox,
{
borderColor: item.isDone ? '#2563EB' : '#60A5FA',
backgroundColor: item.isDone ? '#2563EB' : '#fff',
},
]}>
{item.isDone && <Text style={styles.checkboxIcon}>✓</Text>}
</View>
</TouchableOpacity>
<View style={styles.taskContent}>
<Text
style={[
styles.taskName,
{
textDecorationLine: item.isDone
? 'line-through'
: 'none',
color: item.isDone ? '#9CA3AF' : '#111827',
},
]}>
{item.name}
</Text>
</View>
<TouchableOpacity
style={styles.deleteButton}
onPress={() => deleteTask(item)}>
<View>
<Text style={styles.deleteButtonText}>Delete</Text>
</View>
</TouchableOpacity>
</View>
)}
/>
) : (
<View style={styles.emptyContent}>
<Text style={styles.emptyMessage}>No Tasks</Text>
</View>
)}
</TouchableWithoutFeedback>
</KeyboardAvoidingView>
);
};
// スタイル
const styles = StyleSheet.create({
container: {flex: 1},
header: {
backgroundColor: '#2563EB',
},
headerContent: {paddingHorizontal: 24},
headerTitle: {color: 'white', fontSize: 32, fontWeight: 'bold'},
headerInput: {
borderRadius: 6,
height: 40,
paddingHorizontal: 12,
marginHorizontal: -12,
fontSize: 18,
backgroundColor: '#fff',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2.5,
},
shadowOpacity: 0.3,
shadowRadius: 2.4,
elevation: 4,
},
tasksContainer: {flex: 1},
tasksContentContainer: {paddingBottom: 20},
taskItem: {
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
flexDirection: 'row',
alignItems: 'center',
},
chechboxContainer: {padding: 20},
checkbox: {
width: 25,
height: 25,
borderRadius: 12,
borderWidth: 1.5,
alignItems: 'center',
justifyContent: 'center',
},
checkboxIcon: {color: 'white', fontWeight: 'bold', fontSize: 18},
taskContent: {flex: 1},
taskName: {
fontSize: 20,
fontWeight: '600',
},
deleteButton: {padding: 20},
deleteButtonText: {color: '#EF4444'},
emptyContent: {
justifyContent: 'center',
alignItems: 'center',
flex: 1,
},
emptyMessage: {
fontSize: 20,
fontWeight: '500',
color: '#9CA3AF',
},
});
</code></pre>
<h2 id="動作確認"><a href="#%E5%8B%95%E4%BD%9C%E7%A2%BA%E8%AA%8D">動作確認</a></h2>
<p>ここまでできたら、一度動作確認してみます。今回は Simulator 上の iPhone で動作確認します。以下のコマンドで、シミュレータを立ち上げます。</p>
<p>(以下のコマンドがエラーで失敗する場合、<a target="_blank" rel="nofollow noopener" href="https://stackoverflow.com/questions/66019068/event2-event-config-h-file-not-found">Flipperのバージョンを変更してみてください(Stack Overflow 英語版)</a>)</p>
<pre><code class="bash">$ npx react-native run-ios
</code></pre>
<p>以下の動画のように動作すれば成功です。もしエラーが発生する場合、「DerivedDataの削除」「iosフォルダ内で<code>pod install</code> 再実行」などをお試しください(詳細はエラーメッセージでググってみてください)。</p>
<p>ここまで書いてきたコードでは、タスクの編集ができません。タスク名を自由に編集できるようにしてみると良いかもしれません(本記事では省略します)。</p>
<h2 id="スキーマ変更時の注意点(schemaVersionとマイグレーション)"><a href="#%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E%E5%A4%89%E6%9B%B4%E6%99%82%E3%81%AE%E6%B3%A8%E6%84%8F%E7%82%B9%28schemaVersion%E3%81%A8%E3%83%9E%E3%82%A4%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%29">スキーマ変更時の注意点(schemaVersionとマイグレーション)</a></h2>
<p>アプリストアで配信中のアプリをアップデートする際、データベースのスキーマが変更になる場合、<code>schemaVersion</code> をインクリメントする必要があります。また、必要に応じて、旧スキーマのデータを新スキーマのデータへ移行する処理(マイグレーション)を書く必要があります。</p>
<p>例として、Taskの<code>isDone</code> を変更してみます。ここまで、タスクの完了状況は<code>isDone</code>で管理してきました。<code>isDone</code> はタスクの完了・未完了しか表すことができないため、新しく<code>status</code> として、タスクの状態を以下の3つに分類できるようにしてみます。</p>
<ul>
<li>Open : タスク未着手</li>
<li>InProgress : タスク進行中</li>
<li>Complete : 完了</li>
</ul>
<h3 id="スキーマを書き換える"><a href="#%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E%E3%82%92%E6%9B%B8%E3%81%8D%E6%8F%9B%E3%81%88%E3%82%8B">スキーマを書き換える</a></h3>
<p><code>./realm.js</code> に書いた Task のスキーマ定義を以下のように変更します。この作業はスキーマ定義を直接書き換えます。</p>
<pre><code class="js">// 略
const taskSchema = {
name: "Task",
primaryKey: "_id",
properties: {
_id: "objectId",
name: "string",
description: "string?",
// isDone: 'bool',
status: "string", // isDone を削除し status を追加
createdAt: "date",
subTasks: "SubTask[]",
},
};
// 略
</code></pre>
<h3 id="マイグレーション処理を書く"><a href="#%E3%83%9E%E3%82%A4%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E5%87%A6%E7%90%86%E3%82%92%E6%9B%B8%E3%81%8F">マイグレーション処理を書く</a></h3>
<p><code>./realm.js</code> ファイルを保存し、React Native開発環境を再度実行すると、コンソールに以下のようなエラーが表示されます。</p>
<pre><code>Error: Migration is required due to the following errors:
- Property 'Task.isDone' has been removed.
- Property 'Task.status' has been added.
</code></pre>
<p>このエラーメッセージを日本語に訳すと以下のようになります。</p>
<blockquote>
<p>エラー: 以下のエラーのため、マイグレーションが必要です<br />
- プロパティ 'Task.isDone' が削除されました<br />
- プロパティ 'Task.status' が追加されました</p>
</blockquote>
<p>このようなエラーが表示された場合、<code>./realm.js</code> ファイルにある <code>openRealm</code> 関数内の <code>config</code> 定数を以下のように書き換えます。</p>
<pre><code class="js">// 略
// Realmの初期化
export const openRealm = () => {
const config = {
schema: [taskSchema, subTaskSchema],
// schemaVersion: 1,
schemaVersion: 2, // ① schemaVersion を 1 → 2 へ変更
// ②マイグレーション処理を追加
migration: (oldRealm, newRealm) => {
// 現在保存されているデータの schemaVersion が 2 未満の場合に実行
if (oldRealm.schemaVersion < 2) {
const oldObjects = oldRealm.objects('Task');
const newObjects = newRealm.objects('Task');
// 全TaskデータのisDoneをstatusに変換
for (const objectIndex in oldObjects) {
const oldObject = oldObjects[objectIndex];
const newObject = newObjects[objectIndex];
newObject.status = oldObject.isDone ? 'Complete' : 'Open';
}
}
},
};
return new Realm(config);
};
// 略
</code></pre>
<p>このファイルを保存し、再度アプリを実行すると、マイグレーションが実行され、データベースが更新されます。まだ isDone がコード上で使われているため、必要に応じて status を使うように修正を行ってください(本記事では省略します)。</p>
<p>ここでのポイントは以下の2点です。</p>
<h4 id="① schemaVersion を 1 → 2 へ変更"><a href="#%E2%91%A0+schemaVersion+%E3%82%92+1+%E2%86%92+2+%E3%81%B8%E5%A4%89%E6%9B%B4">① schemaVersion を 1 → 2 へ変更</a></h4>
<p>Realm にデータベーススキーマが変更になったことを伝えるため、schemaVersion をインクリメントします。schemaVersion は、データベーススキーマに変更がある場合のみ変更します。一度 schemaVersion の値を増やした場合、この数値を減らさないでください。</p>
<h4 id="② マイグレーション処理を追加"><a href="#%E2%91%A1+%E3%83%9E%E3%82%A4%E3%82%B0%E3%83%AC%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E5%87%A6%E7%90%86%E3%82%92%E8%BF%BD%E5%8A%A0">② マイグレーション処理を追加</a></h4>
<p>ユーザーのスマホ内に保存されている古いスキーマのデータベース内のデータを、新しいスキーマのデータベースに変換する処理を書きます。多くの場合、oldRealmのデータをもとに、対応するnewRealm内のデータを全部書き換えるようなコードになると思います。もしくは、optional ではない新しいプロパティが追加された場合、そのプロパティの初期値を設定するコードになると思います。</p>
<h2 id="RealmデータベースGUIで操作できる「Realm Studio」"><a href="#Realm%E3%83%87%E3%83%BC%E3%82%BF%E3%83%99%E3%83%BC%E3%82%B9GUI%E3%81%A7%E6%93%8D%E4%BD%9C%E3%81%A7%E3%81%8D%E3%82%8B%E3%80%8CRealm+Studio%E3%80%8D">RealmデータベースGUIで操作できる「Realm Studio」</a></h2>
<p>最後に、Realm Studioを紹介します。Realm Studio はWindows、Linux、macOSに対応したRealmデータベースの管理アプリです。Realm Studio を使うことで、Realmデータベースの中身をGUI上で確認・操作することができます。</p>
<p>Realm Studioは、Realm 公式サイト内にある<a target="_blank" rel="nofollow noopener" href="https://docs.realm.io/sync/realm-studio">Realm Studioのページ</a>からダウンロードできます。Realm Studio を初めて起動するとメールアドレスの登録画面が表示されるのでメールアドレスを登録します。その後、以下の画面が表示されます。</p>
<p><a href="https://crieit.now.sh/upload_images/8ede1c18125488061693d34088d212bf603cd9c3b4a55.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/8ede1c18125488061693d34088d212bf603cd9c3b4a55.png?mw=700" alt="Realm Studioのスタート画面" /></a></p>
<p>この画面が表示されたら [Open Realm file] をクリックし、Realm ファイルを開きます。Realm ファイルのパスは、以下のコードを<code>App.js</code>等に書いて実行することで確認できます(コンソールにファイルパスが表示されます)。</p>
<pre><code class="js">import {openRealm} from './src/realm.js';
console.log(openRealm().path);
</code></pre>
<p>無事ファイルを開けると、以下のような画面になります。この画面から、データの確認・編集ができます。</p>
<p><a href="https://crieit.now.sh/upload_images/93d1f6c129913c5f4eaba69e564ac993603cd9d1b9036.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/93d1f6c129913c5f4eaba69e564ac993603cd9d1b9036.png?mw=700" alt="Realm Studioの画面" /></a></p>
<h2 id="おわりに"><a href="#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB">おわりに</a></h2>
<p>React Nativeアプリでオフラインデータを扱うライブラリとしてRealmを紹介してみました。クラウド上にデータを置かないアプリ開発の参考になれば幸いです。</p>
<h2 id="参考文献"><a href="#%E5%8F%82%E8%80%83%E6%96%87%E7%8C%AE">参考文献</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://docs.mongodb.com/realm/react-native/">MongoDB Realm React Native SDK</a></li>
</ul>
SofPyon
tag:crieit.net,2005:PublicArticle/16612
2021-01-18T02:08:16+09:00
2021-01-18T02:08:16+09:00
https://crieit.net/posts/bloggimg-first-release
📔 ブログを書く用途に特化した Gyazo のツールを開発してみた
<p><img src="https://i.gyazo.com/263820f97c341755faf69d9269471bf8.png" alt="Gyazo を技術記事を書く用途で使っているので専用の便利ツールを作ってみた" /><br />
<strong>Gyazo を技術記事を書く用途で使っているので専用の便利ツールを作ってみた</strong></p>
<h1 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h1>
<p>いつもブログ記事に載せるキャプチャ画像の編集 & アップロード先として <a target="_blank" rel="nofollow noopener" href="https://gyazo.com/">Gyazo</a> を利用させていただいているのですが、日々使っている中で不満に感じる点もちょくちょく出てくるようになってきました。</p>
<p>そのため、3連休を用いて <a target="_blank" rel="nofollow noopener" href="https://www.rust-lang.org/ja">Rust</a> の勉強がてら <a target="_blank" rel="nofollow noopener" href="https://www.bloggimg.net/">Bloggimg</a> というウェブアプリケーションを作ってみました。ソースコードは MIT ライセンスで <a target="_blank" rel="nofollow noopener" href="https://github.com/nikaera/bloggimg">GitHub のリポジトリ</a>にアップしております。<em>ちなみに最初は <code>Gyazo for Blog</code> という名称で開発をしていたため、本記事内のスクショには <code>Gyazo for Blog</code> という文字列が出てきますが、現在は <code>Bloggimg</code> という名称になっております。。</em></p>
<p><strong><code>Bloggimg</code> を開発したのは、ブログ記事を書く際に利用する画像のアップロードから加工、マークダウンとして利用するまでのフローを最適化したかったからです。</strong> ブログ記事を書く際に、記事内で用いるスクショ画像の加工や、そのアップロードにすごく時間を取られてしまうなーと日頃から感じていたのでそれを解決したかったのです。✅</p>
<p>開発中に得た知見等については別途技術記事として書いて残す予定です。</p>
<h1 id="考えていたこと"><a href="#%E8%80%83%E3%81%88%E3%81%A6%E3%81%84%E3%81%9F%E3%81%93%E3%81%A8">考えていたこと</a></h1>
<p>今回 Bloggimg の開発を行うに当たり、考えていた点は下記になります。</p>
<ul>
<li>画像の編集ツールは引き続き Gyazo に用意されているものを使う
<ul>
<li>既に最高に使いやすい 👑</li>
</ul></li>
<li>キャプチャ画像をアップロードする際に、<strong>自動的に特定のコレクションに紐付けるようにする</strong>
<ul>
<li>技術記事毎にコレクションを分けて管理しているため、技術記事を書いている最中にアップするキャプチャ画像は全て特定のコレクションにまとまっていて欲しい</li>
</ul></li>
<li>ワークスペースのようなツールを目指し、ブログを書く時だけに使える機能を開発する
<ul>
<li>例えば、ワンクリックで画像マークダウンの記述がコピーできたり、画像のアップロードをし直しやすくするため画像削除がお手軽に出来るよう削除ボタンに即アクセス出来るようにしたり...</li>
</ul></li>
</ul>
<p>特にアップした画像を <strong>自動的に特定のコレクションに紐付けるようにする</strong> については本記事で紹介しているウェブアプリケーションを作成するキッカケとなった点なので外せない点でした。</p>
<h1 id="使い方"><a href="#%E4%BD%BF%E3%81%84%E6%96%B9">使い方</a></h1>
<p>Bloggimg の使い方についてご紹介いたします。</p>
<h2 id="ログインする"><a href="#%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E3%81%99%E3%82%8B">ログインする</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://www.bloggimg.net/">Bloggimg</a> を利用するためには、まず Gyazo アカウントでログインして頂く必要がございます。トップページの右上にあるログインボタンから Gyazo アカウントでログインします。</p>
<p><img src="https://i.gyazo.com/b0e863464696ce778ca853d7fac56ab9.png" alt="スクリーンショット 2021-01-11 15.31.27.png" /><br />
<strong>1. トップページ右上に配置されたログインボタンから Gyazo アカウント認証を行う</strong></p>
<p><img src="https://i.gyazo.com/1bd6c3212c8fa95196ec0eaaef94d4d7.png" alt="スクリーンショット 2021-01-11 15.35.59.png" /><br />
<strong>2. Gyazo アカウント認証が正常に完了したら、再度トップページを開く</strong></p>
<p><img src="https://i.gyazo.com/110fe178f5f158153640ab25271d90a2.png" alt="スクリーンショット 2021-01-11 15.42.21.png" /><br />
<strong>3. トップページを開いた時に Gyazo にアップした直近の画像が確認できるはずです</strong></p>
<h2 id="ログアウトする"><a href="#%E3%83%AD%E3%82%B0%E3%82%A2%E3%82%A6%E3%83%88%E3%81%99%E3%82%8B">ログアウトする</a></h2>
<p>ウェブアプリケーションからログアウトするには、ログイン後にトップページ右上に表示される <code>ログアウト</code> ボタンをクリックすることでログアウトできます。</p>
<p><img src="https://i.gyazo.com/e7391a84be6e2e22845e975383dd78b0.png" alt="スクリーンショット 2021-01-11 16.00.02.png" /><br />
<strong>ログイン後にトップページ右上に表示される <code>ログアウト</code> ボタンをクリックしてログアウトする</strong></p>
<h2 id="画像ファイルをアップロードする"><a href="#%E7%94%BB%E5%83%8F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%99%E3%82%8B">画像ファイルをアップロードする</a></h2>
<p>画像は一枚でも複数枚でもアップロードすることが可能です。画像アップロードの方法としてドラッグ & ドロップとファイル選択ダイアログを用意しております。</p>
<p><img src="https://i.gyazo.com/4e559abbc492b82b58349cd511f7987c.png" alt="スクリーンショット 2021-01-11 16.04.31.png" /><br />
<strong>画面中央の点線枠内に画像ファイルをドラッグ & ドロップするか、点線枠内をクリックしてファイル選択ダイアログから選択することで画像をアップロードできる</strong></p>
<h2 id="画像ファイルをアップロードする際に自動でコレクションを紐付ける"><a href="#%E7%94%BB%E5%83%8F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%99%E3%82%8B%E9%9A%9B%E3%81%AB%E8%87%AA%E5%8B%95%E3%81%A7%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%82%92%E7%B4%90%E4%BB%98%E3%81%91%E3%82%8B">画像ファイルをアップロードする際に自動でコレクションを紐付ける</a></h2>
<p>Gyazo トップページ左端にコレクションリストが表示されているので、画像を紐づけたいコレクションを選択します。新たにコレクションを作成する場合はコレクションリスト上部にある <code>コレクションを作成</code> ボタンをクリックします。</p>
<p><img src="https://i.gyazo.com/b06be51cd5cde0e09733325e696c655a.png" alt="スクリーンショット 2021-01-11 16.37.30.png" /><br />
<strong>1. コレクションリストの中から画像を紐づけたいコレクションを選択する</strong></p>
<p><img src="https://i.gyazo.com/7d3dec131a20b1a7f6d24d10bbe8c39e.png" alt="スクリーンショット 2021-01-11 16.42.16.png" /><br />
<strong>2. コレクションを選択後に遷移した先の URL 末尾のコレクション ID をコピーする</strong></p>
<p><img src="https://i.gyazo.com/eb052b62aec6500c030e390c85172de8.png" alt="スクリーンショット 2021-01-11 16.45.46.png" /><br />
<strong>3. トップページの最上部に 2. で控えていたコレクション ID をペーストする</strong></p>
<p>上記までのステップが完了し、正しくコレクション ID が入力できていれば、次回以降のファイルアップロード時に自動で指定したコレクションに画像が紐づくようになります。</p>
<h2 id="アップロードした画像ファイルを編集する"><a href="#%E3%82%A2%E3%83%83%E3%83%97%E3%83%AD%E3%83%BC%E3%83%89%E3%81%97%E3%81%9F%E7%94%BB%E5%83%8F%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E7%B7%A8%E9%9B%86%E3%81%99%E3%82%8B">アップロードした画像ファイルを編集する</a></h2>
<p>画像ファイルのアップロード時や <code>画像の再読み込み</code> ボタンをクリックすることで、最新 20件の画像リストを画面最下部にロードされます。画像リストの各項目ではプレビュー、編集、削除、マークダウンのコピーを行うことが可能です。</p>
<p><img src="https://i.gyazo.com/1226a831420cd25992d8de1f4446c5ed.png" alt="スクリーンショット 2021-01-11 16.58.33.png" /><br />
<strong>画像リストの各項目の機能概要図</strong></p>
<h3 id="プレビュー"><a href="#%E3%83%97%E3%83%AC%E3%83%93%E3%83%A5%E3%83%BC">プレビュー</a></h3>
<p>サムネ画像をクリックすることで、Gyazo にアップした元画像をプレビューすることが可能です。サムネ画像では画像の判別がしにくい場合に詳細を確認するための機能となります。</p>
<p><img src="https://i.gyazo.com/e8d3c1b2e3c4247fdcde156dfa18e343.png" alt="スクリーンショット 2021-01-11 17.04.28.png" /><br />
<strong>アップした画像の詳細を確認するためにプレビュー機能を利用する</strong></p>
<h3 id="編集"><a href="#%E7%B7%A8%E9%9B%86">編集</a></h3>
<p>編集は該当画像の Gyazo ページにて行えるように、タイトルをクリックすることで Gyazo ページを別タブで開きます。</p>
<p><img src="https://i.gyazo.com/83ab2ec4c4c4e1251302aea840377870.png" alt="スクリーンショット 2021-01-11 17.07.37.png" /><br />
<strong>別タブで開いた Gyazo ページから画像の編集作業を行う</strong></p>
<h3 id="削除"><a href="#%E5%89%8A%E9%99%A4">削除</a></h3>
<p>画像の削除は <code>画像の削除</code> ボタンをクリックすることで、削除を行うための画面に遷移します。削除しようとしている画像で間違いないか確認後、削除を行うという手順になっております。</p>
<p><img src="https://i.gyazo.com/a3e219b5efb8494103432b369ee99534.png" alt="スクリーンショット 2021-01-11 17.10.11.png" /><br />
<strong>Gyazo から選択した画像を削除する</strong></p>
<h3 id="マークダウンのコピー"><a href="#%E3%83%9E%E3%83%BC%E3%82%AF%E3%83%80%E3%82%A6%E3%83%B3%E3%81%AE%E3%82%B3%E3%83%94%E3%83%BC">マークダウンのコピー</a></h3>
<p><code>マークダウンをコピー</code> ボタンをクリックすることで、クリップボードにマークダウン形式で該当画像を表示するための記述をコピーすることができます。具体的には下記のような記述がコピーされます。</p>
<p>ブログを書く先がマークダウン形式での記述に対応していれば、そのままペーストするだけで画像を表示することが可能です。</p>
<pre><code class="md">![スクリーンショット 2021-01-11 17.10.11.png](https://i.gyazo.com/a3e219b5efb8494103432b369ee99534.png)
</code></pre>
<h1 id="おわりに"><a href="#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB">おわりに</a></h1>
<p>この記事を書くのにも実際に Bloggimg を用いましたが、個人的に今までよりも Gyazo でブログ記事内で利用する画像に関する作業効率は上がったように感じました。ブログを書くという用途に Gyazo を利用されている方のお役に立てれば幸いです。</p>
<p>また、今後は下記の機能実装を進めていく予定です。</p>
<ul>
<li>画像アップ時に自動でアスペクト比を維持した状態で画像のリサイズを自動で行う機能</li>
<li>画像アップ時のタイトルの接頭辞が指定できるようにする機能</li>
<li>編集した画像が自動的にコレクションに紐づく機能
<ul>
<li>心残りな点として編集した画像をコレクションに紐付ける機能は API でできなかったため、現在手動で行う必要があります。。Gyazo の API がコレクションの紐づけにも対応したら対応したいと考えています ✅</li>
</ul></li>
</ul>
nikaera
tag:crieit.net,2005:PublicArticle/16573
2021-01-10T00:49:33+09:00
2021-01-10T00:49:33+09:00
https://crieit.net/posts/react-static-plugin-sitemap-xml-loc-rewrite-20210110
React Static のサイトマップ( sitemap.xml )の loc をサイトURL始まりで出力させる
<p>React Static でサンプルページをビルドした際に、 <code>sitemap.xml</code> に記述されている <code>loc</code> の値がルートディレクトリからの絶対パスで記述されていたのに気付いたので、対処療法的に対処しました。</p>
<h2 id="現象確認"><a href="#%E7%8F%BE%E8%B1%A1%E7%A2%BA%E8%AA%8D">現象確認</a></h2>
<p>現象を確認するために React Static をセットアップします。</p>
<pre><code class="bash">> npm init -y
> yarn add react-static
</code></pre>
<p>ローカルインストール。</p>
<pre><code class="bash">> react-static create
? What should we name this project? rsps_l
? Select a template below... basic
Creating new react-static project...
## 略
[✓] Project "rsps_l" created (192s)
To get started:
cd "rsps_l"
yarn start - Start the development server
yarn build - Build for production
yarn serve - Test a production build locally
</code></pre>
<p><code>react-static create</code> でサイトを生成します。</p>
<pre><code class="javascript">import path from 'path'
import axios from 'axios'
export default {
getRoutes: async () => {
const { data: posts } = await axios.get(
'https://jsonplaceholder.typicode.com/posts'
)
return [
{
path: '/blog',
getData: () => ({
posts,
}),
children: posts.map(post => ({
path: `/post/${post.id}`,
template: 'src/containers/Post',
getData: () => ({
post,
}),
})),
},
]
},
plugins: [
[
require.resolve('react-static-plugin-source-filesystem'),
{
location: path.resolve('./src/pages'),
},
],
require.resolve('react-static-plugin-reach-router'),
require.resolve('react-static-plugin-sitemap'),
],
}
</code></pre>
<p>ちなみに、デフォルトの状態で <code>static.config.js</code> は以上のような状態です。</p>
<pre><code class="bash">> yarn build
</code></pre>
<p>まずはデフォルトの状態でビルドします。</p>
<p><a href="https://crieit.now.sh/upload_images/b95116b3c303325226b47d8790b699185ff87a11ad445.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/b95116b3c303325226b47d8790b699185ff87a11ad445.jpg?mw=700" alt="デフォルトの状態でビルドした sitemap.xml" /></a></p>
<p>図のように、 <code>loc</code>タグ の値がルートディレクトリからの絶対パスになっています。</p>
<h2 id="サイトマップの構造について"><a href="#%E3%82%B5%E3%82%A4%E3%83%88%E3%83%9E%E3%83%83%E3%83%97%E3%81%AE%E6%A7%8B%E9%80%A0%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6">サイトマップの構造について</a></h2>
<p>ここでサイトマップの構造について確認します。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.sitemaps.org/protocol.html">sitemaps.org - Protocol</a></li>
</ul>
<blockquote>
<p><code><loc></code> required URL of the page. This URL must begin with the protocol (such as http) and end with a trailing slash, if your web server requires it. This value must be less than 2,048 characters.</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.sitemaps.org/protocol.html">sitemaps.org - Protocol</a></p>
</blockquote>
<p>「ページのURL。このURLは<strong>プロトコル( <code>http</code> のような)から始まるURLでなければならない</strong>、また、Webサーバが必要とするならばスラッシュ <code>/</code> 終わりにしなければならない。この値は2,048文字未満にすること。」というところでしょうか。</p>
<p>……ということは、デフォルトの状態ではよろしくなさそうですね。</p>
<h3 id="余談"><a href="#%E4%BD%99%E8%AB%87">余談</a></h3>
<p>ちなみに <a target="_blank" rel="nofollow noopener" href="https://www.sitemaps.org/ja/protocol.html">sitemap.org の日本語訳ページ</a>では</p>
<blockquote>
<p><code><loc></code> 必須 ページの URL です。 ウェブ サーバーによっては、http などのプロトコルから始め、末尾にスラッシュを含める必要があります。 この値は 2,048 文字以下で指定する必要があります。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.sitemaps.org/ja/protocol.html">sitemaps.org - プロトコル</a></p>
</blockquote>
<p>となっていて、 <code>if your web server requires it</code> の部分が先頭に来てしまっているため、個人的には分かりづらいと感じました……。</p>
<h2 id="対処"><a href="#%E5%AF%BE%E5%87%A6">対処</a></h2>
<p>まずはどうやってカスタマイズできるか確認。</p>
<pre><code class="javascript">import path from 'path'
import axios from 'axios'
export default {
getRoutes: async () => {
const { data: posts } = await axios.get(
'https://jsonplaceholder.typicode.com/posts'
)
return [
{
path: '/blog',
getData: () => ({
posts,
}),
children: posts.map(post => ({
path: `/post/${post.id}`,
template: 'src/containers/Post',
getData: () => ({
post,
}),
})),
},
]
},
plugins: [
[
require.resolve('react-static-plugin-source-filesystem'),
{
location: path.resolve('./src/pages'),
},
],
require.resolve('react-static-plugin-reach-router'),
[
// react-static-plugin-sitemap にオプションを追加
require.resolve('react-static-plugin-sitemap'),
{
getAttributes: route => {
// とりあえず route に何が渡ってきているか確認
console.log(route)
}
}
]
],
}
</code></pre>
<p><code>react-static-plugin-sitemap</code> の部分にコールバックで <code>console.log</code> を仕込んで、 <code>route</code> の中身を確認します。</p>
<pre><code class="bash">> yarn build
## 略
{
path: 'blog/post/98',
template: '__react_static_root__/src/containers/Post',
getData: [Function: getData],
sitemap: { noindex: false },
data: {
post: {
userId: 10,
id: 98,
title: 'laboriosam dolor voluptates',
body: 'doloremque ex facilis sit sint culpa\n' +
'soluta assumenda eligendi non ut eius\n' +
'sequi ducimus vel quasi\n' +
'veritatis est dolores'
}
},
sharedHashesByProp: {},
sharedData: {}
}
{
path: 'blog/post/99',
template: '__react_static_root__/src/containers/Post',
getData: [Function: getData],
sitemap: { noindex: false },
data: {
post: {
userId: 10,
id: 99,
title: 'temporibus sit alias delectus eligendi possimus magni',
body: 'quo deleniti praesentium dicta non quod\n' +
'aut est molestias\n' +
'molestias et officia quis nihil\n' +
'itaque dolorem quia'
}
},
sharedHashesByProp: {},
sharedData: {}
}
{
path: 'blog/post/100',
template: '__react_static_root__/src/containers/Post',
getData: [Function: getData],
sitemap: { noindex: false },
data: {
post: {
userId: 10,
id: 100,
title: 'at nam consequatur ea labore ea harum',
body: 'cupiditate quo est a modi nesciunt soluta\n' +
'ipsa voluptas error itaque dicta in\n' +
'autem qui minus magnam et distinctio eum\n' +
'accusamus ratione error aut'
}
},
sharedHashesByProp: {},
sharedData: {}
}
</code></pre>
<p>出力結果から、 <code>path</code> が <code>loc</code> に出力されていそうなことが分かりました。</p>
<p>今回は諸事情により時間をあまり割くことができないので、手っ取り早く定数でサイトURLを定義してそれを渡してURLを組み立てることにしました。</p>
<pre><code class="javascript">import path from 'path'
import axios from 'axios'
// オリジン情報を定義
const originSiteURL = 'https://example.com/'
export default {
getRoutes: async () => {
const { data: posts } = await axios.get(
'https://jsonplaceholder.typicode.com/posts'
)
return [
{
path: '/blog',
getData: () => ({
posts,
}),
children: posts.map(post => ({
path: `/post/${post.id}`,
template: 'src/containers/Post',
getData: () => ({
post,
}),
})),
},
]
},
plugins: [
[
require.resolve('react-static-plugin-source-filesystem'),
{
location: path.resolve('./src/pages'),
},
],
require.resolve('react-static-plugin-reach-router'),
[
// react-static-plugin-sitemap にオプションを追加
require.resolve('react-static-plugin-sitemap'),
{
getAttributes: route => ({
loc: new URL(route.path, originSiteURL).href
})
}
]
],
}
</code></pre>
<p>コールバックの戻り値に <code>loc</code> を指定してデフォルトを上書きします。</p>
<p>これで <code>yarn build</code> 。</p>
<p><a href="https://crieit.now.sh/upload_images/2e9a3e972403cec043fee7d6099c177b5ff87a21b3a53.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/2e9a3e972403cec043fee7d6099c177b5ff87a21b3a53.jpg?mw=700" alt="カスタマイズした状態でビルドした sitemap.xml" /></a></p>
<p>結果、意図通りプロトコル始まりのサイトURLに書き換えることができました。</p>
<h2 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h2>
<h3 id="react-static-plugin-sitemap"><a href="#react-static-plugin-sitemap">react-static-plugin-sitemap</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.npmjs.com/package/react-static-plugin-sitemap/v/7.2.0">react-static-plugin-sitemap - npm</a></li>
</ul>
<h3 id="sitemap.xml"><a href="#sitemap.xml">sitemap.xml</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.sitemaps.org/protocol.html">sitemaps.org - Protocol</a></li>
</ul>
<h4 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h4>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://blog.app.melvins-nest.com/posts/613831417/">Hugoのsitemap.xmlがlocを相対パスで出力してた - 常にいまいち</a></li>
</ul>
<p>この記事で「プロトコル始まりではない絶対パスではまずいのでは?」と気付き。</p>
<h3 id="URL組み立て"><a href="#URL%E7%B5%84%E3%81%BF%E7%AB%8B%E3%81%A6">URL組み立て</a></h3>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://stackoverflow.com/questions/16301503/can-i-use-requirepath-join-to-safely-concatenate-urls">node.js - Can I use require("path").join to safely concatenate urls? - Stack Overflow</a></li>
</ul>
<p>ここで <code>path</code> モジュールではなく ECMAScript の <code>new URL()</code> を使うと良い、というレスを見て採用。</p>
arm-band
tag:crieit.net,2005:PublicArticle/16419
2020-12-24T09:42:14+09:00
2020-12-24T09:58:34+09:00
https://crieit.net/posts/react-highlightjs
📝 React で highlight.js を適用する方法
<p>React に <code>highlight.js</code> を組み込もうとしたのですが、若干躓いてしまったので対処法についてメモっておきます。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://www.npmjs.com/package/react">React</a> は既にプロジェクトにインストール済みと仮定します。</p>
<pre><code class="bash"># 一応 React をインストールするためのコマンドは ↓
npm i --save react react-dom
</code></pre>
<p>まずは <a target="_blank" rel="nofollow noopener" href="https://www.npmjs.com/package/highlight.js?activeTab=readme">highlight.js</a> を NPM or Yarn でインストールします。</p>
<pre><code class="bash"># NPM で highlight.js をインストールする
npm i --save highlight.js
yarn add highlight.js
</code></pre>
<p>その後、React ソースコードに <code>highlight.js</code> を組み込みます。<br />
ソースコードの該当部分のみを載せると下記の感じになります。</p>
<pre><code class="javascript">import Head from 'next/head'
import styles from '../styles/Home.module.css'
import React, { useState, useEffect } from 'react';
/**
highlight.js を import する
*/
import hljs from 'highlight.js/lib/core';
/**
シンタックスハイライトしたい言語のみ import として登録する
今回は html をハイライトしたかったので xml を import した
デザインは highlight.js/styles/~ を変更することで調整可能
[https://highlightjs.org/](https://highlightjs.org/) のトップページから各種デザインについては確認可能
(コード右下にある style の右側リンククリックで各種デザインのプレビューが可能)
*/
import xml from 'highlight.js/lib/languages/xml';
import 'highlight.js/styles/github.css';
hljs.registerLanguage('xml', xml);
let inputChecker = null;
export default function Home() {
const [user, setUser] = useState('nikaera');
const [previewUser, setPreviewUser] = useState('nikaera');
const [badgeCode, setBadgeCode] = useState('nikaera');
const [style, setStyle] = useState('plastic')
/**
useEffect のタイミングで hightlight.js の初期化を行う。
called プロパティを false にすることで highlight.js で、
コードが変更された場合でも常にシンタックスハイライトすることが可能
*/
useEffect(() => {
hljs.initHighlighting();
hljs.initHighlighting.called = false;
});
useEffect(() => {
/**
シンタックスハイライトしたいコード input フォームへの入力内容に応じて動的に変わる
*/
setBadgeCode(` <!-- highlight.js でハイライトする -->
<div>Hello ${user}!</div>
}`, [user, style]);
});
const handleChange = (event) => {
if (inputChecker)
clearTimeout(inputChecker);
inputChecker = setTimeout(() => {
clearTimeout(inputChecker);
inputChecker = null;
setPreviewUser(event.target.value);
}, 1 * 1000); // 1 seconds
setUser(event.target.value);
};
return (
<div className={styles.container}>
<Head>
<title>Highlight</title>
</Head>
<main className={styles.main}>
<h1 className={styles.title}>
Highlight sample
</h1>
<input type="text" value={user} onChange={handleChange} />
<div>
{ /* pre -> code タグ内に highlight.js で
シンタックスハイライトしたい内容を出力する */ }
<pre style=<span>{</span><span>{</span> width: '80vw' <span>}</span><span>}</span>>
<code className="xml">
{badgeCode}
</code>
</pre>
</div>
</main>
<footer className={styles.footer}>
</footer>
</div>
)
}
</code></pre>
nikaera
tag:crieit.net,2005:PublicArticle/16407
2020-12-22T16:15:41+09:00
2020-12-22T16:15:41+09:00
https://crieit.net/posts/hugo-react-dev
Hugo で React + TypeScript を利用してサクッとウェブサイトに RSS リーダーを追加する
<p>この記事は <a target="_blank" rel="nofollow noopener" href="https://qiita.com/advent-calendar/2020/static-site-generator">Static Site Generator Advent Calendar 2020</a> 22日目の記事です。</p>
<h1 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h1>
<p>Hugo のウェブサイトに組み込む RSS リーダーを TypeScript で開発してみたいと思い調査したところ、Hugo の最新版には <a target="_blank" rel="nofollow noopener" href="https://github.com/evanw/esbuild">ESBuild</a> が組み込まれていて、<strong>非常に手厚く JavaScript の開発環境がサポートされていることが分かりました。</strong> 本記事では紹介していませんが <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/babel/">Babel</a> も利用できるようです。</p>
<p>また、NPM パッケージも利用できるため、普段のウェブ開発と同様の流れで開発ができ、各種ライブラリを用いた開発も非常に楽でした。<br />
今回は Hugo で JavaScript 開発する方法を RSS リーダーの開発を例に上げ、そこで得た知見についても交える形で記事として残しておくことにしました。</p>
<p><strong>ちなみに本記事内容は Hugo で JavaScript 開発する方法に焦点を絞ったものなのですが、ウェブサイトに RSS リーダーを組み込むことに焦点を絞って見たい方は <a href="#(%E4%BD%99%E8%AB%87)-rss-%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92-hugo-%E3%81%AE-data-templates-%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B"><code>RSS リーダーを Hugo の Data Templates で実装する</code></a> から見ていただくことをオススメします。</strong></p>
<h1 id="Hugo で JavaScript (React + TypeScript) の開発環境を整える"><a href="#Hugo+%E3%81%A7+JavaScript+%28React+%2B+TypeScript%29+%E3%81%AE%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83%E3%82%92%E6%95%B4%E3%81%88%E3%82%8B">Hugo で JavaScript (React + TypeScript) の開発環境を整える</a></h1>
<p>まず、<strong>TypeScript のビルドは ESBuild に任せることができるため何も行う必要はありません。</strong> そのため React 開発用パッケージのインストールのみ行えば大丈夫です。</p>
<p>Hugo プロジェクトのルートディレクトリで下記コマンドを実行し、<code>package.json</code> を作成してから、React の開発に必要なパッケージをインストールします。</p>
<pre><code class="bash">npm init -y
npm install --save react react-dom
</code></pre>
<p>無事パッケージのインストールが完了したら、早速 TSX ファイルを <code>assets/js/App.tsx</code> に作成してしまいます。</p>
<pre><code class="javascript">// assets/js/App.tsx
import * as React from "react";
import * as ReactDOM from "react-dom";
function App() {
return (
<>
Hello React!
</>
);
}
ReactDOM.render(
<App />,
document.getElementById("react")
);
</code></pre>
<p>上記のコードを見てもらえば分かる通り、レンダリング先に <code>id</code> が <code>react</code> の DOM ノードを指定しています。そのため Hugo 側で該当する DOM ノードを用意する必要があります。その際の HTML テンプレートは下記になります。</p>
<pre><code class="html"><!-- ... -->
<!-- 利用するリソースを指定する -->
<span>{</span><span>{</span> with resources.Get "js/App.tsx" <span>}</span><span>}</span>
<!-- id が react の div 要素を用意する -->
<div id="react"></div>
<!-- TSX を ESBuild でビルドする際の Hugo のオプションを指定する -->
<span>{</span><span>{</span> $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict "process.env.NODE_ENV" "\"development\"") <span>}</span><span>}</span>
<!-- TSX のビルドを Hugo のオプションで指定した内容で実行する -->
<span>{</span><span>{</span> $js := resources.Get . | js.Build $options <span>}</span><span>}</span>
<!-- 一応 SRI を有効化した状態でビルドした JS を読み込む -->
<span>{</span><span>{</span> $secureJS := $js | resources.Fingerprint "sha512" <span>}</span><span>}</span>
<script src="<span>{</span><span>{</span> $secureJS.Permalink <span>}</span><span>}</span>" integrity="<span>{</span><span>{</span> $secureJS.Data.Integrity <span>}</span><span>}</span>"></script>
<span>{</span><span>{</span> end <span>}</span><span>}</span>
<!-- ... -->
</code></pre>
<p>ちなみに <code>$options</code> で指定している ESBuild でビルド時に指定可能なオプションは <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/js/">Hugo の公式ページ</a> に記載されています。</p>
<p>上記 HTML の記述を RSS リーダーを埋め込みたいページに追加します。<br />
この状態で該当ページにアクセスすると下記のような表示が確認できるはずです。</p>
<p><img src="https://i.gyazo.com/7e196a2a52f492771deb5dd6913bbe60.png" alt="Hello React! と画面に表示される" /><br />
<strong>App.tsx で定義した内容が画面に表示される</strong></p>
<p>これで React + TypeScript の開発環境が整いました。</p>
<h1 id="RSS リーダーを実装する"><a href="#RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">RSS リーダーを実装する</a></h1>
<p>あとは一般的な Web フロントエンド開発の流れで RSS リーダーの開発を進めていくだけです。</p>
<h2 id="ウェブサイトで読み込みたい RSS フィードを準備する"><a href="#%E3%82%A6%E3%82%A7%E3%83%96%E3%82%B5%E3%82%A4%E3%83%88%E3%81%A7%E8%AA%AD%E3%81%BF%E8%BE%BC%E3%81%BF%E3%81%9F%E3%81%84+RSS+%E3%83%95%E3%82%A3%E3%83%BC%E3%83%89%E3%82%92%E6%BA%96%E5%82%99%E3%81%99%E3%82%8B">ウェブサイトで読み込みたい RSS フィードを準備する</a></h2>
<p>RSS フィードを利用する際は必ず提供しているサービスの利用規約をご確認ください。<br />
<a target="_blank" rel="nofollow noopener" href="https://qiita.com/terms">Qiita</a> 及び <a target="_blank" rel="nofollow noopener" href="https://zenn.dev/terms">Zenn</a> については個人利用かつ自分の情報のみを扱う範囲内であれば利用が許可されているように見受けられました。</p>
<p>下準備としてウェブサイトで読み込みたい RSS フィードを事前にダウンロードするためのバッチを作成します。バッチは NPM を利用して作成していきます。<strong>NPM を導入したので Hugo で利用する簡易なバッチは JavaScript でサクッと作成していきます。</strong></p>
<p>まずはスクリプト作成の際に必要となるパッケージを事前にいくつかインストールします。</p>
<pre><code class="bash"># html をテキスト変換にするパッケージと RSS フィードのパーサーをインストールする
npm i -D --save html-to-text rss-parser
</code></pre>
<p>実際のコードは下記になります。ファイル名末尾が <code>.mjs</code> なのは <a target="_blank" rel="nofollow noopener" href="https://dev.to/mikeesto/top-level-await-in-node-2jad">Top-Level Await</a> を使用したいからです。</p>
<pre><code class="javascript">// scripts/update-rss.mjs
import { writeFileSync } from 'fs';
import pkg from 'html-to-text';
const { htmlToText } = pkg;
import Parser from 'rss-parser';
const parser = new Parser();
// 自ブログで読み込みたい RSS フィードの情報を設定する
const rssFeed = {
Zenn: {
rss_url: 'https://zenn.dev/nikaera/feed',
profile_url: 'https://zenn.dev/nikaera',
},
Qiita: {
rss_url: 'https://qiita.com/nikaera/feed.atom',
profile_url: 'https://qiita.com/nikaera',
}
}
try {
const jsonFeed = {}
// RSS フィード内の description を 73字で切り取り末尾に ... を付与する関数
const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...`
// rssFeed 変数で定義されてる情報を繰り返し処理する
for (const [site, info] of Object.entries(rssFeed)) {
// RSS フィードの URL から必要な情報を取得する
const feed = await parser.parseURL(info.rss_url);
// RSS フィードに登録されている項目で必要な情報のみを取得する
const items = feed.items.map((i) => {
return {
title: i.title,
content: spliceContent(i.content),
url: i.link,
date: i.pubDate
}
})
// 取得内容は jsonFeed に格納する
const { rss_url, profile_url } = info
jsonFeed[site] = { rss_url, profile_url, items };
}
// 最後に jsonFeed に格納された内容を JSON 文字列として static/rss.json に出力する
writeFileSync('./static/rss.json', JSON.stringify(jsonFeed));
} catch(err) {
console.error(err);
}
</code></pre>
<p>次に <code>package.json</code> の <code>scripts</code> に登録してコマンドとして実行可能にします。</p>
<pre><code class="json">{
"scripts": {
"update-rss": "node ./scripts/update-rss.mjs"
}
}
</code></pre>
<p>これで <code>npm run update-rss</code> を実行すれば自ブログで表示する際に用いる JSON ファイルとして RSS フィードの内容を <code>static/rss.json</code> に出力できます。また、JSON ファイルは <code>static</code> フォルダに出力しているため <code>http://localhost:1313/rss.json</code> でアクセスできます。</p>
<p><img src="https://i.gyazo.com/508ba87c41f1c1e410b89ff1bb56be4e.png" alt="npm run update-rss を実行して出力した rss.json" /><br />
<strong>npm run update-rss を実行して出力した rss.json</strong></p>
<p><img src="https://i.gyazo.com/9b7ebeedce1cb69b6b3ab8acacb0b1d1.png" alt="npm run update-rss を実行して出力した rss.json にブラウザからアクセスする" /><br />
<strong><code>http://localhost:1313/rss.json</code> にアクセスして出力した rss.json が参照可能なことを確認する</strong></p>
<h2 id="RSS リーダーを React + TypeScript で実装する"><a href="#RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92+React+%2B+TypeScript+%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">RSS リーダーを React + TypeScript で実装する</a></h2>
<p>準備が整ったので、早速 RSS リーダーを作成していきます。</p>
<p>下記は Hugo のテーマの 1つである <a target="_blank" rel="nofollow noopener" href="https://themes.gohugo.io/hugo-papermod/">hugo-PaperMod</a> の <code>archives</code> テンプレートを利用してページに埋め込むことを想定した RSS リーダーのコードです。</p>
<pre><code class="typescript">// assets/js/Rss.tsx
import React, { useMemo, useState } from 'react'
import * as superagent from 'superagent';
const Rss = (props) => {
const [feed, setFeed] = useState({});
const { name } = props;
useMemo(() => {
(async () => {
try {
const res = await superagent.get('/rss.json');
setFeed(res.body[name]);
} catch (err) {
console.error(err);
}
})()
}, [name]);
if (!("items" in feed)) return null
return (
<div className="archive-month">
<h3 className="archive-month-header">
<a href={feed.profile_url} target="_blank" rel="noopener noreferrer">{name}</a> - <a href={feed.rss_url} target="_blank" rel="noopener noreferrer">RSS</a>
</h3>
<div className="archive-posts">
{feed.items.map((item) => {
return <div className="archive-entry" key={item.url}>
<h3 className="archive-entry-title">{item.title}</h3>
<div className="archive-meta">{item.date} - {item.content}</div>
<a className="entry-link" href={item.url} target="_blank" rel="noopener noreferrer"> </a>
</div>
})}
</div>
</div>
)
}
export default Rss
</code></pre>
<p>次に <code>assets/js/App.tsx</code> で <code>assets/js/Rss.tsx</code> を読み込み画面に表示できるよう改修します。</p>
<pre><code class="javascript">// assets/js/App.tsx
import Rss from './Rss';
import * as React from "react";
import * as ReactDOM from "react-dom";
function App() {
return (
<>
<div class="archive-year">
<h2 class="archive-year-header">
Tech 🦾
</h2>
<Rss name="Zenn" />
<Rss name="Qiita" />
</div>
</>
);
}
ReactDOM.render(
<App />,
document.getElementById("react")
);
</code></pre>
<p>これで RSS リーダーを埋め込んだページを閲覧すると下記のような画面が表示されるはずです。</p>
<p><img src="https://i.gyazo.com/0a6b8923d141ae70f5e298637f5acc69.png" alt="hugo-PaperMod で archives テンプレートを用いて RSS リーダーを表示する" /><br />
<strong>hugo-PaperMod で <code>archives</code> テンプレートを用いて RSS リーダーを表示したときの画面</strong></p>
<p>もし他の RSS フィードを追加したい場合は <code>scripts/update-rss.mjs</code> の <code>rssFeed</code> 変数に情報を追加して、<code>App.tsx</code> に <code><Rss name="<rssFeed 変数で定義した RSS Feed 名>" /></code> を定義することで対応できます。</p>
<h1 id="RSS フィードの内容を自動で更新する"><a href="#RSS+%E3%83%95%E3%82%A3%E3%83%BC%E3%83%89%E3%81%AE%E5%86%85%E5%AE%B9%E3%82%92%E8%87%AA%E5%8B%95%E3%81%A7%E6%9B%B4%E6%96%B0%E3%81%99%E3%82%8B">RSS フィードの内容を自動で更新する</a></h1>
<p><code>npm run update-rss</code> を手元で実行して <code>static/rss.json</code> を更新して公開すれば、最新の RSS フィードの内容をページに反映できる状態ですが、都度手動で更新するのは面倒な作業です。</p>
<p>そこで今回は GitHub Actions の <code>schedule</code> を用いて <code>static/rss.json</code> の更新を自動化します。</p>
<h2 id="GitHub Actions のワークフローファイルを作成する"><a href="#GitHub+Actions+%E3%81%AE%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B">GitHub Actions のワークフローファイルを作成する</a></h2>
<p>実際のワークフローファイルは下記になります。<code>schedule</code> の項目で設定している内容がワークフローの実行スケジュールになります。今回は半日毎に更新が走るようにしました。</p>
<pre><code class="yml"># .github/workflows/update-rss.yml
name: update rss json file
on:
push:
branches:
- main # Set a branch name to trigger deployment
schedule:
- cron: '0 */12 * * *' # 今回は半日に 1回のタイミングで更新するようにした
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
with:
ref: main
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Use Node.js 14.10.1
uses: actions/setup-node@v1
with:
node-version: 14.10.1
- name: Install dependencies
run: npm install
- name: Update RSS Feeds
run: npm run update-rss
- name: Commit files
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action"
git add static/rss.json
STATUS=$(git status -s)
if [ -n "$STATUS" ]; then
git commit -m "Update rss.json `date +'%Y-%m-%d %H:%M:%S'`" -a
git push origin main
fi
</code></pre>
<p>上記ワークフローファイルをプロジェクトに追加して、リモートリポジトリにプッシュした後は、ワークフローが実行されるタイミングを待ちます。</p>
<p>無事にワークフローの実行が完了すると下記のようなコミットが追加されているはずです。</p>
<p><img src="https://i.gyazo.com/ebb7cb2e64b13e4a1e1a592836f511f5.png" alt="GitHub Actions が JSON ファイルを更新してコミットしている" /><br />
<strong>GitHub Actions が JSON ファイルを更新してコミットしている</strong></p>
<p><img src="https://i.gyazo.com/1a74399a3cf1053e0480a01590086fbe.png" alt="コミットの詳細を見ると正常に JSON ファイルが更新されていることを確認できる" /><br />
<strong>コミットの詳細を見ると正常に JSON ファイルが更新されていることが確認できる</strong></p>
<p><img src="https://i.gyazo.com/bf86668d5fc32ca09b6d2cfcf71262ce.png" alt="コミット後 Hugo をビルド & デプロイするとページが更新されていることを確認できる" /><br />
<strong>コミット後 Hugo をビルド & デプロイするとページが更新されていることを確認できる</strong></p>
<p>これで Zenn や Qiita 等に記事を書いた際に、都度手動で <code>static/rss.json</code> を更新してページに最新の内容を反映させる作業は必要なくなりました。</p>
<h1 id="(余談) RSS リーダーを Hugo の Data Templates で実装する"><a href="#%28%E4%BD%99%E8%AB%87%29+RSS+%E3%83%AA%E3%83%BC%E3%83%80%E3%83%BC%E3%82%92+Hugo+%E3%81%AE+Data+Templates+%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%99%E3%82%8B">(余談) RSS リーダーを Hugo の Data Templates で実装する</a></h1>
<p>ちなみに Hugo には <a target="_blank" rel="nofollow noopener" href="https://gohugo.io/templates/data-templates/">Data Templates</a> という仕組みがあり、これを用いることで実は JavaScript を利用しなくても HTML テンプレートで RSS リーダーを実現できるということを後から知りました。</p>
<p>そこで最後に Data Template での RSS リーダーの実装方法について記載します。</p>
<p>まずは、<code>scripts/update-rss.mjs</code> の内容を書き換えます。</p>
<pre><code class="typescript">// scripts/update-rss.mjs
import { writeFileSync } from 'fs';
import pkg from 'html-to-text';
const { htmlToText } = pkg;
import Parser from 'rss-parser';
const parser = new Parser();
const rssFeed = {
Zenn: {
rss_url: 'https://zenn.dev/nikaera/feed',
profile_url: 'https://zenn.dev/nikaera'
},
Qiita: {
rss_url: 'https://qiita.com/nikaera/feed.atom',
profile_url: 'https://qiita.com/nikaera'
}
}
try {
const jsonFeed = {}
const spliceContent = (content) => `${htmlToText(content).slice(0, 73)}...`
for (const [site, info] of Object.entries(rssFeed)) {
const feed = await parser.parseURL(info.rss_url);
const items = feed.items.map((i) => {
console.log(i);
return {
title: i.title,
content: spliceContent(i.content),
url: i.link,
date: i.pubDate
}
})
const { rss_url, profile_url } = info
jsonFeed[site] = { rss_url, profile_url, items };
/*
最終的な JSON ファイルの出力先は data フォルダとなり、RSS フィード毎に出力する
例: ./data/Qiita.json, ./data/Zenn.json, etc.
*/
writeFileSync(`./data/${site}.json`, JSON.stringify(jsonFeed[site]));
}
} catch(err) {
console.error(err);
}
</code></pre>
<p>上記を実行することで <code>data/Qiita.json</code> や <code>data/Zenn.json</code> にファイルが出力されます。</p>
<p>Hugo の Data Template を用いると <code>data</code> フォルダ内に配置した <code>json</code>, <code>yaml</code>, <code>toml</code> 形式のファイルは Go の HTML テンプレートで読み込めるようになります。</p>
<p>例えば、<strong><code>data/Qiita.json</code> に配置された JSON ファイルを読み込みたい場合は Go のテンプレートで <code>$Qiita := $.Site.Data.Qiita</code> のような記述でできます。</strong></p>
<p>次に RSS リーダーを埋め込んでいたページを下記のように書き換えます。</p>
<pre><code class="html"><!-- ... -->
<!-- React 関連の記述を全て削除する -->
<!--
<span>{</span><span>{</span> with resources.Get "js/App.tsx" <span>}</span><span>}</span>
<div id="react"></div>
<span>{</span><span>{</span> $options := dict "targetPath" "js/app.js" "minify" true "defines" (dict "process.env.NODE_ENV" "\"development\"") <span>}</span><span>}</span>
<span>{</span><span>{</span> $js := resources.Get . | js.Build $options <span>}</span><span>}</span>
<span>{</span><span>{</span> $secureJS := $js | resources.Fingerprint "sha512" <span>}</span><span>}</span>
<script src="<span>{</span><span>{</span> $secureJS.Permalink <span>}</span><span>}</span>" integrity="<span>{</span><span>{</span> $secureJS.Data.Integrity <span>}</span><span>}</span>"></script>
<span>{</span><span>{</span> end <span>}</span><span>}</span>
-->
<div class="archive-year">
<h2 class="archive-year-header">
Tech 🦾
</h2>
<div class="archive-month">
<!-- data/Zenn.json の内容を読み込む -->
<span>{</span><span>{</span> $Zenn := $.Site.Data.Zenn <span>}</span><span>}</span>
<h3 class="archive-month-header">
<a href="<span>{</span><span>{</span> $Zenn.profile_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">Zenn</a> - <a
href="<span>{</span><span>{</span> $Zenn.rss_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">RSS</a>
</h3>
<div class="archive-posts">
<!-- 配列で格納されている記事情報を繰り返し処理で取得する -->
<span>{</span><span>{</span>- range $Zenn.items <span>}</span><span>}</span>
<div class="archive-entry" key="<span>{</span><span>{</span> .url <span>}</span><span>}</span>">
<h3 class="archive-entry-title"><span>{</span><span>{</span> .title <span>}</span><span>}</span></h3>
<div class="archive-meta"><span>{</span><span>{</span> .date <span>}</span><span>}</span> - <span>{</span><span>{</span> .content <span>}</span><span>}</span></div>
<a class="entry-link" aria-label="<span>{</span><span>{</span> .content <span>}</span><span>}</span>" href="<span>{</span><span>{</span> .url <span>}</span><span>}</span>" target=" _blank"
rel="noopener noreferrer"></a>
</div>
<span>{</span><span>{</span>- end <span>}</span><span>}</span>
</div>
</div>
<div class="archive-month">
<!-- data/Qiita.json の内容を読み込む -->
<span>{</span><span>{</span> $Qiita := $.Site.Data.Qiita <span>}</span><span>}</span>
<h3 class="archive-month-header">
<a href="<span>{</span><span>{</span> $Qiita.profile_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">Qiita</a> - <a
href="<span>{</span><span>{</span> $Qiita.rss_url <span>}</span><span>}</span>" target="_blank" rel="noopener noreferrer">RSS</a>
</h3>
<div class="archive-posts">
<!-- 配列で格納されている記事情報を繰り返し処理で取得する -->
<span>{</span><span>{</span>- range $Qiita.items <span>}</span><span>}</span>
<div class="archive-entry" key="<span>{</span><span>{</span> .url <span>}</span><span>}</span>">
<h3 class="archive-entry-title"><span>{</span><span>{</span> .title <span>}</span><span>}</span></h3>
<div class="archive-meta"><span>{</span><span>{</span> .date <span>}</span><span>}</span> - <span>{</span><span>{</span> .content <span>}</span><span>}</span></div>
<a class="entry-link" aria-label="<span>{</span><span>{</span> .content <span>}</span><span>}</span>" href="<span>{</span><span>{</span> .url <span>}</span><span>}</span>" target=" _blank"
rel="noopener noreferrer"></a>
</div>
<span>{</span><span>{</span>- end <span>}</span><span>}</span>
</div>
</div>
</div>
<!-- ... -->
</code></pre>
<p>また GitHub Actions のワークフローを用いて RSS フィードの情報を更新していた場合は、<code>.github/workflows/update-rss.yml</code> ファイルの更新も必要になります。</p>
<pre><code class="yml"># .github/workflows/update-rss.yml
name: update rss json file
on:
push:
branches:
- main # Set a branch name to trigger deployment
schedule:
- cron: '0 */12 * * *'
jobs:
build:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
with:
ref: main
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Use Node.js 14.10.1
uses: actions/setup-node@v1
with:
node-version: 14.10.1
- name: Install dependencies
run: npm install
- name: Update RSS Feeds
run: npm run update-rss
# Git で追加する内容を data フォルダに変更する
# git add static/rss.json -> git add data/
- name: Commit files
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action"
git add data/
STATUS=$(git status -s)
if [ -n "$STATUS" ]; then
git commit -m "Update data folder `date +'%Y-%m-%d %H:%M:%S'`" -a
git push origin main
fi
</code></pre>
<p>これで JavaScript で作成した RSS リーダーから、Hugo の Data Templates を用いて作成した RSS リーダーへ移行できました。</p>
<h1 id="おわりに"><a href="#%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%AB">おわりに</a></h1>
<p>Hugo で React + TypeScript 開発を楽にできそうなことが分かり、テンションが上がってしまい、そのままのノリで実際に RSS リーダーを自ブログ向けに作成してみました。</p>
<p>しかし、本記事内容で RSS リーダーを実装するのであれば、Hugo の Data Templates を利用することがベストなことに後から気づきました。ただ Hugo での JavaScript を用いた開発手法が理解でき勉強になったので結果ヨシとしました。</p>
<p>Hugo での JavaScript 開発環境は相当充実していることが分かったので、また何かアイデアを思いついたら気軽に作って自ブログに取り込んでいきます。今はザックリ WebGL/WebVR とかで何か面白いもの作れそうだなと考えています。</p>
<h1 id="参考リンク"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF">参考リンク</a></h1>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://esbuild.github.io/">esbuild - An extremely fast JavaScript bundler</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/templates/data-templates/">Data Templates | Hugo</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/functions/">Functions Quick Reference | Hugo</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://gohugo.io/hugo-pipes/js/">JavaScript Building | Hugo</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://reactjs.org/docs/hooks-intro.html">Introducing Hooks – React</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/rbren/rss-parser">rbren/rss-parser: A lightweight RSS parser, for Node and the browser</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/html-to-text/node-html-to-text">html-to-text/node-html-to-text: Advanced html to text converter</a></li>
</ul>
nikaera
tag:crieit.net,2005:PublicArticle/16119
2020-10-09T21:07:03+09:00
2020-10-12T10:12:06+09:00
https://crieit.net/posts/react-autosuggest-Bootstrap
react-autosuggestをBootstrapで使う
<p>react-autosuggestはReactでオートコンプリートを実装できるライブラリ。しかしこれはデザインがない。自分で作るか、多分デモのやつを使う必要があるが、Bootstrapを使っている場合はそれのDropdownをそのまま使いたい。</p>
<p>その場合はthemeプロパティで使うクラスを指定できる。下記のように設定すればOKっぽい</p>
<pre><code class="jsx"> <Autosuggest
theme=<span>{</span><span>{</span>
container: 'autosuggest',
input: 'form-control',
suggestionsContainer: 'dropdown open',
suggestionsList: `dropdown-menu ${items.length ? 'show' : ''}`,
suggestion: '',
suggestionFocused: 'active'
<span>}</span><span>}</span>
</code></pre>
<p>あとはrenderSuggestionsも調整。</p>
<pre><code class="jsx">const renderItem = (item: Item) => (
<a className="dropdown-item" href="#">
{item.name}
</a>
)
</code></pre>
<p>下記で話題に上がっている(最初の方に古くてちゃんと動かないのもあるのでご注意)。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://github.com/moroshko/react-autosuggest/issues/64">https://github.com/moroshko/react-autosuggest/issues/64</a></p>
だら@Crieit開発者
tag:crieit.net,2005:PublicArticle/16068
2020-09-23T21:25:57+09:00
2020-09-23T21:25:57+09:00
https://crieit.net/posts/Recoil
Recoilの書き方はこんな感じかなというイメージ
<p>主に細かいコンポーネントをいくつも作って開発するようなプロジェクトを進め始めたので、丁度マッチしているかなと思いReduxを使い始めていたのをやめてRecoilを使ってみました。</p>
<p>書いているうちにRecoilがこの仕様であればだいたいこんな感じに使っていくことになるのかな……と思ったのでなんとなく書いていきます。</p>
<p>まず基本的なステートの定義は下記のようです。</p>
<pre><code class="typescript">const textState = atom({
key: 'textState',
default: '',
})
</code></pre>
<p>key-valueのハッシュみたいなもので、keyに対して値を保存するような非常にシンプルなイメージです。</p>
<p>これを実際に使うとなるとこのatomなどをたくさん定義していくわけですが、基本的にRecoilが必要な場面となるとその値をコンポーネントにまたがって使用したい時になると思います。そうすると複数のコンポーネントで毎回文字列でキーを指定してステートを利用していくというのはあまり望ましくないと思います。変更にも弱いですし、単なる書き間違えも発生します。</p>
<p>ということで、下記のどちらかのパターンになるのではないかと思います。</p>
<ul>
<li>カスタムフックでまとめる</li>
<li>定義を個別のファイルで行う</li>
</ul>
<p>後者でも良いのですが、一つ二つしか値を使わない、というパターンもそんなになさそうに思いますのでだいたいカスタムフックでまとめる形がよくある形になるのでは、という気がしています。</p>
<h2 id="カスタムフックのパターン"><a href="#%E3%82%AB%E3%82%B9%E3%82%BF%E3%83%A0%E3%83%95%E3%83%83%E3%82%AF%E3%81%AE%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3">カスタムフックのパターン</a></h2>
<p>例えばカスタムフックの場合はこんな感じです。</p>
<pre><code class="typescript">import { atom, useRecoilState } from 'recoil'
import { Post, createEmptyPost } from '@/models/Post'
export type Mode = 'input' | 'confirm' | 'completed'
const modeState = atom<Mode>({
key: 'post/form/mode',
default: 'input'
})
const postState = atom<Post>({
key: 'post/form/post',
default: createEmtpyPost()
})
const fileState = atom<File | null>({
key: 'post/form/file',
default: null
})
export default function usePostForm() {
const [mode, setMode] = useRecoilState(modeState)
const [post, setPost] = useRecoilState(postState)
const [file, setFile] = useRecoilState(fileState)
return {
mode,
setMode,
post,
setPost,
file,
setFile,
}
}
</code></pre>
<p>実際にコンポーネント上で使う時は下記のような感じです。</p>
<pre><code class="jsx">export default function MyComponent() {
const { mode, post, setPost } = usePostForm()
return (
<div>
{mode === 'input' ? (
<input type="text" value={post.title} onChange={(e) => setPost({ ...post, title: e.target.value })} />
) : (
<span>{post.title}</span>
)}
</div>
)
}
</code></pre>
<p>どのコンポーネントでも同様の使い方ができますので細分化も苦にならず、シンプルに使うことができます。キーを何度も入力する必要もありません。というか、結局ReduxのStoreを定義しているようなイメージになってきますね。書き方はそれよりは簡単ですが。まあ結局どういい感じに管理するかを考えると何を使うにしろ似てくるのかもしれません。</p>
<h2 id="個別に定義するパターン"><a href="#%E5%80%8B%E5%88%A5%E3%81%AB%E5%AE%9A%E7%BE%A9%E3%81%99%E3%82%8B%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3">個別に定義するパターン</a></h2>
<p>さっきの例でいうと、</p>
<pre><code class="typescript">export type Mode = 'input' | 'confirm' | 'completed'
export const modeState = atom<Mode>({
key: 'post/form/mode',
default: 'input'
})
</code></pre>
<p>のような定義をそれぞれファイルとして作り、使用する場合は</p>
<pre><code class="jsx">import { modeState } from '../hooks/mode_state'
export default function MyComponent() {
const [mode, setMode] = useRecoilState(modeState)
:
}
</code></pre>
<p>という感じになると思います。これもまあ間違いは少なくて良いと思いますが、アプリケーション全体で単独で使い回すという目的が大きくなってくると思いますので、あまり利用するパターンは多くなさそうな気もします。ちょっと処理が増えるとカスタムフックにしちゃえばいいか、となってしまうと思いますし。</p>
<h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2>
<p>雑に使おうと思えば使えますが、やはり使い方をある程度決めてまとめた方があとあと困らないかなという気がします。シンプルに使えるライブラリですが、開発が進むにつれて一体どのキーがどう使われているのか、使われているのか分からなくなってしまってぐちゃぐちゃになってしまう状況をなるべく避けるように使って行ったほうが良さそうに思いました。</p>
だら@Crieit開発者
tag:crieit.net,2005:PublicArticle/16064
2020-09-20T19:25:58+09:00
2021-02-15T14:38:44+09:00
https://crieit.net/posts/2-5f672e364d13b
ハッカソンイベントで、「2」に近い式をあてるゲームを作ってみた
<p>こんにちは、最近「個人開発ガリガリやって実力つけたいなー」と思っているよしです。<br />
先日、web1week に参加してきました。<br />
色々とてんやわんやしながらも、なんとか投稿までいけたので、レポートを残しておきますー。</p>
<h2 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h2>
<h3 id="web1week の概要"><a href="#web1week+%E3%81%AE%E6%A6%82%E8%A6%81">web1week の概要</a></h3>
<p>「1週間でお題に沿った Web サービスを作ってみよう」というハッカソン的なイベントです。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">9/7~9/13の1週間でWebサービスを作るイベントです! Hello worldレベルのサービスでもOKですのでぜひご参加をお願いします! <a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/%E9%A7%86%E3%81%91%E5%87%BA%E3%81%97%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2?src=hash&ref_src=twsrc%5Etfw">#駆け出しエンジニア</a> 仲間が多い方は是非シェアもお願いします~お題は「2」です。<a target="_blank" rel="nofollow noopener" href="https://t.co/cl4XbPFici">https://t.co/cl4XbPFici</a></p>— だら🎄いろいろつくってる (@dala00) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/dala00/status/1302622342951002115?ref_src=twsrc%5Etfw">September 6, 2020</a>
</blockquote>
<h3 id="参加したきっかけ"><a href="#%E5%8F%82%E5%8A%A0%E3%81%97%E3%81%9F%E3%81%8D%E3%81%A3%E3%81%8B%E3%81%91">参加したきっかけ</a></h3>
<p>前回も参加して楽しかったので。</p>
<p>前回参加の時は割と使える時間が多かった一方、今回は仕事が終わった後か休日に開発することになりました。<br />
なので、最初はどうしようかとも思いましたが、他の参加者の皆さんの多くは同じ状況下で開発されているはずなので、まぁどうにかなるだろう...くらいの気持ちで参加してました。</p>
<p>※前回参加時のレポート記事<br />
- <a href="https://crieit.net/posts/ac044ef3dd9b2580f6a86c0ac05881c1">ハッカソンイベントで、React Konva 製のジグソーパズルっぽいパズルを作ってみた</a></p>
<h2 id="自分が作ったもの"><a href="#%E8%87%AA%E5%88%86%E3%81%8C%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE">自分が作ったもの</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://h-yoshikawa44.github.io/close-to-2/">Close to 2</a></p>
<p>今回のお題は「2」ということで、<br />
計算結果が一番「2」に近い式をあてるゲームを作りました。<br />
一応、スマホでも遊べます。</p>
<p><a href="https://crieit.now.sh/upload_images/cebd6c87a2c39e160ba6ee313b71e37d5f672cc56b863.gif" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/cebd6c87a2c39e160ba6ee313b71e37d5f672cc56b863.gif?mw=700" alt="close-to-2-overview.gif" /></a></p>
<p><a href="https://crieit.net/boards/web1week-202009/Close-to-2">イベントボードへの投稿</a>から引用。</p>
<blockquote>
<p>初級、中級、上級で遊べて、難易度が高いほど長い式になり、30秒で何問正答できるかな?というゲームです。<br />
自分で作っておいてなんですが、暗算得意じゃないと無理ゲーじゃねーかと思いました。</p>
</blockquote>
<h3 id="なんでこれを作ろうとしたのか"><a href="#%E3%81%AA%E3%82%93%E3%81%A7%E3%81%93%E3%82%8C%E3%82%92%E4%BD%9C%E3%82%8D%E3%81%86%E3%81%A8%E3%81%97%E3%81%9F%E3%81%AE%E3%81%8B">なんでこれを作ろうとしたのか</a></h3>
<p>こちらもボードへの投稿に書いていたので引用。</p>
<blockquote>
<p>元々は「2」に関するクロスワードを作ろうとしていたのですが、「2」縛りで問題を考えるのがしんどいの極みだったので変えました(しかも土曜...)<br />
その時点ですでに投稿されていた方(特に、きらぷかさんとDE-TEIUさん)の作品を見て、シンプルなゲームでも十分面白いものが作れるんだなぁと思い、今回の作品に至りました。<br />
(他の方の作品を参考にさせていただきましたが、丸パクリレベルになってしまわないよう気を付けたつもりです)</p>
</blockquote>
<h3 id="技術構成"><a href="#%E6%8A%80%E8%A1%93%E6%A7%8B%E6%88%90">技術構成</a></h3>
<p>最初は前回同様、Docker の Node.js コンテナを使おうとしてました。<br />
ただ、yarn コマンドやアプリ自体の動作速度、VSCode で開発する上での環境など色々考えたらめんどくさくなったので、WSL の Node.js を使いました。<br />
(フロント開発でわざわざDocker使う意味ある?という議論も見かけたことがあったので...)</p>
<p>本番は GitHub Pages でホスティングしています(gh-pages でデプロイ)</p>
<p>使用した主なライブラリはこちら<br />
- React:言わずと知れたUI構築ライブラリ(create-react-app で導入)<br />
- Material UI:UI コンポーネント集<br />
- React Share:各種 SNS のシェアボタン集<br />
- React Snap:SPA の OGP設定<br />
- PropTypes:props のバリデーション<br />
- ESLint:静的解析<br />
- Prettier:コードフォーマッター</p>
<p>前回同様、基本的な UI は Material UI で構築。<br />
一部、前回作ったコンポーネントを流用したものもあります。</p>
<h3 id="どんな感じで開発してたのか"><a href="#%E3%81%A9%E3%82%93%E3%81%AA%E6%84%9F%E3%81%98%E3%81%A7%E9%96%8B%E7%99%BA%E3%81%97%E3%81%A6%E3%81%9F%E3%81%AE%E3%81%8B">どんな感じで開発してたのか</a></h3>
<p>大体こんなでしたが、「8日目」があるところからわかるように、1日遅刻投稿です(苦笑)</p>
<h4 id="1日目:9/7(月)"><a href="#1%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F7%28%E6%9C%88%29">1日目:9/7(月)</a></h4>
<p>「2」というお題を目にして、何を作るか悩みました。<br />
前回、React Konva というライブラリを使用したので、今回も何かライブラリを使おうかと Reactのライブラリを調べてました。</p>
<p>そこで <a target="_blank" rel="nofollow noopener" href="https://github.com/JaredReisinger/react-crossword">GitHub - React Crossword</a> という、クロスワードを作れるライブラリを見つけ、これで何かやろうかな?となりました。</p>
<h4 id="2日目:9/8(火)"><a href="#2%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F8%28%E7%81%AB%29">2日目:9/8(火)</a></h4>
<p>クロスワードで何かするのであれば問題を考える必要があります。<br />
とりあえず環境構築だけしておこうと、この日はリポジトリ作って環境構築まで。</p>
<h4 id="3日目:9/9(水)"><a href="#3%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F9%28%E6%B0%B4%29">3日目:9/9(水)</a></h4>
<p>技術構成のところで書いた通り、わざわざ Docker 環境を使うの微妙だなと思ったので WSL を使う方向へ移行。<br />
React Crossword を試してみたかったので試してみてました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">おおー、ライブラリでホントにクロスワードできたー。(公式のサンプルコード)<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a> <a target="_blank" rel="nofollow noopener" href="https://t.co/4dA2snPpzC">pic.twitter.com/4dA2snPpzC</a></p>— よし (@yoshi44_lion) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1303696169445462017?ref_src=twsrc%5Etfw">September 9, 2020</a>
</blockquote>
<h4 id="4日目:9/10(木)"><a href="#4%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F10%28%E6%9C%A8%29">4日目:9/10(木)</a></h4>
<p>「2」をいろんな言語の読み方にして成立するような問題を考えてました。</p>
<h4 id="5日目:9/11(金)"><a href="#5%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F11%28%E9%87%91%29">5日目:9/11(金)</a></h4>
<p>この日も問題を考えるも、あまり進まず...。</p>
<h4 id="6日目:9/12(土)"><a href="#6%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F12%28%E5%9C%9F%29">6日目:9/12(土)</a></h4>
<p>さすがに問題をどうにかしないと...と焦り出しました(遅い)</p>
<p>「2」をいろんな言語の読み方にして成立するような問題を考えていたものの、思いのほか難しい<br />
↓<br />
これ無理じゃね?<br />
↓<br />
読み方でなく「2」が含まれるものに関する問題(例:2月の和風月名 → キサラギ)に移行してみる<br />
↓<br />
縦読み、横読みを成立させるのが難しい<br />
↓<br />
これ無理じゃね?</p>
<p>みたいな感じになった結果、クロスワードは諦めることに...。</p>
<p>じゃあ、何を作ろうか?というところで、すでに投稿されていた他の参加者の方の作品を見て、シンプル路線にすることにしました。<br />
ここからやっと当作品を作り始めます。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">元々、2に関するクロスワード作ろうとしてたけど、問題考えるのがしんどすぎて、他のものに切り替え中。明日、とりあえず公開できる程度になればいいなぁ。<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a></p>— よし (@yoshi44_lion) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1304802019585060864?ref_src=twsrc%5Etfw">September 12, 2020</a>
</blockquote>
<h4 id="7日目:9/13(日)"><a href="#7%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F13%28%E6%97%A5%29">7日目:9/13(日)</a></h4>
<p>何とか実装が進み、一旦デプロイしてみようとなりました。<br />
前回は Firebase Hosting を使用しましたが、今回は使うまでもないかなということで GitHub Pages にホスティングすることに。</p>
<p>GitHub Actions でデプロイする記事を見かけたので、それでやろうと試みるもビルドで失敗。<br />
どうも ESLint の react-hooks/exhaustive-deps ルールに引っ掛かっていたようです(.eslintrc 設定に含まれていなくても、ビルド時にチェックされる?)</p>
<p>対応方法を調べて試すも、なかなか解決しなかったので、一旦は無効化で対応(あまりよくないことでしょうが...)<br />
ビルドはパスするようになり、デプロイ自体は成功しているものの、なぜか真っ白なページしか表示されず...。</p>
<p>この時点でもうヘロヘロで瀕死だったので、この日は諦めて終わりました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">ゲームとしてはできてるんだけど、ESLint の新ルールにビルドを阻まれ、GitHub Pages にデプロイできる GitHub Actions 試そうとしたら、デプロイ自体は成功してるっぽいのに、URL にアクセスしても何も表示されないし。やり方悪いんかな...。もう疲れた...。一旦終わろう。 <a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a></p>— よし (@yoshi44_lion) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1305160798155673601?ref_src=twsrc%5Etfw">September 13, 2020</a>
</blockquote>
<h4 id="8日目:9/14(月)"><a href="#8%E6%97%A5%E7%9B%AE%EF%BC%9A9%2F14%28%E6%9C%88%29">8日目:9/14(月)</a></h4>
<p>gh-pages を使用して手動デプロイしてみても真っ白ページに。<br />
試しに S3 にデプロイしたら普通に動作したので、GitHub Pages の問題と切り分け。</p>
<p>真っ白ページの原因を特定。<br />
GitHub Pages 特有の注意点を見落としていただけだったようです。<br />
こちらの記事にまさしくなことが書いてありました。<br />
- <a target="_blank" rel="nofollow noopener" href="https://qiita.com/rhirayamaaan/items/cdbda70670157a8fb705">create-react-appとTypeScriptでサラッと作ったSPAをgh-pagesにスルッとデプロイすっぞ!</a></p>
<p>ただ、これでもなぜか GitHub Actions でのデプロイは真っ白<br />
ページのままだったので、以降は手動デプロイでやるように。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">GitHub Pages のデプロイで動いたー。どうも package.json に homepage キーで URL を指定する必要があったらしい。そういえば Jekyll をデプロイするうえでの注意点とかで、前に見たことあったような…。なんか URL がずれておかしくなる的な。<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a></p>— よし (@yoshi44_lion) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1305355374346661890?ref_src=twsrc%5Etfw">September 14, 2020</a>
</blockquote>
<p>無事動作確認できたし投稿するかと思いましたが、こういうのはやはり OGP 設定や Twitter シェア機能を付けた方がいいよなぁ...ということで対応することに。<br />
この時点でもヘロヘロ気味だったので、やっつけで対応して、日が変わる直前に何とか投稿しました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">なんとか形にできたー。計算結果が一番「2」に近い式をあてるゲームを作りました。よろしければ遊んでやってください🙏Close to 2 - (9/7~9/13)1週間でWebサービスを作るイベント - お題「2」 - Boards - Crieit <a target="_blank" rel="nofollow noopener" href="https://t.co/HDSKmfCsWo">https://t.co/HDSKmfCsWo</a> <a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/Crieit?src=hash&ref_src=twsrc%5Etfw">#Crieit</a> <a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a></p>— よし (@yoshi44_lion) <a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1305520904642805760?ref_src=twsrc%5Etfw">September 14, 2020</a>
</blockquote>
<h2 id="おおよそどんなことをやっているのか"><a href="#%E3%81%8A%E3%81%8A%E3%82%88%E3%81%9D%E3%81%A9%E3%82%93%E3%81%AA%E3%81%93%E3%81%A8%E3%82%92%E3%82%84%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%8B">おおよそどんなことをやっているのか</a></h2>
<p>特に変わったことはしてないと思いますので、ソースコード見てもらった方が早いかとー。<br />
- <a target="_blank" rel="nofollow noopener" href="https://github.com/h-yoshikawa44/close-to-2">GitHub - close-to-2</a></p>
<p>参考にさせていただいた記事はこちらなど<br />
- <a target="_blank" rel="nofollow noopener" href="https://keizokuma.com/js-array-object-sort/">JavaScriptで要素がオブジェクトの配列を日付や数値でソートする方法</a><br />
- <a target="_blank" rel="nofollow noopener" href="https://qiita.com/netebakari/items/7c1db0b0cea14a3d4419">JavaScriptで重複排除を自分で実装してはいけない(Setを使う)</a></p>
<h3 id="課題"><a href="#%E8%AA%B2%E9%A1%8C">課題</a></h3>
<p>React Hooks のこととか、もうちょっとわかったら、全体的にもっとスマートに書けるんでしょうかね...。なんかごちゃついてる感。</p>
<p>OGP はやっつけで5分くらいで作りました(笑)</p>
<p>Twitter シェアボタンは、公式のものを SPA で使うには少し工夫がいるということで、なんかうまくいきませんでした...。<br />
なので今回は React Share を頼りました。<br />
本当は Twitter アイコンとともにツイートと書かれたボタンにしたかったのですが、React Share だとできないっぽい?ということで妥協することに。</p>
<h2 id="今回参加してどうだったか"><a href="#%E4%BB%8A%E5%9B%9E%E5%8F%82%E5%8A%A0%E3%81%97%E3%81%A6%E3%81%A9%E3%81%86%E3%81%A0%E3%81%A3%E3%81%9F%E3%81%8B">今回参加してどうだったか</a></h2>
<p>楽しかったですが、前回以上にてんやわんやしてたなぁと(笑)<br />
特に最後の方。まぁ、自分が前半悠長にやってたせいなんですが。</p>
<p>Twitter シェアボタンを導入したおかげて、遊んでくださってる方の存在を確認することができました。<br />
確認できるとやはり嬉しいですね!</p>
<p>他の方の投稿を見るのも楽しい + 勉強になりますし、こういったイベントに参加するのはいい経験になりますね。<br />
web1week はほどよくゆるい感じの雰囲気があるので、敷居が低くて参加しやすいなと。<br />
また開催されることがありましたら、余程忙しくない限りはなるべく参加していきたいなーと思います。</p>
<p>ありがとうございました!</p>
<h2 id="参考リンクまとめ"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%BE%E3%81%A8%E3%82%81">参考リンクまとめ</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/JaredReisinger/react-crossword">GitHub - React CrossWord</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://keizokuma.com/js-array-object-sort/">JavaScriptで要素がオブジェクトの配列を日付や数値でソートする方法</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/netebakari/items/7c1db0b0cea14a3d4419">JavaScriptで重複排除を自分で実装してはいけない(Setを使う)</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/rhirayamaaan/items/cdbda70670157a8fb705">create-react-appとTypeScriptでサラッと作ったSPAをgh-pagesにスルッとデプロイすっぞ!</a></li>
</ul>
よし
tag:crieit.net,2005:PublicArticle/16054
2020-09-12T11:18:02+09:00
2020-09-15T19:48:01+09:00
https://crieit.net/posts/React-Vue-js-HTML
ReactコンポーネントをVue.jsみたいに素のHTMLに配置する
<p>ReactコンポーネントをVue.jsのように、コンポーネント名のタグで呼び出す小ネタです。具体的には下記のようにするとコンポーネントの実行結果が表示されるようになります。</p>
<pre><code class="html"><my-component moji="aiu" :obuje="{a: 'a', b: 'b'}"></my-component>
</code></pre>
<p>やり方としては下記のようなコードを利用します。anyは手抜きです。単に指定したタグ名を探して、見つかった場合はその属性を取得してコンポーネントのプロパティとして渡してrenderしているだけです。</p>
<pre><code class="typescript">import React from 'react'
import { render } from 'react-dom'
type Components = {
[domId: string]: any
}
function getPropertyName(originalName: string) {
let property = ''
for (let i = 0; i < originalName.length; i++) {
const c = originalName.charAt(i)
if (c === '-') {
i++
property += originalName.charAt(i).toUpperCase()
} else {
property += c
}
}
return property
}
export function startComponents(components: Components) {
for (const id in components) {
const component = components[id]
const targets = document.querySelectorAll(id)
targets.forEach(target => {
const props: { [key: string]: any } = {}
if (target.hasAttributes()) {
target.attributes
for (let i = 0; i < target.attributes.length; i++) {
const attribute = target.attributes[i]
if (attribute.name.charAt(0) === ':') {
props[getPropertyName(attribute.name.substring(1))] = JSON.parse(
attribute.value
)
} else {
props[getPropertyName(attribute.name)] = attribute.value
}
}
}
render(React.createElement(component, props), target)
})
}
}
</code></pre>
<p>ここのコツとして、属性は大文字小文字が関係ないようなので、キャメルケースのプロパティ名を使いたい時はこれもVue.jsと同様にハイフンを利用します。そのための変換処理も入れています。</p>
<pre><code class="html"><my-component :my-property="{a: 'a', b: 'b'}"></my-component>
</code></pre>
<p>例えばさっきのコンポーネントを使う場合は下記のように初期化します。</p>
<pre><code class="javascript">import MyComponent from './components/MyComponent'
startComponents({
'my-component': MyComponent
})
</code></pre>
<p>これでHTML上に簡単にReactコンポーネントを表示できるようになりました。まあガッツリちゃんとやろうと思うと支障が出てくる箇所も出てくる可能性はありますので、あくまでもちょっとした手抜き対応です。</p>
だら@Crieit開発者
tag:crieit.net,2005:PublicArticle/16045
2020-09-01T23:08:37+09:00
2020-09-01T23:08:37+09:00
https://crieit.net/posts/A-Frame-VR-5f4e55e505ae0
A-FrameでVR選手ロッカーを作ってみた話
<p><a href="https://crieit.now.sh/upload_images/c757f121bd19245f1a30d36c3424b0625f4e4eb3ed4c2.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/c757f121bd19245f1a30d36c3424b0625f4e4eb3ed4c2.jpg?mw=700" alt="" /></a></p>
<h2 id="①NextJSでaframe-reactを動かす"><a href="#%E2%91%A0NextJS%E3%81%A7aframe-react%E3%82%92%E5%8B%95%E3%81%8B%E3%81%99">①NextJSでaframe-reactを動かす</a></h2>
<h3 id="aframe-react"><a href="#aframe-react">aframe-react</a></h3>
<p>ReactJSでAFrameを動かすときはこれ。<br />
<a target="_blank" rel="nofollow noopener" href="https://github.com/supermedium/aframe-react">aframe-react</a></p>
<p>NextJSは一部SSR(サーバサイドレンダリング)が走るので、クライアントで動作する状態になってからロードする必要がある。<br />
参考: <a target="_blank" rel="nofollow noopener" href="https://github.com/michaltakac/aframe-next-static/blob/master/pages/index.js">aframe-next-static</a></p>
<p>windowというグローバル変数が使えるようになればクライアント側で動作する状態なので、レンダリング完了フラグで管理する。</p>
<pre><code class="javascript">if (typeof window !== "undefined") {
require("aframe");
setRendered(true);
}
</code></pre>
<h2 id="②aframe-reactで書いてみる"><a href="#%E2%91%A1aframe-react%E3%81%A7%E6%9B%B8%E3%81%84%E3%81%A6%E3%81%BF%E3%82%8B">②aframe-reactで書いてみる</a></h2>
<p>aframeは配置するものが多いと意外とソースが長くなるので、<br />
1個のLockerを構成する単位でコンポーネント化しました。<br />
選手の情報をAPIから取得して、奇数と偶数の場合で振り分けています。</p>
<pre><code class="html">if (!rendered || !teamMembers) {
return <></>;
}
return (
<Scene
//device-orientation-permission-ui="enabled: true"
>
{teamMembers.map((member,idx)=>(
<>
{idx%2 === 0
?<RightLocker member={member} x={3 - idx*0.35} y={0} z={1.7+idx*0.35} rot_x={0} rot_y={0} rot_z={0}/>
:<LeftLocker member={member} x={-idx*0.35} y={0} z={idx*0.35} rot_x={0} rot_y={0} rot_z={0}/>
}
</>
))}
<Entity primitive="a-sky" material="color: #555" />
<Entity camera look-controls wasd-controls position="3 1 2"/>
</Scene>
);
</code></pre>
<p><code>a-sky</code>は背景、<code>camera</code>はカメラの初期位置を定義しています。</p>
<p>これがRightLockerのソースです。<br />
プログラム的に難しいことはないのですが、<br />
座標や光源の調整などが結構面倒でした。<br />
(..planeって光透過すんの?)</p>
<pre><code class="javascript">import { Entity } from "aframe-react";
import { Player } from "../../model/typedef";
type diff={
x:number,
y:number,
z:number,
rot_x:number,
rot_y:number,
rot_z:number,
member:Player
}
const RightLocker:React.FC<diff> = ({x,y,z,rot_x,rot_y,rot_z,member}) => {
return (
<>
<Entity
geometry=<span>{</span><span>{</span> primitive: "plane", width:1, height:2<span>}</span><span>}</span>
material=<span>{</span><span>{</span> src: "/images/wood.jpg",alphaTest:0.1 <span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x+0.7, y: y, z: z-4.3<span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x, y: -135+rot_y, z: rot_z <span>}</span><span>}</span>
/>
<Entity
primitive="a-image"
height="2"
//geometry=<span>{</span><span>{</span> primitive: "plane", width:1, height:2<span>}</span><span>}</span>
material=<span>{</span><span>{</span> src: "/images/wood.jpg", transparent:false<span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x+0.7, y: y, z: z-5 <span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x, y: rot_y-45, z: rot_z <span>}</span><span>}</span>
/>
<Entity
primitive="a-image"
height="2"
//geometry=<span>{</span><span>{</span> primitive: "plane", width:1, height:2<span>}</span><span>}</span>
material=<span>{</span><span>{</span> src: "/images/wood.jpg"<span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x, y: y, z: z-4.3 <span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x, y: rot_y-45, z: rot_z <span>}</span><span>}</span>
/>
<Entity
geometry=<span>{</span><span>{</span> primitive: "box", width:0.5, height:0.3<span>}</span><span>}</span>
material=<span>{</span><span>{</span> color:'gray' <span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x+0.5, y: y-1, z: z-4.5 <span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x, y: rot_y-45, z: rot_z <span>}</span><span>}</span>
/>
<Entity
primitive="a-image"
height="1.5"
width="0.8"
//geometry=<span>{</span><span>{</span> primitive: "plane", width:1, height:2<span>}</span><span>}</span>
material=<span>{</span><span>{</span> src: member.ogp_image<span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x+0.7, y: y, z: z-4.3<span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x, y: -135+rot_y, z: rot_z <span>}</span><span>}</span>
/>
<Entity
primitive="a-light"
light=<span>{</span><span>{</span>type:"spot"<span>}</span><span>}</span>
material=<span>{</span><span>{</span>color:'yellow'<span>}</span><span>}</span>
position=<span>{</span><span>{</span> x: x+0.3, y: y+1.3, z: z-4.5 <span>}</span><span>}</span>
rotation=<span>{</span><span>{</span> x: rot_x-90, y: rot_y-10, z: rot_z-10 <span>}</span><span>}</span>
/>
</>
);
};
export default RightLocker;
</code></pre>
<h2 id="DEMO"><a href="#DEMO">DEMO</a></h2>
<p>まだ本サイトにはデプロイしていませんが、<code>team/[チームID]/vr_locker</code>で各チームのVRロッカーを見ることができます。<br />
- <a target="_blank" rel="nofollow noopener" href="https://deploy-preview-19--cap-baseball-info.netlify.app/team/67/vr_locker">キャップ野球情報局・VRロッカー</a></p>
ckoshien
tag:crieit.net,2005:PublicArticle/16040
2020-08-30T22:58:17+09:00
2020-08-30T22:58:17+09:00
https://crieit.net/posts/GitHub-OAuth-App
GitHub OAuth Appのトークンを取得失敗した
<h1 id="access tokenはクライアント側から取得できない"><a href="#access+token%E3%81%AF%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E5%81%B4%E3%81%8B%E3%82%89%E5%8F%96%E5%BE%97%E3%81%A7%E3%81%8D%E3%81%AA%E3%81%84">access tokenはクライアント側から取得できない</a></h1>
<h2 id="やりたかったこと"><a href="#%E3%82%84%E3%82%8A%E3%81%9F%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%93%E3%81%A8">やりたかったこと</a></h2>
<p>GitHubをJSONデータベース代わりに使いたかった. GitHub APIを使うとGitHubをREST APIから操作できるので, これにフロント付けたら簡易なデータベースを作れるのではと思ったわけです. これは結局Cross-Origin Request Blockedでできないことが分かりました.</p>
<p>忘れちゃうのもあれなので概要をメモしておきます.</p>
<h2 id="環境"><a href="#%E7%92%B0%E5%A2%83">環境</a></h2>
<p>以下のパケージを利用しました.</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://nextjs.org/">Next.js</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://marmelab.com/react-admin/">react-admin</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/octokit/auth-oauth-app.js">octokit/auth-oauth-app.js</a></li>
</ul>
<p>またreact-adminで認可するロジックは以下を参考にしました.</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/marmelab/ra-example-oauth">ra-example-oauth</a></li>
</ul>
<h2 id="OAuth Applicationの作成"><a href="#OAuth+Application%E3%81%AE%E4%BD%9C%E6%88%90">OAuth Applicationの作成</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://docs.github.com/en/developers/apps/creating-an-oauth-app">Creating an OAuth App</a>を参考にしましょう.</p>
<p>作成されたGitHub OAuth Appには固有の値であるclient idとclient secretが付与されるます.</p>
<h2 id="Auth Provider"><a href="#Auth+Provider">Auth Provider</a></h2>
<p>react-adminで認証や認可を行うにはauth providerにロジックを書きます.</p>
<pre><code class="javascript">const authProvider = {
login: params => Promise.resolve(),
logout: params => Promise.resolve(),
checkAuth: params => Promise.resolve(),
checkError: error => Promise.resolve(),
getPermissions: params => Promise.resolve(),
};
</code></pre>
<p>取得したトークンはローカルストレージに保存します. checkAuthではこのトークンがすでに設定されているかを確認し, 無ければログイン画面が表示されます.</p>
<pre><code class="javascript">checkAuth: () => {
const user_name = localStorage.getItem('username');
console.log(`checkAuth: ${user_name}`)
return localStorage.getItem('username')
? Promise.resolve()
: Promise.reject();
},
</code></pre>
<p>ログイン画面では2つの処理を行います. まず認可ページで認可を行います. 認可後はAuthorization callback URLに指定したページに戻ってくるのですが, この際codeというURLパラメータが付与されます. これとclient id, client secretを使ってトークンを取得します. stateは任意ですが一応付けてます.</p>
<pre><code class="javascript">import { createOAuthAppAuth } from '@octokit/auth-oauth-app';
const authorizeURL = new URL('oauth/authorize', process.env.login_url);
authorizeURL.searchParams.append('client_id', process.env.client_id);
authorizeURL.searchParams.append('scope', 'public_repo');
authorizeURL.searchParams.append('state', 'QAbgG6f9Rit2C5');
const auth = createOAuthAppAuth({
clientId: process.env.client_id,
clientSecret: process.env.client_secret,
});
login: async (params) => {
const { searchParams } = new URL(window.location.href);
if (!searchParams.has('code') || !searchParams.has('state')) {
redirectToURL(authorizeURL)
return Promise.resolve();
}
const code = searchParams.get('code');
const state = searchParams.get('state');
const tokenAuthentication = await auth({
type: 'token',
code: code,
state: state,
});
const { token } = tokenAuthentication;
localStorage.setItem('username', token);
return Promise.resolve();
},
</code></pre>
<p>ところがauthでtokenを取得しようとすると失敗します. リバース・プロキシを設置してやるといけるようですが, 試してません.</p>
<p><a target="_blank" rel="nofollow noopener" href="https://andreybleme.com/2018-02-24/oauth-github-web-flow-cors-problem/">OAuth Github web flow doesn't support CORS</a></p>
<h2 id="補足"><a href="#%E8%A3%9C%E8%B6%B3">補足</a></h2>
<p>Next.jsとreact-adminを使うとDocument is not definedというエラーが出ます. hisotryというパッケージが内部でDOM APIを利用しているのですがNext.jsは最初ローカル側(サーバー側)で動くのでこの時点ではDOMが存在しません. クライアント側かどうか調べる必要があります. getStaticPropsはロカールで実行され, ファイルなどを取得してpropsとしてページ・コンポーネントに渡すことができます. この処理が終了するとブラウザ側の処理に移ることを利用するとコンポーネントを切り替えることができるようです.</p>
<p><a target="_blank" rel="nofollow noopener" href="https://stackoverflow.com/questions/58987174/how-to-prevent-parent-component-from-re-rendering-with-react-next-js-ssr-two-p">How to prevent parent component from re-rendering with React (next.js) SSR two-pass rendering?</a></p>
ブレイン
tag:crieit.net,2005:PublicArticle/15996
2020-07-05T23:38:19+09:00
2020-07-06T22:33:40+09:00
https://crieit.net/posts/Semantic-UI-React-Form-Input
Semantic UI ReactのForm.Inputとはなんなのか
<p><a target="_blank" rel="nofollow noopener" href="https://react.semantic-ui.com/">Semantic UI React</a> を使っていて <code>Input</code> と <code>Form.Input</code> の違いがよくわかっていなかったので、ちゃんと調べてみました。</p>
<p>バージョンは次のとおりです。</p>
<ul>
<li>React: 16.13.1</li>
<li>Semantic UI React: 0.88.2</li>
</ul>
<h2 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://react.semantic-ui.com/collections/form/">リファレンス</a> で <code>Form.Input</code> を選択すると書かれている通り、 <code>Form.Input</code> は <code><Form.Field control={Input} /></code> のシンタックスシュガーです。</p>
<blockquote>
<p>Sugar for <code><Form.Field control={Input} /></code>.</p>
</blockquote>
<p>さらに <code>From.Field</code> の項目を読むと、次のように書かれています。</p>
<blockquote>
<p>A field is a form element containing a label and an input.</p>
</blockquote>
<p>しかし、バリデーションエラー用のLabelも重要な役割を担っているので、 <code>From.Field</code> は次の3つを含む複合コンポーネントだと捉えた方が良いでしょう <sup id="fnref:1"><a href="#fn:1" class="footnote-ref" role="doc-noteref">1</a></sup> 。</p>
<ul>
<li>項目名を表すlabel</li>
<li>メインの入力欄であるInput</li>
<li>バリデーションエラー用のLabel</li>
</ul>
<p>さて、 <code>Form.Field</code> の中身を記述する方法は、大きく分けて次の2つがあります。</p>
<ul>
<li><code>control</code> を指定する方法( <code>Form.Input</code> を使ったときに発生)</li>
<li><code>children</code> (子要素)を指定する方法</li>
</ul>
<p>個人的には後者の方が暗黙的な挙動が少なくて好きですが、前者の方が記述は楽です。<br />
この記事では、同じDOM構造を実現するための2種類の書き方を比較することで、 <code>Form.Input</code> を使った場合に行われることを理解します。</p>
<h2 id="2種類の記述方法を比較する"><a href="#2%E7%A8%AE%E9%A1%9E%E3%81%AE%E8%A8%98%E8%BF%B0%E6%96%B9%E6%B3%95%E3%82%92%E6%AF%94%E8%BC%83%E3%81%99%E3%82%8B">2種類の記述方法を比較する</a></h2>
<p>ここでは次の2つの記述方法でほぼ同じDOM構造を実現します。</p>
<ol>
<li><code>Form.Input</code> のみを使う</li>
<li><code>Form.Field</code> の子要素に <code>Input</code> を含める</li>
</ol>
<p>ソースコードは次の通りです。</p>
<pre><code class="js">import React, { useState } from "react";
import { Form, Input, Label } from "semantic-ui-react";
const FormExampleFieldError = () => {
const [firstName, setFirstName] = useState("");
const handleChange = e => {
setFirstName(e.target.value);
};
const error = firstName === "" ? "First name is required" : undefined;
return (
<Form>
{/* 1. Form.Inputのみを使う */}
<Form.Input
type="text"
name="firstName"
id="input1-firstName"
placeholder="First name"
value={firstName}
onChange={handleChange}
label="First name"
error={error}
/>
{/* 2. Form.Fieldの子要素にInputを含める */}
<Form.Field error={!!error}>
<label htmlFor="input2-firstName">First name</label>
<Input
type="text"
name="firstName"
id="input2-firstName"
placeholder="First name"
value={firstName}
onChange={handleChange}
aria-describedby={error && "input2-firstName-error-message"}
aria-invalid={!!error}
/>
{error && (
<Label
prompt
pointing="above"
id="input2-firstName-error-message"
role="alert"
aria-atomic
>
{error}
</Label>
)}
</Form.Field>
</Form>
);
};
export default FormExampleFieldError;
</code></pre>
<h3 id="レンダリング結果: エラー無しの場合"><a href="#%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%E7%B5%90%E6%9E%9C%3A+%E3%82%A8%E3%83%A9%E3%83%BC%E7%84%A1%E3%81%97%E3%81%AE%E5%A0%B4%E5%90%88">レンダリング結果: エラー無しの場合</a></h3>
<p>エラーが無い場合、レンダリング結果は次のようになりました。同等のDOM構造になっていることがわかります。なお <code>aria-invalid="false"</code> はデフォルト値なので、属性が無いのと同じ意味です。</p>
<pre><code class="html"><form class="ui form">
<!-- 1. Form.Inputのみを使う -->
<div class="field"><label for="input1-firstName">First name</label>
<div class="ui input"><input name="firstName" placeholder="First name" id="input1-firstName" type="text"
value="a"></div>
</div>
<!-- 2. Form.Fieldの子要素にInputを含める -->
<div class="field"><label for="input2-firstName">First name</label>
<div class="ui input"><input name="firstName" id="input2-firstName" placeholder="First name"
aria-invalid="false" type="text" value="a"></div>
</div>
</form>
</code></pre>
<h3 id="レンダリング結果: エラー有りの場合"><a href="#%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%E7%B5%90%E6%9E%9C%3A+%E3%82%A8%E3%83%A9%E3%83%BC%E6%9C%89%E3%82%8A%E3%81%AE%E5%A0%B4%E5%90%88">レンダリング結果: エラー有りの場合</a></h3>
<p>エラーが有る場合、レンダリング結果は次のようになりました。こちらも同等のDOM構造になっています。 <code>Form.Input</code> を使うと、 <code>aria-invalid</code> や <code>aria-describedby</code> などのアクセシビリティ関連の属性が自動で追加されていることがわかります。</p>
<pre><code class="html"><form class="ui form">
<!-- 1. Form.Inputのみを使う -->
<div class="error field"><label for="input1-firstName">First name</label>
<div class="ui input"><input aria-describedby="input1-firstName-error-message" aria-invalid="true"
name="firstName" placeholder="First name" id="input1-firstName" type="text" value=""></div>
<div class="ui pointing above prompt label" id="input1-firstName-error-message" role="alert" aria-atomic="true">
First name is required</div>
</div>
<!-- 2. Form.Fieldの子要素にInputを含める -->
<div class="error field"><label for="input2-firstName">First name</label>
<div class="ui input"><input name="firstName" id="input2-firstName" placeholder="First name"
aria-describedby="input2-firstName-error-message" aria-invalid="true" type="text" value=""></div>
<div id="input2-firstName-error-message" role="alert" aria-atomic="true" class="ui pointing above prompt label">
First name is required</div>
</div>
</form>
</code></pre>
<h2 id="まとめ"><a href="#%E3%81%BE%E3%81%A8%E3%82%81">まとめ</a></h2>
<p>Semantic UI Reactでは、 <code>Form.Input</code> を使うことで、少ない記述で次の3要素を持つ入力欄を作成できます。</p>
<ul>
<li>項目名を表すlabel</li>
<li>メインの入力欄であるInput</li>
<li>バリデーションエラー用のLabel</li>
</ul>
<p>特にバリデーションエラーの表示は自前で記述するとそれなりに大変なので、積極的に使っていきたいです。</p>
<h2 id="参考"><a href="#%E5%8F%82%E8%80%83">参考</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://github.com/Semantic-Org/Semantic-UI-React/blob/v0.88.2/src/collections/Form/FormField.js">Form.Fieldのソースコード</a></li>
</ul>
<h2 id="おまけ: 子要素にinputを含めた場合"><a href="#%E3%81%8A%E3%81%BE%E3%81%91%3A+%E5%AD%90%E8%A6%81%E7%B4%A0%E3%81%ABinput%E3%82%92%E5%90%AB%E3%82%81%E3%81%9F%E5%A0%B4%E5%90%88">おまけ: 子要素にinputを含めた場合</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://react.semantic-ui.com/collections/form/">リファレンス</a> に書かれているように、 <code>Form.Field</code> には <code>Input</code> の代わりに、HTMLの <code>input</code> 要素を含めることもできます。この場合、若干異なるDOM構造が生成されるものの、見た目は同じになりました。DOM構造の差異は、inputが <code><div class="ui input"></code> によって囲まれない点だけです。</p>
<p>ソース</p>
<pre><code class="js"> {/* 3. Form.Fieldの子要素にhtmlのinputを含める */}
<Form.Field error={error}>
<label htmlFor="input3-firstName">First name</label>
<input
type="text"
name="firstName"
id="input3-firstName"
placeholder="First name"
value={firstName}
onChange={handleChange}
aria-describedby={error && "input3-firstName-error-message"}
aria-invalid={!!error}
/>
{error && (
<Label
prompt
pointing="above"
id="input3-firstName-error-message"
role="alert"
aria-atomic
>
{error}
</Label>
)}
</Form.Field>
</code></pre>
<p>レンダリング結果: エラー無しの場合</p>
<pre><code class="html"> <!-- 3. Form.Fieldの子要素にhtmlのinputを含める -->
<div class="field"><label for="input3-firstName">First name</label><input type="text" name="firstName"
id="input3-firstName" placeholder="First name" aria-invalid="false" value="a"></div>
</code></pre>
<p>レンダリング結果: エラー有りの場合</p>
<pre><code class="html"> <!-- 3. Form.Fieldの子要素にhtmlのinputを含める -->
<div class="error field"><label for="input3-firstName">First name</label><input type="text" name="firstName"
id="input3-firstName" placeholder="First name" aria-describedby="input3-firstName-error-message"
aria-invalid="true" value="">
<div id="input3-firstName-error-message" role="alert" aria-atomic="true" class="ui pointing above prompt label">
First name is required</div>
</div>
</code></pre>
<p>エラーの有無にかかわらず、見た目では区別がつきません。</p>
<p><a href="https://crieit.now.sh/upload_images/5a11f6f3844392eef8bb653508a390ab5f01e4095de34.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/5a11f6f3844392eef8bb653508a390ab5f01e4095de34.png?mw=700" alt="エラーが無い場合の見た目" /></a></p>
<p><a href="https://crieit.now.sh/upload_images/5a11f6f3844392eef8bb653508a390ab5f01e43621baf.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/5a11f6f3844392eef8bb653508a390ab5f01e43621baf.png?mw=700" alt="エラーが有る場合の見た目" /></a></p>
<hr />
<div class="footnotes" role="doc-endnotes">
<hr />
<ol>
<li id="fn:1" role="doc-endnote">
<p>Material UIの <a target="_blank" rel="nofollow noopener" href="https://material-ui.com/components/text-fields/">TextField</a> と同じようなイメージです。 <a href="#fnref:1" class="footnote-backref" role="doc-backlink">↩︎</a></p>
</li>
</ol>
</div>
かと
tag:crieit.net,2005:PublicArticle/15925
2020-06-07T23:44:07+09:00
2020-06-07T23:44:07+09:00
https://crieit.net/posts/NextJS-5edcfd37d54af
NextJSでマークダウンエディタを扱う際の注意点
<h2 id="SSR(サーバサイドレンダリング)の影響"><a href="#SSR%28%E3%82%B5%E3%83%BC%E3%83%90%E3%82%B5%E3%82%A4%E3%83%89%E3%83%AC%E3%83%B3%E3%83%80%E3%83%AA%E3%83%B3%E3%82%B0%29%E3%81%AE%E5%BD%B1%E9%9F%BF">SSR(サーバサイドレンダリング)の影響</a></h2>
<p>draft.jsやtui-editorに共通して言えることですが、<br />
元々ブラウザでの動作を想定して作られているので、<br />
NextJSなどのSSR環境下では<code>window</code>、<code>document</code>などの変数の中身が<code>undefined</code>になり、動作しなくなります。</p>
<h2 id="Next.JSではSSRを無効にできる"><a href="#Next.JS%E3%81%A7%E3%81%AFSSR%E3%82%92%E7%84%A1%E5%8A%B9%E3%81%AB%E3%81%A7%E3%81%8D%E3%82%8B">Next.JSではSSRを無効にできる</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://nextjs.org/docs/advanced-features/dynamic-import#with-no-ssr">With no SSR</a></p>
<p>インポート方法を変えることでSSRを無効にできるオプションがあります。<br />
dynamic import自体はES2020で導入される構文だとか。</p>
<pre><code class="typescript">const DraftEditorNoSSR = dynamic<DraftEditorProp>(
() => import('../components/DraftEditor') as any,
{ ssr: false }
)
</code></pre>
<h2 id="Draft.jsを断念した理由"><a href="#Draft.js%E3%82%92%E6%96%AD%E5%BF%B5%E3%81%97%E3%81%9F%E7%90%86%E7%94%B1">Draft.jsを断念した理由</a></h2>
<p>draft.jsでは再描画した際にカーソルが行の先頭に戻るというバグがあります。<br />
公式リポジトリでは行の最後に移動する対処も議論されていますが、文章の中で編集したくなったらどうするんだ....という声もちらほら。</p>
<p><a target="_blank" rel="nofollow noopener" href="https://github.com/facebook/draft-js/issues/1198">How to stop DraftJS cursor jumping to beginning of text?</a></p>
<p>さっさと見切りをつけてToastUI editorに移りました。<br />
そうです、crieitにも採用されているエディタですね。</p>
<h2 id="Toast UI Editorで実装できた!"><a href="#Toast+UI+Editor%E3%81%A7%E5%AE%9F%E8%A3%85%E3%81%A7%E3%81%8D%E3%81%9F%21">Toast UI Editorで実装できた!</a></h2>
<p>ただし、Toast UI Editorはrefを使わないと値が取れず、<br />
マウントされていない状態でrefがnullになってしまうことにかなり苦しめられました。</p>
<p>changeイベントがeditorで発火するのでフラグを更新するようにして、外側でrefを使ってデータを取り出すという実装になりました。<br />
それから、画像アップロードをdisabledにしました。</p>
<p>editorコンポーネントを使いまわしたかったので、<br />
表示用の初期値<code>initialValue</code>と値を更新して外部に渡す<code>setFunc</code>をpropsとして与えています。</p>
<pre><code class="javascript">const DraftEditor = ({ initialValue, setFunc }) => {
const editorRef = createRef<Editor>();
const [changed, setChanged] = useState(false);
useEffect(() => {
if (editorRef.current && changed) {
setFunc(editorRef.current.getInstance().getMarkdown());
console.log("set");
setChanged(false);
}
}, [editorRef.current, changed]);
return (
<div
style=<span>{</span><span>{</span>
width: "100vw",
<span>}</span><span>}</span>
>
<div>
<Editor
previewStyle="vertical"
toolbarItems={[
"heading",
"bold",
"italic",
"strike",
"divider",
"hr",
"quote",
"ul",
"ol",
"task",
"table",
"link",
"divider",
]}
height="400px"
initialEditType="markdown"
initialValue={initialValue}
ref={editorRef}
hideModeSwitch={true}
events=<span>{</span><span>{</span>
change: (e) => {
setChanged(true);
},
<span>}</span><span>}</span>
/>
</div>
</div>
);
};
export default DraftEditor;
export type DraftEditorProp = {
initialValue: string;
setFunc: Dispatch<any>;
};
</code></pre>
ckoshien
tag:crieit.net,2005:PublicArticle/15915
2020-05-29T13:19:06+09:00
2021-02-15T14:36:20+09:00
https://crieit.net/posts/ac044ef3dd9b2580f6a86c0ac05881c1
ハッカソンイベントで、React Konva製のジグソーパズルっぽいパズルを作ってみた
<p>個人開発って何作るか悩んだり、モチベを保つのが難しかったりしますよね。<br />
そんな自分が、先日web1weekというイベントで個人開発にチャレンジしました。<br />
何とかリリースまでできたので、使用した技術やどんなことをやったのかといった内容を残しておきます。</p>
<h2 id="はじめに"><a href="#%E3%81%AF%E3%81%98%E3%82%81%E3%81%AB">はじめに</a></h2>
<h3 id="web1weekって?"><a href="#web1week%E3%81%A3%E3%81%A6%EF%BC%9F">web1weekって?</a></h3>
<p>簡単に言うと「1週間でお題に沿ったWebサービスを作ってみよう」というイベントです。<br />
当コミュニティ Crieit の運営者である、だらさん主催で行われました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">5/18~5/24の1週間でWebサービスを作るイベントです! Hello worldレベルのサービスでもOKですのでぜひご参加をお願いします!
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/%E9%A7%86%E3%81%91%E5%87%BA%E3%81%97%E3%82%A8%E3%83%B3%E3%82%B8%E3%83%8B%E3%82%A2?src=hash&ref_src=twsrc%5Etfw">#駆け出しエンジニア</a>
仲間が多い方は是非シェアもお願いします~お題は「Like」です。
<a target="_blank" rel="nofollow noopener" href="https://t.co/ORZQGb6Yu2">https://t.co/ORZQGb6Yu2</a>
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/Crieit?src=hash&ref_src=twsrc%5Etfw">#Crieit</a>
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a>
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/StayHome?src=hash&ref_src=twsrc%5Etfw">#StayHome</a>
</p>— だら🎄サービスづくりひたすら (@dala00)
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/dala00/status/1262035532386689025?ref_src=twsrc%5Etfw">May 17, 2020</a>
</blockquote>
<h3 id="参加しようと思ったきっかけ"><a href="#%E5%8F%82%E5%8A%A0%E3%81%97%E3%82%88%E3%81%86%E3%81%A8%E6%80%9D%E3%81%A3%E3%81%9F%E3%81%8D%E3%81%A3%E3%81%8B%E3%81%91">参加しようと思ったきっかけ</a></h3>
<p>主な背景としては<br />
- 個人開発に使える時間があった<br />
- 前回の開催時も面白そうと気になっていた(今回は2回目の開催)<br />
- 個人開発をやってみたかった(+こういったイベントならモチベも保てるかなと思った)<br />
といった感じです。</p>
<p>個人開発は以前からやりたいと思うことはありつつ、結局モチベが続かなくて止めてしまったりということが多かったのです...。<br />
もし何か作ることができたら、1つの実績にできて自分の財産になるかなーと思いました。</p>
<p>これは...やるしかない。</p>
<p>ということで参加。</p>
<h2 id="自分が作ったもの"><a href="#%E8%87%AA%E5%88%86%E3%81%8C%E4%BD%9C%E3%81%A3%E3%81%9F%E3%82%82%E3%81%AE">自分が作ったもの</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://jigsaw-like-puzzle.web.app/">Jigsaw Like Puzzle</a></p>
<p>ちょっとしたパズルを作りました。<br />
PC向け。スマホ対応は現状してません。</p>
<p><a href="https://crieit.now.sh/upload_images/d57cb2f3cdd1b7e92ccbbb0fa0d4565e5ed0aab957fa9.gif" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/d57cb2f3cdd1b7e92ccbbb0fa0d4565e5ed0aab957fa9.gif?mw=700" width="80%" alt="パズルのプレイ画面GIF"></a></p>
<h3 id="なんでパズル?"><a href="#%E3%81%AA%E3%82%93%E3%81%A7%E3%83%91%E3%82%BA%E3%83%AB%EF%BC%9F">なんでパズル?</a></h3>
<p><a href="https://crieit.net/boards/web1week-202005/41152da9fccdaae73e45967feced2e2a">イベント記事への投稿</a>にも書いていたので引用します。</p>
<blockquote>
<p>「Like」ということで<br />
好きなもの → 趣味とかかな? → 絵を描くこと(最近全然描いてないけど) → Canvasでお絵描き実装ができるらしい → でも、ただ絵を描くだけじゃつまらない → もしかしてジグソーパズル作れそう? といった感じで行きつきました。</p>
<p>ジグソーパズル特有の形は再現できてないので、あくまで「ジグソーパズルっぽいもの」ですね。<br />
完全に後付け理由ですが、Like って「~ようなもの」って意味もありますし、意図せずテーマに沿ったものになりました(笑)<br />
こんなことあるんですねー。</p>
</blockquote>
<h3 id="構成"><a href="#%E6%A7%8B%E6%88%90">構成</a></h3>
<p>構成図描こうかなとも思ったんですが、大した構成でもないのでざっくり文面で書きます。</p>
<p>開発はDockerのNode.jsコンテナで。<br />
本番はFirebase Hostingでホスティングしています。</p>
<p>使用した主なライブラリはこちら(正確には他にもあります)<br />
- React:言わずと知れたUI構築ライブラリ(create-react-appで導入)<br />
- React Konva:Canvasを扱うライブラリであるKonva.jsのReact版<br />
- React Router:ルーティング<br />
- Material UI:UIコンポーネント集<br />
- PropTypes:propsのバリデーション<br />
- ESLint:静的解析<br />
- Prettier:コードフォーマッター</p>
<p>基本的なUIはMaterial UIで構築しました。<br />
Boxコンポーネントがすごい便利でしたね。おかげであまりCSSを書かずにすみました。divがその分増えましたが。<br />
パズル部分はReact Konvaで構築しています。</p>
<p>React KonvaとFirebaseについては初めて使ったので、まだまだちゃんとわかってないことも多いです。</p>
<h3 id="どんな感じで開発してたのか"><a href="#%E3%81%A9%E3%82%93%E3%81%AA%E6%84%9F%E3%81%98%E3%81%A7%E9%96%8B%E7%99%BA%E3%81%97%E3%81%A6%E3%81%9F%E3%81%AE%E3%81%8B">どんな感じで開発してたのか</a></h3>
<p>ざっくりこんな感じでした。</p>
<h4 id="1日目:5/18(月)"><a href="#1%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F18%28%E6%9C%88%29">1日目:5/18(月)</a></h4>
<p>お題に沿って何を作るか案がなかなか出てこなかったです。<br />
React Konvaに行きついてからドキュメントをひたすら読んで、夕方くらいにやっとパズルいけるかも?ってなりました。</p>
<h4 id="2日目:5/19(火)"><a href="#2%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F19%28%E7%81%AB%29">2日目:5/19(火)</a></h4>
<p>午後からやっと手元の環境で動かしてみました。<br />
色々試してみて、なんとなくいけそうかな?という手ごたえがありました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">パズルっぽいものを作成チャレンジ。
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a>
<a target="_blank" rel="nofollow noopener" href="https://t.co/JL4D2noURE">pic.twitter.com/JL4D2noURE</a>
</p>
— よし (@yoshi44_lion)
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1262635512889610241?ref_src=twsrc%5Etfw">May 19, 2020</a>
</blockquote>
<p>ただ、そこからパズルの元となる画像の適切なサイズ割り出しに時間かかりました...。3パターンできれいに割れて、適度な大きさのサイズがいいよねってなりまして。</p>
<h4 id="3日目:5/20(水)"><a href="#3%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F20%28%E6%B0%B4%29">3日目:5/20(水)</a></h4>
<p>ようやく画像サイズを決定。</p>
<p>720 * 480<br />
初級:120 * 120 → 6 * 4 = 24<br />
中級:80 * 80 → 9 * 6 = 54<br />
上級:60 * 60 → 12 * 8 = 96</p>
<p>その後、ストップウォッチの実装をどうやるかめっちゃ悩みました...。<br />
記事を参考にしながら試すも、うまくいかずドはまり。<br />
時間かかりつつも一応実装できました。</p>
<p>この時点でいまだにリポジトリを作っていなかったので、とりあえず作成だけ。</p>
<h4 id="4日目:5/21(木)"><a href="#4%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F21%28%E6%9C%A8%29">4日目:5/21(木)</a></h4>
<p>ルーティングやOGP、画像の取り扱いをどうしようか悩みました。<br />
結果的には、最低限遊べるレベルのリリースができればいいやということで、一旦は妥協することにしました。</p>
<p>午後からやっとプロジェクトのセットアップ。<br />
Issueやプルリクのテンプレ、Dockerで開発環境構築ともろもろ必要な準備を整えました。</p>
<p>ピース位置チェック(正解位置に置かれたらはまる)のやり方がなんとなくわかって、よーし作っていくぞーという流れへ。</p>
<h4 id="5日目:5/22(金)"><a href="#5%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F22%28%E9%87%91%29">5日目:5/22(金)</a></h4>
<p>パズル画面に背景テクスチャをいれようとしましたが、迷ったので結局止めました。</p>
<p>黙々と進めて、ぼんやりとしたイメージで作っていった画面のモックがおおよそできました。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">やっとモック的なものができた
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a>
<a target="_blank" rel="nofollow noopener" href="https://t.co/r9GK4fwEuM">pic.twitter.com/r9GK4fwEuM</a>
</p>
— よし (@yoshi44_lion)
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1263709728640610305?ref_src=twsrc%5Etfw">May 22, 2020</a>
</blockquote>
<p>モックに続いて、難易度選択モーダル、ピース生成ロジックを作成。</p>
<h4 id="6日目:5/23(土)"><a href="#6%E6%97%A5%E7%9B%AE%EF%BC%9A5%2F23%28%E5%9C%9F%29">6日目:5/23(土)</a></h4>
<p>ポーズモーダル、クリアモーダルを作成。</p>
<p>ここまでで、とりあえず最低限遊べることは確認できたのでリリースすることに。<br />
コンポーネントを多少分けてはいましたが、ファイル分割とか全然できてなかったので、そこまでリファクタやってからリリースするかとも考えたのですが、もうやっちゃえとなりました。</p>
<p>ホスティングに関しては、多少慣れてるNetlifyでやる手もあるなと思いました。<br />
ただ、今後機能拡張していくとしたらFirebaseの方が色々やれてよさそうと思い、こちらにすることにしました。</p>
<p>リリース(v0.1.0)して投稿。<br />
リリースと言いつつ、色々と足りてないものがまだあるのでプレリリースみたいなものですね。</p>
<blockquote class="twitter-tweet">
<p lang="ja" dir="ltr">パズルリリースしましたー。
<a target="_blank" rel="nofollow noopener" href="https://t.co/028WAbS7W9">https://t.co/028WAbS7W9</a>
よかったら遊んでみてください。
PC向け。簡易的なものなので、クオリティはご容赦ください🙏(5/18~5/24)1週間でWebサービスを作るイベント - お題「Like」 - Boards の投稿 - Crieit
<a target="_blank" rel="nofollow noopener" href="https://t.co/FldwX4odRN">https://t.co/FldwX4odRN</a>
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/Crieit?src=hash&ref_src=twsrc%5Etfw">#Crieit</a> <a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/web1week?src=hash&ref_src=twsrc%5Etfw">#web1week</a>
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/hashtag/StayHome?src=hash&ref_src=twsrc%5Etfw">#StayHome</a>
</p>— よし (@yoshi44_lion)
<a target="_blank" rel="nofollow noopener" href="https://twitter.com/yoshi44_lion/status/1264144419818573825?ref_src=twsrc%5Etfw">May 23, 2020</a>
</blockquote>
<h2 id="おおよそどんなことをやっているのか"><a href="#%E3%81%8A%E3%81%8A%E3%82%88%E3%81%9D%E3%81%A9%E3%82%93%E3%81%AA%E3%81%93%E3%81%A8%E3%82%92%E3%82%84%E3%81%A3%E3%81%A6%E3%81%84%E3%82%8B%E3%81%AE%E3%81%8B">おおよそどんなことをやっているのか</a></h2>
<p>ソースコードはリファクタやったりして変わる可能性があるので、リポジトリ見ていただいた方が確実かもしれません(あまりきれいなコードではないですが...)<br />
一応、Issue書いたりしながら進めました。<br />
- <a target="_blank" rel="nofollow noopener" href="https://github.com/h-yoshikawa44/jigsaw-like-puzzle">GitHub - jigsaw-like-puzzle</a></p>
<p>以下の内容は執筆時点(v0.1.4)での実装のものとなります。<br />
有識者の方からすると、この実装イケてないとかあると思いますがご容赦ください。</p>
<h3 id="ストップウォッチ"><a href="#%E3%82%B9%E3%83%88%E3%83%83%E3%83%97%E3%82%A6%E3%82%A9%E3%83%83%E3%83%81">ストップウォッチ</a></h3>
<p><a href="https://crieit.now.sh/upload_images/7296a1e13c3ba9f86e222e09f2c2e8c85ed0abacd6412.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/7296a1e13c3ba9f86e222e09f2c2e8c85ed0abacd6412.png?mw=700" width="80%" alt="パズル画面のストップウォッチ部分画像"></a></p>
<h4 id="概要"><a href="#%E6%A6%82%E8%A6%81">概要</a></h4>
<p><code>setInterval()</code>と<code>clearInterval()</code>を使って実装。<br />
恥ずかしながら自分はこの関数を使用したことがなかったこともあり、最初はストップウォッチってどうやって実装したらいいんだろう?という状態でした。</p>
<p>実装のやり方についてはこちらの記事をとても参考にさせていただきました。<br />
- <a target="_blank" rel="nofollow noopener" href="https://blitzgate.co.jp/blog/805/">【1から始めるReact】ストップウォッチを作る</a></p>
<p>1秒ごとに秒数カウントを+1して、その秒数カウントをもとに時、分、秒を計算して更新していくというものです。</p>
<h4 id="問題点"><a href="#%E5%95%8F%E9%A1%8C%E7%82%B9">問題点</a></h4>
<p>同画面で開始と停止を行う上では問題なかったのですが、今回の場合は<br />
- パズル画面の「一時停止」ボタンを押す → ストップウォッチを停止してポーズモーダルを開く<br />
- ポーズモーダルの「復帰」ボタンを押す → ポーズモーダルを閉じて、ストップウォッチ再開</p>
<p>という仕様だったので、再開時に時間が最初からになってしまう問題が起きました。</p>
<p>秒数カウントはstateで管理してない変数だったので、再レンダリング時に値がリセットされていたんだろうなと。<br />
そのため、秒数カウントの値をバックアップを取っておく感じでstateでも保持するようにしました。<br />
1秒ごとの更新処理の際に、秒数カウント(変数)が0 だったらstateを確認して、バックアップがあればそこから復元するイメージです。</p>
<p>秒数カウントを最初からstateで管理すればいいのでは?となるかもしれませんが、stateでやるとうまく動いてくれなかったので、こういった形をとりました。</p>
<h3 id="パズル"><a href="#%E3%83%91%E3%82%BA%E3%83%AB">パズル</a></h3>
<p><a href="https://crieit.now.sh/upload_images/a753b71f782fdb5bf171c87c58829a4d5ed0ac2d7633b.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/a753b71f782fdb5bf171c87c58829a4d5ed0ac2d7633b.png?mw=700" width="80%" alt="パズル画面のパズル部分画像"></a></p>
<h4 id="概要"><a href="#%E6%A6%82%E8%A6%81">概要</a></h4>
<p>React Konvaで実装していますが、その実体はCanvasです。<br />
Canvasを扱う<code>Konva.js</code>というライブラリがあり、そのReact版だそうです。</p>
<p>Canvasを扱うには<code>Konva.js</code>が便利らしいみたいな記事は複数見かけたんですが、自分はCanvas自体を使ったことがなかったため、いまいちピンとこず...。<br />
なので、最初はひたすらドキュメントを読んで、おおよそどんなことができるものなのかを見ていきました。<br />
その結果、パズルいけそうだなという目途がついたので使ってみたという背景があります。</p>
<p>Konva.jsの構造としては、以下のようになっています(<a target="_blank" rel="nofollow noopener" href="https://konvajs.org/docs/overview.html">公式</a>より引用)</p>
<p><a href="https://crieit.now.sh/upload_images/d4558c875c94cb3594a9cd556d3393435ed0ac8deeece.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/d4558c875c94cb3594a9cd556d3393435ed0ac8deeece.png?mw=700" width="60%" alt="Konva.jsの構造画像"></a></p>
<p>Shapeは複数種類があり<br />
- Rect(長方形)<br />
- Circle(円)<br />
- Ellipse(楕円)<br />
- Line(線)<br />
- Image(画像)<br />
- Text(テキスト)<br />
- Star(星)</p>
<p>などがあてはまります。</p>
<p>これらは上位の要素を基準とした x、y座標であったり、横幅、縦幅、色、影などを指定することで、Canvasに描画をしていけるようになっています。</p>
<h4 id="パズルの元画像"><a href="#%E3%83%91%E3%82%BA%E3%83%AB%E3%81%AE%E5%85%83%E7%94%BB%E5%83%8F">パズルの元画像</a></h4>
<p>Imageコンポーネントを使用。<br />
普通に画像パスを渡すのではダメらしく、<code>use-image</code>ライブラリの<code>useImage</code>フックを使って生成した、DOM画像を渡すようにしています。<br />
- <a target="_blank" rel="nofollow noopener" href="https://github.com/konvajs/use-image">GitHub - use-image</a></p>
<p>ちなみにピース数の計算との兼ね合いで、画像サイズおよびこのImageコンポーネントのサイズは 720 * 480 で固定しています。</p>
<h4 id="パズルの額縁"><a href="#%E3%83%91%E3%82%BA%E3%83%AB%E3%81%AE%E9%A1%8D%E7%B8%81">パズルの額縁</a></h4>
<p>Lineコンポーネントを使用。<br />
4つの点の座標を指定して繋ぐことで図形を描画。これを上下左右で4つ作成しています。<br />
ただの塗りつぶしだと安っぽくなるので、グラデーション指定にしてみました。</p>
<h4 id="パズルのピース"><a href="#%E3%83%91%E3%82%BA%E3%83%AB%E3%81%AE%E3%83%94%E3%83%BC%E3%82%B9">パズルのピース</a></h4>
<p>Imageコンポーネントを使用。<br />
<code>useImage</code>フックによる DOM 画像を渡しているのは同様ですが、cropを指定することで画像の切り抜きをしています。</p>
<p>例として初級の場合であれば、ピースサイズは 120 * 120 なので<br />
1行目<br />
- {x:0 y:0 width:120 height:120}<br />
- {x:120 y:0 width:120 height:120}<br />
- {x:240 y:0 width:120 height:120}<br />
.<br />
.<br />
.</p>
<p>2行目<br />
- {x:0 y:120 width:120 height:120}<br />
- {x:120 y:120 width:120 height:120}<br />
- {x:240 y:120 width:120 height:120}<br />
.<br />
.<br />
.</p>
<p>みたいな感じです。<br />
合わせてコンポーネント自体のサイズも 120 * 120 を指定になります。</p>
<p>draggableを有効化してドラッグアンドドロップができるように。<br />
そのうえ、onDragStartとonDragEndでイベント処理を実装しています。</p>
<h4 id="ピースドラッグ時の挙動"><a href="#%E3%83%94%E3%83%BC%E3%82%B9%E3%83%89%E3%83%A9%E3%83%83%E3%82%B0%E6%99%82%E3%81%AE%E6%8C%99%E5%8B%95">ピースドラッグ時の挙動</a></h4>
<p>scaleを変えて、少しだけピースが大きくなるようになっています。<br />
(公式デモのコードそのまま持ってきた感じです)</p>
<p>それに加えて、ドラッグしているピースが必ず最前面に来るような処理をしています。<br />
Canvas要素は、あとに定義したものが前面に来るようになっているようです。<br />
そのため、この処理をやらないと場合によっては、はめ込まれたピースの背面にドラッグ中のピースが隠れてしまい操作不能になってしまうことがあります。<br />
そんなことなったら一気に萎えちゃいますよね。</p>
<h4 id="ピースドロップ時の挙動"><a href="#%E3%83%94%E3%83%BC%E3%82%B9%E3%83%89%E3%83%AD%E3%83%83%E3%83%97%E6%99%82%E3%81%AE%E6%8C%99%E5%8B%95">ピースドロップ時の挙動</a></h4>
<p>scaleを元に戻します。durationも設定してるので、ポヨンと大きさが戻るような見た目になってます。<br />
(これも公式デモのコードをそのまま持ってきた感じです)</p>
<p>加えて、ドロップされた座標と正解位置の座標を比較。<br />
誤差の範囲内であれば、draggableを無効 + ピースの座標を正解位置の座標に更新 することで、ピースがはめこまれるような挙動を実現しています。<br />
この処理はこちらの公式デモを参考にしました。<br />
- <a target="_blank" rel="nofollow noopener" href="https://konvajs.org/docs/sandbox/Animals_on_the_Beach_Game.html">Konva.js - Animals on the Beach Game</a></p>
<h3 id="ゲームの流れ"><a href="#%E3%82%B2%E3%83%BC%E3%83%A0%E3%81%AE%E6%B5%81%E3%82%8C">ゲームの流れ</a></h3>
<p>これまでの内容を踏まえて、ゲームの流れとしてはおおまかにこんな感じです。<br />
(並列で処理しているところもあります)</p>
<p>難易度選択<br />
↓<br />
難易度に応じたピース数(縦、横)、ピースサイズの値をセット<br />
↓<br />
この3つの値の変更を検知して、初期化ロジック実行<br />
ピースの情報を持ったオブジェクトの配列を生成後、その順番をシャッフル<br />
↓<br />
ピースの情報を持ったオブジェクトの配列をもとにピースのコンポーネントが描画される<br />
↓<br />
ゲーム開始(ストップウォッチ開始)<br />
↓<br />
ピースのドラッグアンドドロップ<br />
ドロップ座標が正解位置の座標の誤差範囲であればはめこまれ、正解ピース数が+1される<br />
(これを全てのピースがはめ込まれるまで繰り返す)<br />
↓<br />
正解ピース数の値の変更を検知して、総ピース数と一致すればクリア(ストップウォッチ停止)</p>
<h2 id="今回参加してみてどうだったか"><a href="#%E4%BB%8A%E5%9B%9E%E5%8F%82%E5%8A%A0%E3%81%97%E3%81%A6%E3%81%BF%E3%81%A6%E3%81%A9%E3%81%86%E3%81%A0%E3%81%A3%E3%81%9F%E3%81%8B">今回参加してみてどうだったか</a></h2>
<p>楽しかったです!ただ、疲れました(笑)<br />
お題があったとはいえ、ほぼ一から自分で考えて作る必要があったので、普段よりもいっぱい頭使ったからかなと思います。</p>
<p>とはいえ、個人開発として無事にリリースまでできたのはこれが初めてなので素直に嬉しいですね。<br />
他の方の投稿を見るのも楽しいですし、学ばせていただく機会にもなりました。<br />
こういった機会を設けてくださり、ありがとうございました!</p>
<hr />
<p>ちょっとしたレポート記事を書くつもりがすっかり長くなってしまいました...。<br />
ここまで読んでくださった方、ありがとうございます!</p>
<p>パズルの方は今後も合間を見つけて改修していこうかなと思ってます。<br />
ちなみに最初に導入したGoogle Analyticsがちゃんと動いておらず、投稿時のアクセス数を見られなかったというヘマをやらかしていましたが、修正して現在は無事に動いてます(冷や汗)</p>
<p>もしお暇な時があれば、パズル部屋を覗いてみてください。</p>
<h2 id="参考リンクまとめ"><a href="#%E5%8F%82%E8%80%83%E3%83%AA%E3%83%B3%E3%82%AF%E3%81%BE%E3%81%A8%E3%82%81">参考リンクまとめ</a></h2>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://blitzgate.co.jp/blog/805/">【1から始めるReact】ストップウォッチを作る</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://konvajs.org/docs/react/">Konva.js</a></li>
</ul>
よし
tag:crieit.net,2005:PublicArticle/15899
2020-05-16T23:29:13+09:00
2020-05-16T23:29:13+09:00
https://crieit.net/posts/NextJS
アニメのレコメンドサービス、NextJS化しました。
<p>先週<a href="https://crieit.net/posts/b3c547c34df6393a1f86f8072aaf510a">アニメのレコメンドサービス</a>を作ったばかりなのですが、<br />
業務で<strong>Next.js</strong>と<strong>React hooks</strong>が必要になり、<br />
勉強がてらソースコードの少ないものをリメイクしてみました。</p>
<h1 id="サービスのURL"><a href="#%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9%E3%81%AEURL">サービスのURL</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://annict-suggest.netlify.app/">Annictさじぇすと!</a></p>
<h1 id="Next.js"><a href="#Next.js">Next.js</a></h1>
<p>Next.jsはReactJSを用いたフレームワークです。<br />
<a target="_blank" rel="nofollow noopener" href="https://bagelee.com/programming/next-js/setup-next-js/">Next.jsの環境構築【これからはじめるNext.js】</a></p>
<p>Next.jsのメリットとしては読み込むファイルが軽量になった分パフォーマンスが向上することでしょうか。</p>
<h2 id="環境構築"><a href="#%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89">環境構築</a></h2>
<p>今回環境構築の参考にしたのは<a target="_blank" rel="nofollow noopener" href="https://future-architect.github.io/typescript-guide/reactenv.html">こちら</a></p>
<pre><code>(プロジェクトルート)
├ components (コンポーネント類)
├ context (React contextを格納するフォルダ)
├ hooks (React hooksを格納するフォルダ)
├ lib
│ └ gtag.js (analytics)
├ pages
│ ├ _app.tsx (レイアウト)
│ ├ _document.tsx (index.htmlに相当)
│ ├ index.tsx
│ └ works
│ └ [id].tsx (idは可変なのでdynamic routes)
├ public
│ └ images
└ styles (CSS)
</code></pre>
<h2 id="pages"><a href="#pages">pages</a></h2>
<p>ファイルシステムの構造がそのままWEBシステムのパスになります。<br />
例外として、<code>_app.tsx</code>と<code>_document.tsx</code>があります。</p>
<h3 id="_app.tsx"><a href="#_app.tsx">_app.tsx</a></h3>
<p>レイアウトを担当。</p>
<pre><code class="js">const App = ({Component,pageProps}:AppProps) => {
Router.events.on('routeChangeComplete', url => gtag.pageview(url));
return(
<storeContext.Provider value={useSeasonName()}>
<Layout>
<Component {...pageProps} />
</Layout>
</storeContext.Provider>
);
}
</code></pre>
<h3 id="_document.tsx"><a href="#_document.tsx">_document.tsx</a></h3>
<p>いわゆるindex.htmlに相当。<br />
あれ、これclass構文だったわ。(コピペ)</p>
<pre><code class="js">class MyDocument extends Document<Props> {
render() {
return(
<Html lang="ja-JP">
<Head>
<title></title>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
}
export default MyDocument;
</code></pre>
<h3 id="index.tsx"><a href="#index.tsx">index.tsx</a></h3>
<pre><code class="js">export interface Work{
image:{
recommendedImageUrl:string
},
twitterUsername:string,
annictId:number,
watchersCount:number,
title:string,
seasonYear:string,
seasonName:string,
wikipediaUrl:string,
syobocalTid:string
}
const Index:NextPage = () => {
const [pageNum,setPageNum] = useState(1);
const [data,setData] = useState<Work[]>();
const store = useContext(storeContext);
useEffect(() => {
const f = async()=>{
let resData;
if(store.query.length === 0){
resData = await fetchSeasonWorks(store.seasonName);
}else{
resData = await fetchByTitle(store.query);
}
setData(resData);
console.log(resData);
}
f();
},[store.seasonName,store.query]);
const myRef = useRef<HTMLInputElement>(null);
....
</code></pre>
<h2 id="hooks"><a href="#hooks">hooks</a></h2>
<p>hooksを格納する....はずなのですが、ビューとロジック分離するの失敗してるので<br />
またどこかで使うでしょう(笑)</p>
<h2 id="context"><a href="#context">context</a></h2>
<p>今回はreduxの代わりにcontextで全体の状態を管理します。<br />
<a target="_blank" rel="nofollow noopener" href="https://qiita.com/ragnar1904/items/0a4338523863922952bb">【React + Typescript】useContext の値を子コンポーネントから更新</a></p>
<pre><code class="js">type StoreContext = {
seasonName: string;
query:string;
setSeasonName: (seasonName: string) => void;
setQuery: (query: string) => void;
};
const defaultContext: StoreContext = {
seasonName: CURRENT_SEASON,
query:'',
setSeasonName: () => {},
setQuery: () => {},
};
export const storeContext = createContext<StoreContext>(defaultContext);
export const useSeasonName = (): StoreContext => {
// state名はThemeContext typeのプロパティに合わせる。
const [seasonName, setSeason] = useState(CURRENT_SEASON);
const [query, setQ] = useState('');
// 関数名はThemeContext typeのプロパティに合わせる。
const setSeasonName = useCallback((current: string): void => {
setSeason(current);
}, []);
const setQuery = useCallback((current: string): void => {
setQ(current);
}, []);
return {
seasonName,
query,
setSeasonName,
setQuery
};
};
</code></pre>
<p>個々のコンポーネントでも状態を持っています。</p>
<h2 id="gtag"><a href="#gtag">gtag</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://qiita.com/b95oss/items/0402d2a0fa0edeecb67c">Next.jsでGoogle Analyticsを適用する</a></p>
<h1 id="lintに苦戦..."><a href="#lint%E3%81%AB%E8%8B%A6%E6%88%A6...">lintに苦戦...</a></h1>
<p>がっつりtypescriptを書いていたわけではなかったので、<br />
nextjsはなかなかビルドを通してくれませんでした。<br />
<a target="_blank" rel="nofollow noopener" href="https://github.com/zeit/next.js/issues/10284">ParsedUrlQuery typings should allow undefined values</a></p>
<pre><code class="js">function isString(value: unknown): value is string {
return typeof value === "string"
}
const id = isString(router.query.id) ? router.query.id : ""
useEffect(()=>{
const f = async () => {
if(router !== undefined && router.query !== undefined){
const resData = await fetchByWorkId(id);
setData(resData);
}
}
f();
},[router.query]);
</code></pre>
<h1 id="netlifyにデプロイ"><a href="#netlify%E3%81%AB%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4">netlifyにデプロイ</a></h1>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/mottox2/items/29fbb55129f7c41f1ae6">Next.jsのDynamic Routing + Static HTML exportを組み合わせて使う</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://docs.netlify.com/routing/redirects/#syntax-for-the-netlify-configuration-file">Redirects and rewrites</a></li>
</ul>
<p><code>netlify.toml</code>に必要なルーティングを記述します。</p>
<pre><code>[build]
base = "/"
# next exportの出力先
publish = "out/"
# next buildからのnext exportでファイル生成
command = "yarn run build && yarn run export"
[context.production]
command = "NODE_ENV=production yarn run build && yarn run export"
[[redirects]]
from = "/"
to = "/index.html"
status = 200
[[redirects]]
from = "/works/*"
to = "/works/[id].html"
status = 200
</code></pre>
ckoshien
tag:crieit.net,2005:PublicArticle/15888
2020-05-08T19:25:38+09:00
2020-05-08T19:25:38+09:00
https://crieit.net/posts/b3c547c34df6393a1f86f8072aaf510a
アニメのレコメンドサービスを作りました。
<p><a href="https://crieit.now.sh/upload_images/b0f24117575660588f657c26c91d370e5eb525f79a7b2.png" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/b0f24117575660588f657c26c91d370e5eb525f79a7b2.png?mw=700" alt="" /></a></p>
<h1 id="サービスURL"><a href="#%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9URL">サービスURL</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://annict-suggest.netlify.app/">https://annict-suggest.netlify.app/</a></p>
<p><a href="https://crieit.now.sh/upload_images/102e92a702e89059033b1dff5b0f87c55eb5336deb10d.jpg" target="_blank" rel="nofollow noopener"><img src="https://crieit.now.sh/upload_images/102e92a702e89059033b1dff5b0f87c55eb5336deb10d.jpg?mw=700" alt="" /></a></p>
<h1 id="アニメの類似性をどう計算するか"><a href="#%E3%82%A2%E3%83%8B%E3%83%A1%E3%81%AE%E9%A1%9E%E4%BC%BC%E6%80%A7%E3%82%92%E3%81%A9%E3%81%86%E8%A8%88%E7%AE%97%E3%81%99%E3%82%8B%E3%81%8B">アニメの類似性をどう計算するか</a></h1>
<h2 id="コサイン類似度"><a href="#%E3%82%B3%E3%82%B5%E3%82%A4%E3%83%B3%E9%A1%9E%E4%BC%BC%E5%BA%A6">コサイン類似度</a></h2>
<p>人工知能を使わずにアニメのレコメンドサービスを作ろうと思ったのがきっかけです。<br />
<a href="https://crieit.net/posts/5308d8a3ed140ecc15e1310dad28e9e9">ユークリッド距離は触ったことがある</a>ので、他の指標としてコサイン類似度が面白そうだと思いました。<br />
ユークリッド距離は2点間の距離、コサイン類似度は2点のベクトル同士の角度です。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://www.albert2005.co.jp/knowledge/data_mining/cluster/cluster_summary">クラスター分析の手法①(概要)</a></li>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/tetsutaroendo/items/61942d25ae2a017831f2">コサイン類似度を利用し、集団の類似性を測ってみる</a></li>
</ul>
<h3 id="使った指標"><a href="#%E4%BD%BF%E3%81%A3%E3%81%9F%E6%8C%87%E6%A8%99">使った指標</a></h3>
<p>約3300の作品に対して「見た」「見てない」のベクトルを作ってコサイン類似度を算出しようとしました。<br />
「あにこれ」のように成分分析されているタグの類似度を計算するのもありだと思いました。</p>
<h3 id="挫折"><a href="#%E6%8C%AB%E6%8A%98">挫折</a></h3>
<p>導入は比較的楽なように思えたのですが、計算量が尋常ではありませんでした。事前にフィルタリングを何もかけていなかったため、3300レコードx3300レコードの計算をしようとしていて、あまりに時間がかかるので諦めました。</p>
<ul>
<li><a target="_blank" rel="nofollow noopener" href="https://qiita.com/harperfu6/items/3238d8f78c8a8d8cf114">アイテムの類似性について考察してみる</a></li>
</ul>
<h3 id="結局SQL"><a href="#%E7%B5%90%E5%B1%80SQL">結局SQL</a></h3>
<p>例:ID4342の作品を見たユーザを抽出して、<br />
それらのユーザが他に見た作品のうち共通している人数が多い順に30件を取得する。</p>
<pre><code class="sql">select
sum(st2.watch_status) as count,
st2.work_id,
w.title
from status st2
-- ID:4342の作品を見たユーザを取得
inner join (
select
st.user_id
from status st
where st.work_id = 4342
)st3
on st2.user_id = st3.user_id
inner join works w
on w.annict_id = st2.work_id
-- 作品自身を除く
where st2.work_id != 4342
group by st2.work_id
order by count desc
limit 30
</code></pre>
<h1 id="今回使った技術"><a href="#%E4%BB%8A%E5%9B%9E%E4%BD%BF%E3%81%A3%E3%81%9F%E6%8A%80%E8%A1%93">今回使った技術</a></h1>
<ul>
<li>GraphQL(Annict API)</li>
<li>ReactJS</li>
<li>NodeJS(TypeScript)</li>
<li>twitterAPI</li>
<li>netlify</li>
</ul>
<h2 id="データの棲み分け"><a href="#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E6%A3%B2%E3%81%BF%E5%88%86%E3%81%91">データの棲み分け</a></h2>
<p>最新のデータが欲しい場合はAnnictAPI(GraphQL)、分析データが欲しい場合はDBから読み込み、というようにデータの棲み分けを行っています。</p>
<h2 id="GraphQLでエイリアスを使う"><a href="#GraphQL%E3%81%A7%E3%82%A8%E3%82%A4%E3%83%AA%E3%82%A2%E3%82%B9%E3%82%92%E4%BD%BF%E3%81%86">GraphQLでエイリアスを使う</a></h2>
<p><a target="_blank" rel="nofollow noopener" href="https://developers.annict.jp/graphql-api/reference/">Annict API</a><br />
ユーザが見たアニメと見ているアニメ両方が欲しい場合、<br />
エイリアスを使うと複数条件が記述できる。</p>
<pre><code class="javascript">query {
user(username:"${username}"){
annictId,
works(state:WATCHED){
nodes{
annictId
title
}
}
ing:works(state:WATCHING){
nodes{
annictId
title
}
}
}
}
</code></pre>
<h2 id="CSP(コンテンツセキュリティポリシー)"><a href="#CSP%28%E3%82%B3%E3%83%B3%E3%83%86%E3%83%B3%E3%83%84%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3%E3%83%9D%E3%83%AA%E3%82%B7%E3%83%BC%29">CSP(コンテンツセキュリティポリシー)</a></h2>
<p>アニメのOGPがない場合は公式twitterアカウントの画像を使用しているが、CSPなどで同じサイトでないコンテンツは表示できなくなったので、<br />
URLに「twitter」が含まれる場合はサーバにプロキシさせて画像を読み込むようにした。</p>
<h1 id="ご意見"><a href="#%E3%81%94%E6%84%8F%E8%A6%8B">ご意見</a></h1>
<p><a target="_blank" rel="nofollow noopener" href="https://ikens.net/ckoshien_tech/annict-suggest?v=1">こちら</a>から使ってみた感想・ご意見をお寄せください。</p>
ckoshien