Skip to main content

django manage SPA

アプリを作成する

djangoのstartappの代わりに、create-react-appでアプリを作成します。 nodeをインストール後、npm install -g create-react-appを実行することで使用できます。

  • django-admin startproject my-project
  • cd my-project
  • create-react-app my-react-app
  • cd my-react-app

ルーティングの設定

urls.pyを次のように設定させます

from django.conf import settings
...
url_patterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

submodules = ["my-react-app"]

for name in submodules:
route = name if name != "core" else ""
temp = 'static/%s/index.html' % name
view = TemplateView.as_view(template_name=temp)
urlpatterns += [re_path(route, view, name=name)]

staticのディレクトリを設定する

ビルド結果のフォルダを次のようにsettings.pyで指定させます。

submodules = ["my-react-app"]
STATICFILES_DIRS = []
for s in submodules:
STATICFILES_DIRS += [
os.path.join(BASE_DIR, "%s/build/%s/static" % (s, s)),
os.path.join(BASE_DIR, "%s/build" % (s))
]

ここで二重に設定させたのは、複数のReactアプリが存在する場合、 static内のファイルを同じ場所で管理できるようにするためです。 このとき、staticディレクトリが重複してしまうため、 ignoreを指定してファイルを集める必要があります。

CRAのbuild先を上書きする

npm install -D react-app-rewiredを実行後、config-overrides.jsを次のようにします

const fs = require('fs');
const path = require('path');
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);

module.exports = {
paths: (paths, env) => {
const basename = path.basename(__dirname)
paths.appBuild = resolveApp(`build/${basename}`);
return paths;
}
}
  • npx react-app-rewired build
  • python manage.py collectstatic --ignore=static/

Rest APIを作る

先ほど作成したmy-react-appでAPIを使用します。 python manage.py startapp my-react-appを実行すると、 ディレクトリ内にPythonファイルが作成されます。 settings.pyを編集します。

INSTALLED_APPS += ['my-react-app']

my-project/urls.pyに次のように追加します

from django.conf import settings
# ...
url_patterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

submodules = ["my-react-app"]

for name in submodules:
route = name if name != "core" else ""
temp = 'static/%s/index.html' % name
view = TemplateView.as_view(template_name=temp)
urlpatterns += [re_path(route, view, name=name)]

開発用にサーバーを立てるコマンドを作成する

DjangoとNode.jsのサーバーを同時に用意する際のコマンドを用意しておきます。

my-react-app/management
│ __init__.py

└─commands
│ npm_start_runserver.py
└─ __init__.py

npm_start_runserver.pyを次のように編集します。

import sys
import subprocess
from django.core.management.base import BaseCommand

class Command(BaseCommand):
help = "npm start and runserver"

def handle(self, *args, **options):
proc = subprocess.Popen(["npm", "start"], shell=True, cwd='./note')
cmds = [sys.executable, sys.argv[0], 'runserver', '0.0.0.0:8000']
subprocess.run(cmds, shell=True, cwd=".")
proc.close()

python manage.py npm_start_runserver で立ち上げることができます。

Setup Atom

突然Atomが起動しなくなったときに、再び入れたときのメモです。 AtomはChromeベース (electron) で作られたアプリなので拡張機能が多く、 vimのようなコマンドやほかのエディターのツールなど、無限に拡張できます。

