20191001のReactに関する記事は9件です。

[React][webpack]テスト環境でルートパス以外表示できない時の対処法

環境

    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "webpack": "^4.41.0",
    "webpack-cli": "^3.3.9",
    "webpack-dev-server": "^3.8.1"

現象1

/ 以外にアクセスすると 404 Not Found になる。

解決法1

webpack.config.js に以下のように書き足す。

webpack.config.js
:
module.exports = {
    :
    devServer: {
        historyApiFallback: true,
    },
    :
}

現象2

ルーターにサブディレクトリを指定し、サブディレクトリに直接アクセスすると、 bundle.js が 404 Not Found になって真っ白になる。

解決法2

webpack.config.jsoutputpublicPath: '/' を書き足す。

webpack.config.js
:
module.exports = {
    :
    output: {
        :
        publicPath: '/',
    },
    :
}

参考

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

expo push通知に関して

expoにおいてpush通知関連の参考コード
※ コマンドラインでexpoに予めログインしていないとpush tokenを取得できません。

push tokenの作成

フロントエンドの実装

App.jsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { Notifications } from 'expo';
import * as Permissions from 'expo-permissions';
import Constants from 'expo-constants';

export default class App extends React.Component {

  registerForPushNotificationsAsync = async () => {

    // 実機端末か否かを判定
    if (Constants.isDevice) {

      const { status: existingStatus } = await Permissions.getAsync(Permissions.NOTIFICATIONS);
      let finalStatus = existingStatus;

      // ユーザーによる通知の許可or許可しないが決定していないときのみ
      if (existingStatus !== 'granted') {
        const { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
        finalStatus = status;
      }

      // ユーザーが通知許可しなかった場合は処理を抜ける
      if (finalStatus !== 'granted') return;

      // デバイストークンを取得する
      let token = await Notifications.getExpoPushTokenAsync();

      alert(token);

    } else {
      alert('プッシュ通知は、実機端末を使用してください。');
    }
  };

  componentDidMount() {
    this.registerForPushNotificationsAsync();
  }

  render () {
    return (
      <View>
        <Text>Open up App.js to start working on your app!</Text>
      </View>
    );
  }
}

pushの送信

バックエンドの実装(express)

expo-server-sdkのインストール

npm i expo-server-sdk

expressサーバーにて関数に下記を設定する

index.js
import Expo from 'expo-server-sdk';
//const { Expo } = require('expo-server-sdk')

// Create a new Expo SDK client
let expo = new Expo();

//以下は関数内に設定
// Create the messages that you want to send to clents
let messages = [];
for (let pushToken of somePushTokens) {
  // Each push token looks like ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]

  // Check that all your push tokens appear to be valid Expo push tokens
  if (!Expo.isExpoPushToken(pushToken)) {
    console.error(`Push token ${pushToken} is not a valid Expo push token`);
    continue;
  }

  // Construct a message (see https://docs.expo.io/versions/latest/guides/push-notifications.html)
  messages.push({
    to: pushToken,
    sound: 'default',
    body: 'This is a test notification',
    data: { withSome: 'data' },
  })
}

// The Expo push notification service accepts batches of notifications so
// that you don't need to send 1000 requests to send 1000 notifications. We
// recommend you batch your notifications to reduce the number of requests
// and to compress them (notifications with similar content will get
// compressed).
let chunks = expo.chunkPushNotifications(messages);
let tickets = [];
(async () => {
  // Send the chunks to the Expo push notification service. There are
  // different strategies you could use. A simple one is to send one chunk at a
  // time, which nicely spreads the load out over time:
  for (let chunk of chunks) {
    try {
      let ticketChunk = await expo.sendPushNotificationsAsync(chunk);
      console.log(ticketChunk);
      tickets.push(...ticketChunk);
      // NOTE: If a ticket contains an error code in ticket.details.error, you
      // must handle it appropriately. The error codes are listed in the Expo
      // documentation:
      // https://docs.expo.io/versions/latest/guides/push-notifications#response-format
    } catch (error) {
      console.error(error);
    }
  }
})();

...

// Later, after the Expo push notification service has delivered the
// notifications to Apple or Google (usually quickly, but allow the the service
// up to 30 minutes when under load), a "receipt" for each notification is
// created. The receipts will be available for at least a day; stale receipts
// are deleted.
//
// The ID of each receipt is sent back in the response "ticket" for each
// notification. In summary, sending a notification produces a ticket, which
// contains a receipt ID you later use to get the receipt.
//
// The receipts may contain error codes to which you must respond. In
// particular, Apple or Google may block apps that continue to send
// notifications to devices that have blocked notifications or have uninstalled
// your app. Expo does not control this policy and sends back the feedback from
// Apple and Google so you can handle it appropriately.
let receiptIds = [];
for (let ticket of tickets) {
  // NOTE: Not all tickets have IDs; for example, tickets for notifications
  // that could not be enqueued will have error information and no receipt ID.
  if (ticket.id) {
    receiptIds.push(ticket.id);
  }
}

let receiptIdChunks = expo.chunkPushNotificationReceiptIds(receiptIds);
(async () => {
  // Like sending notifications, there are different strategies you could use
  // to retrieve batches of receipts from the Expo service.
  for (let chunk of receiptIdChunks) {
    try {
      let receipts = await expo.getPushNotificationReceiptsAsync(chunk);
      console.log(receipts);

      // The receipts specify whether Apple or Google successfully received the
      // notification and information about an error, if one occurred.
      for (let receipt of receipts) {
        if (receipt.status === 'ok') {
          continue;
        } else if (receipt.status === 'error') {
          console.error(`There was an error sending a notification: ${receipt.message}`);
          if (receipt.details && receipt.details.error) {
            // The error codes are listed in the Expo documentation:
            // https://docs.expo.io/versions/latest/guides/push-notifications#response-format
            // You must handle the errors appropriately.
            console.error(`The error code is ${receipt.details.error}`);
          }
        }
      }
    } catch (error) {
      console.error(error);
    }
  }
})();

参考

push tokenの作成

Push Notifications
https://docs.expo.io/versions/latest/guides/push-notifications/

[React Native] ExpoのPush Notificationsでプッシュ通知する
http://www.aizulab.com/blog/expo-push-notifications-example/

pushの送信

expo-server-sdk-node
https://github.com/expo/expo-server-sdk-node

【React Native】【Expo】Expoでプッシュ通知を送る
https://tech.maricuru.com/entry/2018/04/23/185331

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

react-selectザックリ紹介

React-Selectを使う機会があったので、自分が使ったタイプを備忘録がてら紹介しようと思います。

React-Select

分かりやすいデモが公式サイトの方にあります。
React Select 公式サイト

上記のサイト通りですが、ドロップダウンメニュー内で絞り込み検索が出来たり、複数個選択できるようになったり、UIの良いライブラリです。

1.gif
(※デモサイトのキャプチャ)

導入

ライブラリをインストールしてスタートです!
下は公式サイトに載っているソースコードです。

import React from 'react'
import Select from 'react-select'

const options = [
  { value: 'pikachu', label: 'ピカチュウ' },
  { value: 'bulbasaur', label: 'フシギダネ' },
  { value: 'charmander', label: 'ヒトカゲ' },
  { value: 'squirtle', label: 'ゼニガメ' },
]

const App: React.FC = () => {
  return (
    <Select options={options} />
  )
}

export default App

2.gif

デフォルトでもいい感じの見た目になってくれます。

複数個選択できるSelect

「Multi」機能を使うと、複数個指定することができます!もちろんテキストによる検索機能も使えます。下のソースコードにある通りですが、isMultiオプションを付けるだけです!

<Select
  isMulti
  name="pokemon"
  options={options}
/>

3.gif

他のProps

指定できるPropsは以下の通りです。

Props名 役割
autoFocus 描画時テキストエリアをFocusさせる
className className指定
classNamePrefix 指定した接頭辞を持つclassNameを適用
isDisabled disableのコントール
isMulti 複数選択可能か指定
isSearchable 検索機能を付けられる。一致するoptionの絞り込み機能
name name
onChange onChangeイベント
options Selectの選択肢(option配列)
placeholder プレースホルダー
value value

入力したテキストを複数指定できる

入力エリアに直接ユーザーがテキストを入力し、エンターキーを押すとチップを複数個作成できるCreatableSelectという機能もオススメです!
公式サイト紹介: https://react-select.com/creatable

下の例は、入力してエンターを押すと自動でlabel、valueが生成され、配列を取得することができます。

import Creatable from 'react-select/creatable'

下はサンプルの実装です。

import React, { useState } from 'react'
import CreatableSelect from "react-select/creatable"

type OptionType = {
  label: string
  value: string
}

/**
 * 入力された値をデータ用に整形
 */
const createOption = (label: string): OptionType => ({
  label,
  value: label,
})

const Sample: React.FC = () => {
  const [pokemon, setPokemons] = useState("")
  const [pokemonList, setPokemonList] = useState<OptionType[]>([])

  const onChange = (value: any): void => {
    setPokemonList(value)
  }

  const onInputChange = (inputValue: string): void => {
    setPokemons(inputValue)
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
    if (pokemon == null) {
      return
    }

    switch (event.key) {
      case "Enter":
      case "Tab":
        if (pokemon) {
          setPokemons("")
          setPokemonList([...pokemonList, createOption(pokemon)])
          event.preventDefault()
        }
    }
  }

  return (
    <CreatableSelect
      inputValue={pokemon}
      isClearable
      isMulti
      menuIsOpen={false}
      onChange={onChange}
      onInputChange={onInputChange}
      onKeyDown={onKeyDown}
      value={pokemonList}
      placeholder="お気に入りのポケモンを入力してください!"
    />
  )
}

上の実装は下のような挙動になってくれます!

5.gif

Material-UIとの組み合わせ

Material-UIの本家サイトの方に、react-selectライブラリを組み合わせた書き方について載っています。自分でも実装してみました!
<Select />タグにcomponentsを指定することでMaterial-UIのFormが使えるようです。

import MenuItem from '@material-ui/core/MenuItem'
import { createStyles, makeStyles } from '@material-ui/core/styles'
import TextField, { BaseTextFieldProps } from '@material-ui/core/TextField'
import React, { HTMLAttributes } from 'react'
import Select from 'react-select'
import { ControlProps } from 'react-select/src/components/Control'
import { OptionProps } from 'react-select/src/components/Option'
import { ValueType } from 'react-select/src/types'

interface OptionType {
  label: string
  value: string
}

const pokemons: OptionType[] = [
  { value: 'pikachu', label: 'ピカチュウ' },
  { value: 'bulbasaur', label: 'フシギダネ' },
  { value: 'charmander', label: 'ヒトカゲ' },
  { value: 'squirtle', label: 'ゼニガメ' },
].map(suggestion => ({
  value: suggestion.label,
  label: suggestion.label,
}))

const useStyles = makeStyles(() =>
  createStyles({
    root: {
      padding: 40,
    },
    input: {
      display: 'flex',
      padding: 0,
      height: 'auto',
    },
  }),
)

type InputComponentProps = Pick<BaseTextFieldProps, 'inputRef'> & HTMLAttributes<HTMLDivElement>

const inputComponent = ({ inputRef, ...props }: InputComponentProps) => {
  return <div ref={inputRef} {...props} />
}

const Control = (props: ControlProps<OptionType>) => {
  const {
    children,
    innerProps,
    innerRef,
    selectProps: { classes, TextFieldProps },
  } = props

  return (
    <TextField
      fullWidth
      InputProps={{
        inputComponent,
        inputProps: {
          className: classes.input,
          ref: innerRef,
          children,
          ...innerProps,
        },
      }}
      {...TextFieldProps}
    />
  )
}

const Option = (props: OptionProps<OptionType>) => {
  return (
    <MenuItem
      ref={props.innerRef}
      selected={props.isFocused}
      component="div"
      style={{
        fontWeight: props.isSelected ? 500 : 400,
      }}
      {...props.innerProps}
    >
      {props.children}
    </MenuItem>
  )
}

const components = {
  Control,
  Option,
}

const App: React.FC = () => {
  const classes = useStyles()
  const [pokemon, setPokemon] = React.useState<ValueType<OptionType>>(null)

  const onChange = (value: ValueType<OptionType>) => {
    setPokemon(value)
  }

  return (
    <div className={classes.root}>
      <Select
        classes={classes}
        inputId="pokemon"
        TextFieldProps={{
          label: 'あなたのポケモン',
          InputLabelProps: {
            htmlFor: 'pokemon',
            shrink: true,
          },
        }}
        placeholder="選択してください"
        options={pokemons}
        components={components}
        value={pokemon}
        onChange={onChange}
      />
    </div>
  )
}

4.gif

(componentsでそれぞれ指定する必要があるのかなぁ…?)
コード長くなりましたが、Material-UIでも実装可能なようです。

使ってみた感想

cssで見た目を調整することも可能なようです。↓
公式: Custom Styles

簡単にただのSelectボックスに検索機能や複数選択など、拡張することができました!

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

ReactでThree.jsとshaderを触りたいのでサンプルを読みほぐしながらreact-three-fiberを理解する回

WebGLなんもわからん

名称未設定.mov.gif

WebGL、かっこいいですよね。こういった3Dのパワフルな描画能力を生かしたかっこいいサイトを作りたいと思うのは全人類共通の夢だと思います。

流体表現とかかっこいいの極みです。

ただ、まだこういった表現をReactなどのモダンなフレームワークで扱うのは敷居が高い印象があります。

ReactでThree.jsを触るならreact-three-fiberがおすすめです。

Three.jsの手続き的な記述を、コンポーネント指向でわかりやすく記述できて、しかも手軽に再利用ができます。

ただReact-three-fiber、全然参考実装が落ちてません。解説もないです。

Three.jsの実装をReactに移行したい時とか、割と書き方が変わる場面があり、そういった参考があると学習のしやすさが全然違う気がします。

というわけで今回は、react-three-fiberのリポジトリにある参考実装のコードを読みほぐしながら、ReactでThree.jsとshaderを扱う方法を学んでいきます。

扱わせていただく参考実装は、上記にgif画像の作品です。

実際のコードは以下のサイトでみることが出来ます。

codesandbox

では早速やっていきましょい!

注意点

本記事で用いるreact-three-fiberのバージョンは3.x系です!
現在のstableは2.x系なのですが、3.x系でとても便利な機能が追加されたり、Hooksに対応したりとかなり変わっています。

破壊的な変更もそんなにみられないので、いまからreact-three-fiberを始めるなら3.x系がおすすめです。

Githubなどでコードを確認する際は、ブランチが3.x系になっているかをきちんと確認してからコードを読むことをおすすめします。

対象コード

以下のコードを対象として読んでいきます。
これらは、上記codesandboxにて公開されているコードをお借りしたものです。

index.jsにて各コンポーネントの定義やGeometryの作成などを、shadersディレクトリにshader関連のファイルが入っています。

今回はシェーダプログラム自体を扱うことはしません。
あくまでReactに関係する部分のみに絞ってみていきます。

index.js

index.js
import * as THREE from 'three'
import { render } from 'react-dom'
import React, { useEffect, useRef, useMemo } from 'react'
import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'
import './styles.css'

extend({ EffectComposer, RenderPass, ShaderPass })

const DEFAULT_LAYER = 0
const OCCLUSION_LAYER = 1

function Torusknot({ layer = DEFAULT_LAYER }) {
  const ref = useRef()
  const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])
  const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])
  useFrame(({ clock }) => {
    ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
    ref.current.rotation.x += 0.01
    ref.current.rotation.y += 0.01
    ref.current.rotation.z += 0.01
  })
  return (
    <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
      <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
      <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
    </mesh>
  )
}

