Open4
Next.js細々TIPS
認証付きAPIでgetServerSidePropsを使う
-
useGameInfo: クライアントレンダリング用のAPI
- 非公開モードに設定すると作成者しか閲覧できない(非ログイン時に404を返す)
-
game: SSRから渡されるprops
-
gameInfo: クライアントレンダリングで渡される情報
const Game = ({game}) => {
const [gameInfo] = useGameInfo();
return(
<View>
{gameInfo
?<GameView game={gameInfo}/>
:<></>
}
{game
?
<MetaTags
description={
moment(game.game_date).format("YYYY/MM/DD") +
" " +
game.first_team_name +
"-" +
game.last_team_name +
"のスコアブック"
}
title={
moment(game.game_date).format("YYYY/MM/DD") +
" " +
game.first_team_name +
"-" +
game.last_team_name +
"のスコアブック"
}
/>
:<></>
}
</View>
)
}
export const getServerSideProps = async({params}) => {
const response = await fetch(CAP_BACKEND + "/game/"+ params.game_id);
if(response.status === 200){
const game = await response.json();
return{
props:{
game
}
}
}else{
//responseが200以外なら空のpropsを返す
return {
props:{}
}
}
}
export default Game;
- 場合分けをしないと
404
のレスポンスではNext.jsが500
を返してしまう - Next10からは
notfound:true
で404ページを表示できるようになった - APIが404を返す時はメタタグも描画しない
- firebaseログインを使って非公開モードでログイン時はCSRでページ自体の表示はできるようにする
getServerSidePropsでページ全体を描画すると重くなるので一部だけを返して残りはCSR
useSWRを使ってみる
SWRとは
SWRとは、Next.jsを作成しているVercel製のライブラリです。SWRはuseSWRというReact Hooksを提供し、APIを通じたデータの取得をラクに記述する手助けをしてくれます。
SWRは「stale-while-revalidate」の略
useSWRは第二引数で与えたfetcherが一度取得したデータをクライアント側でキャッシュしてくれます。
useSWRがキャッシュからデータを取得する流れは以下です。
- キャッシュからデータを返そうとする(Stale)
- キャッシュにデータがなければ、データを取得する
- キャッシュにデータがあれば、再度データを取得してキャッシュを更新する(Revalidate)
useSWRの第三引数にオプションとして指定することで、Revalidateを柔軟に設定できます。
実装
import useSWR from "swr";
const { data: info } = useSWR("fetchInfo", async () => await fetchInfo());
const { data: data } = useSWR(CAP_BACKEND + "/repute", async () => {
const response = await fetch(CAP_BACKEND + "/repute");
return await response.json();
});
useSWRの第一引数はキャッシュするためのユニークなkey(URLをキーにするのが慣習)。
第二引数はfetcher
と呼ばれるfetchするメソッドを渡す。
複数のAPIをuseSWRで投げる場合はdataを格納する変数を分ける。
SSRで使う
まだこの辺り実装試せてないです。
export async function getServerSideProps() {
const data = await fetcher('/api/data')
return { props: { data } }
}
function App (props) {
const initialData = props.data
const { data } = useSWR('/api/data', fetcher, { initialData })
return <div>{data}</div>
}
参考リンク
"@react-three/cannon": "^4.2.0",
"@react-three/fiber": "^7.0.21",
"camera-controls": "^1.33.1",
"moment": "^2.27.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"three": "^0.134.0",
import * as THREE from "three";
import {
Physics,
usePlane,
useCylinder,
Debug,
useBox,
} from "@react-three/cannon";
import { TextureLoader } from "three/src/loaders/TextureLoader";
import { Canvas, useFrame, useLoader, useThree } from "@react-three/fiber";
import { useEffect, useMemo, useRef, useState } from "react";
import CameraControls from "camera-controls";
import moment, { Moment } from "moment";
CameraControls.install({ THREE });
const VRCapThrowV3 = () => {
const [throwTime, setThrowTime] = useState<Moment>(null);
const [swingPos, setSwingPos] = useState<{ x: number; y: number; t: Moment }>(
null
);
const isContact = useRef(false);
const Field = () => {
const [ref] = usePlane(() => ({
rotation: [(-85 * Math.PI) / 180, 0, 0],
position: [0, 0, 0],
type: "Static",
}));
const fieldMap = useLoader(TextureLoader, "/images/field.jpg");
return (
<mesh ref={ref}>
<planeGeometry args={[60, 30]} />
<meshBasicMaterial map={fieldMap} />
</mesh>
);
};
const Backnet = () => {
const [ref] = usePlane(() => ({
rotation: [(0 * Math.PI) / 180, 0, 0],
position: [0, 0, 20],
type: "Kinematic",
}));
return (
<mesh ref={ref}>
<planeGeometry args={[1000, 1000]} />
<meshBasicMaterial transparent />
</mesh>
);
};
const Pitcher = () => {
const [ref] = useBox(() => ({
type: "Static",
rotation: [0, 0, 0],
position: [0, 1, -2],
mass: 60,
args:[1.5, 2, 0.1]
}));
const fieldMap = useLoader(
TextureLoader,
"/images/baseball_pitcher_man.png"
);
return (
<mesh
castShadow
ref={ref}
onClick={async () => {
if (throwTime) return;
await sleep(1000);
isContact.current = false;
setThrowTime(moment());
}}
>
<planeGeometry args={[1.5, 2]} />
<meshStandardMaterial map={fieldMap} transparent />
</mesh>
);
};
const Batter = () => {
const [ref] = useBox(() => ({
args: [0.5, 1, 0.1],
rotation: [0, 0, 0],
position: [-1.2, 0, 13.5],
type: "Static",
mass: 60,
}));
const fieldMap = useLoader(
TextureLoader,
"/images/baseball_batter_man.png"
);
return (
<mesh receiveShadow castShadow ref={ref}>
<planeGeometry args={[1.5, 2]} />
<meshStandardMaterial map={fieldMap} transparent />
</mesh>
);
};
const Zone = () => {
const [ref] = usePlane(() => ({
rotation: [0, 0, 0],
position: [-0.1, 0, 15],
type: "Static",
mass: 0,
args: [0.7, 0.9],
onCollide: (ev) => {
//console.log(ev.contact.contactPoint)
if (
throwTime
&& ev.contact.contactPoint[0] >= -0.35 &&
ev.contact.contactPoint[0] <= 0.35 &&
ev.contact.contactPoint[1] >= 0 &&
ev.contact.contactPoint[1] <= 0.9
&& ev.body.geometry.type === 'CylinderGeometry'
) {
console.log("strike!");
}
},
}));
return (
<mesh
ref={ref}
onClick={(ev) => {
ev.stopPropagation();
setSwingPos({ x: ev["point"].x, y: ev["point"].y, t: moment() });
}}
>
<planeGeometry args={[0.7, 0.9]} />
<meshStandardMaterial color="lightblue" opacity={0.7} transparent />
</mesh>
);
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const Cap = () => {
const [capRef, api] = useCylinder(() => ({
args: [0.1, 0.1, 0.1, 40],
rotation: [0, 0, 0],
position: [0.2, 0.5, 0],
mass: 1,
}));
useEffect(() => {
const unsubscribe = api.position.subscribe(([x, y, z]) => {
if (z > 20) {
//バックネット超えたらリセット
setThrowTime(null);
}
});
return unsubscribe
}, [])
useFrame((state) => {
if (throwTime) {
let V;
let elapsed;
if (!isContact.current) {
const t = moment();
elapsed = t.diff(throwTime, "milliseconds") / 1000;
const k = 0.1;
V = 20 / 3.6 - k * elapsed;
api.position.set(0, 0, V * elapsed);
api.position.subscribe(([x, y, z])=>{
console.log(x, y, z, V, elapsed);
})
}else{
api.position.subscribe(([x,y,z])=>{
state.camera.position.x = x + 1;
state.camera.position.y = y + 5;
state.camera.position.z = z + 4;
})
}
console.log(elapsed, state.clock.getElapsedTime())
}
});
return (
<mesh ref={capRef}>
<cylinderGeometry args={[0.1, 0.1, 0.1, 40]} />
<meshBasicMaterial color="gray" />
</mesh>
);
};
const Bat = () => {
//const [isThrow, setIsThrow] = useState(false);
const [batRef, api] = useCylinder(() => ({
//rotation: [0, 0, Math.PI/2],
args: [0.27, 0.15, 1.5, 40],
position: [-0.5, 0, 14],
mass: 1000,
type: "Kinematic",
onCollide: (ev) => {
if(ev.body.geometry.type === 'CylinderGeometry'){
isContact.current = true;
console.log("hit", isContact.current);
}
console.log(ev.contact.impactVelocity)
},
}));
useFrame((state) => {
if (swingPos?.t) {
const elapsed = swingPos.t.diff(moment(), "milliseconds") / 1000;
api.rotation.set(
0,
-Math.PI / 2 - (elapsed * Math.PI) / 0.1,
-Math.PI / 2 + (Math.PI / 4) * swingPos.y
);
api.rotation.subscribe(([x, y, z]) => {
if (!throwTime || y > Math.PI/2 - 0.1) {
setSwingPos({ x: null, y: null, t: null });
}
});
}
//console.log(state.mouse.y)
});
return (
<group ref={batRef}>
<mesh position={[0, 0.5, 0]}>
<cylinderGeometry args={[0.09, 0.05, 1, 40]} />
<meshBasicMaterial color="yellow" />
</mesh>
</group>
);
};
function Controls({
zoom,
focus,
pos = new THREE.Vector3(),
look = new THREE.Vector3(),
}) {
const camera = useThree((state) => state.camera);
const gl = useThree((state) => state.gl);
const controls = useMemo(
() => new CameraControls(camera, gl.domElement),
[]
);
return useFrame((state, delta) => {
//zoom ? pos.set(focus.x, focus.y, focus.z + 0.2) : pos.set(0, 0, 5);
//zoom ? look.set(focus.x, focus.y, focus.z - 0.2) : look.set(0, 0, 4);
state.camera.position.lerp(pos, 0.5);
state.camera.updateProjectionMatrix();
controls.setLookAt(
state.camera.position.x,
state.camera.position.y,
state.camera.position.z,
look.x,
look.y,
look.z,
true
);
return controls.update(delta);
});
}
return (
<div id="VRCapThrow">
<Canvas
onClick={() => {}}
dpr={[1, 2]}
shadows
style={{
height: "80vh",
}}
>
<ambientLight />
<Physics gravity={[0, -9.8, 0]}>
{/* <Debug> */}
<Field />
<Pitcher />
<Batter />
<Bat />
<Cap />
<Zone />
{/* <Backnet/> */}
{/* </Debug> */}
</Physics>
<Controls
zoom={false}
focus={{}}
pos={new THREE.Vector3(0, 0.8, 18)}
look={new THREE.Vector3(0, 0, 0)}
/>
</Canvas>
</div>
);
};
export default VRCapThrowV3;
babel設定
babel.config.js
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
"@babel/preset-typescript",
],
plugins: ["@babel/plugin-transform-react-jsx"],
};
jest設定
- 静的ファイルはmock化
- jsx,tsxはbabel-jest通す
- css/scssはスタブ
const {defaults} = require('jest-config');
/** @type {import('jest').Config} */
const config = {
preset: 'ts-jest',
moduleFileExtensions: [...defaults.moduleFileExtensions, 'mts', 'cts'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
'\\.(css|less)$': 'identity-obj-proxy',
},
transform: {
"\\.[jt]sx?$": "babel-jest",
'^.+\\.(css|scss)$': 'jest-transform-stub'
},
};
module.exports = config;
ページ単位のスナップショットテスト
- カスタムフックのモック化
- routerのmock化
- matchMedia.mockの作成
spec.tsx
/**
* @jest-environment jsdom
*/
import "../../../mock/matchMedia.mock";
import React from "react";
import { render } from "@testing-library/react";
import TopPage from "../../../pages/index";
import * as hooks from "../../../hooks/usePlayerList";
jest.mock("next/router", () => ({
useRouter() {
return {
asPath: "/",
};
},
}));
jest.spyOn(hooks, 'default').mockImplementation(() => ([{
players: [],
teams: [],
league: {},
ranks: [],
}, false]));
it("renders homepage unchanged", () => {
const { container } = render(<TopPage host={""} />);
expect(container).toMatchSnapshot();
});