インストール

  • ( 設定ファイルがあればgit cloneする )
  • ( cacheがあれば消す~/.atom, ~/AppData/Local/atom )
  • atom.ioからAtomSetupをインストール → 実行 → 起動
  • ファイルタブ(alt+F) → Setting(T) → + Install → japanese-menuをインストール
  • ~/AppData/Local/atom, ~/AppData/Local/atom/binのPathを通す
  • (任意)Githubエディター設定を残す
    • ~/.atom/gitignorepackages/*を追加
    • apm list --installed --bare > packages.txt
    • git add . => git commit

設定

  • コア設定すべて [o]
  • エディタ設定すべて [o]
  • ソフトラップと右端ソフトラップ [x] :折り返して開業しないようにする
  • タブ幅 :2
  • タブタイプ : soft
  • インターフェーステーマ : One Dark
  • シンタックステーマ : Github Atom Dark
  • テーマをインストール

ツールの拡張

INSTALL FOR UTILS

  • clipboard-plus : clipboardの履歴の一覧を表示
  • editor-stats : 6時間分の作業履歴をグラフにして表示
  • hyperclick : Ctrl+Alt+Enterで、選択した単語が定義がされた場所を開く
  • git-plus : atomでgithubを扱う決定版
  • merge-conflicts : githubでconfligtが起きたときに直しやすくする(精神を安定させる)

タブの拡張

INSTALL FOR Tabs

  • file-icons : ファイル名の隣にアイコンがつく
  • foldername-tabs : タブにディレクトリ名も表示
  • multiline-tab : タブが多いとき、改行して表示する
  • tree-view-git-status:treeにgitの情報を細かく表示させる
  • tool-bar :押すとコマンドを実行するボタンを設置できる
  • flex-tool-bar : tool-barの設定が簡単になる
  • Zen :
    • Fullscreenを [x] : あるとよくバグる(別のコマンドで代用できる)
    • SoftWrapを [x] && Width=200 (横幅が広いディスプレイ用)
    • Typewriterを [x] : クリックした位置が中心になるが、邪魔。

カーソルの拡張

INSTALL FOR CURSOR

  • highlight-column : カーソルの位置に縦ハイライト
  • highlight-line : カーソルの位置に横ハイライト
    • Enable Background Colorを [o]
    • Enable Selection Borderを [o]
  • highlight-selected : 選択した単語すべてにハイライト
  • atom-bracket-highlight : 選択した括弧をハイライト
  • quick-highlight : クリックした単語すべてにハイライト
  • auto-highlight : クリックした単語すべてにハイライト (過去の選択も残る)
    • Decolateをbox
    • [x]Display Count On Status Bar : あまり見ないのでoff

デザインの拡張

INSTALL FOR GAMING

  • activate-power-mode : コーディングをゲームっぽくする
    • screen-shakeを [x]
    • play audioを [x]
    • pluginsを [x]
  • neon-selection : 選択した場所がネオンの光を発する
  • glowing-cursor : カーソルがネオンの光を発する
  • syntax-neonize:シンタックスが光る(Github Atom Darkだと逆に見やすくなる)

FOR SCROLL

  • scroll-marker : スクロールバー(右端)にハイライトを追加
  • find-scroll-marker : 検索した単語の位置をスクロールバーにハイライト

ミニマップの追加

FOR minimap

その他

FOR jsx, tsx

  • atom-browser : atom内でブラウザを使用できる。自動リロード付き。
  • atom-ide-ui : Atomをエディターから総合開発環境にする。
  • atom-typescript:他のide-typescriptだと変なエラーが出る
  • react:JSXのシンタックス用。

FOR Markdown

Typescript | and &

REF

Typescriptの合併|と公差&がわかりにくかったので、食事の例で考えてみました。 献立表を作るとき、次のようにサラダとパスタとピザの型を定義します。 今回は、サラダとパスタのトマトは数を数えられるとしてnumber型、 ピザのトマトはペースト状なのでboolean型にしているので注意してください。

type Salad = {tomato: number}
type Pasta = {tomato: number, macaroni: boolean}
type Pizza = {tomato: boolean, cheeze: boolean}

合併|について

各食事で必ずサラダをとり、ランチではパスタ、ディナーではピザを食べる ときの、 サラダとPasta,Pizzaの合併|を考えます。

type Lunch1  = Salad | Pasta
type Dinner1 = Salad | Pizza

合併は、いわゆる"または"の意味なので、どちらかの食事である必要があります。 ランチはPasta ⇒ Saladの関係なので、 少なくともtomatoがbooleanであればtrueとなり、 ディナーはPizzaとSaladは全く異なるので、SaladでもPizzaでもないとfalseとエラーになります。

const  salad: Lunch1  = {tomato:0} //OK!
const pasta: Lunch1 = {tomato:0, macaroni:true} //OK!
const pizza: Dinner1 = {tomato:false, cheeze:true} //OK!
const _pizza: Dinner1 = {tomato:1 , cheeze:true} //ERROR!
//Object literal may only specify known properties, and 'cheeze' does not exist in type 'Salad'.

交差&について

ランチではパスタ、ディナーではピザを食べる食生活として、サラダとの交差&を用いて定義します。

type Lunch2  = Salad & Pasta
type Dinner2 = Salad & Pizza

公差&はかつという意味なのですが、Typescriptでは少しあつかいにくいので注意が必要です。 ランチではPasta ⇒ Saladの関係であり、必要条件であるパスタでないとfalseになるので、 SaladかつPastaだとマカロニサラダしか該当しなくなります。 ディナーに関しては、tomatoの型がSaladとPizzaで異なるので、 tomato:number&boolean)=>tomato:neverになり, どんな料理でも合致しなくなります。

const salad2: Lunch2 = {tomato:0} //ERROR!
const pasta2: Lunch2 = {tomato:0, macaroni:true} //OK!

//Property 'macaroni' is missing in type '{ tomato: number; }' but required in type 'Pasta'.

const _pizza2: Dinner2 = {tomato:1 , cheeze:true} //ERROR!
const __pizza2: Dinner2 = {tomato:true, cheeze:true} //ERROR!

// Type 'number' is not assignable to type 'never'.
// Type 'true' is not assignable to type 'never'.

型のmergeについて

重複したkeyの型が異なるSaladとPizzaの型を合体させるには、条件とマップが必要となります。 Pizzaのkeyに対して、keyがSaladのkeyにあればSaladの型、出なければPizzaの型を返すことができます。 このとき、tomatoの型は、saladが優先されるのでnumber型になります。

type Salad = {tomato:number}
type Pizza = {tomato:boolean, cheeze:boolean}
type Dinner = {
[K in keyof Pizza]: K extends keyof Salad ? Salad[K] : Pizza[K]
} & Salad
const pizza: Dinner = {tomato:0 , cheeze:true} // OK !
const _pizza: Dinner = {tomato:false, cheeze:true} //Error !

// Type 'false' is not assignable to type 'number'.
// Dinner is {tomato:number, cheeze:boolean}

JS, TS, Error and solution

よく指摘されるエラーと、回避させる方法をまとめました。

Uncaught SyntaxError: Unexpected token '.'

  • xxx.yyyなどで参照するとき、xxxがnullだとerrorが出ます。
  • xxx && xxx.yyyと一間開けるか、xxx?.yyyで回避できます。

Uncaught TypeError: xxx.map is not a function.

  • props.children.map(v=>v.key)などで参照するとき、childrenが配列でないとerrorが出ます
  • const getarr =a=>a instanceof Array?a:a?[]:[a]が便利。
  • getarr(props.children).map(v=>v.key)と一間開けるか、React.Children.mapを使います。

Cannot read property '1' of undefined

  • 長さ1の配列arrarr[1]するとプロパティーがないといわれます。
  • arr.find((_,i) = >i===1) || null を使うか、型を定義します。

Typescript error

JSX element 'T' has no corresponding closing tag.

  • Typescriptの<T>がcomponentsとして認識されてしまいます。
  • tsconfigを直すか、<T><T=unknown>にします。

Argument of type 'any[]' is not assignable to parameter of type 'ConcatArray<never>'.

  • 型がない場合は、.concat(...(arr as never[])を通します。

Django and Pagination

ref

本サイトに使ったfetchするurlを相対的に指定するCursorPaginationのメモです。 次のDjangoのPaginationのうち、データ更新が頻繁なアプリにはカーソル型が適しています。

  • PageNumberPagination e.g. ~/?page=4
  • LimitOffsetPagination e.g. ~/?limit=5&offset=400
  • CursorPagination e.g. ~/?cursor=cj0xJnA9MjAxOC

viewsetに適用する

ListViewsetなどでは、pagination_classを指定するだけで完了します。 今回はテンプレートを利用していないため、自分でページネーションを適用します。 後半の関数引数requestは、各クラスにrequestを与えるためあとでオーバーライドさせます。

class CustomViewSet (GenericViewSet):
queryset = CustomModel.objects.all() # CustomModelは別で定義
serializer_class = CustomSerializer # CustomSerializerは後で定義
pagination_class = CustomPagination # CustomPaginationは後で定義
def list (self, request):
queryset = self.filter_queryset( self.get_queryset() )
paginate = self.paginate_qeryset(objs)
if paginate is None:
return self.get_paginated_response(None , request=request)
data = self.get_serializer(paginate, many=True, request=request)
return self.get_paginated_response(data.data , request=request)

ViewsetのResponseを拡張する

GenericViewSetで定義されているself.get_paginated_responseのソースコードを確認すると、 self.paginatorの値を参照し,Dictを返しているだけなので、簡単にオーバーライドできます。 第二引数にrequestを指定することで、アクセスしたユーザー情報によってResponseを指定できます。

class CustomViewSet (GenericViewSet):
...
def get_paginated_response(self, data, request=None):
return Response({
'next' : self.paginator.get_next_link() if data else None,
'previous': self.paginator.get_previous_link() if data else None,
'results' : data if data else "Page Not found.",
'isAuth' : True if request.user else False,
}, status = 404 if data is None else 200)

Serializerを拡張する

ユーザーの情報によって、シリアライズする値を変更します。 例えば、アクセスしたユーザーがそのデータの著者かどうかを判定します。

from rest_framework import serializers as s

class NoteSerializer(s.ModelSerializer):
...
is_author = s.SerializerMethodField()
def __init__(self, *args, **kwargs):
self.request = kwargs.pop('request', None)
super().__init__()
def get_is_author(self, obj):
user = self.request and self.request.user
return user and user.id == obj.posted_user.id

paginationをカスタムする

self.ordering = yyyとViewSetの途中のコードからpaginationの値を変更させるため、 各パラメータのget_xxxのコード (例えばorderingだと、get_ordering)をつぎのようにオーバーライドします。

class CustomViewSet (GenericViewSet):
ordering = "-id"
...
def ...
self.ordering = "id"

class CustomPagination(CursorPagination):
page_size = 5
max_page_size = 5
cursor_query_param = 'cursor'
invalid_cursor_message = 'Invalid cursor(;_;)'
def get_ordering(self, request, queryset, view):
view_ordering = getattr(view, 'ordering', None)
if view_ordering:
self.ordering = view_ordering
return super(NotePagination, self).get_ordering(request, queryset, view)

React hooks

Reactは簡単にいうと、Webなどの処理と開発を最適化するための新しいエコシステムといえます。 また、hookは関数ベースのみでReactを実装する方法なので、型システムと相性がいいです。

前半では、DOMを直接触らない大体のjsコードをhookで使用する方法をまとめました。 後半では、前半で使ったhookを使って、独自のhookを新たに作る方法をまとめました。

useRefについて

Reactでは、データは一方向(親から子)へ渡されて計算されるが、 親から子のElementのrefに参照することで, 子の要素の値を外から参照したり操作できます。 また、React向けでないライブラリの変数を入れることで、全体が再renderしても初期化されないようにできます。

以下の例で➊では、指定した要素の値を参照するための基本的な使い方です。 ➋では、再renderしたときuseCallbackやChildrenのpropsを変化させず、子要素の再renderを防ぎます ➌では、App全体が再renderしたときに再びインスタンス化されないように値を保持します。

import {useRef, useCallback} from 'react'
const App = ({src="/static/test.png"}) => {
const ref = useRef(null) // ➊:通常のref
const err = useRef(false) // ➋:変化しても再renderしたくない!
const obj = useRef(new Image())// ➌:再renderしても初期化されない!
const onError = useCallback(()=>(err.current=true), [])
const onClick = useCallback(()=> err.current&&window.open(ref.current.src)),[])
return <img {...{src, ref, onClick, onLoad}} />
}

useEffectについて

componentを生成し、Renderしたあとに実行する処理を入れます。 例えば、fetchなど時間がかかる処理を入れることで、ほかの要素のrenderに影響を与えません。

また、第二引数を空の配列にすることで、再renderしたときに再実行したくない重い処理を入れることができます。 React向けではない(DOMを直接触るような)ライブラリは、すべての処理をこの中の入れることで利用できます。

const App = (props) => {
const [data, set] = useState('')
useEffect(()=>{
fetch(props.url).then(res => set(res))
}, [url])
return data && <span>{data}</span>
}

その他

  • useState:値が変化したら、再renderしてほしいような値に使います。(特に表示させる値)
  • useMemo : とりあえずすべての変数をこの中に入れておくと、高速化します。
  • useCallback: とりあえずすべての関数をこの中に入れておくと、高速化します。

自作hookについて

アプリの状態を保存するuseStateでは、前の値を参考に新たな値をsetするときは関数を引数に指定します。 例として、window.location から状態を管理するhookを作成します。 事前に、新しい値に関数を指定できるように、次のような型を定義します。

export type BasicProps<T>  = (()=>T) | T
export type BasicState<T> = ((pre:T)=>T) | T
export type BasicAction<T> = (fn:BasicState<T>) => void

引数の型が関数の場合を最初に除き、useRef内で値を補完することで、 (useStateのset(p=>p)の様な)、過去を参照するhookを作成することができます。 useRefにはデフォルト値と入力値をmergeして入力することで、多くの状態を同時に管理できます。

import {useState, useRef} from 'react'
import {Page, Conf} from '../types'
import {defaultConf, defaultPage} from '../utils'

export const usePage = <T=any>(
props :BasicProps<Partial<Page<T>>>={},
config:BasicProps<Partial<Conf<T>>>={},
) : [
Page<T>,
BasicAction<Partial<Page<T>>>
] => {
if (typeof props === "function")
props = props()
if (typeof config === "function")
config = config()
const pageRef = useRef({...defaultPage, ...props } as Page<T>)
const confRef = useRef({...defaultConf, ...config} as Conf<T>)
const [page, set] = useState(pageRef.current)

const setPage = useCallback(state => {
if (typeof state === "function")
state = state(pageRef.current as Partial<Page<T>>)
pageRef.current = {...pageRef.current, ...state}
set(pageRef.current)
}, [])

return [page, setPage]
}

React.js, assign to arguments

useStateのような状態を扱う自作hookを作る際、引数に関数を使うことがあります。 Reactのpropsなど、引数代入することで型を特定できたり、コードが縮小できて便利です。 たとえば、useStateでは初期値の計算が重かったり前の値を扱痛い場合、引数に関数を指定させます。 const [rows, setRows] = useState(() => createRows(props.count));

このuseStateのソースコードをみると、引数の型は(()=>S)|Sとして、初めに引数自体に関数の結果を代入していました。

function mountState<S>(
initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
const hook = mountWorkInProgressHook();
if (typeof initialState === 'function')
initialState = initialState(); // 引数にも代入できる!
/*...*/
}