function Effects() {
  const { gl, scene, camera, size } = useThree()
  const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])
  const occlusionComposer = useRef()
  const composer = useRef()
  const light = useRef()

  useEffect(() => {
    occlusionComposer.current.setSize(size.width, size.height)
    composer.current.setSize(size.width, size.height)
  }, [size])

  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

  return (
    <>
      <mesh ref={light} layers={OCCLUSION_LAYER}>
        <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
        <meshBasicMaterial attach="material" color="lightblue" />
      </mesh>
      <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
      </effectComposer>
      <effectComposer ref={composer} args={[gl]}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
        <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
      </effectComposer>
    </>
  )
}

function App() {
  return (
    <Canvas shadowMap>
      <ambientLight />
      <pointLight />
      <spotLight castShadow intensity={4} angle={Math.PI / 10} position={[10, 10, 10]} shadow-mapSize-width={2048} shadow-mapSize-height={2048} />
      <Torusknot />
      <Torusknot layer={OCCLUSION_LAYER} />
      <Effects />
    </Canvas>
  )
}

render(<App />, document.querySelector('#root'))

こんな感じです!

index.jsを読んでいく

とにもかくにも少しずつ読んでいかなければ始まりません。
順番に読みつつ、関連する話題も取り上げながら見ていきます。

importされているモジュール

まずはindex.jsにてimportされている各モジュールを見ていきましょう。

対象のコードは以下の通りです。

index.js
import * as THREE from 'three'
import { render } from 'react-dom'
import React, { useEffect, useRef, useMemo } from 'react'
import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'
import './styles.css'

いっこずつ読んでいきます。

import * as THREE from 'three'

これは簡単ですね。
npmパッケージとして落としてあるThee.jsをimportして、普通に使えるようにしているだけです。

import { render } from 'react-dom'

これも余裕です。ReactでDOM要素にReactコンポーネントを差し込む時に使うやつです。
ところでこれ、自分は以下の書き方の方が好きだったりします。

import ReactDom from 'react-dom';

...

ReactDOM.render(<App />, document.querySelector('#root'))

多分好みの問題だと思いますが、等価な表現だというのは覚えときましょう。

次です。

import React, { useEffect, useRef, useMemo } from 'react'

React本体のimport(これはjsxを使う時に必要なやつです)と、各種Hooksのimportをしています。

各種Hooksに関しては、日本語の公式ドキュメントを読むのが一番早いです。

