- 投稿日:2019-09-29T20:35:47+09:00
ElectronのIPCをReact Hooksを使って、コンポーネント間で使い回す
Electronでアプリを作るときに、React Hooksを使ってプロセス間通信のロジックをコンポーネント間で使い回せると楽しそうだなと思って、やってみました。
動機
Reactのコンポーネント内で非同期通信を扱おうとすると、関連するロジックがコンポーネントのライフサイクルを扱うメソッド内に散らばります。
class SomeComponent extends React.Component { componentDidMount() { ipcRenderer.on(onShowSaveDialogComplete, this.onSave) } componentWillUnmount() { ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave) } onSave(event, message) { this.setState({ message }) } }これは2つの点でイマイチです。
- ロジックが別のコンポーネントで使い回しにくい
- 別のステートを扱う非同期通信を同じコンポーネントに追加すると、ライフサイクルメソッド内に無関係のロジックが同居することになる(扱うステートが増えるほど、可読性が下がる)
Hooks APIを使えば、ロジックを一箇所にまとめた上で、簡単に使い回せるようになります。
参考: フックの導入(https://ja.reactjs.org/docs/hooks-intro.html)
環境構築
ElectronでReactを使う
Electron内でReactを使うための環境構築は結構めんどくさい(と思う)のですが、以下のページで紹介されていた方法は比較的お手軽でした。
How to build a React based Electron app
事前準備
Electronのバージョン6ではデフォルトでnodeIntegrationがfalseなので、preload機能で、ipcRendererをwindowに渡しておきます。
public/preload.jsconst { ipcRenderer } = require('electron') window.ipcRenderer = ipcRendererpublic/main.jsmainWindow = new BrowserWindow({ webPreferences: { nodeIntegration: false, preload: `${__dirname}/preload.js`, }, })実践
ここでは例として、メインプロセス側でdialog.showSaveDialogを呼び出します。
- inputボックスにテキストを入力
- Saveボタンをクリックする
- レンダラープロセスからメインプロセスにテキストを送信
- メインプロセスでdialog.showSaveDialogを呼び出し
- 指定されたファイルにテキストを保存
- メインプロセスからレンダラープロセスに結果を返却
channel用に定数を用意
share/channelsconst Channels = { showSaveDialog: 'showSaveDialog', onShowSaveDialogComplete: 'onShowSaveDialogComplete', } module.exports = ChannelsipcMainのセットアップ
main/setupIpc.jsconst { dialog, ipcMain } = require('electron') const fs = require('fs') const Channels = require('../shared/channels') const { showSaveDialog, onShowSaveDialogComplete } = Channels module.exports = function setupIpc() { // showSaveDialogのメッセージを受け取る ipcMain.on(showSaveDialog, async (event, arg) => { const { text, options } = arg // ダイアログを開いて、結果を受け取る const { canceled, filePath } = await dialog.showSaveDialog({ ...(options ? options : {}), }) let error // 入力とpathをチェックして、ファイルに保存する if (text && !canceled && filePath) { error = await new Promise(resolve => { fs.writeFile(filePath, text, err => resolve(err)) }) } else { // 入力が不正のときは、エラーを返す error = new Error( `Invalid input or operation: ${JSON.stringify({ text, canceled, filePath, })}` ) } // 結果をrendererに返却 event.reply(onShowSaveDialogComplete, { // Error型のままではrenderer側で受け取れなかったので、messageを返す errorMessage: error ? error.message : null, filePath, }) }) }Hooks APIを利用しない場合
ipcRendererのリスナー登録・解除という関連するロジックが、別々の箇所に記載されており、見通しが悪くなりがちです。
src/components/WriteTextToFileClass.jsximport React, { createRef } from 'react' import Channels from '../shared/channels' const { ipcRenderer } = window const { showSaveDialog, onShowSaveDialogComplete } = Channels export default class WriteTextToFileClass extends React.Component { constructor(props) { super(props) this.state = { message: null } this.onSave = this.onSave.bind(this) this.onSaveTextClick = this.onSaveTextClick.bind(this) this.inputRef = createRef() } componentDidMount() { ipcRenderer.on(onShowSaveDialogComplete, this.onSave) } componentWillUnmount() { ipcRenderer.removeListener(onShowSaveDialogComplete, this.onSave) } render() { return ( <div> <p> <input type="text" ref={this.inputRef}></input> <button onClick={this.onSaveTextClick}>Save Text</button> </p> <p style={{ fontStyle: 'italic', fontSize: '0.5em' }}> {this.state.message} </p> </div> ) } onSave(event, arg) { const { errorMessage, filePath } = arg const message = errorMessage || `Text has been saved successfully to ${filePath}` this.setState({ message }) } onSaveTextClick() { const text = this.inputRef.current.value ipcRenderer.send(showSaveDialog, { text, options: { filters: [ { name: 'Text File', extensions: ['txt'] }, ], } }) } }Hooks APIを利用してリライト
ipcRendererに関連するロジックのみを抽出したカスタムフックを作成します。
関連するステートの初期化・更新、リスナーの登録・解除が一つの関数内で完結します。
Hooksを利用しない場合も、レンダープロップや高階関数コンポーネントを利用すれば、コンポーネントの再利用は可能ですが、ロジックだけを使いたい場合はこちらの方が簡潔です。src/hooks/useShowSaveDialog.jsimport { useCallback, useEffect, useState } from 'react' import Channels from '../shared/channels' const { ipcRenderer } = window const { showSaveDialog, onShowSaveDialogComplete } = Channels export default function useShowSaveDialog(options = {}) { const [filePath, setFilePath] = useState(null) const [errorMessage, setErrorMessage] = useState(null) const sendMessage = useCallback( text => { ipcRenderer.send(showSaveDialog, { text, options }) }, [options] ) useEffect(() => { function handleOnComplete(event, arg) { const { errorMessage, filePath } = arg setFilePath(filePath) setErrorMessage(errorMessage) } ipcRenderer.on(onShowSaveDialogComplete, handleOnComplete) return () => ipcRenderer.removeListener(onShowSaveDialogComplete, handleOnComplete) }[]) return [errorMessage, filePath, sendMessage] }作成したフックは関数コンポーネント内で下記のように呼び出します。
別のコンポーネントでも同じように呼び出すことで、簡単に使いまわすことができます。src/components/WriteTextToFile.jsximport React, { useRef } from 'react' import useShowSaveDialog from '../hooks/useShowSaveDialog' export default function WriteTextToFile() { const [errorMessage, filePath, showSaveDialog] = useShowSaveDialog({ filters: [ { name: 'Text File', extensions: ['txt'] }, ], }) const inputRef = useRef(null) const message = errorMessage || (filePath ? `Text has been saved successfully to ${filePath}` : null) return ( <div> <p> <input type="text" ref={inputRef}></input> <button onClick={() => showSaveDialog(inputRef.current.value)}>Save Text</button> </p> <p style={{ fontStyle: 'italic', fontSize: '0.5em' }}> {message} </p> </div> ) }パフォーマンスについて
関数コンポーネントでHooksを使うことで、レンダーごとにコールバック関数を作るようになりますが、パフォーマンスへの影響はあまりないそうです。
https://ja.reactjs.org/docs/hooks-faq.html#are-hooks-slow-because-of-creating-functions-in-render
最後に
Hooks APIやElectronに関しては、まだあまり使い慣れていないので、おかしな点が
あればコメントいただけると嬉しいです。
- 投稿日:2019-09-29T19:17:10+09:00
StorybookのReactのチュートリアルを見ながら基本を勉強してみた
React、Storybook初心者です。チュートリアルをやってみただけの投稿です。よろしくお願いします
私が実際に試したコードはこちらです。
https://github.com/okumurakengo/react-storybook-sample
Storybook for React tutorial
Get started | Storybook Tutorial
Setup React Storybook
# Reactのプロジェクト作成 npx create-react-app taskbox cd taskbox # Storybookを追加 npx -p @storybook/cli sb init # port:9009 でコンポーネントエクスプローラーが起動する yarn run storybookReuse CSS
1 チュートリアルを進める為に、cssをこちらかコピペする
↓このcssをコピーして
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css
./src/index.css
にコピペする2
./.storybook/config.js
でindex.css
を読み込む.storybook/config.jsimport { configure } from '@storybook/react'; // 追加 import '../src/index.css'; // automatically import all files ending in *.stories.js configure(require.context('../src/stories', true, /\.stories\.js$/), module);css読み込み前
css読み込み後
Add assets
フォントとアイコンも読み込む
↓ここのiconとfontフォルダをそのまま
./public/
フォルダに追加しておく
https://github.com/chromaui/learnstorybook-code/tree/master/publicBuild a simple component
Build a simple component | Storybook Tutorial
Task
タスクコンポーネントを作成する
Get setup
src/components/Task.js
とsrc/components/Task.stories.js
を作成するsrc/components/Task.jsimport React from 'react'; export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) { return ( <div className="list-item"> <input type="text" value={title} readOnly={true} /> </div> ); }src/components/Task.stories.jsimport React from 'react'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; import Task from './Task'; export const task = { id: '1', title: 'Test Task', state: 'TASK_INBOX', updatedAt: new Date(2018, 0, 1, 9, 0), }; export const actions = { onPinTask: action('onPinTask'), onArchiveTask: action('onArchiveTask'), }; storiesOf('Task', module) .add('default', () => <Task task={task} {...actions} />) .add('pinned', () => <Task task={{ ...task, state: 'TASK_PINNED' }} {...actions} />) .add('archived', () => <Task task={{ ...task, state: 'TASK_ARCHIVED' }} {...actions} />);Storybookはコンポーネントと、それのストーリーで構成されます。
コンポーネント1つに対して、必要な数のストーリーを作成できます。
- Component
- Story
- Story
- Story
Config
./.storybook/config.js
を変更して作成した、stories.jsファイルを読み込めるようにします.storybook/config.jsimport { configure } from '@storybook/react'; import '../src/index.css'; const req = require.context('../src', true, /\.stories.js$/); function loadStories() { req.keys().forEach(filename => req(filename)); } configure(loadStories, module);このように表示されます。
storiesOf
とするたびにメニューが増え、
add
とするたびに下の階層にメニューが増えてきます。Build out the states
storybookでコンポーネントを表示までできたので、
次にコンポーネントに状態やイベントを設定、UIの見た目を設定します。
storybookで見た目を確認しながら作成することができます。src/components/Task.jsimport React from 'react'; export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) { return ( <div className={`list-item ${state}`}> <label className="checkbox"> <input type="checkbox" defaultChecked={state === 'TASK_ARCHIVED'} disabled={true} name="checked" /> <span className="checkbox-custom" onClick={() => onArchiveTask(id)} /> </label> <div className="title"> <input type="text" value={title} readOnly={true} placeholder="Input title" /> </div> <div className="actions" onClick={event => event.stopPropagation()}> {state !== 'TASK_ARCHIVED' && ( <a onClick={() => onPinTask(id)}> <span className={`icon-star`} /> </a> )} </div> </div> ); }storybookでイベントを拾えたのを確認でき、状態ごとに見た目が変わることも確認できました。
Specify data requirements
コンポーネントの見た目、イベントを作成完了まで行い、その後に
propTypes
を設定すると良いようです。src/components/Task.jsimport React from 'react'; import PropTypes from 'prop-types'; export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) { return ( <div className={`list-item ${state}`}> <label className="checkbox"> <input type="checkbox" defaultChecked={state === 'TASK_ARCHIVED'} disabled={true} name="checked" /> <span className="checkbox-custom" onClick={() => onArchiveTask(id)} /> </label> <div className="title"> <input type="text" value={title} readOnly={true} placeholder="Input title" /> </div> <div className="actions" onClick={event => event.stopPropagation()}> {state !== 'TASK_ARCHIVED' && ( <a onClick={() => onPinTask(id)}> <span className={`icon-star`} /> </a> )} </div> </div> ); } Task.propTypes = { task: PropTypes.shape({ id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, state: PropTypes.string.isRequired, }), onArchiveTask: PropTypes.func, onPinTask: PropTypes.func, };※参考ページによると、この後にjestによるTask.jsのコンポーネントを書くという流れのようですが、今回はそこまでやりませんでした。
Assemble a composite component
Assemble a composite component | Storybook Tutorial
コンポーネントを組み合わせた時の場合を見ていきます。
Tasklist
Taskを組み合わせたTaskListをstorybookで表示します
Get setup
src/components/TaskList.js
とsrc/components/TaskList.stories.js
を作成するsrc/components/TaskList.jsimport React from 'react'; import Task from './Task'; function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { const events = { onPinTask, onArchiveTask, }; if (loading) { return <div className="list-items">loading</div>; } if (tasks.length === 0) { return <div className="list-items">empty</div>; } return ( <div className="list-items"> {tasks.map(task => <Task key={task.id} task={task} {...events} />)} </div> ); } export default TaskList;src/components/TaskList.stories.jsimport React from 'react'; import { storiesOf } from '@storybook/react'; import TaskList from './TaskList'; import { task, actions } from './Task.stories'; export const defaultTasks = [ { ...task, id: '1', title: 'Task 1' }, { ...task, id: '2', title: 'Task 2' }, { ...task, id: '3', title: 'Task 3' }, { ...task, id: '4', title: 'Task 4' }, { ...task, id: '5', title: 'Task 5' }, { ...task, id: '6', title: 'Task 6' }, ]; export const withPinnedTasks = [ ...defaultTasks.slice(0, 5), { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, ]; storiesOf('TaskList', module) .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>) .add('default', () => <TaskList tasks={defaultTasks} {...actions} />) .add('withPinnedTasks', () => <TaskList tasks={withPinnedTasks} {...actions} />) .add('loading', () => <TaskList loading tasks={[]} {...actions} />) .add('empty', () => <TaskList tasks={[]} {...actions} />);
- デフォルト状態のリスト
- pinned状態のタスクありのリスト
- リスト読み込み中
- リストが0個
↑の状態でそれぞれstorybookで見れるとこまでできました。
Build out the states
状態ごとのコンポーネントのUIを作り込んでいきます。
src/components/TaskList.jsimport React from 'react'; import Task from './Task'; function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { const events = { onPinTask, onArchiveTask, }; const LoadingRow = ( <div className="loading-item"> <span className="glow-checkbox" /> <span className="glow-text"> <span>Loading</span> <span>cool</span> <span>state</span> </span> </div> ); if (loading) { return ( <div className="list-items"> {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} </div> ); } if (tasks.length === 0) { return ( <div className="list-items"> <div className="wrapper-message"> <span className="icon-check" /> <div className="title-message">You have no tasks</div> <div className="subtitle-message">Sit back and relax</div> </div> </div> ); } const tasksInOrder = [ ...tasks.filter(t => t.state === 'TASK_PINNED'), ...tasks.filter(t => t.state !== 'TASK_PINNED'), ]; return ( <div className="list-items"> {tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)} </div> ); } export default TaskList;今回はただコピペしただけですが、storybookでコンポーネントの表示を見ながら作成できるのでやりやすいと思いました。
Data requirements and props
コンポーネントの見た目、イベントを作成完了まで行ったので、
propTypes
も設定しておくsrc/components/TaskList.jsimport React from 'react'; import Task from './Task'; import PropTypes from 'prop-types'; function TaskList({ loading, tasks, onPinTask, onArchiveTask }) { const events = { onPinTask, onArchiveTask, }; const LoadingRow = ( <div className="loading-item"> <span className="glow-checkbox" /> <span className="glow-text"> <span>Loading</span> <span>cool</span> <span>state</span> </span> </div> ); if (loading) { return ( <div className="list-items"> {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} </div> ); } if (tasks.length === 0) { return ( <div className="list-items"> <div className="wrapper-message"> <span className="icon-check" /> <div className="title-message">You have no tasks</div> <div className="subtitle-message">Sit back and relax</div> </div> </div> ); } const tasksInOrder = [ ...tasks.filter(t => t.state === 'TASK_PINNED'), ...tasks.filter(t => t.state !== 'TASK_PINNED'), ]; return ( <div className="list-items"> {tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)} </div> ); } TaskList.propTypes = { loading: PropTypes.bool, tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired, onPinTask: PropTypes.func.isRequired, onArchiveTask: PropTypes.func.isRequired, }; TaskList.defaultProps = { loading: false, }; export default TaskList;※参考ページによると、この後にjestによるTaskList.jsのコンポーネントを書くという流れのようですが、今回はそこまでやりませんでした。
最後まで読んでいただいてありがとうございました
- 投稿日:2019-09-29T18:55:57+09:00
TypeScriptで書くHOC 〜OriginalPropsを添えて〜
React Hooksの登場でやや影の薄くなりつつあるHOC(Higher Order Component)ですが、まだまだ登場機会がなくなった訳ではありません。
TypeScriptで書くHOCは型定義のシンタックスがごちゃごちゃしてわかりづらくなりがちなので、「英語・日本語出し分け用のHOCを作ってみる」というケースをイメージしつつ、改めて整理してみました。
なお、returnを完全に省略したときのHOCのシンタックスの一見さんお断り感は異常なので、適宜returnを残した状態の記述にしています。
やりたいこと
コンポーネントに言語設定を適用したい。
具体的には、アプリケーションのトップレベルで指定されているlang
というpropsを任意のコンポーネントから参照して、'en'
のときと'ja'
のときとで表示するテキストを変更するなどしたい。作戦
LangProvider
というコンポーネントを用意し、アプリケーションのトップレベルで使用する。
withLang
というHOCを用意し、この引数に与えられたコンポーネントからはlang
の値を参照できるようにする。使用イメージ
まず始めに、
withLang
(とLangProvider
)の使用方法をイメージしてみます。const App = () => ( <LangProvider lang='ja'> <Hello /> </LangProvider> )export const Hello: React.FC<LangProps> = withLang(({ lang }) => { const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.' return ( <div> <h1>{text}</h1> </div> ) })こんな風に、
withLang
というHOCの引数に与えられたコンポーネントでは、LangProvider
で指定したlang
にアクセスできるようになる、という算段です。今回はあまり関係ありませんが、一応ディレクトリ構成も書いておきます。
src ├── App.tsx ├── components │ ├── Hello.tsx │ └── index.ts ├── index.tsx └── lang ├── index.ts ├── lang.tsx └── withLang.tsxコードを書いてみる
Step1:
LangProvider
を用意する
lang.tsx
内に、LangProvider
コンポーネント(と諸々の型定義)を作ります。lang.tsximport React from 'react' export interface LangProps { lang?: 'en' | 'ja' } const defaultLang: LangProps = { lang: 'en', } const setLang = (lang: LangProps = {}): LangProps => ({ ...defaultLang, ...lang, }) export const LangContext = React.createContext<LangProps>(setLang()) export const LangProvider: React.FC<LangProps> = ({ children, ...props }) => { return ( <LangContext.Provider value={setLang({ ...props } || {})}> {children} </LangContext.Provider> ) }普通にProviderを作っているだけなので、特に解説はありません。
lang
を引数として与えなかった場合はデフォルトで'en'
になるんだな程度の理解でOKです。Step2:
withLang
を用意する次に、今回のポイントである
withLang
を用意しましょう。コードを書き始める前に、もう一度
withLang
の最終的な使用イメージを確認しておきます。(前述と同じコード)
Hello.tsxconst Hello: React.FC<LangProps> = withLang(({ lang }) => { const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.' return ( <div> <h1>{text}</h1> </div> ) })
withLang
の引数はFunctional Componentで、返り値もFunctional Componentということになります。では、
withLang
を書いていきましょう。素直に書いていくと、こんな感じになるのではないでしょうか。(このコードには改善の余地があります。後述。)
withLang.tsximport React from 'react' import { LangContext, LangProps } from './lang' export const withLang = (WrappedComponent: React.ComponentType<LangProps>) => { return (): React.ReactElement => ( <LangContext.Consumer> {({ lang }: LangProps): React.ReactElement => <WrappedComponent lang={lang} />} </LangContext.Consumer> ) }
withLang
の引数となるFunctional ComponentをWrappedComponent
という名前でとり、返り値としてFunctional Componentを返しています。
返り値のFunctional Component内では引数のコンポーネント(=WrappedComponent
)をLangContext.Consumer
でラップし、lang
にアクセスできるようにした上でpropsとして渡しています。これで
<LangProvider>
にlang='ja'
のように引数を渡せば、Hello
コンポーネント内でもlang
を参照できるようになりました。ちなみに
React.ComponentType<P>
というのは、ComponentClass<P> | StatelessComponent<P>
の型で、要するにClass ComponentとFunctional Componentのどちらも解決できるという型です。Step3:
withLang
を修正するさて、前述の状態だと、実は問題が一つあります。
それは、HOCがネスト状態になった時に、propsが正しく渡されないという問題です。
どういうことでしょうか。
例えば、
withNightMode
のようなHOCを新たに用意して、ナイトモードの場合デザインが変わるような機能をコンポーネントに与えたいとします。最終的な使用イメージとしては、こんな感じです。
Hello.tsxconst HelloComponent: React.FC<HelloProps> = ({ lang, nightMode }): React.ReactElement => { // nightModeがtrueのときとfalseのときで背景のグラデーションを変える const background = nightMode ? 'linear-gradient(45deg, #485563, #29323c)' : 'linear-gradient(45deg, #fe8c00, #f83600)' const text = lang === 'ja' ? 'こんにちは、世界。' : 'Hello, world.' return ( <div style={{ background }}> <h1>{text}</h1> </div> ) } export const Hello = withNightMode(withLang(HelloComponent)) // withNightModeにwithLangを渡している先ほど作った
withLang
を、さらにwithNightMode
に渡すことで、最終的に返されるコンポーネントからlang
、nightMode
のどちらにもアクセスしようとしています。
withNightMode
の実装は省略しますが、withLang
とほぼ同じだと思ってください。そして
App.tsx
内で下記のようにNightModeProvider
のコンポーネントを使うと、一見<Hello />
にnightModeが適用されそうな気がします。App.tsxconst App: React.FC = () => { return ( <NightModeProvider nightMode={true}> <LangProvider lang={'ja'}> <Hello /> </LangProvider> </NightModeProvider> ) }しかし実際は、この状態だと
Hello
コンポーネント内でアクセスできるnightMode
はundefined
となってしまいます。その理由は、
withNightMode
によって渡されるpropsがwithLang
内では無視され、末端のコンポーネントには渡されていないからです。というのも、
withLang
の実装をもう一度見てみると、lang
こそWrappedComponent
へのpropsとして渡されているものの、それ以外のpropsに関する記述はありません。(前述と同じコード)
withLang.tsximport React from 'react' import { LangContext, LangProps } from './lang' export const withLang = (WrappedComponent: React.ComponentType<LangProps>) => { return (): React.ReactElement => ( <LangContext.Consumer> {({ lang }: LangProps): React.ReactElement => <WrappedComponent lang={lang} />} </LangContext.Consumer> ) }つまり、
withNightMode
のHOCによってnightMode
のpropsがwithLang
に渡されても、withLang
的にはそんなこと知らないよ、ということですね。そこで、
withLang
自体に渡されるprops(今回で言えばnightMode
)もWrappedComponent
に渡されるように、withLang
を修正していきます。完成系はこうなります。
withLang.tsximport React from 'react' import { LangContext, LangProps } from './lang' export const withLang = <OriginalProps extends {}>( WrappedComponent: React.ComponentType<OriginalProps & LangProps> ) => { return (props: OriginalProps): React.ReactElement => ( <LangContext.Consumer> {({ lang }: LangProps): React.ReactElement => ( <WrappedComponent lang={lang} {...props} /> )} </LangContext.Consumer> ) }ややシンタックスがわかりづらいので、分けて説明します。
まずは前半部分。
withLang.tsxexport const withLang = <OriginalProps extends {}>( WrappedComponent: React.ComponentType<OriginalProps & LangProps> ) => { // 略 }前半の説明としては、
withLang
に渡されるpropsの型をOriginalProps
というジェネリックで指定。- 最終的に返すコンポーネントのpropsの型に、
OriginalProps
をintersection形式で追加ということになります。
つまり、今回でいえばwithNightMode
から渡されるpropsをwithLang
内でOriginalProps
型(の一つの値)として受け取り、それを最終的に返すコンポーネント(WrappedComponent
)に含めるように型を指定している、ということですね。ちなみに
<OriginalProps extends {}>
のextends {}
は、特に機能的な役割を果たしている訳ではなく、「JSXのシンタックスではないよ」というのを指示するための決まり文句のようなものです。次に、後半部分です。
withLang.tsx// 略 return (props: OriginalProps): React.ReactElement => ( <LangContext.Consumer> {({ lang }: LangProps): React.ReactElement => ( <WrappedComponent lang={lang} {...props} /> )} </LangContext.Consumer> ) // 略ここでは、
- 実際にpropsを受け取る
- 最終的に返されるコンポーネントにpropsを渡す
という作業を追加しています。
今回のケースで言えば、
nightMode
などのpropsを受け取り、それをそのままコンポーネントに渡している({...props}
)、ということですね。上記のような作りにすることで、HOCがネストされても、全てのpropsが末端のコンポーネントまで正しく渡されるようになります。
これで完璧なHOCの完成です。
ソースコード全容
ソースコードはhttps://github.com/Tokky0425/ts-hocに置いてあるので、全体を見たい場合はこちらから見てください。
今回のようなProviderを使ったパターンの場合は
React.useContext
を使ってHooksで実装した方がシンプルかつ綺麗に書けますが、styled-componentsを使うときなど、あえてpropsとして何かしらの値を受け取りたい場合はHOCが便利です。
以下、基本的なパターンチートシート
TypeScriptでの基本的なHOCの雛形は下記のようになります。
Enhancerパターン
「とりあえずpropsを増やす(拡張する)」というパターンです。
例えば、loading
などのように、他の場所でも同じ型定義で使い回しそうなpropsを追加したいときに便利です。interface EnhencedProps { someProp: any } export const withSomething = <OriginalProps extends {}>( WrappedComponent: React.ComponentType<OriginalProps & EnhencedProps> ) => { return (props: OriginalProps & EnhencedProps) => <WrappedComponent {...props} /> }Injectorパターン
目的のコンポーネントに任意のpropsを注入できるようにするパターンです。
基本的な仕組みとしてはEnhancerパターンと同じで、propsの枠だけ与えるか、値も与えちゃうかだけの違いです。
記事内で実装したものも、Providerを用いているため若干構造が雛形とは違いますが、カテゴリ的にはこちらですね。react-reduxの
connect
関数でお馴染みですが、withSomething({someProp: someValue})(TargetComponent)
のように引数を取れるようにもできるのがポイントです。
(雛形のコードは引数を取るパターンにしています)interface ExternalProps { someProp: any } interface InjectedProps { someFunction: any } export const withSomething = ({ someProp }: ExternalProps) => { return <OriginalProps extends {}>( WrappedComponent: React.ComponentType<OriginalProps & InjectedProps> ) => { // whatever const someFunction = () => { console.log(someProp) } return (props: OriginalProps) => <WrappedComponent someFunction={someFunction} {...props} /> } }上記のコードでは
someFunction
という関数をpropsに渡すようなHOCになっていますが、使い方としては自由なのでなんでもできます。
所感
できるだけHooksで済ませよう。
- 投稿日:2019-09-29T12:50:26+09:00
redux-sagaのthrottleとdebounceを試してみる
はじめに
redux-sagaのAPI referenceを眺めていて、読んだだけでは動きがイメージしにくかったthrottleとdebounceについて、実際に動かしてイメージを掴んでみました。
(英語難しい)
実際の開発で使用したわけではないので、サンプルを作って動かしてみただけになります。サンプルのソースはこちらからどうぞ。使用したredux-sagaのバージョン: 1.0.5
TL;DR
基本的にはどちらのAPIもtakeと同様にactionのdispatchを待ち受けてタスクを起動します。同じactionが短時間内に複数dispatchされた際の挙動が異なります。
- throttle: actionがdispatchされたらタスクを起動します。指定時間内に同じactionがdispatchされた場合はタスクを起動せずに最新のactionを1個だけ保持しておき、指定時間経過後にタスクを起動します。
- debounce: actionがdispatchがされたら、actionを保持して指定時間待ってからタスクを起動します。待っている間に同じactionがdispatchされた場合は、新しいactionを保持してまた指定時間待ちます。
サンプルコード
文字だけだと「なるほど、わからん」状態になるので、実際に動かしてみました。
サンプルは前回の記事で使用したものにthrottleとdebounceを追加したものを使用しました。throttleとdecounceのボタンをクリックしたらそれぞれ
onClickThrottleButton
、onClickDebounceButton
が実行されます。sampleContainer.js// 省略 onClickThrottleButton: () => { let count = 0 const interval = setInterval(() => { dispatch(throttleSampleStart(count)) count++ if(count >= 6) { clearInterval(interval) } }, 500) }, onClickDebounceButton: () => { let count = 0 const interval = setInterval(() => { dispatch(debounceSampleStart(count)) count++ if(count >= 6) { clearInterval(interval) } }, 500) }, // 省略sampleSaga.js// 省略 function* handleThrottleSampleStart() { yield throttle(1800, THROTTLE_SAMPLE_START, runThrottleSampleStart) } function* runThrottleSampleStart(action) { console.log(`take action ${JSON.stringify(action)}`) yield call(sleepAsync, action.payload.count) yield put(throttleSampleSuccess()) } function* handleDebounceSampleStart() { yield debounce(1200, DEBOUNCE_SAMPLE_START, runDebounceSampleStart) } function* runDebounceSampleStart(action) { console.log(`take action ${JSON.stringify(action)}`) yield call(sleepAsync, action.payload.count) yield put(debounceSampleSuccess()) } const sleepAsync = async (count) => { await new Promise(r => setTimeout(r, 5000)) } // 省略動作確認
throttle
実行結果は以下になります。まずdispatchされたaction(payloadのcountが0)でタスクが起動し、1800ミリ秒待ちます。その間dispathされたaction(payloadのcountが1、2、3)ではタスクは起動しません。
1800ミリ秒経過後、保持していた最新のaction(payloadのcountが3)で再度タスクを起動します。画像ではcount4のactionではタスクが起動されていません。これは、指定時間経過後にタスク(count3のaction)が起動されていますが、このタスクが起動してからも指定時間が経過するまで同じactionを保持する状態になっているからです。(ややこしい‥)
count3のタスクが起動してから1800ミリ秒以内にcount4と5のactionがdispatchされているので、最新のaction(countが5)が保持されて1800ミリ秒経過後にタスクが起動されているようです。debounce
実行結果は以下になります。throttleと比較していくらかシンプルですね。同じactionがdispatchされる度に最新のactionを保持して新たに指定秒数待ちます。待っている間に同じactionが来なかった場合にタスクを起動しています。
まとめ
throttleはtakeEveryの代わりに使えそうだと感じました。takeEveryはdispatchされたactionをすべて拾うので、負荷の面でちょっと心配がありますが、throttleならある程度コントロールができます。そのため、actionを取りこぼしたくない、かといって負荷もあまりかけたくない、といった場面では出番がありそうですね。
debounceは‥ちょっと思いつきませんでした。開発を進める中で有効に活用できる場面が出てきたらまた紹介したいと思います。
- 投稿日:2019-09-29T11:59:39+09:00
react-visibility-sensorを使って、ブラウザの表示領域内に入っている時だけ動くコンポーネントを作る
react-visibility-sensorとは
react-visibility-sensor は、そのコンポーネントがブラウザの表示領域に入っているかをチェックしてくれるライブラリです。
このライブラリを使うことで、ブラウザ上で見えている場合にだけ、動作するコンポーネントを作ることができます。
サンプル: https://github.com/geekduck/visibility-sensor-sample
デモ1
コンポーネントが表示領域内(白色の領域)に存在している場合にだけ、
タイマーで現在時刻を更新するコンポーネントを作ってみました。
サンプルコード
コンポーネントが表示領域に出たり入ったりするたびに、
VisibilitySensor
のonChange
に渡した関数が実行されます。const CurrentTimer = () => { const [currentTime, setCurrentTime] = useState(new Date()); const [timerId, setTimerId] = useState(0); useEffect(() => { return () => clearInterval(timerId); // タイマーの後始末 }, [timerId]); const onChange = (isVisible) => { if (isVisible) { // このコンポーネントが表示されている場合、タイマーで現在時刻を更新する。 setTimerId(setInterval(() => { setCurrentTime(new Date()); }, 10)); } else { // このコンポーネントが表示されていない場合、タイマーを削除して現在時刻を更新しない。 clearInterval(timerId); setTimerId(undefined); } }; return ( <VisibilitySensor onChange={onChange} // コンポーネントの表示状態が変わるたびに実行される partialVisibility={true} // 表示領域にちょっとでも入っていたらisVisible=trueと判定させる。 offset={{top: 100, bottom: 100}} // 見た目でわかりやすいように、上下100pxの範囲は表示領域外にする。 > <div className="CurrentTimer">{dayjs(currentTime).format('YYYY/MM/DD HH:mm:ss.SSS')}</div> </VisibilitySensor> ); };デモ2
VisibilitySensor
コンポーネントの子要素として、関数を渡すこともできます。return ( <VisibilitySensor onChange={onChange} // コンポーネントの表示状態が変わるたびに実行される partialVisibility={true} // 表示領域にちょっとでも入っていたらisVisible=trueと判定させる。 offset={{top: 100, bottom: 100}} // 見た目でわかりやすいように、上下100pxの範囲は表示領域外にする。 > { ({isVisible}) => isVisible ? <div className="CurrentTimer">{dayjs(currentTime).format('YYYY/MM/DD HH:mm:ss.SSS')}</div> : <div className="CurrentTimer"/> // 表示されていない場合は、子要素を表示しない。 } </VisibilitySensor> );この場合、関数の引数として
isVisible
が取得できるので、表示されていない場合は空の
div
要素を表示して、renderの負荷を下げるコードが書けます。類似のライブラリ
表示領域外のコンポーネントをrenderしないライブラリとして有名なものは、react-windowや、react-virtualizedがあります。
大量のコンポーネントをリスト形式で表示する際は、これらのライブラリを使う方が適切です。
参考
https://reactjs.org/docs/optimizing-performance.html#virtualize-long-lists
- 投稿日:2019-09-29T11:23:28+09:00
react express
import React from 'react'; // import logo from './logo.svg'; // import './App.css'; // import styled from 'styled-components' // import SearchIcon from './components/shared/searchIcon' // import { BrowserRouter as Router, Route, Link,Switch } from "react-router-dom"; // import UserDetail from './components/User/UserDetail' // import App from './App' // const RootWrapper = styled.div` // ` class Root extends React.Component { constructor(props){ super(props) this.state = { lists: [], nputValue: "" } this.handleChange = this.handleChange.bind(this) this.handleSubmit = this.handleSubmit.bind(this) } componentWillMount(){ fetch('http://localhost:5000/posts') .then((response) => response.json()) .then((lists)=> this.setState({lists: lists}))} handleSubmit(e){ const obj = {hello: this.state.inputValue} const body = JSON.stringify(obj); const headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' }; const method = "POST"; fetch('http://localhost:5000/createPost', {method, headers, body}) .then((res) => res.json()) .then((lists)=>{console.log(lists)}) e.target[0].value = "" } handleChange(e){ console.log(e.target.value) this.setState({inputValue: e.target.value}) } renderEdit(){ } render(){ const {lists} = this.state console.log(lists) return( <div> {lists.map((list) => { return ( <div key={list.id}> <span >{list.id}:</span> <span>{list.body}</span> <button>編集</button> </div> ) } )} <form onSubmit={this.handleSubmit}> <input type="text" name="title" onChange={this.handleChange}/> <input type="submit" value="送信"/> </form> </div> ) } } // class Root extends React.Component{ // constructor(props){ // super(props) // } // render(){ // return ( // <RootWrapper className="App"> // <Router> // <Switch> // <Route exact path="/" component={App} /> // <Route path="/user" component={UserDetail}/> // </Switch> // </Router> // </RootWrapper> // ); // } // } export default Root;var express = require('express'); var app = express(); var bodyParser = require('body-parser') app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); let listCount = 3 const lists = [ {id:1, body:"test1"}, {id:2, body:"test2"}, {id:3, body:"test3"}, ] app.use(function(req, res, next) { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); next(); }); app.get("/posts", function(req,res){ console.log("get Lists") console.log(lists) res.send(lists) }) app.post('/createPost',function(req,res){ console.log("post List") console.log(req.body.hello) const list = {id: listCount + 1, body: req.body.hello} lists.push(list) listCount += 1 }) app.listen(5000)
- 投稿日:2019-09-29T03:37:13+09:00
React PWAやってみた
完全に参考にしてます
https://qiita.com/YIPG/items/47f9d025a32effdadcad
react-create-appでプロジェクトを作る
まずはプロジェクトを作る
npx create-react-app test cd test npm startcreate-react-appを利用するだけで実はPWA対応ができている。。。
オフライン対応
src/index.jsをちょいと修正する
serviceWorker.unregister(); // ↓ serviceWorker.register();アイコンの設定
してもしなくてもいいが、アプリっぽくするために試しにやってみる。
ここの紹介されているサイトを利用して、一気にアイコンを作る。
参考:https://qiita.com/shisama/items/d4d0b24980beaea57231そして、出来上がったmanifest.jsonのiconsの部分を書き換える。
がしかし、iosのsafariではうまく表示されないため、
google様が提供し始めたこれを使うと、うまいこと大体のブラウザのフォーム画面追加で、
アイコンが表示される。
参考:https://github.com/GoogleChromeLabs/pwacompat<link rel="manifest" href="manifest.json" /> <script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.9/pwacompat.min.js" integrity="sha384-VcI6S+HIsE80FVM1jgbd6WDFhzKYA0PecD/LcIyMQpT4fMJdijBh0I7Iblaacawc" crossorigin="anonymous"></script>スプラッシュ
気分が乗ってきたのでスプラッシュもやってみる。
と思ったが、google様のスクリプトのおかげで、うまいことlinkを差し込まずともよしなに対応してくれていた!
神や。。。manifest.json
結果こんな感じでいけてます。
{ "nape": "hellow! ribery!", "short_name": "!!ribery!!", "start_url": ".", "icons": [ { "src": "images/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" }, { "src": "images/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" }, { "src": "images/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" }, { "src": "images/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" }, { "src": "images/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" }, { "src": "images/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "images/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" }, { "src": "images/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } ], "display": "standalone", "theme_color": "#4682b4", "background_color": "#ffffff" }まとめ
今後、webアプリを作るにあたって、アプリの工数を削減できる手段の一つになるかもしれない!