Reactのchildrenに便利

props.childrenでは子要素が二つ以上のときだけ配列になります。 なので子要素がなかったり一つだったりするときは配列ではないので、 children.lengthchildren.mapにエラーが出ます。 次のように、 children を先に配列に直すと予期しないエラーを減らすことができたりできます。

import React, {Children} from 'react';
const App = ({children}) => {
children = Children.toArray(children)
return children.filter(Boolean) // エラー出ない!
}

再帰化させるのに便利

孫要素に自身のコンポーネントを付与することで、自作した機能を子要素すべてに作用させることができます。 次のようにchildrenを変更すれば、孫要素が複数のときだけ再帰的に機能を与えられます。 (以下の例では、Redsで囲んだすべての子要素が赤くなります。)

import React, {Children, cloneElement} from 'react'
const Reds = ({children}) => {
children = Children.map( children, (child, key) => {
const grand = Children.toArray( child.props.children ) || []
return grand.length > 1
? cloneElement(child, {children:grand[0], grand:
<Reds> {grand.slice(0)} </Reds>
})
: child
}
const style = {background:"red"}
return <div {...{style, children}} />
}}

build with webpack and rollup

REF

アプリにはcss等のファイルが扱えるwebpack, ライブラリには一つのファイルに縮小して変換してくれるrollupが使いやすいです。 特にcreate-react-appでReact環境を作成後、デモページの作成と同時にライブラリを開発するのが安定しているのでおすすめです。 今回は、cross-envでグローバル変数を設定し、react-app-rewiredcreate-react-appの中の設定を変更します. tsやcssなど追加で用いる場合はrollup/plugins: 🍣からプラグインを選んで使います。

  • create-react-app {yourapp} and cd {yourapp}
  • npm i -D cross-env react-app-rewired rollup @rollup/plugin-babel @rollup/plugin-node-resolve @rollup/plugin-commonjs fs

