20190929のReactに関する記事は7件です。

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つの点でイマイチです。

  1. ロジックが別のコンポーネントで使い回しにくい
  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.js
const { ipcRenderer } = require('electron')
window.ipcRenderer = ipcRenderer
public/main.js
mainWindow = new BrowserWindow({
  webPreferences: {
    nodeIntegration: false,
    preload: `${__dirname}/preload.js`,
  },
})

実践

ここでは例として、メインプロセス側でdialog.showSaveDialogを呼び出します。

  1. inputボックスにテキストを入力
  2. Saveボタンをクリックする
  3. レンダラープロセスからメインプロセスにテキストを送信
  4. メインプロセスでdialog.showSaveDialogを呼び出し
  5. 指定されたファイルにテキストを保存
  6. メインプロセスからレンダラープロセスに結果を返却

channel用に定数を用意

share/channels
const Channels = {
  showSaveDialog: 'showSaveDialog',
  onShowSaveDialogComplete: 'onShowSaveDialogComplete',
}
module.exports = Channels

ipcMainのセットアップ

main/setupIpc.js
const { 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.jsx
import 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.js
import { 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.jsx
import 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に関しては、まだあまり使い慣れていないので、おかしな点が
あればコメントいただけると嬉しいです。

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

StorybookのReactのチュートリアルを見ながら基本を勉強してみた

React、Storybook初心者です。チュートリアルをやってみただけの投稿です。よろしくお願いします:bow:

私が実際に試したコードはこちらです。

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 storybook

Screen Shot 2019-09-29 at 16.19.33.png

Reuse CSS

1 チュートリアルを進める為に、cssをこちらかコピペする

↓このcssをコピーして
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css

./src/index.cssにコピペする

2 ./.storybook/config.jsindex.css を読み込む

.storybook/config.js
import { 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読み込み前

Screen Shot 2019-09-29 at 17.05.36.png

css読み込み後

Screen Shot 2019-09-29 at 17.05.17.png

Add assets

フォントとアイコンも読み込む

↓ここのiconとfontフォルダをそのまま./public/フォルダに追加しておく
https://github.com/chromaui/learnstorybook-code/tree/master/public

Build a simple component

Build a simple component | Storybook Tutorial

Task

タスクコンポーネントを作成する

Get setup

src/components/Task.jssrc/components/Task.stories.js を作成する

src/components/Task.js
import 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.js
import 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.js
import { 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);

このように表示されます。

Screen Shot 2019-09-29 at 18.00.26.png

storiesOf とするたびにメニューが増え、
add とするたびに下の階層にメニューが増えてきます。

Screen Shot 2019-09-29 at 18.png

Build out the states

storybookでコンポーネントを表示までできたので、
次にコンポーネントに状態やイベントを設定、UIの見た目を設定します。
storybookで見た目を確認しながら作成することができます。

src/components/Task.js
import 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でイベントを拾えたのを確認でき、状態ごとに見た目が変わることも確認できました。

gCY4ipVxK2.gif

Specify data requirements

コンポーネントの見た目、イベントを作成完了まで行い、その後にpropTypesを設定すると良いようです。

src/components/Task.js
import 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.jssrc/components/TaskList.stories.js を作成する

src/components/TaskList.js
import 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.js
import 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} />);

s2guCvO2VM.gif

  • デフォルト状態のリスト
  • pinned状態のタスクありのリスト
  • リスト読み込み中
  • リストが0個

↑の状態でそれぞれstorybookで見れるとこまでできました。

Build out the states

状態ごとのコンポーネントのUIを作り込んでいきます。

src/components/TaskList.js
import 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でコンポーネントの表示を見ながら作成できるのでやりやすいと思いました。

bqxVjYSAvl.gif

Data requirements and props

コンポーネントの見た目、イベントを作成完了まで行ったので、propTypesも設定しておく

src/components/TaskList.js
import 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のコンポーネントを書くという流れのようですが、今回はそこまでやりませんでした。


最後まで読んでいただいてありがとうございました

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

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.tsx
import 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.tsx
const 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.tsx
import 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.tsx
const 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に渡すことで、最終的に返されるコンポーネントからlangnightModeのどちらにもアクセスしようとしています。

withNightModeの実装は省略しますが、withLangとほぼ同じだと思ってください。

そしてApp.tsx内で下記のようにNightModeProviderのコンポーネントを使うと、一見<Hello />にnightModeが適用されそうな気がします。

App.tsx
const App: React.FC = () => {
  return (
    <NightModeProvider nightMode={true}>
      <LangProvider lang={'ja'}>
        <Hello />
      </LangProvider>
    </NightModeProvider>
  )
}

しかし実際は、この状態だとHelloコンポーネント内でアクセスできるnightModeundefinedとなってしまいます。

その理由は、withNightModeによって渡されるpropsがwithLang内では無視され、末端のコンポーネントには渡されていないからです。

というのも、withLangの実装をもう一度見てみると、langこそWrappedComponentへのpropsとして渡されているものの、それ以外のpropsに関する記述はありません。

(前述と同じコード)

withLang.tsx
import 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.tsx
import 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.tsx
export 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で済ませよう。

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

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のボタンをクリックしたらそれぞれonClickThrottleButtononClickDebounceButtonが実行されます。

スクリーンショット 2019-09-29 12.02.09.png

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)で再度タスクを起動します。

