Open4

Next.js細々TIPS

ckoshienckoshien

認証付き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

ckoshienckoshien

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

参考リンク

https://zenn.dev/uttk/articles/b3bcbedbc1fd00
https://panda-program.com/posts/useswr

ckoshienckoshien

"@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;

ckoshienckoshien

https://dabohaze.site/swr-react-testing-library-mock/
https://zenn.dev/kazuki_tam/scraps/1e2984ea02b838

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