次にconfig-overrides.jsファイルを追加します。(ウェブページをsrcからdocsに変更する。appSrcsrcから.にすると、srcdocsの両方がつかえる。)

const fs = require('fs');
const path = require('path');
const appDirectory = fs.realpathSync(process.cwd());
const resolveApp = relativePath => path.resolve(appDirectory, relativePath);
module.exports = {
paths: function(paths, env) {
paths.appSrc = resolveApp('.');
paths.appIndexJs = resolveApp('docs/index.js');
// Typescript の場合
// paths.appTypeDeclarations = resolveApp('docs/react-app-env.d.ts');
return paths;
}
}

次にpackage.jsonを修正します。(ライブラリのコンパイルにはrollupを用い、他はreact-app-rewiredを使う。)Typescriptの場合は、tsconfig.jsoninclude:[...]のフォルダを修正します.

{
"module": "index.js",
"types": "index.d.ts",
"main": "index.cjs.js",
"private": false,
"scripts": {
"test":"react-app-rewired test",
"eject":"react-app-rewired eject",
"start":"cross-env NODE_ENV=development BABEL_ENV=development react-app-rewired start",
"build":"cross-env NODE_ENV=production BABEL_ENV=production react-app-rewired build",
"compile":"cross-env NODE_ENV=production BABEL_ENV=production rollup -c config-rollup.js"
},
~~省略~~
}