useEffect
useRef
useMemo

これらは実際のコードにて使われ方を見ていくこととしましょう。

import { Canvas, useThree, useFrame, extend } from 'react-three-fiber'

React-three-fiberから色々引っ張り出しています。このうちCanvasはreact-three-fiberを使う上で必須ですね。

CanvasでWebGLを使う際のオブジェクトなどを囲んで使います。
公式ドキュメントには、「Canvasはreact-three-fiberを使う上でのポータルだ」という説明がありました。

useThree、useFrameはreact-three-fiberから提供されているカスタムフックです。

そもそもカスタムフックとはなんぞやという話ですが、これはReactが提供しているHooksを自前で改造して便利な機能を提供するようにしたモノのことを言います。

useThreeはカメラ座標やglコンテキストなどを返してくれます。
また、useFrameは毎フレーム走る処理を記述する際に使えます。

useFrame(() => {ref.current.rotation.z += 1})のように書くことで、ref.current.rotation.zの値を毎フレーム+1することができます。超便利機能です。

extendは知られざる便利機能という感じがします。

例えばextend({EffectComposer})などのようにすると、もとはReactコンポーネントでなかったEffectComposerがJSX記法で<EffectComposer />のようにかけるようになります。

これでThree.jsで公開されている参考実装やシェーダ、マテリアルなどを、気軽にextendしてコンポーネントとして利用できるようになります。

import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'

ここらへんはいい感じにシェーダを使うための便利な機能たちです。

これらはレンダリングやポストプロセッシング(後処理)のために用いられます。

もともとのThree.jsでは、EffectComposerクラスのインスタンスに、rederPassクラスのインスタンスとshaderPassクラスのインスタンスを突っ込んでいって使います。

three.jsのGitHubリポジトリを見てみると実装はこんな感じになっています。

それぞれのTypeScriptによる型定義ファイルを見てみると以下のような感じです。

まずEffectComposerから。

three/examples/jsm/postprocessing/EffectComposer.d.ts
import {
    Clock,
    WebGLRenderer,
    WebGLRenderTarget,
} from '../../../src/Three';

import { Pass } from './Pass';
import { ShaderPass } from './ShaderPass';

export class EffectComposer {

    constructor( renderer: WebGLRenderer, renderTarget?: WebGLRenderTarget );
    renderer: WebGLRenderer;
    renderTarget1: WebGLRenderTarget;
    renderTarget2: WebGLRenderTarget;
    writeBuffer: WebGLRenderTarget;
    readBuffer: WebGLRenderTarget;
    passes: Pass[];
    copyPass: ShaderPass;
    clock: Clock;

    swapBuffers(): void;
    addPass( pass: Pass ): void;
    insertPass( pass: Pass, index: number ): void;
    isLastEnabledPass( passIndex: number ): boolean;
    render( deltaTime?: number ): void;
    reset( renderTarget?: WebGLRenderTarget ): void;
    setSize( width: number, height: number ): void;
    setPixelRatio( pixelRatio: number ): void;
}

いろいろメソッドがくっついていそうですが、最初に各種バッファやレンダラを渡せそうだなというのがわかります。
実際の動作や実装は同階層にある.jsファイルをご覧ください。

お次はRenderPass。

three/examples/jsm/postprocessing/RenderPass
import {
    Scene,
    Camera,
    Material,
    Color
} from '../../../src/Three';

import { Pass } from './Pass';

export class RenderPass extends Pass {

    constructor( scene: Scene, camera: Camera, overrideMaterial?: Material, clearColor?: Color, clearAlpha?: number );
    scene: Scene;
    camera: Camera;
    overrideMaterial: Material;
    clearColor: Color;
    clearAlpha: number;
    clearDepth: boolean;

}

なんかシーンオブジェクトとかカメラとか塗りつぶし色とか色々渡せそうです。

最後はShaderPass。

three/examples/jsm/postprocessing/ShaderPass
import {
    Material
} from '../../../src/Three';

import { Pass } from './Pass';

export class ShaderPass extends Pass {

    constructor( shader: object, textureID?: string );
    textureID: string;
    uniforms: object;
    material: Material;
    fsQuad: object;

}

こっちは名前の通りシェーダを渡してあげる感じみたいですね。

次のimportを見てみます。

import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
import { AdditiveBlendingShader, VolumetricLightShader } from './shaders'

FXAAShaderというものと、今回使用するシェーダをimportしています。

FSAAShaderが何かと言うと、アンチエイリアス用のシェーダのようです。
ジャギってる画面をいい感じに滑らかにしてくれるみたいです。

以下を参考にさせていただきました。

[WebGL] レイマーチングでアンチエイリアス(FXAA)してみる
【Unity】アンチエイリアシングの概説(SSAA / MSAA / FXAA / TemporalAA)
今週の進み具合 #8 - FXAA を実装しました

他のMSAA、SSAAといったシェーダと違い、FXAAはポストエフェクトとして画面に対して処理を行います。

今回使用する他のシェーダは後述します。

import './styles.css'

これはただcssをひっぱてきてるだけです。余裕です。

extend({ EffectComposer, RenderPass, ShaderPass })

これは先ほど書いたextendを実際にやっている感じですね。
これで上記の書く関数がコンポーネントとして使えるようになります。

const DEFAULT_LAYER = 0
const OCCLUSION_LAYER = 1

こちらの値は後述することとします。

とりあえずはこんな感じです。次に行きましょう!

Torusknot関数

このサンプルはトーラスノット(ドーナツ*結び目の意味)をくるくる回しながら横にブンブン振っています。
その動作を実現する方法をみていきましょう。

Torusknot関数をみます。

function Torusknot({ layer = DEFAULT_LAYER }) {
  const ref = useRef()
  const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])
  const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])
  useFrame(({ clock }) => {
    ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
    ref.current.rotation.x += 0.01
    ref.current.rotation.y += 0.01
    ref.current.rotation.z += 0.01
  })
  return (
    <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
      <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
      <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
    </mesh>
  )
}

とりあえずはデフォルト引数としてlayer = DEFAULT_LAYERが指定されていることだけ見ておけばよいでしょう。

では、この関数の中身も一つずつ見ていきましょう。

const ref = useRef()

先述のuseRefを使って値を保存しておくためのオブジェクトを作っています。
useRefで作られたオブジェクトrefは、その値が書き換わってもDOMの再レンダリングが走らないので、描画負担がなくて大変よいです。

ここで作ったrefをmeshに渡してあげて、その値でオブジェクトを操作します。

const Material = useMemo(() => `mesh${layer === DEFAULT_LAYER ? 'Physical' : 'Basic'}Material`, [layer])

ここではuseMemoを使って使用するマテリアルを選択しています。

useMemoでは、第2引数に指定されている値が変更されると、useMemoの第1引数に指定した関数が走って再計算が行われます。
それ以外の場合は、毎回計算するわけではなく、キャッシュしてある計算結果が使用されます。

今回は、layer(Torusknotに渡されている引数、デフォルトではDEFAULT_LAYER = 0です)の値によって使用するMaterialを変えます。

layer === DEFAULT_LAYER ? 'Physical' : 'Basic'は、layerの値がDEFAULT_LAYERに等しければ'Physical'を、それ以外ならばBasicを返します。

これがテンプレートリテラルの中に入っているので、結局useMemoで帰ってくる値は、文字列の'meshPhysicalMaterial'か、'meshBasicMaterial'のどちらかということになります。

const color = useMemo(() => (layer === DEFAULT_LAYER ? '#873740' : '#070707'), [layer])

こちらも同様ですが、layerがDEFAULT_LAYERなら'#873740'が、それ以外なら'#070707'が帰ってくることになります。

次にuseFrameの中を見ていきます。
useFrameには、毎フレーム実行してほしい処理を関数として渡します。

useFrame(({ clock }) => {
  ref.current.position.x = Math.cos(clock.getElapsedTime()) * 1.5
  ref.current.rotation.x += 0.01
  ref.current.rotation.y += 0.01
  ref.current.rotation.z += 0.01
})

こちらでは、useFrameがclockを引数として受け取っています。
ちなみに、({clock})という書き方は、引数としてとったオブジェクトの、clockという名前のメンバを引っ張り出してくる、みたいな意味合いです。

つまり、実はuseFrameの中に渡される関数にはreact-three-fiberが用意した便利なオブジェクトが渡されるので、その中の必要なものを取ってくる、みたいなことをしているわけです。

今回は時間によって挙動を制御したいので、clockを受け取っています。

先ほどの説明の通り、1フレームごとにオブジェクトのposition、および回転具合を計算しています。
見た方が早いと思いますが、それぞれx座標を時間にしたがったcosの値に更新(cosなので振動します)。
また、x、y、z軸における回転角をそれぞれ+0.01します。

次はreturnしているものを見ていきます。

return (
  <mesh ref={ref} position={[0, 0, 2]} layers={layer} receiveShadow castShadow>
    <torusKnotBufferGeometry attach="geometry" args={[0.5, 0.15, 150, 32]} />
    <Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />
  </mesh>
)