スクリーンショット 2019-09-29 12.13.09.png

画像ではcount4のactionではタスクが起動されていません。これは、指定時間経過後にタスク(count3のaction)が起動されていますが、このタスクが起動してからも指定時間が経過するまで同じactionを保持する状態になっているからです。(ややこしい‥)
count3のタスクが起動してから1800ミリ秒以内にcount4と5のactionがdispatchされているので、最新のaction(countが5)が保持されて1800ミリ秒経過後にタスクが起動されているようです。

debounce

実行結果は以下になります。throttleと比較していくらかシンプルですね。同じactionがdispatchされる度に最新のactionを保持して新たに指定秒数待ちます。待っている間に同じactionが来なかった場合にタスクを起動しています。

スクリーンショット 2019-09-29 12.35.28.png

まとめ

throttleはtakeEveryの代わりに使えそうだと感じました。takeEveryはdispatchされたactionをすべて拾うので、負荷の面でちょっと心配がありますが、throttleならある程度コントロールができます。そのため、actionを取りこぼしたくない、かといって負荷もあまりかけたくない、といった場面では出番がありそうですね。
debounceは‥ちょっと思いつきませんでした。開発を進める中で有効に活用できる場面が出てきたらまた紹介したいと思います。

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

react-visibility-sensorを使って、ブラウザの表示領域内に入っている時だけ動くコンポーネントを作る

react-visibility-sensorとは

react-visibility-sensor は、そのコンポーネントがブラウザの表示領域に入っているかをチェックしてくれるライブラリです。

このライブラリを使うことで、ブラウザ上で見えている場合にだけ、動作するコンポーネントを作ることができます。

サンプル: https://github.com/geekduck/visibility-sensor-sample

デモ1

コンポーネントが表示領域内(白色の領域)に存在している場合にだけ、

タイマーで現在時刻を更新するコンポーネントを作ってみました。

600-30fpx.gif

サンプルコード

コンポーネントが表示領域に出たり入ったりするたびに、VisibilitySensoronChangeに渡した関数が実行されます。

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の負荷を下げるコードが書けます。

600-30fps-2.gif

類似のライブラリ

表示領域外のコンポーネントをrenderしないライブラリとして有名なものは、react-windowや、react-virtualizedがあります。

大量のコンポーネントをリスト形式で表示する際は、これらのライブラリを使う方が適切です。

参考

https://reactjs.org/docs/optimizing-performance.html#virtualize-long-lists

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

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)

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

React PWAやってみた

完全に参考にしてます

https://qiita.com/YIPG/items/47f9d025a32effdadcad

react-create-appでプロジェクトを作る

まずはプロジェクトを作る

npx create-react-app test
cd test
npm start

create-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アプリを作るにあたって、アプリの工数を削減できる手段の一つになるかもしれない!

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