先程、scriptscompileで指定したファイルのconfig-rollup.jsを追加します. (jsonglslをつかうなど、場合によってライブラリをインポートし、pluginsに追加していく)

import { promises as fs } from 'fs';
import babel from '@rollup/plugin-babel';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import pkg from './package.json';

const input = 'src/index'
const external = Object.keys({...pkg.dependencies,...pkg.devDependencies})
const extensions = ['.js', '.jsx', '.ts', '.tsx']

function babelOption (useESModules) {
return {
babelrc:false,
babelHelpers:'runtime',
exclude:'**/node_modules/**',
extensions,
presets : [
['@babel/env', {loose:true, modules:false}],
'@babel/preset-react','@babel/preset-typescript'
],
plugins : [
[ '@babel/proposal-class-properties' , {loose:true} ],
[ '@babel/plugin-proposal-object-rest-spread', {loose:true} ],
[ '@babel/transform-runtime', {regenerator:false,useESModules} ],
],
}
}
function targetTypings(out) {
return {
writeBundle () {
return fs.lstat(pkg.types).catch(() => {
return fs.writeFile(pkg.types, `export * from "./${input}"`)
})
}
}
}

export default [
{ input, output:{file:pkg.main ,format:'cjs'}, external, plugins:[
babel( babelOption(true) ),
commonjs({extensions}),
resolve ({extensions}),
targetTypings(),
]},
{ input, output:{file:pkg.module ,format:'esm'}, external, plugins:[
babel( babelOption(false) ),
commonjs({extensions}),
resolve ({extensions}),
targetTypings(),
] },
]