とにもかくにもThree.jsでオブジェクトを扱う際には、meshでgeometryとmaterialを包んであげないと始まりません。

上記の書き方は、大まかにですがピュアなThree.jsにおける

sample.js
//こちらは適当なサンプルです
var geometry = new THREE.BoxBufferGeometry( 1, 1, 1 );
var material = new THREE.MeshBasicMaterial( { color: 0xffff00 } );
var mesh = new THREE.Mesh( geometry, material );

という書き方に対応していると考えていただければ大丈夫です。

meshコンポーネントで包む感じが、JSX記法の方が直感的だと感じていただけると思います。

meshに渡されているのは、

  • positionやrotationの情報が入ったref
  • positionの初期状態
  • layerに関する情報
  • 落ち影を受けるか(receiveShadow)
  • meshが影を落とすか(castShadow)

receiveShadowやcastShadowなどのbooleanの値は、プロパティとして指定してあげればtrueと認識されます。
これにより、このmeshは影を落としたり受けたりするmeshになったわけです。

torusKnotBufferGeometryはオブジェクトの頂点情報などが入ったコンポーネントです。

argsというプロパティに値を渡してあげると、Three.jsにおけるnew torusKnotBufferGeometry()と書いた際の、引数にあたる部分に一気に値を渡すことができます。

TorusKnotBufferGeometryの引数には以下の値があります。

  • radius : Float
  • tube : Float
  • tubularSegments : Integer
  • radialSegments : Integer
  • p : Integer
  • q : Integer

p, qに関してはデフォルト値で2が指定されるので、他の値を引数として渡してあげます。
それぞれの値の詳しい解説は公式ドキュメントをご覧ください。

attachプロパティには何も考えずにgeometryとつけておきましょう。よくわかりません。

変数MaterialにはmeshPhycalMaterial または meshBasicMaterialのどちらかが入っています。

これに対して値を設定するには、上記のようにargsで値を渡してあげるか、下記のようにプロパティとして名前を直接指定して値を渡す方法のどちらかを行います。

<Material attach="material" color={color} roughness={1} clearcoat={1} clearcoatRoughness={0.2} />

Three.jsの公式ドキュメントを読めば、どのような値を引数にとるかなどは書いてあるので、実際に使うときはチェックしながらやっていくといいと思います。

トーラスをくるくる回すのはこのくらいで出来ます。
割と手続き的に書くより見通し良くて自分は好きです。

では次にいきます。

Effects関数

トーラスを回しつつ、シェーダを使って画面に効果を加えているのがこの関数です。

function Effects() {
  const { gl, scene, camera, size } = useThree()
  const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])
  const occlusionComposer = useRef()
  const composer = useRef()
  const light = useRef()

  useEffect(() => {
    occlusionComposer.current.setSize(size.width, size.height)
    composer.current.setSize(size.width, size.height)
  }, [size])

  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

  return (
    <>
      <mesh ref={light} layers={OCCLUSION_LAYER}>
        <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
        <meshBasicMaterial attach="material" color="lightblue" />
      </mesh>
      <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
      </effectComposer>
      <effectComposer ref={composer} args={[gl]}>
        <renderPass attachArray="passes" args={[scene, camera]} />
        <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
        <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
      </effectComposer>
    </>
  )
}

また一つずつ見ていきましょう。

const { gl, scene, camera, size } = useThree()

これは構文的には簡単です。
useThree()で利用できるものには以下があります。

  • gl: WebGLレンダラー
  • canvas: 作成されたcanvas(DOM要素)
  • scene: デフォルトで設定されているシーンオブジェクト
  • camera: デフォルトのカメラ
  • size: ビューの境界(100%に拡大して自動調整)
  • viewport: 3D単位のビューポートの境界+係数(サイズ/ビューポート)
  • aspect: アスペクト比(size.width / size.height)
  • mouse: 現在の2Dマウス座標
  • clock: THREE.Clock
  • invalidate: 単一のフレームを無効にする(<Canvas invalidateFrameloop />の場合)
  • intersection: カーソルの下にあるオブジェクトのonMouseMoveハンドラーを呼び出す
  • setDefaultCamera: デフォルトのカメラを設定する

公式ドキュメント読んだだけだとなんだかよくわからない感じですが、とにかくシェーダなどを使おうと思ったらglsceneが必須になります。

次も見てみます。

const occlusionRenderTarget = useMemo(() => new THREE.WebGLRenderTarget(), [])

occlusionってなんやという話ですが、これはレンダリング対象の手前のオブジェクトが、奥のオブジェクトを遮って見えないようにしている状態のこと、らしいです。

今回は物体(TorusKnotですね)が光を遮っている効果を演出するために使用されています。

アンビエントオクルージョン

上記では、オクルージョン用のレンダラーを生成している感じですね。

次も見ていきます。

const occlusionComposer = useRef()

よくわかりませんが、多分オクルージョンをいい感じにしてくれるやつでしょう。
次に行きます。

const composer = useRef()
const light = useRef()

これもよくわかりませんが、多分いい感じに色々調整するためのものでしょう。

さらに次へ行きます。

useEffect(() => {
  occlusionComposer.current.setSize(size.width, size.height)
  composer.current.setSize(size.width, size.height)
}, [size])

ここで何をやっているのかが、意外とわかりにくいと思います。

まず中身の処理は置いておき、第2引数に渡されているものの意味を考えていきます。

useEffectは第2引数にリストとして渡された変数の値が変わっていれば、再描画時にuseEffect内の処理を再実行、変更がなければ処理を行わない、という動作をします。

sizeはuseThreeから渡されている変数で、ウインドウサイズを変更するとこのsizeの値も自動的に変更されるので、useEffectの処理も再実行されるというわけです。

試しに以下のようにコードを追加してみて、Google Chromeでconsoleを見てみると動作がよくわかると思います。

useEffect(() => {
+ console.log(occlusionComposer.current);
  occlusionComposer.current.setSize(size.width, size.height);
+ console.log(composer.current);
  composer.current.setSize(size.width, size.height);
}, [size]);

それでは処理の中身を見ていきます。
多分これ、初見だとすごく謎だと思うんですが、useRefの挙動を理解するとわかるようになります。

実はuseEffectはreturnしているコンポーネントの中で呼ばれています。

また、useRefで作成したオブジェクトをref属性としてコンポーネントに渡してあげると、そのコンポーネントを.currentがさすようになります。

つまり以下のようなコンポーネントを書いておくと、occlusionComposer.currentEffectComposerクラスのインスタンスが入ります。

<effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
</effectComposer>

すなわち、occlusionComposer.currentをよびだせばEffectComposerのインスタンスメソッドや変数にアクセスできるようになるということです。

それを踏まえて以下を見てみると

occlusionComposer.current.setSize(size.width, size.height);

これで実はEffectComposerクラスのsetSizeメソッドを呼び出しており、画面のサイズなどを決めている、ということがわかります。

また、引数に取られているsize.width、size.heightなどは、useThreeから渡されている画面のサイズですね。

とりあえずuseEffectでやっていることは画面の変更だということがわかりました。

では次に行きましょう。

  useFrame(() => {
    light.current.rotation.z += 0.005
    camera.layers.set(OCCLUSION_LAYER)
    occlusionComposer.current.render()
    camera.layers.set(DEFAULT_LAYER)
    composer.current.render()
  }, 1)

おなじみuseFrameですが、第2引数が存在していますね。
このuseFrameに渡されている1はなんなのでしょうか。

実はuseFrameは第2引数が渡されていないとき、自動的にcanvasのレンダリングを行うようになっています。
逆に、第2引数に値が存在しているときは、自分で明示的にレンダリングを行わないといけないということです。

そこでuseFrameの中を見てみると、3行目と5行目でocclusionComposer.currentとcomposer.currentのrender()メソッドを呼び出していることがわかります。

試しにこのrender()をコメントアウトしてみます。
すると、occulusionComposerの方をコメントアウトすると光が消え、composerの方をコメントアウトすると両方消えます。

要はここでEffectComposerが処理した結果を画面に表示しているわけですね。

ちなみにuseFrameを複数書く場合は、cssのz-indexと同様に、数字の大きい方が上のレイヤーとして(より正確には後から)レンダリングされます。
これを生かして層を重ねていくような表現もできる感じです。

また、camera.layers.setで描画するレイヤーを指定しています。
これはocculusionをかけるレイヤーを指定している感じですね。

では実験としてこのレイヤーの順番を入れ替えてみます。

まずもとの状態だとこう。

スクリーンショット 2019-09-30 14.56.39.png

光が物体に遮られている感じがしてかっこいいいです。

で、順番を入れ替えるとこう。

スクリーンショット 2019-09-30 14.56.27.png

物体の迫力が増してしまいました。

こんな感じで、エフェクトの順序を変えることでも映像表現を変化させることができます。

light.current.rotation.z += 0.005に関しては特にみなくてもいいと思うので、次にいきましょう。

returnの中身

次はreturnで何を返しているかを読んでいきます。

<>
  <mesh ref={light} layers={OCCLUSION_LAYER}>
    <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
    <meshBasicMaterial attach="material" color="lightblue" />
  </mesh>
  <effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
    <renderPass attachArray="passes" args={[scene, camera]} />
    <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
  </effectComposer>
  <effectComposer ref={composer} args={[gl]}>
    <renderPass attachArray="passes" args={[scene, camera]} />
    <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
    <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
  </effectComposer>
</>

まず<>という謎のタグは、React.Fragmentの糖衣構文です。
これは<Fragment></Fragment>と書くのと同じ意味になります。

じゃあまず<Fragment>ってなんやねんという話ですが、これはReactがreturn内で単一のコンポーネントしか返せないため、並列のコンポーネントを返すときはくくってあげることが必要だからです。

Fragmentは実際のDOMとしては描画されないので、無駄なネストが発生せずよいです。

Fragmentの気持ちがわかったところで次に行きます。

<mesh ref={light} layers={OCCLUSION_LAYER}>
  <boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />
  <meshBasicMaterial attach="material" color="lightblue" />
</mesh>

こちらではmeshにlayerを設定し、その中でいろいろ設定しています。

meshに対してref属性でlightが渡されているのはもう大丈夫ですね。
このlightのcurrentをいじることで、meshのプロパティを弄ることができます。

useFrameではlight.current.rotation.z += 0.005しているので、これはz軸(画面手前が正、奥が負)を回転軸として、1フレームごとに0.005[rad]だけ回転していることになります。

また、layerに設定されているOCCULUTION_LAYERですが、これは最初に定数として1と設定されていました。この値によって描画順が変わります。

さらに中をみていきます。

boxBufferGeometryというのは、箱型のオブジェクトを作成するときに用いることが出来るクラスです。

attach属性に値を指定すると、その親要素に対してbindすることが出来ます。
今回はmeshのgeometryに対してboxBufferGeometryをbindしているわけですね。

また、argsはおなじみ、Three.jsで扱っていた際の引数に当たる部分です。

Three.jsでは以下のように書いていたところを、

var geometry = new THREE.BoxBufferGeometry( 1, 1, 1 );

react-three-fiberでは以下のようにかけます。

<boxBufferGeometry attach="geometry" args={[0.5, 20, 1]} />

やってることは特に変わらないので、ここはThree.jsのサンプルコードとの対応もしやすいと思います。

オブジェクトの色などはmaterialのプロパティで指定できます。
これはいつも通りな感じがありますね。

それでは次にいきます。

<effectComposer ref={occlusionComposer} args={[gl, occlusionRenderTarget]} renderToScreen={false}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[VolumetricLightShader]} needsSwap={false} />
</effectComposer>

難解そうなコードが出てきましたね...

ところが実はそんなにむずかしくなかったりもするので、むずかしく考えずにやっていきましょう。

そもそもEffectComposerが何かと言うと、これはポストプロセッシング(後処理)用のクラスです。
3D世界に存在するオブジェクトを画面にレンダリングする際に、その描画結果に色々な処理をしてから画面に描画する、という流れで処理を加えてます。

EffectComposerクラスは、この後処理を簡単に出来るようにしてくれているクラスな訳ですね。

EffectComposerクラスのインスタンスを生成する際には、レンダラオブジェクトと、レンダラターゲットオブジェクトを渡します。

レンダラターゲットオブジェクトはoptionalで、渡さなかった場合は適当なものが内部で自動生成されます。

renderToScreenをtrueにすると処理結果を画面に描画することができるので、逆に言えばこれをtrueにするのは処理の最後です。

ではrenderToScreenをfalseにするとどうなるかというと、これはただ描画が行われないということではなく、レンダラーターゲットオブジェクトに対してレンダリング結果が出力されます。

renderPassはエフェクトを加える元となる、3D空間のレンダラパスオブジェクトを生成するクラスです。
レンダリングに利用するシーンオブジェクトとカメラオブジェクトを突っ込みます。

上記でもargsにscenecameraが渡されていますね。

また、renderPassにはattachArrayというプロパティが存在しています。
これは先ほどまで見ていた通常のattachとは違い、bindする側(子オブジェクトのようなもの)が複数存在する場合にこれを使います。

shaderPassクラスはシェーダプログラムを格納してシェーダをいい感じにやるためのクラスです。

こちらも同じくattachArrayで"passes"が指定されていますね。

また、argsにはimportしたシェーダが渡されています。シェーダに関してはあとで見ることとして、今はスルーしておきましょう。

needSwapはポストプロセッシングを行なったのちに、描画前と描画結果を入れ替えるかどうかを選択できます。(これはよくわかりません)

シェーダ以外はそんなに複雑でないので、次にいってしまいましょう。

残りの部分を見ていきます。

<effectComposer ref={composer} args={[gl]}>
  <renderPass attachArray="passes" args={[scene, camera]} />
  <shaderPass attachArray="passes" args={[AdditiveBlendingShader]} uniforms-tAdd-value={occlusionRenderTarget.texture} />
  <shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />
</effectComposer>

ここではrenderを一つ、shaderを二つ渡しています。

renderPassには先ほどと同じくレンダリングターゲットとしてsceneとcameraを渡し、shaderPassにはshaderプログラムを渡しています。

渡しているシェーダはAdditiveBlendingShaderFXAAShaderというものです。

AdditiveBlendingShader...?

これは絵描きに馴染み深い加算レイヤー的なものではなく、前回までの描画結果を引き継いで描画するためのものです。

さきほどEffectComposerオブジェクトにrenderToScreen={false}を設定していましたね。
あの設定でocclusionRenderTargetに描画結果が保存されています。

また、上記のshaderPassのプロパティをみてみると、以下の記述が見つかります。

uniforms-tAdd-value={occlusionRenderTarget.texture}

シェーダを利用する際には、シェーダプログラムに対して変数をThree.jsから渡すことができます。

その値がuniformsといって、react-three-fiberでは上記のように値を設定してあげることになっています。

上記のようにすると、シェーダプログラム側でtAddが変数として利用できます。

また、occlusionRenderTarget.textureには先ほどのEffectComposerでの描画結果が格納されています。
よって、上記で行なっているのは、先ほどの描画結果を次のシェーダに渡す、という処理です。

<shaderPass attachArray="passes" args={[FXAAShader]} uniforms-resolution-value={[1 / size.width, 1 / size.height]} renderToScreen />

また、こちらのコードではFXAAShaderというものを渡しています。

このshaderは描画結果にエイリアスを掛けるものです。
試しにこの1行をまるまるコメントアウトしてみてください。

すると、先ほどまでよりも画面のジャギーがきになるようになったと思います(よくみないとわからないです)。

このジャギーは、オブジェクトを斜めからみるときによく観測されるものです。
このシェーダを掛けておけば、描画結果の品質をあげることが出来ます。

また、このシェーダはthree.jsから提供されているサンプルのシェーダです。
three.jsからimportするだけで使える公式のシェーダは以下にまとまっているので、ぜひみてみてください。

github.com/mrdoob/three.js/tree/dev/examples/jsm/postprocessing

App関数

最後にこちらをみていきます。

function App() {
  return (
    <Canvas shadowMap>
      <ambientLight />
      <pointLight />
      <spotLight castShadow intensity={4} angle={Math.PI / 10} position={[10, 10, 10]} shadow-mapSize-width={2048} shadow-mapSize-height={2048} />
      <Torusknot />
      <Torusknot layer={OCCLUSION_LAYER} />
      <Effects />
    </Canvas>
  )
}

render(<App />, document.querySelector('#root'))

ここでは、Canvas要素の中に今までに定義したオブジェクトやポストプロセッシング、ライトなどを配置しています。

<Canvas shadowMap>
...
</Canvas>

react-three-fiberのコンポーネントは全てCanvasタグの中になくてはいけません。
これはreact-three-fiberを使う上でのルールなのでしっかり覚えていきましょう。

Canvasにはプロパティとしてcameraなどを指定できます。
shadowMapをtrueにすると、PCFsoftという柔らかい影を用いることができるようです。

また、Canvasの中で設定しているライト(光源)は以下です。

  • ambientLight: 環境光源。シーン全体に光が当たる。
  • pointLight: 点光源。ある点から光が放射状に広がる。
  • spotLight: スポットライト光源。ある点からある方向に向かって光が広がる。

それぞれのライトごとに様々なプロパティがあります。
光源の強さや位置も細かく調整できるので、ぜひthree.jsの公式ドキュメントを読んでカスタマイズしてみてください。

Three.js ライト機能まとめ

threejs.org

コードを読んでいると以下の部分を不思議に思ったかもしれません。

<Torusknot />
<Torusknot layer={OCCLUSION_LAYER} />
<Effects />

なぜTorusknotが二つ用いられているのでしょう。

これは、もしocculusionを有効にして光源を遮っているオブジェクトのみを描画した場合は、その光源の裏側にカメラが存在しているためにTorusknot自体が真っ暗になってしまい、occulusionを無効にしているオブジェクトのみ描画すると、光源が見えなくなってしまうためです。

試しにeditor上でどちらかのTorusknotを消してみてください。
すると、描画結果が期待しないものになってしまったはずです。