npm publish

  • デモページができたら、npm run build
  • ライブラリができたら、npm run compile
  • .npmignoreを作成し, rollupで作成したファイルとREADME.md以外を指定する.
  • npm publishで公開
.git
.gitignore
build
docs
node_modules
public
scripts

GLSL and THREE.js in React

THREE.jsはWebGLを用いて3D表現ができるライブラリで、 GLSLファイルもビルドしてくれるのでとてもわくわくできます。 (トップページにglslを利用してます。) 従来のjsだと大規模なアプリになると予期しないことが多く起こるので、 viewに特化したReactのライフサイクル上で安全に構築します。(特にhookだと関数型プログラミングできる。)

ポイント

DOMを直接操作するライブラリなどはReactでは使えないけど、 hookではuseEffect内に処理をかくと, Reactのライフサイクルから隠すことができます. classベースではcomponentDidMountをうまく利用すればできる.)

あと,THREE.jsはmountごとに再実行すると重くなるので、 Reactのライフサイクルと関係ない変数にはuseRefを使うと再renderせずうまくいきます。

useEffect内に初期処理をかき、一度だけ実行されるようにしてます。 特にWebGLRenderer作成時にcanvasを指定させ、 sceneとcameraにはuseRefを使うことであとから変更しても初期処理が再実行されないようにします。

import React, {useState, useRef, useEffect} from 'react'
import * as THREE from "three";

const App = (props) => {
const scene = useRef(new THREE.Scene());
const camera = useRef(new THREE.Camera());
useEffect(()=>{
const canvas = document.getElementById('renderer');
const renderer = new THREE.WebGLRenderer({canvas});
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
light.position.set( 1, 1, 1 );
scene.current.add(light);
/*~~more process~~*/
const render = () => {
requestAnimationFrame( render );
renderer.render( scene.current, camera.current );
}
render();
}, []);
return <canvas id="renderer" style={{position:"fixed",top:0,left:0}}/>
}