この描画結果を補うために、本プログラムではオブジェクトを2回描画して見えるようにする、という手段を取っているのですね。

<Effect/>に関しては特に言うことがありません。
先ほどのポストプロセッシングをコンポーネントとして呼び出しているだけです。

最後に、こちらです。

render(<App />, document.querySelector('#root'))

ここでは、HTMLファイルのid名がrootのDOM要素に対して、上記のAppコンポーネントを差し込んで描画しています。

これはReactのお作法のようなものなので覚えておきましょう。

まとめ

react-three-fiberのコードリーディングはこんな所です!

他のサンプルもとてもためになるので、ぜひじっくり読んでみてください。

また、本記事に分かり難とことがあれば追記や修正を行いますので、ぜひお気軽にコメントなどしていただけると幸いです。

また、以下の記事にてReactでThree.jsを触るまでのチュートリアルをやっています。
実際の作品づくりや見直しにぜひどうぞ!!

超楽しくてカッコいい、Reactで始めるThree.js入門

Reactで楽しくWebGLしていきましょう!!

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

TypeScript + React + styled componentsで開発する時に行ったこと

TypeScriptで値を書き換えられないようにする

TypeScriptの標準のReadonlyのジェネリック型だと、ネストしたデーター構造の場合にネストした階層に対しては書き換えられてしまう。

utility-typesDeepReadonlyを使うとネストした場合でも値を書き換えられないようにでできる。

Readonly

type Foo = Readonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // 値が書き換えられる

DeepReadonly

import { DeepReadonly } from 'utility-types'

type Foo = DeepReadonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // TS2540: Cannot assign to 'name' because it is a read-only property.

styled-componentsのthemeに型付けて定義してない色を付けた場合は型エラーにする

color.ts
export type Colors = {
  black: string
  white: string
}

type Color = keyof Colors

export const color = (color: Color): ((props: { theme: Theme }) => string) => props => props.theme.colors[color]
import React from 'react'
import styled, { ThemeProvider } from 'styled-components'
import { Colors, color } from './color'

type Theme = {
  colors: Colors
}

const theme: Theme = {
  colors: {
    black: '#000',
    white: '#fff'
  }
}

export const App: React.FC = () => (
  <ThemeProvider theme={theme}>
    <Main>main</Main>
  </ThemeProvider>
)

const Main = styled.div`
  color: ${color('black')};
`

定義されてない色を指定した場合

const Main = styled.div`
  color: ${color('green')}; // TS2345: Argument of type '"green"' is not assignable to parameter of type '"black" | "white"'.
`

透明度の指定

themeの色のパターンに同じ色でも違う透明度のものを定義してしまうとパターンが増えてしまうので、polishedrgbaという関数を使うと、themeの色に対して透明度の調整した色を指定できる。

import React from 'react'
import { rgba } from 'polished'
import styled from 'styled-components'
import { color } from './color'

const Main = styled.div`
  color: ${props => rgba(color('black')(props), 0.3)};
`

SVGをJSXとして扱う

jsxとして使う場合svgのタグのままでは使えなくて、 attributeに:-が入ってる場合は変換する必要がある。

https://reactjs.org/docs/dom-elements.html#all-supported-html-attributes

stroke-width => strokeWidth

JSXに変換する方法

新しいsvgアイコンが増えない限り、毎回webpackのloaderで変換する必要がないためsvgrを使うことにした。

svgrの設定例

svgrrc.js
module.exports = {
  icon: true,
  prettierConfig: {
    semi: false,
    singleQuote: true,
    printWidth: 128
  },
  template(
    { template },
    opts,
    { imports, componentName, props, jsx, exports }
  ) {
    const typeScriptTpl = template.smart({ plugins: ['typescript'] })
    const name = `${componentName.name.replace('Svg', '')}: React.FC<SVGProps<SVGSVGElement>>`
    return typeScriptTpl.ast`
      import React, { SVGProps } from 'react';
      export const ${name} = props => ${jsx};
    `
  }
}
package.json
{
  "scripts": {
    "svgr:build": "svgr ./svgs --ext tsx --out-dir ./components/ui/icons"
  }
}

svgrで変換したコンポーネントの組込例

import React from 'react'
import styled from 'styled-components'
import { Icon } from './components/ui/icons/Icon'

const App: React.FC = () => (
  <StyledIcon />
)

const StyledIcon = styled(Icon)`
  width: 16px;
  height: 16px;
  fill: blue;
`

同じアイコンでサイズや色のバリエーションを変える例

const StyledIcon = styled(Icon)`
  width: 32px;
  height: 32px;
  fill: red;
`
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TypeScript + React + styled componentsで開発するのに行ったこと

TypeScriptで値を書き換えられないようにする

TypeScriptの標準のReadonlyのジェネリック型だと、ネストしたデーター構造の場合にネストした階層に対しては書き換えられてしまう。

utility-typesDeepReadonlyを使うとネストした場合でも値を書き換えられないようにでできる。

Readonly

type Foo = Readonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // 値が書き換えられる

DeepReadonly

import { DeepReadonly } from 'utility-types'

type Foo = DeepReadonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // TS2540: Cannot assign to 'name' because it is a read-only property.

styled-componentsのthemeに型付けて定義してない色を付けた場合は型エラーにする

color.ts
export type Colors = {
  black: string
  white: string
}

type Color = keyof Colors

export const color = (color: Color): ((props: { theme: Theme }) => string) => props => props.theme.colors[color]
import React from 'react'
import styled, { ThemeProvider } from 'styled-components'
import { Colors, color } from './color'

type Theme = {
  colors: Colors
}

const theme: Theme = {
  colors: {
    black: '#000',
    white: '#fff'
  }
}

export const App: React.FC = () => (
  <ThemeProvider theme={theme}>
    <Main>main</Main>
  </ThemeProvider>
)

const Main = styled.div`
  color: ${color('black')};
`

定義されてない色を指定した場合

const Main = styled.div`
  color: ${color('green')}; // TS2345: Argument of type '"green"' is not assignable to parameter of type '"black" | "white"'.
`

透明度の指定

themeの色のパターンに同じ色でも違う透明度のものを定義してしまうとパターンが増えてしまうので、polishedrgbaという関数を使うと、themeの色に対して透明度の調整した色を指定できる。

import React from 'react'
import { rgba } from 'polished'
import styled from 'styled-components'
import { color } from './color'

const Main = styled.div`
  color: ${props => rgba(color('black')(props), 0.3)};
`

SVGをJSXとして扱う

jsxとして使う場合svgのタグのままでは使えなくて、 attributeに:-が入ってる場合は変換する必要がある。

https://reactjs.org/docs/dom-elements.html#all-supported-html-attributes

stroke-width => strokeWidth

JSXに変換する方法

新しいsvgアイコンが増えない限り、毎回webpackのloaderで変換する必要がないためsvgrを使うことにした。

svgrの設定例

svgrrc.js
module.exports = {
  icon: true,
  prettierConfig: {
    semi: false,
    singleQuote: true,
    printWidth: 128
  },
  template(
    { template },
    opts,
    { imports, componentName, props, jsx, exports }
  ) {
    const typeScriptTpl = template.smart({ plugins: ['typescript'] })
    const name = `${componentName.name.replace('Svg', '')}: React.FC<SVGProps<SVGSVGElement>>`
    return typeScriptTpl.ast`
      import React, { SVGProps } from 'react';
      export const ${name} = props => ${jsx};
    `
  }
}
package.json
{
  "scripts": {
    "svgr:build": "svgr ./svgs --ext tsx --out-dir ./components/ui/icons"
  }
}

svgrで変換したコンポーネントの組込例

import React from 'react'
import styled from 'styled-components'
import { Icon } from './components/ui/icons/Icon'

const App: React.FC = () => (
  <StyledIcon />
)

const StyledIcon = styled(Icon)`
  width: 16px;
  height: 16px;
  fill: blue;
`

同じアイコンでサイズや色のバリエーションを変える例

const StyledIcon = styled(Icon)`
  width: 32px;
  height: 32px;
  fill: red;
`
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Reactで開発するのに行ったこと

前回からの続きです。

https://qiita.com/f96q/items/a18c39daa91a8263e720

TypeScriptで値を書き換えられないようにする

TypeScriptの標準のReadonlyのジェネリック型だと、ネストしたデーター構造の場合にネストした階層に対しては書き換えられてしまう。

utility-typesDeepReadonlyを使うとネストした場合でも値を書き換えられないようにでできる。

Readonly

type Foo = Readonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // 値が書き換えられる

DeepReadonly

import { DeepReadonly } from 'utility-types'

type Foo = DeepReadonly<{
  first: {
    second: {
      name: string
    }
  }
}>

const foo: Foo = {
  first: {
    second: {
      name: 'name'
    }
  }
}

ネストしてない値を書き換えた場合

foo.first = { second: 'name2' } // TS2540: Cannot assign to 'first' because it is a read-only property.

ネストした値を書き換えた場合

foo.first.second.name = 'name2' // TS2540: Cannot assign to 'name' because it is a read-only property.

styled-componentsのthemeに型付けて定義してない色を付けた場合は型エラーにする

color.ts
export type Colors = {
  black: string
  white: string
}

type Color = keyof Colors

export const color = (color: Color): ((props: { theme: Theme }) => string) => props => props.theme.colors[color]
import React from 'react'
import styled, { ThemeProvider } from 'styled-components'
import { Colors, color } from './color'

type Theme = {
  colors: Colors
}

const theme: Theme = {
  colors: {
    black: '#000',
    white: '#fff'
  }
}

export const App: React.FC = () => (
  <ThemeProvider theme={theme}>
    <Main>main</Main>
  </ThemeProvider>
)

const Main = styled.div`
  color: ${color('black')};
`

定義されてない色を指定した場合

const Main = styled.div`
  color: ${color('green')}; // TS2345: Argument of type '"green"' is not assignable to parameter of type '"black" | "white"'.
`

透明度の指定

themeの色のパターンに同じ色でも違う透明度のものを定義してしまうとパターンが増えてしまうので、polishedrgbaという関数を使うと、themeの色に対して透明度の調整した色を指定できる。

import React from 'react'
import { rgba } from 'polished'
import styled from 'styled-components'
import { color } from './color'

const Main = styled.div`
  color: ${props => rgba(color('black')(props), 0.3)};
`

SVGをJSXとして扱う

jsxとして使う場合svgのタグのままでは使えなくて、 attributeに:-が入ってる場合は変換する必要がある。

https://reactjs.org/docs/dom-elements.html#all-supported-html-attributes

stroke-width => strokeWidth

JSXに変換する方法

新しいsvgアイコンが増えない限り、毎回webpackのloaderで変換する必要がないためsvgrを使うことにした。

svgrの設定例

svgrrc.js
module.exports = {
  icon: true,
  prettierConfig: {
    semi: false,
    singleQuote: true,
    printWidth: 128
  },
  template(
    { template },
    opts,
    { imports, componentName, props, jsx, exports }
  ) {
    const typeScriptTpl = template.smart({ plugins: ['typescript'] })
    const name = `${componentName.name.replace('Svg', '')}: React.FC<SVGProps<SVGSVGElement>>`
    return typeScriptTpl.ast`
      import React, { SVGProps } from 'react';
      export const ${name} = props => ${jsx};
    `
  }
}
package.json
{
  "scripts": {
    "svgr:build": "svgr ./svgs --ext tsx --out-dir ./components/ui/icons"
  }
}

svgrで変換したコンポーネントの組込例

import React from 'react'
import styled from 'styled-components'
import { Icon } from './components/ui/icons/Icon'

const App: React.FC = () => (
  <StyledIcon />
)

const StyledIcon = styled(Icon)`
  width: 16px;
  height: 16px;
  fill: blue;
`

同じアイコンでサイズや色のバリエーションを変える例

const StyledIcon = styled(Icon)`
  width: 32px;
  height: 32px;
  fill: red;
`
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

React-ReduxだけどReducerもActionも書かず、Dispatchすら使わず、データも何となく受け取れるようにする方法

React-ReduxだけどReducerもActionも書かず、Dispatchすら使わず、データも何となく受け取れるようにする方法

前回
Reduxがあまりにも面倒なので、何もかも隠蔽しつつ、その力を引き出すことにした

とにかく面倒くさいFlux

 React上での状態管理は、ことごとくがFluxの考え方を用いて作られています。

 Actionのコマンドを定義してDispatchで送信し、Reducerで処理してStoreに書き込み、ViewがStoreの更新を察知して処理を行うという流れです。これらの処理の厄介なところは、全部が全部、記述箇所がバラバラになってしまうということです。

 とにかく何もかも面倒くさいので、この地獄から抜け出す方法を探しました。結果として、上記に書いたようなことを一切書かず、簡単にStoreデータを操作する方法にたどり着きました。

 環境設定と必要なパッケージ

今回のサンプルプログラムを動かすにはReact+TypeScript環境を用意してください。
また、以下のパッケージをインストールする必要があります。

npm -D i @jswf/redux-module

サンプルプログラム

 HooksのFunctionコンポーネントと、Classコンポーネントでの利用法を同時に載せているので、プログラムが少々長くなっています。
 中でやっているのはinputの内容をコンポーネント間で共有するというものです。
 もしこれをやるためだけにReduxの書式を真面目に書いたら、確実に無駄地獄へ落ちることが出来るでしょう。

 コンポーネント間のデータ共有にはReduxModuleというクラスを継承して利用します。
 クラスを作ると、クラスごとに一つStore領域が割り当てられます。
 そのため同じクラスを使用する限り、同じデータを参照することが出来ます。
 また、読み書きの受付も全てこのReduxModule継承クラスが担当するので、処理をあちこちに書く必要はありません

Microsoft Edge 2019-09-06 23-01-19.gif

https://github.com/JavaScript-WindowFramework/redux-module-sample

index.tsx
import React, { Component } from "react";
import * as ReactDOM from "react-dom";
import { createStore } from "redux";
import { Provider } from "react-redux";
import {
  ModuleReducer,
  useModule,
  ReduxModule,
  mapModule,
  mapConnect
} from "@jswf/redux-module";

/**
 *データ構造の定義(TypeScript使用時)
 *
 * @export
 * @interface TestState
 */
export interface TestState {
  msg: string;
}
/**
 *Storeアクセス用クラス
 *(クラスごとに自動的にStoreに領域を確保する)
 * @export
 * @class TestModule
 * @extends {ReduxModule<TestState>}
 */
export class TestModule extends ReduxModule<TestState> {
  //ここに初期値を設定可能
  protected static defaultState: TestState = {
    msg: "初期値"
  };
  //以下のようなアクセス用のメソッドは、必ずしも作る必要は無い
  //getStateとsetStateはpublicなので、外から直接書き換えてしまってもOK
  public getMessage() {
    return this.getState("msg")!;
  }
  public setMessage(msg: string) {
    this.setState({ msg });
  }
}

/**
 *Hooks用サンプル
 *
 * @returns
 */
function HooksApp() {
  //モジュールのインスタンスを受け取る
  //useModuleの使用可能場所の制限は他のhookと同じ
  const testModule = useModule(TestModule);
  //以下のようにPrefixを付けると、同じクラスが違う領域を持つことも出来る
  //const testModule = useModule(TestModule,"Prefix");
  return (
    <>
      <div>FunctionComponent</div>
      <input
        value={testModule.getMessage()}
        onChange={e => testModule.setMessage(e.target.value)}
      />
      <hr />
    </>
  );
}

/**
 *Class用サンプル
 *
 * @class _ClassApp
 * @extends {Component}
 */
class _ClassApp extends Component {
  render() {
    //モジュールのインスタンスを受け取る
    //Hooksと名前と引数が微妙に違うので注意
    const testModule = mapModule(this.props, TestModule);
    return (
      <>
        <div>ClassComponent</div>
        <input
          value={testModule.getMessage()}
          onChange={e => testModule.setMessage(e.target.value)}
        />
        <hr />
      </>
    );
  }
}
//クラスコンポーネントを利用する場合は以下の方法でマッピングする
//ここで宣言したモジュール以外はクラスで使用できない
//モジュールは配列で複数指定も可能
const ClassApp = mapConnect(_ClassApp, TestModule);

//Reduxに専用のReducerを関連付ける
//他のReducerと併用することも可能
const store = createStore(ModuleReducer);
ReactDOM.render(
  <Provider store={store}>
    <HooksApp />
    <ClassApp />
  </Provider>,
  document.getElementById("root") as HTMLElement
);

 必要なことのまとめ

1 ModuleReducerをReduxのStoreに関連付ける
2 ReduxModuleを継承したデータクラスを作成する
3 Classコンポーネントを使う場合はmapConnectで使用するデータクラスを関連付ける
4 useModule/mapModuleでデータクラスを呼び出す
5 setState/getStateでデータの読み書きを行う

一応解説しておく付加機能

 蛇足になるので最小限にとどめますが、データクラスは外部参照機能も付いています。
 別のデータクラスの機能が必要な場合はincludesに利用するモジュールを指定しておけば、getModuleで対象のクラスを呼び出すことが出来ます。

export class OtherModule extends ReduxModule {
  static includes = [TestModule]
  public getMessage() {
    return this.getModule(TestModule).getState("msg")!;
  }
  public setMessage(msg: string) {
    this.getModule(TestModule).setState({ msg });
  }
}

まとめ

 とにかく状態管理が楽になりました。これを使うことによって、コンポーネント間の手続きの大部分が省略可能となります。コンポーネント間のデータ共有にReduxを使いたいけれど、Flux的な書き方が嫌だと考えているのならぜひ使ってみてください。

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

ReactでStorybook チュートリアルをやってみました。

はじめに

k.s.ロジャースの藤本です。

今回は公式のチュートリアルをやってみました。

その前に「そもそもstorybookってなんだろう」という状態からだったので
いろんな方の記事を参考にしてみました。

僕なりにまとめると、
サンドボックス環境を作り、
その中でコンポーネントの動作確認ができ
かつスタイルガイドとしても使える便利なツール