GLSLを利用する

glslファイルをfetchでloadし、変数へsetしたとき, 二つ目のuseEffectが実行します. (mount時と,各glslファイルがloadできたときの計三回実行される.) glslに渡すuniformsの値はuseRefで後から変更しても再実行されないようにします。

using media query in React

css in jsではメディアクエリ(@media)でのスタイルの場合分けができないので、 styledRadiumというライブラリを使ってましたが、 typescriptやReactの新しいバージョンへの対応が遅かったり変なエラーが多かったり不便なので、 代わりとして、自作hookのライブラリを公開しました。 use-mediaというリポジトリを参考にしています。

use-mediaについて

入力した値をメディアクエリの文字列に変換してuseRefの中に入れます。 はじめに、メディアクエリの初期値defaultMediaと、css in js をcss (例えばminWidthからmin-width)に直すqueryObjectToString関数をインポートしています。 const isMedium = useMedia({minWidth:500})のようにつかえば、 指定したメディアクエリを満たしているかを判定できるようになります。

import {defaultMedia, queryObjectToString as qO2S, } from '../utils'
export function useMedia (rawQuery={}, defaultState=false) {
const query = useRef( qO2S(rawQuery) );
const [state, set] = useState(defaultState);
useEffect (()=>{
const media = typeof window===undefined
? defaultMedia
: window.matchMedia(query.current)
const onChange =()=> set(Boolean(media.matches))
state && (onChange(), media.addListener(onChange))
return () => media.removeListener(onChange)
}, [])
return state
}

use-gridについて

const fontSize = useGrid({xs:"25px", md:"50px", xl:"75px"})のように使うと、 値の内容を画面のサイズに合わせてtレスポンシブに変化させられます。

import {mockMediaString, queryPropsToList as qP2L } from '../utils'
const useGrid = (props) => {
const queries = useRef( qP2L(props) )
const [state, set] = useState(queries.current[0][1])
useEffect ( () => {
const media = queries.current.map( ([query,value]) => {
const md = typeof window==="undefined"? mockMediaString:window.matchMedia(query)
const fn =()=> Boolean(md.matches) && set(value)
value && (fn(), md.addListener(fn))
return {md, fn}
})
return () => media.map( ({md,fn}) => md.removeListener(fn) )
}, [] )
return state
}

queryObjectToStringについて

css in jsのオブジェクトからcssに変換する関数queryObjectToStringを定義します。 例えば、{minWidth:500}min-width:500のように変換します。

export function queryObjectToString (query) {
if (typeof query === 'string') return query;
const toS = ([key, val]) => {
const feature = key.replace(/[A-Z]/g,s=>`-${s.toLowerCase()}`).toLowerCase();
const isN = typeof val==='number' && /[height|width]$/.test(feature)
return (typeof val==='boolean')
? `${val?'':'not '}${feature}`;
: `(${feature}: ${val}${isN?'px':''})`;
}
return Object.entries(query).map(toS).join(' and ');
}

queryPropsToListについて

"md"(medium)のような文字列からメディアクエリの文字列に変換する関数を定義します。 例えば、useGrid({xs:"ham",lg:"egg"})内で実行されるqP2L([["xs","ham"],["lg","egg"]])は、 [["(min-width:1px)and(max-width:969px)","ham"],["(min-width:970)","egg"]]のように変換します。

export function queryPropsToList ( props ) {
const SIZE = ["xs","sm","md","lg","xl"]
const toN =(key)=> SIZE.find(s=>s===key)? {xs:1,sm:576,md:720,lg:960,xl:1140}[key] : 0
const toS =(key,next)=>`(min-width:${ toN(key) }px)${ next?` and (max-width:${toN(next)-1}px)`:'' }`
const getMedia = (props) => {
const grid = SIZE.map(s=>props.find(p=>p[0]===s)||null).filter((m)=>m!==null)
const xsGr = (grid.length)? grid.find(g=>g[0]==="xs")?[]:[["xs",grid[0][1]]]: []
const noGr = (grid.length)? props.filter(p=>!SIZE.find(s=>s===p[0])) : props
return [...noGr, ...[...xsGr,...grid].map((g,i) => [
toS(g[0],i<grid.length-1?grid[i+1][0]:null), g[1]]) ]
}
return getMedia( props.map( ([key,val]) => [queryObjectToString(key),val] ) )
}