と解釈しました。
(間違えてたらすいません)

色んな記事を見てるとデザイナーさんとの認識合わせができることについて触れられている気がしました。

インストール

npx create-react-app taskbox
cd taskbox
npx -p @storybook/cli sb init

実行

npm run storybook

初期画面

image.png

Simple component

以下のコンパイル済みCSSをsrc/index.cssに格納
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css

public/fonticonを格納
https://github.com/chromaui/learnstorybook-code/tree/master/public

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

Task.jsTask.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} />);

.addで追加したタスクが表示されていることを確認
image.png

状態、イベントを設定

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>
  );
}

確認してみます。

d.gif

その後、propTypesを設定することで問題を早期発見できるとのことでした。

src/components/Task.js
import React from 'react';
import PropTypes from 'prop-types';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  // ...
}

Task.propTypes = {
  task: PropTypes.shape({
    id: PropTypes.string.isRequired,
    title: PropTypes.string.isRequired,
    state: PropTypes.string.isRequired,
  }),
  onArchiveTask: PropTypes.func,
  onPinTask: PropTypes.func,
};

自動テスト

アドオンとbabel-macrosを追加

yarn add --dev @storybook/addon-storyshots react-test-renderer require-context.macro
yarn add --dev babel-plugin-macros
src/storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
babelrc/package.json
{
  "plugins": ["macros"]
}

各ファイルの追加とconfig.jsの一部変更
require.contextからrequireContextに変更されています。

.storybook/config.js
import { configure } from '@storybook/react';
import requireContext from 'require-context.macro';

import '../src/index.css';

const req = requireContext('../src/components', true, /\.stories\.js$/);

function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);
結果
$ yarn test
yarn run v1.17.3
$ react-scripts test
PASS src/storybook.test.js
  Storyshots
    Task
      √ default (15ms)
      √ pinned (2ms)
      √ archived (1ms)

 › 3 snapshots written.
Snapshot Summary
 › 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   3 written, 3 total
Time:        9.264s
Ran all test suites related to changed files.

Composite component

コンポーネントを組み合わせて実際の画面を確認してみます。

TaskList.jsTaskList.stories.jsを作成
addDecorator()で各タスクにスタイルなどを追加できるようです。

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

a.gif

各状態の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;

全体的にチュートリアル通りの表示になっていることを確認
c.gif

propTypesを設定

TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';

function TaskList() {
  ...
}

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;

Data

アプリケーション側にデータの配線を行います。

react-redux reduxをインストール

yarn add react-redux redux

単純なStoreを作成

src/lib/redux.js
import { createStore } from 'redux';

export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
};

export const archiveTask = id => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = id => ({ type: actions.PIN_TASK, id });

function taskStateReducer(taskState) {
  return (state, action) => {
    return {
      ...state,
      tasks: state.tasks.map(
        task => (task.id === action.id ? { ...task, state: taskState } : task)
      ),
    };
  };
};

export const reducer = (state, action) => {
  switch (action.type) {
    case actions.ARCHIVE_TASK:
      return taskStateReducer('TASK_ARCHIVED')(state, action);
    case actions.PIN_TASK:
      return taskStateReducer('TASK_PINNED')(state, action);
    default:
      return state;
  }
};

const defaultTasks = [
  { id: '1', title: 'Something', state: 'TASK_INBOX' },
  { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  { id: '4', title: 'Something again', state: 'TASK_INBOX' },
];

export default createStore(reducer, { tasks: defaultTasks });

storeに接続し、該当タスクをレンダリングするようTaskList.jsを修正

src/components/TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';

export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  /* 以前のTaskList内を張り付ける */
}

PureTaskList.propTypes = {
  loading: PropTypes.bool,
  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  onPinTask: PropTypes.func.isRequired,
  onArchiveTask: PropTypes.func.isRequired,
};

PureTaskList.defaultProps = {
  loading: false,
};

export default connect(
  ({ tasks }) => ({
    tasks: tasks.filter(t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
  }),
  dispatch => ({
    onArchiveTask: id => dispatch(archiveTask(id)),
    onPinTask: id => dispatch(pinTask(id)),
  })
)(PureTaskList);

TaskListPureTaskListになっているので合わせて修正

src/components/TaskList.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';

import { PureTaskList } 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', () => <PureTaskList tasks={defaultTasks} {...actions} />)
  .add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
  .add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
  .add('empty', () => <PureTaskList tasks={[]} {...actions} />);

Screens

コンポーネントを組み合わせて
実際の画面を使い開発をしてみます。

InboxScreen.jsを作成

src/components/InboxScreen.js
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";

import TaskList from "./TaskList";

export function PureInboxScreen({ error }) {
  if (error) {
    return (
      <div className="page lists-show">
        <div className="wrapper-message">
          <span className="icon-face-sad" />
          <div className="title-message">Oh no!</div>
          <div className="subtitle-message">Something went wrong</div>
        </div>
      </div>
    );
  }

  return (
    <div className="page lists-show">
      <nav>
        <h1 className="title-page">
          <span className="title-wrapper">Taskbox</span>
        </h1>
      </nav>
      <TaskList />
    </div>
  );
}

PureInboxScreen.propTypes = {
  error: PropTypes.string
};

PureInboxScreen.defaultProps = {
  error: null
};

export default connect(({ error }) => ({ error }))(PureInboxScreen);

InboxScreenのコンポーネントを使用するようにApp.jsを修正

src/App.js
import React, { Component } from "react";
import { Provider } from "react-redux";
import store from "./lib/redux";

import InboxScreen from "./components/InboxScreen";

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <InboxScreen />
      </Provider>
    );
  }
}

export default App;

チュートリアルにはありませんが、実際の画面を見てみます。

npm start

f.gif

StorybookでもInboxScreenをレンダリングしてみます。

InboxScreen.stories.jsを作成

src/components/InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";

import { PureInboxScreen } from "./InboxScreen";

storiesOf("InboxScreen", module)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);

チュートリアル通りですが、エラーが出ます。
image.png
Presentational Component(見た目)をレンダリングするコンポーネントなので
Container Component(ロジック)を単独でレンダリングすることはできないようです。

今回の例では、PureInboxScreen単体はPresentational Componentですが
TaskListがそうではないことが原因のようです。

.addDecoratorを使用し、取り回せるよう
src/components/InboxScreen.stories.jsを修正

InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Provider } from "react-redux";

import { PureInboxScreen } from "./InboxScreen";
import { defaultTasks } from "./TaskList.stories";

const store = {
  getState: () => {
    return {
      tasks: defaultTasks
    };
  },
  subscribe: () => 0,
  dispatch: action("dispatch")
};

storiesOf("InboxScreen", module)
  .addDecorator(story => <Provider store={store}>{story()}</Provider>)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);

正しく表示されていることを確認
g.gif

Addons

アドオンを設定することができるようです

Knobsをセットアップ

yarn add @storybook/addon-knobs

.storybook/addons.jsaddon-knobsを追記

.storybook/addons.js
import "@storybook/addon-actions/register";
import "@storybook/addon-knobs/register";
import "@storybook/addon-links/register";

addon-knobsのインポートの追加
.addDecoratorにパラメーターを渡します
defaultのストーリーにobjectを結合

src/components/Task.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object } from "@storybook/addon-knobs/react";

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)
  .addDecorator(withKnobs)
  .add("default", () => {
    return <Task task={object("task", { ...task })} {...actions} />;
  })
  .add("pinned", () => (
    <Task task={{ ...task, state: "TASK_PINNED" }} {...actions} />
  ))
  .add("archived", () => (
    <Task task={{ ...task, state: "TASK_ARCHIVED" }} {...actions} />
  ));

Knobsのタブが追加されていて、defaultにパラメータ―が表示されていることを確認
image.png

その他(チュートリアル外)

テーマを設定

https://storybook.js.org/docs/configurations/theming/

storybookのテーマが設定できるみたいなので設定してみました

config.js
import { addParameters } from '@storybook/react';
import { themes } from '@storybook/theming';

// Option defaults.
addParameters({
  options: {
    theme: themes.dark,
  },
});

image.png

終わりに

「Creating addons」や「Deploy」などもありましたが
Storybookを実務で利用する方法とは若干逸れていると思い一部割愛させていただきました。

今回、チュートリアルを触ってみた個人的な感想を簡単にまとめると

良いところ
・コンポーネントカタログから直感的に必要なコンポーネントを探せる
・UIコンポーネントの再利用性が高い
・アプリを実行する必要が無く、Storybook単体でデザインの確認や実装ができる
・HMRが標準で設定されているため素早く動作確認ができる

気になるところ
・小規模プロジェクトだとわざわざ導入する必要は無いかも

個人的にはこんなところでした。
少し使い方は複雑かなとも思いましたが
慣れてくるとスムーズに開発できそうです。

弊社のプロジェクトでも導入されているため
実務部分についてはこれから理解を深めていきます。
応用に関しては知見が深まったらまたブログを書きます。

間違いやご指摘がありましたが是非ご教授頂ければと思います。

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